fix(sync): 恢复过期会话并持久化上传尝试结果
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user