fix(sync): 恢复过期会话并持久化上传尝试结果

This commit is contained in:
2026-09-08 20:10:26 +08:00
parent 4ff8fdbd70
commit 5a6fa86d10
10 changed files with 325 additions and 41 deletions
+95 -10
View File
@@ -17,7 +17,57 @@ impl Retry {
.unwrap_or(0)
}
}
fn machine_code(code: &str) -> &str {
if !code.is_empty()
&& code.len() <= 80
&& code
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
{
code
} else {
"SYNC_REMOTE_ERROR"
}
}
impl Workspace {
pub fn sync_attempt_start(&self, job: &crate::sync_state::Job) -> Result<()> {
self.check_job(job)?;
self.db.execute("INSERT INTO sync_attempts VALUES (?1,?2,1,'running',NULL) ON CONFLICT(binding,operation_id) DO UPDATE SET attempts=MIN(attempts+1,2147483647),outcome='running',error=NULL", params![job.binding, job.operation_id])?;
Ok(())
}
pub fn sync_attempt_interrupt(&self, job: &crate::sync_state::Job) -> Result<()> {
self.check_job(job)?;
self.db.execute("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=?1 AND j.operation_id=?2 AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE binding=?1 AND operation_id=?2 AND outcome='running'", params![job.binding,job.operation_id])?;
Ok(())
}
pub fn sync_attempt_finish(
&self,
job: &crate::sync_state::Job,
error: Option<&str>,
) -> Result<()> {
self.check_job(job)?;
self.db.execute(
"UPDATE sync_attempts SET outcome=?3,error=?4 WHERE binding=?1 AND operation_id=?2",
params![
job.binding,
job.operation_id,
if error.is_some() {
"failed"
} else {
"succeeded"
},
error.map(machine_code)
],
)?;
Ok(())
}
pub fn sync_attempts(&self, binding: &str) -> Result<Vec<serde_json::Value>> {
self.check_binding(binding)?;
let mut statement = self.db.prepare("SELECT a.operation_id,j.path,a.attempts,a.outcome,a.error,j.state FROM sync_attempts a JOIN sync_jobs j ON j.binding=a.binding AND j.operation_id=a.operation_id WHERE a.binding=?1 AND j.state NOT IN ('acked','archived') ORDER BY a.rowid LIMIT 20")?;
let rows = statement.query_map([binding], |row| Ok(serde_json::json!({"operation_id":row.get::<_,String>(0)?,"path":row.get::<_,String>(1)?,"attempts":row.get::<_,i64>(2)?,"outcome":row.get::<_,String>(3)?,"error":row.get::<_,Option<String>>(4)?,"state":row.get::<_,String>(5)?})))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn sync_retry(&self, binding: &str) -> Result<Retry> {
self.check_binding(binding)?;
Ok(self
@@ -56,16 +106,7 @@ impl Workspace {
return Ok(());
}
// Only retain a bounded machine code, never an arbitrary remote response string.
let code = if !code.is_empty()
&& code.len() <= 80
&& code
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
{
code
} else {
"SYNC_REMOTE_ERROR"
};
let code = machine_code(code);
let failures = if code == "CREDENTIALS_LOCKED" {
0
} else {
@@ -87,6 +128,50 @@ impl Workspace {
mod tests {
use super::*;
#[test]
fn interrupted_job_attempts_survive_twenty_restarts_without_changing_commit_base() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
ws.write("attempt.md", "", b"body", "local").unwrap();
let binding = ws
.sync_bind_empty("https://sync.example", "remote", "account")
.unwrap();
let job = ws.sync_next(&binding.id).unwrap().unwrap();
let payload = ws.sync_commit_payload(&job).unwrap();
for count in 1..=20 {
ws.sync_attempt_start(&job).unwrap();
drop(ws);
ws = Workspace::open(dir.path()).unwrap();
let rows = ws.sync_attempts(&binding.id).unwrap();
assert_eq!(rows[0]["attempts"], count);
assert_eq!(rows[0]["outcome"], "interrupted");
assert_eq!(ws.sync_commit_payload(&job).unwrap(), payload);
}
ws.sync_attempt_finish(&job, Some("response with secret body"))
.unwrap();
assert_eq!(
ws.sync_attempts(&binding.id).unwrap()[0]["error"],
"SYNC_REMOTE_ERROR"
);
ws.sync_attempt_start(&job).unwrap();
let mut revision = payload.clone();
revision["hash"] = payload["content_hash"].clone();
revision["vault_id"] = serde_json::json!("remote");
revision["sequence"] = serde_json::json!(1);
ws.sync_ack(&job, &revision).unwrap();
drop(ws);
ws = Workspace::open(dir.path()).unwrap();
assert!(ws.sync_attempts(&binding.id).unwrap().is_empty());
assert_eq!(
ws.db
.query_row("SELECT outcome FROM sync_attempts", [], |r| r
.get::<_, String>(0))
.unwrap(),
"succeeded"
);
ws.sync_unbind(&binding.id).unwrap();
assert!(ws.sync_attempt_finish(&job, None).is_err());
}
#[test]
fn retry_history_survives_twenty_reopens_and_isolates_bindings() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();