max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+1018 insertions,
-64 deletions
| @@ -16,11 +16,12 @@ | |||
| 16 | 16 | use std::collections::{HashMap, HashSet}; | |
| 17 | 17 | ||
| 18 | 18 | use rusqlite::types::ToSql; | |
| 19 | - | use rusqlite::{Connection, ErrorCode, Transaction}; | |
| 19 | + | use rusqlite::{Connection, ErrorCode, OptionalExtension, Transaction}; | |
| 20 | 20 | use serde_json::Value; | |
| 21 | 21 | use tracing::warn; | |
| 22 | 22 | ||
| 23 | 23 | use super::db::with_applying_remote; | |
| 24 | + | use super::migrate::row_id_expr; | |
| 24 | 25 | use super::schema::{DeleteMode, SyncMode, SyncSchema, SyncTable}; | |
| 25 | 26 | use crate::error::Result; | |
| 26 | 27 | use crate::types::{ChangeEntry, ChangeOp}; | |
| @@ -28,18 +29,77 @@ | |||
| 28 | 29 | /// A JSON null to borrow when a payload omits a whitelisted column. | |
| 29 | 30 | static JSON_NULL: Value = Value::Null; | |
| 30 | 31 | ||
| 32 | + | /// A row the apply could not write, and why. | |
| 33 | + | /// | |
| 34 | + | /// Identified by `(table, row_id)`, which survives conflict resolution, so the | |
| 35 | + | /// caller can match it back to the entry as it was pulled and hold that verbatim. | |
| 36 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 37 | + | pub struct Unapplied { | |
| 38 | + | /// Table the change targeted. | |
| 39 | + | pub table: String, | |
| 40 | + | /// Wire row id of the change. | |
| 41 | + | pub row_id: String, | |
| 42 | + | /// Why it could not be written, for display and for the hold. | |
| 43 | + | pub cause: String, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl Unapplied { | |
| 47 | + | fn new(table: &str, row_id: &str, cause: impl Into<String>) -> Self { | |
| 48 | + | Self { | |
| 49 | + | table: table.to_string(), | |
| 50 | + | row_id: row_id.to_string(), | |
| 51 | + | cause: cause.into(), | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 31 | 56 | /// Summary of an apply pass. | |
| 57 | + | /// | |
| 58 | + | /// The three not-applied outcomes are kept apart because they need opposite | |
| 59 | + | /// handling, and folding them into one number is what let a lost row look like a | |
| 60 | + | /// clean sync: | |
| 61 | + | /// | |
| 62 | + | /// - [`filtered`](Self::filtered) is policy, not failure. An `exclude_where` | |
| 63 | + | /// predicate or a `DeleteMode::Ignore` says the row does not belong on this | |
| 64 | + | /// device. Never surfaced, never held, never retried. | |
| 65 | + | /// - [`rejected`](Self::rejected) can never succeed: the same bytes will fail the | |
| 66 | + | /// same way. Held for display, not retried. | |
| 67 | + | /// - [`deferred`](Self::deferred) can succeed later: an unknown table clears on a | |
| 68 | + | /// client upgrade, a constraint violation clears when the missing parent lands. | |
| 69 | + | /// Held and retried. | |
| 32 | 70 | #[derive(Debug, Default, PartialEq, Eq)] | |
| 33 | 71 | pub struct ApplyOutcome { | |
| 34 | 72 | /// Rows written (insert/update/delete/tombstone). | |
| 35 | 73 | pub applied: usize, | |
| 36 | - | /// Rows intentionally dropped or unappliable (excluded, unknown table, | |
| 37 | - | /// missing key, or a skipped constraint violation). | |
| 38 | - | pub skipped: usize, | |
| 74 | + | /// Rows the schema deliberately dropped. Not a failure. | |
| 75 | + | pub filtered: usize, | |
| 76 | + | /// Rows that are permanently unappliable. | |
| 77 | + | pub rejected: Vec<Unapplied>, | |
| 78 | + | /// Rows that could not be applied yet, but might be later. | |
| 79 | + | pub deferred: Vec<Unapplied>, | |
| 39 | 80 | /// Distinct tables an applied change touched, for selective UI invalidation. | |
| 40 | 81 | pub changed_tables: HashSet<String>, | |
| 41 | 82 | } | |
| 42 | 83 | ||
| 84 | + | impl ApplyOutcome { | |
| 85 | + | /// Rows that were not written, for whatever reason. The old single `skipped` | |
| 86 | + | /// counter, kept as a derived total for a caller that only wants "did | |
| 87 | + | /// everything land". | |
| 88 | + | pub fn unapplied(&self) -> usize { | |
| 89 | + | self.filtered + self.rejected.len() + self.deferred.len() | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// What became of one row. | |
| 94 | + | enum RowOutcome { | |
| 95 | + | /// Written. | |
| 96 | + | Applied, | |
| 97 | + | /// Dropped on purpose, per the table's policy. | |
| 98 | + | Filtered, | |
| 99 | + | /// Unappliable, and retrying cannot change that. | |
| 100 | + | Rejected(&'static str), | |
| 101 | + | } | |
| 102 | + | ||
| 43 | 103 | /// Apply resolved remote `changes` to `conn` in FK-safe order. | |
| 44 | 104 | /// | |
| 45 | 105 | /// Upserts run parents-first (schema declaration order); deletes run | |
| @@ -64,8 +124,9 @@ | |||
| 64 | 124 | conn.execute_batch("PRAGMA foreign_keys=OFF")?; | |
| 65 | 125 | } | |
| 66 | 126 | ||
| 67 | - | let outcome = | |
| 68 | - | with_applying_remote(conn, |tx| apply_inner(tx, schema, &by_name, changes, scope)); | |
| 127 | + | let outcome = with_applying_remote(conn, |tx| { | |
| 128 | + | apply_inner(tx, schema, &by_name, changes, scope, fk_off) | |
| 129 | + | }); | |
| 69 | 130 | ||
| 70 | 131 | if fk_off { | |
| 71 | 132 | // Restore enforcement regardless of the apply result. A failed restore is | |
| @@ -85,18 +146,24 @@ | |||
| 85 | 146 | by_name: &HashMap<&str, &SyncTable>, | |
| 86 | 147 | changes: &[ChangeEntry], | |
| 87 | 148 | scope: &str, | |
| 149 | + | fk_relaxed: bool, | |
| 88 | 150 | ) -> Result<ApplyOutcome> { | |
| 89 | 151 | let mut out = ApplyOutcome::default(); | |
| 90 | 152 | ||
| 91 | - | // Changes targeting a table the schema doesn't know are dropped (defensive, | |
| 92 | - | // a hostile or newer server could send one), not applied. | |
| 93 | - | let unknown = changes | |
| 153 | + | // Changes targeting a table the schema doesn't know are held, not applied: a | |
| 154 | + | // hostile or newer server could send one, but so could a server that is simply | |
| 155 | + | // ahead of this client, and that case clears on upgrade. Deferred, so the row | |
| 156 | + | // survives the upgrade rather than being lost at the cursor. | |
| 157 | + | for change in changes | |
| 94 | 158 | .iter() | |
| 95 | 159 | .filter(|c| !by_name.contains_key(c.table.as_str())) | |
| 96 | - | .count(); | |
| 97 | - | if unknown > 0 { | |
| 98 | - | warn!("dropping {unknown} change(s) for unknown table(s)"); | |
| 99 | - | out.skipped += unknown; | |
| 160 | + | { | |
| 161 | + | warn!(table = %change.table, "deferring change for unknown table"); | |
| 162 | + | out.deferred.push(Unapplied::new( | |
| 163 | + | &change.table, | |
| 164 | + | &change.row_id, | |
| 165 | + | "table is not in this client's schema", | |
| 166 | + | )); | |
| 100 | 167 | } | |
| 101 | 168 | ||
| 102 | 169 | // Precompute NOT NULL columns for each Full-mode table once, not per row. | |
| @@ -115,6 +182,7 @@ | |||
| 115 | 182 | step( | |
| 116 | 183 | &mut out, | |
| 117 | 184 | table, | |
| 185 | + | change, | |
| 118 | 186 | apply_upsert(tx, table, change, ¬_null, scope), | |
| 119 | 187 | )?; | |
| 120 | 188 | } | |
| @@ -126,43 +194,152 @@ | |||
| 126 | 194 | .iter() | |
| 127 | 195 | .filter(|c| c.table == table.name && matches!(c.op, ChangeOp::Delete)) | |
| 128 | 196 | { | |
| 129 | - | step(&mut out, table, apply_delete(tx, table, change))?; | |
| 197 | + | step(&mut out, table, change, apply_delete(tx, table, change))?; | |
| 130 | 198 | } | |
| 131 | 199 | } | |
| 132 | 200 | ||
| 201 | + | if fk_relaxed { | |
| 202 | + | fk_sweep(tx, by_name, changes, &mut out)?; | |
| 203 | + | } | |
| 204 | + | ||
| 133 | 205 | Ok(out) | |
| 134 | 206 | } | |
| 135 | 207 | ||
| 136 | - | /// Fold one row's result into the outcome. A constraint violation is skipped and | |
| 208 | + | /// Fold one row's result into the outcome. A constraint violation is deferred and | |
| 137 | 209 | /// logged; any other SQLite error is returned so `?` rolls the whole batch back | |
| 138 | 210 | /// (converting to `SyncKitError` at the caller). | |
| 139 | 211 | fn step( | |
| 140 | 212 | out: &mut ApplyOutcome, | |
| 141 | 213 | table: &SyncTable, | |
| 142 | - | result: rusqlite::Result<bool>, | |
| 214 | + | change: &ChangeEntry, | |
| 215 | + | result: rusqlite::Result<RowOutcome>, | |
| 143 | 216 | ) -> rusqlite::Result<()> { | |
| 144 | 217 | match result { | |
| 145 | - | Ok(true) => { | |
| 218 | + | Ok(RowOutcome::Applied) => { | |
| 146 | 219 | out.applied += 1; | |
| 147 | 220 | out.changed_tables.insert(table.name.to_string()); | |
| 148 | 221 | Ok(()) | |
| 149 | 222 | } | |
| 150 | - | Ok(false) => { | |
| 151 | - | out.skipped += 1; | |
| 223 | + | Ok(RowOutcome::Filtered) => { | |
| 224 | + | out.filtered += 1; | |
| 225 | + | Ok(()) | |
| 226 | + | } | |
| 227 | + | Ok(RowOutcome::Rejected(cause)) => { | |
| 228 | + | warn!(table = table.name, row_id = %change.row_id, "rejecting row: {cause}"); | |
| 229 | + | out.rejected | |
| 230 | + | .push(Unapplied::new(table.name, &change.row_id, cause)); | |
| 152 | 231 | Ok(()) | |
| 153 | 232 | } | |
| 154 | 233 | Err(e) if is_constraint_violation(&e) => { | |
| 155 | 234 | warn!( | |
| 156 | 235 | table = table.name, | |
| 157 | - | "skipping row on constraint violation: {e}" | |
| 236 | + | row_id = %change.row_id, | |
| 237 | + | "deferring row on constraint violation: {e}" | |
| 158 | 238 | ); | |
| 159 | - | out.skipped += 1; | |
| 239 | + | out.deferred.push(Unapplied::new( | |
| 240 | + | table.name, | |
| 241 | + | &change.row_id, | |
| 242 | + | format!("constraint violation: {e}"), | |
| 243 | + | )); | |
| 160 | 244 | Ok(()) | |
| 161 | 245 | } | |
| 162 | 246 | Err(e) => Err(e), | |
| 163 | 247 | } | |
| 164 | 248 | } | |
| 165 | 249 | ||
| 250 | + | // foreign keys | |
| 251 | + | ||
| 252 | + | /// Catch the violations the batch-wide `foreign_keys=OFF` let through. | |
| 253 | + | /// | |
| 254 | + | /// The pragma is all-or-nothing (SQLite has no per-table toggle) and it is only | |
| 255 | + | /// meant to cover tables that declare `references_unsynced`, whose parents may | |
| 256 | + | /// legitimately never arrive over sync. On every *other* table in the batch it | |
| 257 | + | /// silently lets bad rows land, and the divergence resurfaces later as orphans | |
| 258 | + | /// rather than as something anyone reported. That defeats the deferred counter, | |
| 259 | + | /// which exists to catch exactly this class. | |
| 260 | + | /// | |
| 261 | + | /// So: run `PRAGMA foreign_key_check` over the tables the batch touched that do | |
| 262 | + | /// *not* declare the relaxation, still inside the apply transaction. Any | |
| 263 | + | /// violating row that belongs to a change from this batch is removed and deferred, | |
| 264 | + | /// so it retries once its parent lands. Restricted to touched tables because the | |
| 265 | + | /// argument-less form scans the whole database. | |
| 266 | + | /// | |
| 267 | + | /// A violation that matches no change in this batch is a pre-existing orphan from | |
| 268 | + | /// an earlier relaxed apply. It is logged and left alone: there is no pulled entry | |
| 269 | + | /// to hold for it, so deleting it would destroy a row with no way to get it back. | |
| 270 | + | fn fk_sweep( | |
| 271 | + | tx: &Transaction<'_>, | |
| 272 | + | by_name: &HashMap<&str, &SyncTable>, | |
| 273 | + | changes: &[ChangeEntry], | |
| 274 | + | out: &mut ApplyOutcome, | |
| 275 | + | ) -> rusqlite::Result<()> { | |
| 276 | + | let touched: HashSet<&str> = changes | |
| 277 | + | .iter() | |
| 278 | + | .filter_map(|c| by_name.get(c.table.as_str())) | |
| 279 | + | .filter(|t| !t.references_unsynced) | |
| 280 | + | .map(|t| t.name) | |
| 281 | + | .collect(); | |
| 282 | + | ||
| 283 | + | for name in touched { | |
| 284 | + | let table = by_name[name]; | |
| 285 | + | // rowid is NULL for a WITHOUT ROWID table, which we cannot address here; | |
| 286 | + | // such a row is reported and left in place. | |
| 287 | + | let mut stmt = tx.prepare(&format!("PRAGMA foreign_key_check({name})"))?; | |
| 288 | + | let rowids: Vec<Option<i64>> = stmt | |
| 289 | + | .query_map([], |r| r.get::<_, Option<i64>>(1))? | |
| 290 | + | .collect::<rusqlite::Result<_>>()?; | |
| 291 | + | drop(stmt); | |
| 292 | + | ||
| 293 | + | for rowid in rowids { | |
| 294 | + | let Some(rowid) = rowid else { | |
| 295 | + | warn!( | |
| 296 | + | table = name, | |
| 297 | + | "foreign-key violation on a WITHOUT ROWID table, cannot isolate the row" | |
| 298 | + | ); | |
| 299 | + | continue; | |
| 300 | + | }; | |
| 301 | + | // Derive the wire row id the same way the triggers do, so it matches | |
| 302 | + | // the changes in this batch even for a hashed table. | |
| 303 | + | let row_id: Option<String> = tx | |
| 304 | + | .query_row( | |
| 305 | + | &format!( | |
| 306 | + | "SELECT {} FROM {name} WHERE rowid = ?1", | |
| 307 | + | row_id_expr(table, name) | |
| 308 | + | ), | |
| 309 | + | [rowid], | |
| 310 | + | |r| r.get(0), | |
| 311 | + | ) | |
| 312 | + | .optional()?; | |
| 313 | + | let Some(row_id) = row_id else { continue }; | |
| 314 | + | ||
| 315 | + | if !changes | |
| 316 | + | .iter() | |
| 317 | + | .any(|c| c.table == name && c.row_id == row_id) | |
| 318 | + | { | |
| 319 | + | warn!( | |
| 320 | + | table = name, | |
| 321 | + | "pre-existing foreign-key orphan left in place; no pulled entry to retry" | |
| 322 | + | ); | |
| 323 | + | continue; | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | tx.execute(&format!("DELETE FROM {name} WHERE rowid = ?1"), [rowid])?; | |
| 327 | + | warn!( | |
| 328 | + | table = name, | |
| 329 | + | row_id = %row_id, | |
| 330 | + | "deferring row: foreign-key violation hidden by the batch-wide relaxation" | |
| 331 | + | ); | |
| 332 | + | out.applied = out.applied.saturating_sub(1); | |
| 333 | + | out.deferred.push(Unapplied::new( | |
| 334 | + | name, | |
| 335 | + | &row_id, | |
| 336 | + | "foreign key violation: referenced parent row is missing", | |
| 337 | + | )); | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | Ok(()) | |
| 341 | + | } | |
| 342 | + | ||
| 166 | 343 | /// Names of the NOT NULL columns of `table`, read from the live schema so the | |
| 167 | 344 | /// list can never drift from the migrations. | |
| 168 | 345 | fn not_null_columns(tx: &Transaction<'_>, table: &str) -> rusqlite::Result<HashSet<String>> { | |
| @@ -217,12 +394,12 @@ | |||
| 217 | 394 | change: &ChangeEntry, | |
| 218 | 395 | not_null: &HashMap<&str, HashSet<String>>, | |
| 219 | 396 | scope: &str, | |
| 220 | - | ) -> rusqlite::Result<bool> { | |
| 397 | + | ) -> rusqlite::Result<RowOutcome> { | |
| 221 | 398 | let Some(data) = change.data.as_ref().filter(|v| v.is_object()) else { | |
| 222 | - | return Ok(false); // an upsert with no object payload cannot be applied | |
| 399 | + | return Ok(RowOutcome::Rejected("upsert carries no object payload")); | |
| 223 | 400 | }; | |
| 224 | 401 | if is_excluded(tx, table, Some(data))? { | |
| 225 | - | return Ok(false); | |
| 402 | + | return Ok(RowOutcome::Filtered); | |
| 226 | 403 | } | |
| 227 | 404 | match &table.mode { | |
| 228 | 405 | SyncMode::PartialUpdate { set } => apply_partial_update(tx, table, data, set), | |
| @@ -241,7 +418,7 @@ | |||
| 241 | 418 | data: &Value, | |
| 242 | 419 | not_null: &HashSet<String>, | |
| 243 | 420 | scope: &str, | |
| 244 | - | ) -> rusqlite::Result<bool> { | |
| 421 | + | ) -> rusqlite::Result<RowOutcome> { | |
| 245 | 422 | // A remote null for a NOT NULL column is invalid input the changelog never | |
| 246 | 423 | // emits; omit such columns so a new row takes the schema default and an | |
| 247 | 424 | // existing row keeps its value. Nullable columns keep null so legitimate | |
| @@ -264,7 +441,7 @@ | |||
| 264 | 441 | } | |
| 265 | 442 | } | |
| 266 | 443 | if insert_cols.is_empty() { | |
| 267 | - | return Ok(false); | |
| 444 | + | return Ok(RowOutcome::Rejected("no insertable columns in the payload")); | |
| 268 | 445 | } | |
| 269 | 446 | ||
| 270 | 447 | // Group provenance: stamp a group-scoped table's `group_id` column from the | |
| @@ -331,7 +508,7 @@ | |||
| 331 | 508 | } | |
| 332 | 509 | ||
| 333 | 510 | exec(tx, &sql, ¶ms)?; | |
| 334 | - | Ok(true) | |
| 511 | + | Ok(RowOutcome::Applied) | |
| 335 | 512 | } | |
| 336 | 513 | ||
| 337 | 514 | fn apply_partial_update( | |
| @@ -339,9 +516,9 @@ | |||
| 339 | 516 | table: &SyncTable, | |
| 340 | 517 | data: &Value, | |
| 341 | 518 | set: &[&str], | |
| 342 | - | ) -> rusqlite::Result<bool> { | |
| 519 | + | ) -> rusqlite::Result<RowOutcome> { | |
| 343 | 520 | let Some(pk) = pk_bindings(table, data, None) else { | |
| 344 | - | return Ok(false); // no primary key to target | |
| 521 | + | return Ok(RowOutcome::Rejected("no primary key to target")); | |
| 345 | 522 | }; | |
| 346 | 523 | let set_present: Vec<&str> = set | |
| 347 | 524 | .iter() | |
| @@ -349,7 +526,7 @@ | |||
| 349 | 526 | .filter(|c| data.get(*c).is_some()) | |
| 350 | 527 | .collect(); | |
| 351 | 528 | if set_present.is_empty() { | |
| 352 | - | return Ok(false); | |
| 529 | + | return Ok(RowOutcome::Rejected("no updatable columns in the payload")); | |
| 353 | 530 | } | |
| 354 | 531 | ||
| 355 | 532 | let mut params: Vec<Box<dyn ToSql>> = Vec::new(); | |
| @@ -377,7 +554,7 @@ | |||
| 377 | 554 | ||
| 378 | 555 | let sql = format!("UPDATE {} SET {set_sql} WHERE {where_sql}", table.name); | |
| 379 | 556 | exec(tx, &sql, ¶ms)?; | |
| 380 | - | Ok(true) | |
| 557 | + | Ok(RowOutcome::Applied) | |
| 381 | 558 | } | |
| 382 | 559 | ||
| 383 | 560 | // delete | |
| @@ -386,19 +563,21 @@ | |||
| 386 | 563 | tx: &Transaction<'_>, | |
| 387 | 564 | table: &SyncTable, | |
| 388 | 565 | change: &ChangeEntry, | |
| 389 | - | ) -> rusqlite::Result<bool> { | |
| 566 | + | ) -> rusqlite::Result<RowOutcome> { | |
| 390 | 567 | if matches!(table.deletes, DeleteMode::Ignore) { | |
| 391 | - | return Ok(false); | |
| 568 | + | return Ok(RowOutcome::Filtered); | |
| 392 | 569 | } | |
| 393 | 570 | if is_excluded(tx, table, change.data.as_ref())? { | |
| 394 | - | return Ok(false); | |
| 571 | + | return Ok(RowOutcome::Filtered); | |
| 395 | 572 | } | |
| 396 | 573 | let Some(pk) = pk_bindings( | |
| 397 | 574 | table, | |
| 398 | 575 | change.data.as_ref().unwrap_or(&JSON_NULL), | |
| 399 | 576 | Some(&change.row_id), | |
| 400 | 577 | ) else { | |
| 401 | - | return Ok(false); // can't reconstruct the key, skip | |
| 578 | + | return Ok(RowOutcome::Rejected( | |
| 579 | + | "delete key cannot be reconstructed from the payload or row id", | |
| 580 | + | )); | |
| 402 | 581 | }; | |
| 403 | 582 | ||
| 404 | 583 | let mut params: Vec<Box<dyn ToSql>> = Vec::new(); | |
| @@ -426,7 +605,7 @@ | |||
| 426 | 605 | DeleteMode::Ignore => unreachable!("returned above"), | |
| 427 | 606 | }; | |
| 428 | 607 | exec(tx, &sql, ¶ms)?; | |
| 429 | - | Ok(true) | |
| 608 | + | Ok(RowOutcome::Applied) | |
| 430 | 609 | } | |
| 431 | 610 | ||
| 432 | 611 | /// Reconstruct the primary-key bindings for a row. | |
| @@ -684,7 +863,7 @@ | |||
| 684 | 863 | .unwrap(); | |
| 685 | 864 | assert_eq!(name, None); | |
| 686 | 865 | // A null for a NOT NULL column (child.parent_id) is omitted, so an insert | |
| 687 | - | // takes no value for it → constraint violation → skipped, not fatal. | |
| 866 | + | // takes no value for it → constraint violation → deferred, not fatal. | |
| 688 | 867 | let o = apply( | |
| 689 | 868 | &mut conn, | |
| 690 | 869 | &[upsert( | |
| @@ -694,7 +873,8 @@ | |||
| 694 | 873 | )], | |
| 695 | 874 | ); | |
| 696 | 875 | assert_eq!(o.applied, 0); | |
| 697 | - | assert_eq!(o.skipped, 1); | |
| 876 | + | assert_eq!(o.deferred.len(), 1); | |
| 877 | + | assert_eq!(o.deferred[0].row_id, "c1"); | |
| 698 | 878 | } | |
| 699 | 879 | ||
| 700 | 880 | #[test] | |
| @@ -915,7 +1095,7 @@ | |||
| 915 | 1095 | ], | |
| 916 | 1096 | ); | |
| 917 | 1097 | assert_eq!(o.applied, 1); | |
| 918 | - | assert_eq!(o.skipped, 1); | |
| 1098 | + | assert_eq!(o.deferred.len(), 1, "the poison row is held, not lost"); | |
| 919 | 1099 | assert_eq!( | |
| 920 | 1100 | conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0)) | |
| 921 | 1101 | .unwrap(), | |
| @@ -924,10 +1104,104 @@ | |||
| 924 | 1104 | } | |
| 925 | 1105 | ||
| 926 | 1106 | #[test] | |
| 927 | - | fn unknown_table_change_is_skipped() { | |
| 1107 | + | fn unknown_table_change_is_deferred_not_dropped() { | |
| 928 | 1108 | let mut conn = db(); | |
| 929 | 1109 | let o = apply(&mut conn, &[upsert("nonexistent", "x", json!({"id":"x"}))]); | |
| 930 | 1110 | assert_eq!(o.applied, 0); | |
| 931 | - | assert_eq!(o.skipped, 1); | |
| 1111 | + | // Deferred rather than rejected: the table may exist after a client | |
| 1112 | + | // upgrade, and then the held entry applies. | |
| 1113 | + | assert_eq!(o.deferred.len(), 1); | |
| 1114 | + | assert_eq!(o.deferred[0].table, "nonexistent"); | |
| 1115 | + | assert!(o.rejected.is_empty()); | |
| 1116 | + | } | |
| 1117 | + | ||
| 1118 | + | #[test] | |
| 1119 | + | fn an_excluded_row_is_filtered_not_held() { | |
| 1120 | + | let mut conn = db(); | |
| 1121 | + | // cfg's include predicate is "key NOT LIKE 'sync_%'", so a sync_ key is | |
| 1122 | + | // excluded on import. That is policy, not failure, and must never reach | |
| 1123 | + | // the dead-letter. | |
| 1124 | + | let o = apply( | |
| 1125 | + | &mut conn, | |
| 1126 | + | &[upsert( | |
| 1127 | + | "cfg", | |
| 1128 | + | "sync_token", | |
| 1129 | + | json!({"key":"sync_token","value":"x"}), | |
| 1130 | + | )], | |
| 1131 | + | ); | |
| 1132 | + | assert_eq!(o.applied, 0); | |
| 1133 | + | assert_eq!(o.filtered, 1); | |
| 1134 | + | assert!(o.deferred.is_empty()); | |
| 1135 | + | assert!(o.rejected.is_empty()); | |
| 1136 | + | } | |
| 1137 | + | ||
| 1138 | + | #[test] | |
| 1139 | + | fn a_payloadless_upsert_is_rejected_not_deferred() { | |
| 1140 | + | let mut conn = db(); | |
| 1141 | + | let mut change = upsert("parent", "p1", json!({"id":"p1"})); | |
| 1142 | + | change.data = None; | |
| 1143 | + | let o = apply(&mut conn, &[change]); | |
| 1144 | + | assert_eq!(o.applied, 0); | |
| 1145 | + | assert_eq!( | |
| 1146 | + | o.rejected.len(), | |
| 1147 | + | 1, | |
| 1148 | + | "identical bytes would fail identically" | |
| 1149 | + | ); | |
| 1150 | + | assert!(o.deferred.is_empty()); | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | #[test] | |
| 1154 | + | fn fk_sweep_catches_what_the_batch_wide_relaxation_hides() { | |
| 1155 | + | let mut conn = db(); | |
| 1156 | + | // `reffer` declares references_unsynced, so the whole apply runs with | |
| 1157 | + | // foreign_keys=OFF. Without the sweep, the child row below lands with a | |
| 1158 | + | // missing parent and nothing is reported. | |
| 1159 | + | let o = apply( | |
| 1160 | + | &mut conn, | |
| 1161 | + | &[ | |
| 1162 | + | upsert("reffer", "r1", json!({"id":"r1","ext_id":404})), | |
| 1163 | + | upsert( | |
| 1164 | + | "child", | |
| 1165 | + | "c1", | |
| 1166 | + | json!({"id":"c1","parent_id":"missing","note":"x"}), | |
| 1167 | + | ), | |
| 1168 | + | ], | |
| 1169 | + | ); | |
| 1170 | + | ||
| 1171 | + | assert_eq!( | |
| 1172 | + | conn.query_row("SELECT COUNT(*) FROM child", [], |r| r.get::<_, i64>(0)) | |
| 1173 | + | .unwrap(), | |
| 1174 | + | 0, | |
| 1175 | + | "the orphan is removed, not left to resurface later" |
Lines truncated
| @@ -125,6 +125,10 @@ | |||
| 125 | 125 | INSERT OR IGNORE INTO sync_scope_cursor (scope, cursor) | |
| 126 | 126 | SELECT '', CAST(value AS INTEGER) FROM sync_state WHERE key = 'pull_cursor';", | |
| 127 | 127 | )?; | |
| 128 | + | // The dead-letter hold, created here as well as in `migration_sql` so an | |
| 129 | + | // install that predates it gets the table on the next connection open rather | |
| 130 | + | // than on the next time the app happens to re-run its migration. | |
| 131 | + | conn.execute_batch(super::deferred::DEFERRED_DDL)?; | |
| 128 | 132 | Ok(()) | |
| 129 | 133 | } | |
| 130 | 134 |
| @@ -82,6 +82,7 @@ | |||
| 82 | 82 | /// ledger, an optional `row_id_salt`, and every table's triggers. | |
| 83 | 83 | pub fn migration_sql(&self) -> String { | |
| 84 | 84 | let mut out = String::from(BASE_TABLES); | |
| 85 | + | out.push_str(super::deferred::DEFERRED_DDL); | |
| 85 | 86 | if self.any_hashed() { | |
| 86 | 87 | out.push_str(SALT_SEED); | |
| 87 | 88 | } |
| @@ -21,6 +21,7 @@ | |||
| 21 | 21 | #[cfg(feature = "store")] | |
| 22 | 22 | pub mod config; | |
| 23 | 23 | pub mod db; | |
| 24 | + | pub mod deferred; | |
| 24 | 25 | pub mod facade; | |
| 25 | 26 | pub mod hlc; | |
| 26 | 27 | pub mod migrate; | |
| @@ -28,7 +29,7 @@ | |||
| 28 | 29 | pub mod schema; | |
| 29 | 30 | pub mod sync; | |
| 30 | 31 | ||
| 31 | - | pub use apply::{ApplyOutcome, apply_remote_changes}; | |
| 32 | + | pub use apply::{ApplyOutcome, Unapplied, apply_remote_changes}; | |
| 32 | 33 | pub use blob::{ | |
| 33 | 34 | BlobOutcome, BlobPolicy, BlobRef, BlobTransport, download_blobs, download_one, sync_blobs, | |
| 34 | 35 | upload_blobs, | |
| @@ -38,6 +39,7 @@ | |||
| 38 | 39 | DbSource, clear_applying_remote, device_id, get_scope_cursor, get_sync_state, | |
| 39 | 40 | get_sync_state_or, set_device_id, set_scope_cursor, set_sync_state, with_applying_remote, | |
| 40 | 41 | }; | |
| 42 | + | pub use deferred::{HeldEntry, HoldCounts, HoldState, MAX_ATTEMPTS}; | |
| 41 | 43 | pub use facade::{SchedulerHandle, SyncConfig, SyncOutcome, SyncStore, SyncStoreBuilder}; | |
| 42 | 44 | pub use hlc::{ | |
| 43 | 45 | committed_hlc, load_clock, observe, record_committed, resolve_pull, set_committed, |
| @@ -12,7 +12,7 @@ | |||
| 12 | 12 | //! behind [`SyncTransport`] so the loops test end-to-end against an in-memory | |
| 13 | 13 | //! fake, and `SyncKitClient` satisfies it in production. | |
| 14 | 14 | ||
| 15 | - | use std::collections::HashSet; | |
| 15 | + | use std::collections::{HashMap, HashSet}; | |
| 16 | 16 | use std::future::Future; | |
| 17 | 17 | ||
| 18 | 18 | use chrono::Utc; | |
| @@ -22,7 +22,8 @@ | |||
| 22 | 22 | use super::db::{ | |
| 23 | 23 | DbSource, device_id, get_scope_cursor, set_device_id, set_scope_cursor, set_sync_state, | |
| 24 | 24 | }; | |
| 25 | - | use super::hlc::{record_committed, resolve_pull, set_committed, stamp_pending}; | |
| 25 | + | use super::deferred; | |
| 26 | + | use super::hlc::{resolve_pull, set_committed, stamp_pending}; | |
| 26 | 27 | use super::migrate::{json_object, row_id_expr}; | |
| 27 | 28 | use super::schema::{SyncMode, SyncSchema}; | |
| 28 | 29 | use crate::client::SyncKitClient; | |
| @@ -246,10 +247,25 @@ | |||
| 246 | 247 | } | |
| 247 | 248 | ||
| 248 | 249 | /// Outcome of a pull pass. | |
| 250 | + | /// | |
| 251 | + | /// The unapplied counts are carried up deliberately. They used to stop at | |
| 252 | + | /// [`ApplyOutcome`] and never reach a caller, so an app could not have shown a | |
| 253 | + | /// failed row even if it wanted to; a divergence looked exactly like a clean | |
| 254 | + | /// sync. Anything counted in [`deferred`](Self::deferred) or | |
| 255 | + | /// [`rejected`](Self::rejected) is durably held (see | |
| 256 | + | /// [`deferred`](super::deferred)), so the numbers are a pointer into that table | |
| 257 | + | /// rather than the only trace of the failure. | |
| 249 | 258 | #[derive(Debug, Default, PartialEq, Eq)] | |
| 250 | 259 | pub struct PullOutcome { | |
| 251 | 260 | /// Number of remote changes applied. | |
| 252 | 261 | pub applied: u64, | |
| 262 | + | /// Changes the schema deliberately dropped (`exclude_where`, | |
| 263 | + | /// `DeleteMode::Ignore`). Not failures, not held. | |
| 264 | + | pub filtered: u64, | |
| 265 | + | /// Changes that can never be applied, held for display only. | |
| 266 | + | pub rejected: u64, | |
| 267 | + | /// Changes that could not be applied yet, held and retried on later pulls. | |
| 268 | + | pub deferred: u64, | |
| 253 | 269 | /// Tables whose rows changed as a result of the pull. | |
| 254 | 270 | pub changed_tables: HashSet<String>, | |
| 255 | 271 | } | |
| @@ -461,40 +477,43 @@ | |||
| 461 | 477 | } | |
| 462 | 478 | }; | |
| 463 | 479 | ||
| 464 | - | if pulled.is_empty() { | |
| 465 | - | let db_c = db.clone(); | |
| 466 | - | let sk = scope_key.clone(); | |
| 467 | - | tokio::task::spawn_blocking(move || set_scope_cursor(&db_c.open()?, &sk, new_cursor)) | |
| 468 | - | .await | |
| 469 | - | .map_err(|e| join_err(&e))??; | |
| 470 | - | break; | |
| 471 | - | } | |
| 472 | - | ||
| 480 | + | // An empty page still runs an apply when something is held, so a row | |
| 481 | + | // waiting on a parent that arrived through another path clears without | |
| 482 | + | // needing new remote traffic to carry it. It is still the last page. | |
| 483 | + | let is_empty_page = pulled.is_empty(); | |
| 473 | 484 | let db_apply = db.clone(); | |
| 474 | 485 | let schema = schema.clone(); | |
| 475 | 486 | let sk = scope_key.clone(); | |
| 476 | 487 | let outcome = tokio::task::spawn_blocking(move || { | |
| 477 | - | apply_pull( | |
| 478 | - | &mut db_apply.open()?, | |
| 479 | - | &schema, | |
| 480 | - | device_id, | |
| 481 | - | pulled, | |
| 482 | - | new_cursor, | |
| 483 | - | &sk, | |
| 484 | - | ) | |
| 488 | + | let mut conn = db_apply.open()?; | |
| 489 | + | if pulled.is_empty() && !deferred::has_retryable(&conn, &sk)? { | |
| 490 | + | set_scope_cursor(&conn, &sk, new_cursor)?; | |
| 491 | + | return Ok(ApplyOutcome::default()); | |
| 492 | + | } | |
| 493 | + | apply_pull(&mut conn, &schema, device_id, pulled, new_cursor, &sk) | |
| 485 | 494 | }) | |
| 486 | 495 | .await | |
| 487 | 496 | .map_err(|e| join_err(&e))??; | |
| 488 | 497 | ||
| 489 | 498 | out.applied += outcome.applied as u64; | |
| 499 | + | out.filtered += outcome.filtered as u64; | |
| 500 | + | out.rejected += outcome.rejected.len() as u64; | |
| 501 | + | out.deferred += outcome.deferred.len() as u64; | |
| 490 | 502 | out.changed_tables.extend(outcome.changed_tables); | |
| 491 | - | if !has_more { | |
| 503 | + | if is_empty_page || !has_more { | |
| 492 | 504 | break; | |
| 493 | 505 | } | |
| 494 | 506 | } | |
| 495 | 507 | Ok(out) | |
| 496 | 508 | } | |
| 497 | 509 | ||
| 510 | + | /// Resolve and apply one page, retrying whatever the scope is holding first. | |
| 511 | + | /// | |
| 512 | + | /// Held entries go in front of the new page and back through `resolve_pull`, so | |
| 513 | + | /// the conflict layer judges them against the local state of *now* rather than | |
| 514 | + | /// replaying a decision made when they first failed. That ordering also means a | |
| 515 | + | /// newer change to the same row supersedes the held one, since resolution | |
| 516 | + | /// collapses a row to its highest HLC. | |
| 498 | 517 | fn apply_pull( | |
| 499 | 518 | conn: &mut Connection, | |
| 500 | 519 | schema: &SyncSchema, | |
| @@ -503,9 +522,40 @@ | |||
| 503 | 522 | new_cursor: i64, | |
| 504 | 523 | scope_key: &str, | |
| 505 | 524 | ) -> Result<ApplyOutcome> { | |
| 506 | - | let resolved = resolve_pull(conn, schema, device_id, pulled, Utc::now())?; | |
| 525 | + | let retry = deferred::load_retryable(conn, scope_key)?; | |
| 526 | + | let retried: HashSet<deferred::RowKey> = | |
| 527 | + | retry.iter().map(|p| deferred::key_of(&p.entry)).collect(); | |
| 528 | + | ||
| 529 | + | // Index the batch as pulled, before resolution rewrites it. What gets held | |
| 530 | + | // must be the wire entry, not the resolved one. | |
| 531 | + | let mut batch: HashMap<deferred::RowKey, PulledChange> = HashMap::new(); | |
| 532 | + | for p in retry.iter().chain(pulled.iter()) { | |
| 533 | + | batch.insert(deferred::key_of(&p.entry), p.clone()); | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | let mut all = retry; | |
| 537 | + | all.extend(pulled); | |
| 538 | + | ||
| 539 | + | let resolved = resolve_pull(conn, schema, device_id, all, Utc::now())?; | |
| 507 | 540 | let outcome = apply_remote_changes(conn, schema, &resolved, scope_key)?; | |
| 508 | - | record_committed(conn, &resolved)?; | |
| 541 | + | ||
| 542 | + | // Only what actually landed advances the committed ledger. Recording an | |
| 543 | + | // unapplied row would gate its own retry out on the next pull, since the gate | |
| 544 | + | // drops any entry whose HLC is not newer than the committed one, and the row | |
| 545 | + | // would be lost exactly the way the hold exists to prevent. | |
| 546 | + | let unapplied: HashSet<deferred::RowKey> = outcome | |
| 547 | + | .deferred | |
| 548 | + | .iter() | |
| 549 | + | .chain(outcome.rejected.iter()) | |
| 550 | + | .map(|u| (u.table.clone(), u.row_id.clone())) | |
| 551 | + | .collect(); | |
| 552 | + | for entry in &resolved { | |
| 553 | + | if !unapplied.contains(&deferred::key_of(entry)) { | |
| 554 | + | set_committed(conn, &entry.table, &entry.row_id, &entry.hlc)?; | |
| 555 | + | } | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | deferred::settle(conn, scope_key, &outcome, &batch, &retried)?; | |
| 509 | 559 | set_scope_cursor(conn, scope_key, new_cursor)?; | |
| 510 | 560 | Ok(outcome) | |
| 511 | 561 | } | |
| @@ -745,6 +795,159 @@ | |||
| 745 | 795 | .unwrap(); | |
| 746 | 796 | } | |
| 747 | 797 | ||
| 798 | + | /// A schema whose `child` table has a real foreign key to `parent`, so a | |
| 799 | + | /// child arriving first violates a constraint instead of quietly landing. | |
| 800 | + | fn fk_schema() -> SyncSchema { | |
| 801 | + | SyncSchema::new(vec![ | |
| 802 | + | SyncTable::full("parent", &["id", "name"]), | |
| 803 | + | SyncTable::full("child", &["id", "parent_id"]), | |
| 804 | + | ]) | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | fn fk_device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) { | |
| 808 | + | let db = DbSource::path(path); | |
| 809 | + | let conn = db.open().unwrap(); | |
| 810 | + | conn.execute_batch( | |
| 811 | + | "CREATE TABLE parent (id TEXT PRIMARY KEY, name TEXT); | |
| 812 | + | CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id));", | |
| 813 | + | ) | |
| 814 | + | .unwrap(); | |
| 815 | + | conn.execute_batch(&fk_schema().migration_sql()).unwrap(); | |
| 816 | + | (db, DeviceId::new(uuid::Uuid::from_u128(n))) | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | /// Put an entry on the fake server as if another device had pushed it. | |
| 820 | + | fn serve(server: &FakeServer, table: &str, row_id: &str, data: serde_json::Value) { | |
| 821 | + | server.log.lock().unwrap().push(( | |
| 822 | + | DeviceId::new(uuid::Uuid::from_u128(0xAA)), | |
| 823 | + | ChangeEntry { | |
| 824 | + | table: table.into(), | |
| 825 | + | op: ChangeOp::Insert, | |
| 826 | + | row_id: row_id.into(), | |
| 827 | + | timestamp: Utc::now(), | |
| 828 | + | hlc: crate::types::hlc_legacy_floor(), | |
| 829 | + | data: Some(data), | |
| 830 | + | extra: serde_json::Map::default(), | |
| 831 | + | }, | |
| 832 | + | )); | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | fn row_count(db: &DbSource, table: &str) -> i64 { | |
| 836 | + | db.open() | |
| 837 | + | .unwrap() | |
| 838 | + | .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) | |
| 839 | + | .unwrap() | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | #[tokio::test] | |
| 843 | + | async fn a_child_that_arrives_before_its_parent_is_held_and_lands_on_the_next_pull() { | |
| 844 | + | let dir = tempdir(); | |
| 845 | + | let (db, node) = fk_device(&dir.join("fk.db"), 11); | |
| 846 | + | let server = FakeServer::default(); | |
| 847 | + | ||
| 848 | + | // The child arrives alone. Its parent does not exist yet, so it cannot be | |
| 849 | + | // written; before the hold existed this row was gone for good, because the | |
| 850 | + | // cursor moved past it and the server never sends an entry twice. | |
| 851 | + | serve( | |
| 852 | + | &server, | |
| 853 | + | "child", | |
| 854 | + | "c1", | |
| 855 | + | serde_json::json!({"id":"c1","parent_id":"p1"}), | |
| 856 | + | ); | |
| 857 | + | let out = pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal) | |
| 858 | + | .await | |
| 859 | + | .unwrap(); | |
| 860 | + | ||
| 861 | + | assert_eq!(out.applied, 0); | |
| 862 | + | assert_eq!(out.deferred, 1); | |
| 863 | + | assert_eq!(row_count(&db, "child"), 0); | |
| 864 | + | { | |
| 865 | + | let conn = db.open().unwrap(); | |
| 866 | + | assert_eq!( | |
| 867 | + | get_scope_cursor(&conn, "").unwrap(), | |
| 868 | + | 1, | |
| 869 | + | "the cursor still advances; the hold is what makes that safe" | |
| 870 | + | ); | |
| 871 | + | assert_eq!(deferred::counts(&conn, "").unwrap().deferred, 1); | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | // The parent lands on the next pull, and the held child rides in with it. | |
| 875 | + | serve( | |
| 876 | + | &server, | |
| 877 | + | "parent", | |
| 878 | + | "p1", | |
| 879 | + | serde_json::json!({"id":"p1","name":"p"}), | |
| 880 | + | ); | |
| 881 | + | let out = pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal) | |
| 882 | + | .await | |
| 883 | + | .unwrap(); | |
| 884 | + | ||
| 885 | + | assert_eq!(out.applied, 2, "the new parent plus the retried child"); | |
| 886 | + | assert_eq!(out.deferred, 0); | |
| 887 | + | assert_eq!(row_count(&db, "child"), 1); | |
| 888 | + | assert_eq!( | |
| 889 | + | deferred::counts(&db.open().unwrap(), "").unwrap().total(), | |
| 890 | + | 0, | |
| 891 | + | "a held entry that lands is cleared" | |
| 892 | + | ); | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | #[tokio::test] | |
| 896 | + | async fn a_parent_that_never_arrives_stops_being_retried_at_the_cap() { | |
| 897 | + | let dir = tempdir(); | |
| 898 | + | let (db, node) = fk_device(&dir.join("fk_cap.db"), 12); | |
| 899 | + | let server = FakeServer::default(); | |
| 900 | + | ||
| 901 | + | serve( | |
| 902 | + | &server, | |
| 903 | + | "child", | |
| 904 | + | "c1", | |
| 905 | + | serde_json::json!({"id":"c1","parent_id":"nope"}), | |
| 906 | + | ); | |
| 907 | + | ||
| 908 | + | // Each pull spends one attempt. The first holds it; MAX_ATTEMPTS more | |
| 909 | + | // exhaust it. An empty page still runs the retry, which is the point. | |
| 910 | + | for _ in 0..=deferred::MAX_ATTEMPTS { | |
| 911 | + | pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal) | |
| 912 | + | .await | |
| 913 | + | .unwrap(); | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | let conn = db.open().unwrap(); | |
| 917 | + | let counts = deferred::counts(&conn, "").unwrap(); | |
| 918 | + | assert_eq!(counts.deferred, 0, "no longer retried"); | |
| 919 | + | assert_eq!(counts.rejected, 1, "but still visible, not discarded"); | |
| 920 | + | let listed = deferred::list(&conn, "").unwrap(); | |
| 921 | + | assert_eq!(listed[0].row_id, "c1"); | |
| 922 | + | assert_eq!(listed[0].attempts, deferred::MAX_ATTEMPTS); | |
| 923 | + | } | |
| 924 | + | ||
| 925 | + | #[tokio::test] | |
| 926 | + | async fn a_deferred_entry_is_not_recorded_as_committed() { | |
| 927 | + | let dir = tempdir(); | |
| 928 | + | let (db, node) = fk_device(&dir.join("fk_gate.db"), 13); | |
| 929 | + | let server = FakeServer::default(); | |
| 930 | + | ||
| 931 | + | serve( | |
| 932 | + | &server, | |
| 933 | + | "child", | |
| 934 | + | "c1", | |
| 935 | + | serde_json::json!({"id":"c1","parent_id":"p1"}), | |
| 936 | + | ); | |
| 937 | + | pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal) | |
| 938 | + | .await | |
| 939 | + | .unwrap(); | |
| 940 | + | ||
| 941 | + | // Recording an unapplied row's HLC would gate its own retry out on the | |
| 942 | + | // next pull, since the gate drops anything not newer than what is | |
| 943 | + | // committed, and the hold would be a queue that never drains. | |
| 944 | + | assert!( | |
| 945 | + | super::super::hlc::committed_hlc(&db.open().unwrap(), "child", "c1") | |
| 946 | + | .unwrap() | |
| 947 | + | .is_none() | |
| 948 | + | ); | |
| 949 | + | } | |
| 950 | + | ||
| 748 | 951 | fn stamp_at(db: &DbSource, node: DeviceId, now_ms: i64) { | |
| 749 | 952 | stamp_pending(&db.open().unwrap(), node, now_ms).unwrap(); | |
| 750 | 953 | } |
| @@ -1,0 +1,539 @@ | |||
| 1 | + | //! The dead-letter hold for remote changes an apply pass could not land. | |
| 2 | + | //! | |
| 3 | + | //! Before this existed, `apply` folded every unapplied row into one `skipped` | |
| 4 | + | //! counter, `sync` dropped that counter on the floor, and the pull cursor | |
| 5 | + | //! advanced regardless. The server never sends an entry twice, so the row was | |
| 6 | + | //! gone: device A had it, device B did not, and both reported a clean sync. | |
| 7 | + | //! | |
| 8 | + | //! The hold makes the cursor safe to advance. Anything the apply could not write | |
| 9 | + | //! is stored here **as it came off the wire**, not as the conflict layer resolved | |
| 10 | + | //! it, so a retry re-enters [`resolve_pull`](super::hlc::resolve_pull) and is | |
| 11 | + | //! judged against the local state of the moment rather than replaying a decision | |
| 12 | + | //! made against a database that has since moved. | |
| 13 | + | //! | |
| 14 | + | //! Two states, and the difference is whether trying again could ever help: | |
| 15 | + | //! | |
| 16 | + | //! - **Deferred** is retryable: an unknown table (clears when the client is | |
| 17 | + | //! upgraded) or a constraint violation (clears when the missing parent lands). | |
| 18 | + | //! Retried automatically at the top of every pull, up to [`MAX_ATTEMPTS`]. | |
| 19 | + | //! - **Rejected** is not: a payload with no object, no reconstructable primary | |
| 20 | + | //! key, no insertable columns. Identical bytes fail identically, so it is held | |
| 21 | + | //! for display only. A deferred entry that exhausts its attempts is promoted | |
| 22 | + | //! here rather than retried forever. | |
| 23 | + | //! | |
| 24 | + | //! Rows the schema *intends* to drop (an `exclude_where` predicate, | |
| 25 | + | //! `DeleteMode::Ignore`) are not failures and never reach this table. | |
| 26 | + | ||
| 27 | + | use std::collections::HashSet; | |
| 28 | + | ||
| 29 | + | use rusqlite::{Connection, OptionalExtension}; | |
| 30 | + | ||
| 31 | + | use super::apply::{ApplyOutcome, Unapplied}; | |
| 32 | + | use crate::error::Result; | |
| 33 | + | use crate::ids::DeviceId; | |
| 34 | + | use crate::types::{ChangeEntry, PulledChange}; | |
| 35 | + | ||
| 36 | + | /// Retries before a deferred entry is promoted to rejected. | |
| 37 | + | /// | |
| 38 | + | /// A constraint violation whose parent genuinely never arrives would otherwise | |
| 39 | + | /// be retried on every pull for the life of the install, and the held set would | |
| 40 | + | /// grow without bound. Five is enough for the ordering cases this exists for (a | |
| 41 | + | /// late parent lands on the next pull, not the fifth) and short enough that a | |
| 42 | + | /// permanently-broken entry stops costing work. | |
| 43 | + | pub const MAX_ATTEMPTS: i64 = 5; | |
| 44 | + | ||
| 45 | + | /// DDL for the hold, shared by [`SyncSchema::migration_sql`](super::schema::SyncSchema::migration_sql) | |
| 46 | + | /// and the per-connection upgrade in [`db::ensure_scope_schema`](super::db::ensure_scope_schema), | |
| 47 | + | /// so an existing install gets the table on its next connection open rather than | |
| 48 | + | /// waiting for the app to re-run its migration. | |
| 49 | + | /// | |
| 50 | + | /// One row per (scope, table, row_id): a later change to a row that is already | |
| 51 | + | /// held replaces the payload and keeps the attempt count, so a row that keeps | |
| 52 | + | /// failing does not accumulate copies. | |
| 53 | + | pub(crate) const DEFERRED_DDL: &str = "\ | |
| 54 | + | CREATE TABLE IF NOT EXISTS sync_deferred ( | |
| 55 | + | scope TEXT NOT NULL DEFAULT '', | |
| 56 | + | table_name TEXT NOT NULL, | |
| 57 | + | row_id TEXT NOT NULL, | |
| 58 | + | cause TEXT NOT NULL, | |
| 59 | + | state TEXT NOT NULL DEFAULT 'deferred', | |
| 60 | + | attempts INTEGER NOT NULL DEFAULT 0, | |
| 61 | + | seq INTEGER NOT NULL DEFAULT 0, | |
| 62 | + | device_id TEXT NOT NULL DEFAULT '', | |
| 63 | + | entry TEXT NOT NULL, | |
| 64 | + | first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 65 | + | last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), | |
| 66 | + | PRIMARY KEY (scope, table_name, row_id) | |
| 67 | + | ); | |
| 68 | + | "; | |
| 69 | + | ||
| 70 | + | /// What a held entry is waiting on. | |
| 71 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 72 | + | pub enum HoldState { | |
| 73 | + | /// Retryable: retried at the top of each pull until [`MAX_ATTEMPTS`]. | |
| 74 | + | Deferred, | |
| 75 | + | /// Unretryable, or out of attempts. Held for display only. | |
| 76 | + | Rejected, | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | impl HoldState { | |
| 80 | + | fn as_str(self) -> &'static str { | |
| 81 | + | match self { | |
| 82 | + | Self::Deferred => "deferred", | |
| 83 | + | Self::Rejected => "rejected", | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// One entry in the hold, as listed for a human. | |
| 89 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 90 | + | pub struct HeldEntry { | |
| 91 | + | /// Table the change targets. | |
| 92 | + | pub table: String, | |
| 93 | + | /// Wire row id of the change. | |
| 94 | + | pub row_id: String, | |
| 95 | + | /// Why the apply could not land it. | |
| 96 | + | pub cause: String, | |
| 97 | + | /// Whether a retry could still help. | |
| 98 | + | pub state: HoldState, | |
| 99 | + | /// Retries spent so far. | |
| 100 | + | pub attempts: i64, | |
| 101 | + | /// When the entry was first held (RFC 3339, UTC). | |
| 102 | + | pub first_seen: String, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// How much is being held for a scope, for a sync-status surface. | |
| 106 | + | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 107 | + | pub struct HoldCounts { | |
| 108 | + | /// Entries still awaiting an automatic retry. | |
| 109 | + | pub deferred: u64, | |
| 110 | + | /// Entries that will not be retried. | |
| 111 | + | pub rejected: u64, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | impl HoldCounts { | |
| 115 | + | /// Total held entries, whatever their state. | |
| 116 | + | pub fn total(self) -> u64 { | |
| 117 | + | self.deferred + self.rejected | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// Identity of a held row: `(table, row_id)`. Enough to match an apply outcome | |
| 122 | + | /// against the batch it came from, since a resolved entry keeps both. | |
| 123 | + | pub(crate) type RowKey = (String, String); | |
| 124 | + | ||
| 125 | + | pub(crate) fn key_of(entry: &ChangeEntry) -> RowKey { | |
| 126 | + | (entry.table.clone(), entry.row_id.clone()) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | fn key_of_unapplied(row: &Unapplied) -> RowKey { | |
| 130 | + | (row.table.clone(), row.row_id.clone()) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Load the entries eligible for an automatic retry, as they were pulled. | |
| 134 | + | /// | |
| 135 | + | /// Returned in `seq` order so a held batch keeps its original server ordering | |
| 136 | + | /// relative to itself; the caller puts them in front of the newly pulled batch. | |
| 137 | + | pub fn load_retryable(conn: &Connection, scope: &str) -> Result<Vec<PulledChange>> { | |
| 138 | + | let mut stmt = conn.prepare( | |
| 139 | + | "SELECT entry, device_id, seq FROM sync_deferred \ | |
| 140 | + | WHERE scope = ?1 AND state = 'deferred' AND attempts < ?2 \ | |
| 141 | + | ORDER BY seq", | |
| 142 | + | )?; | |
| 143 | + | let rows = stmt.query_map(rusqlite::params![scope, MAX_ATTEMPTS], |r| { | |
| 144 | + | Ok(( | |
| 145 | + | r.get::<_, String>(0)?, | |
| 146 | + | r.get::<_, String>(1)?, | |
| 147 | + | r.get::<_, i64>(2)?, | |
| 148 | + | )) | |
| 149 | + | })?; | |
| 150 | + | ||
| 151 | + | let mut out = Vec::new(); | |
| 152 | + | for row in rows { | |
| 153 | + | let (entry_json, device, seq) = row?; | |
| 154 | + | // A payload that will not deserialize is a held entry we can never retry | |
| 155 | + | // (a downgrade, or a corrupted row). Skip it rather than failing the pull; | |
| 156 | + | // it stays in the table and stays visible. | |
| 157 | + | let Ok(entry) = serde_json::from_str::<ChangeEntry>(&entry_json) else { | |
| 158 | + | tracing::warn!("held entry could not be deserialized, skipping retry"); | |
| 159 | + | continue; | |
| 160 | + | }; | |
| 161 | + | // The originating device only feeds conflict detection ("did this come | |
| 162 | + | // from me"); an unparseable one degrades to nil, which is never a live | |
| 163 | + | // device, rather than failing the retry. | |
| 164 | + | let device_id = | |
| 165 | + | uuid::Uuid::parse_str(&device).map_or_else(|_| DeviceId::nil(), DeviceId::new); | |
| 166 | + | out.push(PulledChange { | |
| 167 | + | entry, | |
| 168 | + | device_id, | |
| 169 | + | seq, | |
| 170 | + | }); | |
| 171 | + | } | |
| 172 | + | Ok(out) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// List everything held for a scope, newest first, for a UI surface. | |
| 176 | + | pub fn list(conn: &Connection, scope: &str) -> Result<Vec<HeldEntry>> { | |
| 177 | + | let mut stmt = conn.prepare( | |
| 178 | + | "SELECT table_name, row_id, cause, state, attempts, first_seen \ | |
| 179 | + | FROM sync_deferred WHERE scope = ?1 ORDER BY last_seen DESC", | |
| 180 | + | )?; | |
| 181 | + | let rows = stmt.query_map([scope], |r| { | |
| 182 | + | let state: String = r.get(3)?; | |
| 183 | + | Ok(HeldEntry { | |
| 184 | + | table: r.get(0)?, | |
| 185 | + | row_id: r.get(1)?, | |
| 186 | + | cause: r.get(2)?, | |
| 187 | + | state: if state == "rejected" { | |
| 188 | + | HoldState::Rejected | |
| 189 | + | } else { | |
| 190 | + | HoldState::Deferred | |
| 191 | + | }, | |
| 192 | + | attempts: r.get(4)?, | |
| 193 | + | first_seen: r.get(5)?, | |
| 194 | + | }) | |
| 195 | + | })?; | |
| 196 | + | let mut out = Vec::new(); | |
| 197 | + | for row in rows { | |
| 198 | + | out.push(row?); | |
| 199 | + | } | |
| 200 | + | Ok(out) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// Held counts for a scope. | |
| 204 | + | pub fn counts(conn: &Connection, scope: &str) -> Result<HoldCounts> { | |
| 205 | + | let mut out = HoldCounts::default(); | |
| 206 | + | let mut stmt = | |
| 207 | + | conn.prepare("SELECT state, COUNT(*) FROM sync_deferred WHERE scope = ?1 GROUP BY state")?; | |
| 208 | + | let rows = stmt.query_map([scope], |r| { | |
| 209 | + | Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)) | |
| 210 | + | })?; | |
| 211 | + | for row in rows { | |
| 212 | + | let (state, n) = row?; | |
| 213 | + | let n = u64::try_from(n).unwrap_or(0); | |
| 214 | + | match state.as_str() { | |
| 215 | + | "rejected" => out.rejected += n, | |
| 216 | + | _ => out.deferred += n, | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | Ok(out) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// Clear one held entry, by row identity. Returns whether a row was removed. | |
| 223 | + | /// | |
| 224 | + | /// The retry path calls this when an entry finally lands; a UI can call it to | |
| 225 | + | /// discard something the user has decided to abandon. | |
| 226 | + | pub fn clear(conn: &Connection, scope: &str, table: &str, row_id: &str) -> Result<bool> { | |
| 227 | + | let n = conn.execute( | |
| 228 | + | "DELETE FROM sync_deferred WHERE scope = ?1 AND table_name = ?2 AND row_id = ?3", | |
| 229 | + | rusqlite::params![scope, table, row_id], | |
| 230 | + | )?; | |
| 231 | + | Ok(n > 0) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Reset a rejected entry so the next pull retries it once more. | |
| 235 | + | /// | |
| 236 | + | /// This is the per-row Retry affordance: a rejected entry is not retried | |
| 237 | + | /// automatically, but a human who has fixed the cause (upgraded the client, | |
| 238 | + | /// restored the missing parent by hand) can put it back in the queue. | |
| 239 | + | pub fn requeue(conn: &Connection, scope: &str, table: &str, row_id: &str) -> Result<bool> { | |
| 240 | + | let n = conn.execute( | |
| 241 | + | "UPDATE sync_deferred SET state = 'deferred', attempts = 0 \ | |
| 242 | + | WHERE scope = ?1 AND table_name = ?2 AND row_id = ?3", | |
| 243 | + | rusqlite::params![scope, table, row_id], | |
| 244 | + | )?; | |
| 245 | + | Ok(n > 0) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | /// Reconcile the hold with the outcome of an apply pass. | |
| 249 | + | /// | |
| 250 | + | /// `batch` is every entry the pass was given, keyed by row, as pulled. `retried` | |
| 251 | + | /// is the subset that came out of the hold. For each row: | |
| 252 | + | /// | |
| 253 | + | /// - unapplied again, and it was retried: spend an attempt, promoting to rejected | |
| 254 | + | /// at [`MAX_ATTEMPTS`]; | |
| 255 | + | /// - unapplied for the first time: hold it; | |
| 256 | + | /// - retried and no longer unapplied: it landed (or the schema filtered it), so | |
| 257 | + | /// clear it. | |
| 258 | + | pub(crate) fn settle( | |
| 259 | + | conn: &Connection, | |
| 260 | + | scope: &str, | |
| 261 | + | outcome: &ApplyOutcome, | |
| 262 | + | batch: &std::collections::HashMap<RowKey, PulledChange>, | |
| 263 | + | retried: &HashSet<RowKey>, | |
| 264 | + | ) -> Result<()> { | |
| 265 | + | let mut still_failing: HashSet<RowKey> = HashSet::new(); | |
| 266 | + | ||
| 267 | + | for row in &outcome.deferred { | |
| 268 | + | let key = key_of_unapplied(row); | |
| 269 | + | still_failing.insert(key.clone()); | |
| 270 | + | if retried.contains(&key) { | |
| 271 | + | spend_attempt(conn, scope, row)?; | |
| 272 | + | } else { | |
| 273 | + | hold(conn, scope, row, HoldState::Deferred, batch.get(&key))?; | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | for row in &outcome.rejected { | |
| 278 | + | let key = key_of_unapplied(row); | |
| 279 | + | still_failing.insert(key.clone()); | |
| 280 | + | // A reject never earns another automatic attempt, whether it is new or a | |
| 281 | + | // retry that failed the same way again. | |
| 282 | + | hold(conn, scope, row, HoldState::Rejected, batch.get(&key))?; | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | for key in retried { | |
| 286 | + | if !still_failing.contains(key) { | |
| 287 | + | clear(conn, scope, &key.0, &key.1)?; | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | Ok(()) | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// Insert or refresh a held row. | |
| 294 | + | fn hold( | |
| 295 | + | conn: &Connection, | |
| 296 | + | scope: &str, | |
| 297 | + | row: &Unapplied, | |
| 298 | + | state: HoldState, | |
| 299 | + | pulled: Option<&PulledChange>, | |
| 300 | + | ) -> Result<()> { | |
| 301 | + | let Some(pulled) = pulled else { | |
| 302 | + | // Every unapplied row is one the caller handed us, so this cannot happen | |
| 303 | + | // in the pull path. Refuse to hold a row with no payload rather than | |
| 304 | + | // write an entry no retry could ever use. | |
| 305 | + | tracing::warn!( | |
| 306 | + | table = %row.table, | |
| 307 | + | row_id = %row.row_id, | |
| 308 | + | "unapplied row has no pulled entry to hold; not recorded" | |
| 309 | + | ); | |
| 310 | + | return Ok(()); | |
| 311 | + | }; | |
| 312 | + | let entry = serde_json::to_string(&pulled.entry)?; | |
| 313 | + | conn.execute( | |
| 314 | + | "INSERT INTO sync_deferred \ | |
| 315 | + | (scope, table_name, row_id, cause, state, seq, device_id, entry) \ | |
| 316 | + | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ | |
| 317 | + | ON CONFLICT(scope, table_name, row_id) DO UPDATE SET \ | |
| 318 | + | cause = excluded.cause, \ | |
| 319 | + | state = excluded.state, \ | |
| 320 | + | seq = excluded.seq, \ | |
| 321 | + | device_id = excluded.device_id, \ | |
| 322 | + | entry = excluded.entry, \ | |
| 323 | + | last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", | |
| 324 | + | rusqlite::params![ | |
| 325 | + | scope, | |
| 326 | + | row.table, | |
| 327 | + | row.row_id, | |
| 328 | + | row.cause, | |
| 329 | + | state.as_str(), | |
| 330 | + | pulled.seq, | |
| 331 | + | pulled.device_id.to_string(), | |
| 332 | + | entry, | |
| 333 | + | ], | |
| 334 | + | )?; | |
| 335 | + | Ok(()) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | /// Spend one of a held entry's attempts, promoting it to rejected at the cap. | |
| 339 | + | fn spend_attempt(conn: &Connection, scope: &str, row: &Unapplied) -> Result<()> { | |
| 340 | + | conn.execute( | |
| 341 | + | "UPDATE sync_deferred SET \ | |
| 342 | + | attempts = attempts + 1, \ | |
| 343 | + | cause = ?4, \ | |
| 344 | + | state = CASE WHEN attempts + 1 >= ?5 THEN 'rejected' ELSE state END, \ | |
| 345 | + | last_seen = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') \ | |
| 346 | + | WHERE scope = ?1 AND table_name = ?2 AND row_id = ?3", | |
| 347 | + | rusqlite::params![scope, row.table, row.row_id, row.cause, MAX_ATTEMPTS], | |
| 348 | + | )?; | |
| 349 | + | Ok(()) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /// Whether a scope holds anything at all, the cheap check the pull loop uses to | |
| 353 | + | /// decide if an empty batch is worth a retry pass. | |
| 354 | + | pub(crate) fn has_retryable(conn: &Connection, scope: &str) -> Result<bool> { | |
| 355 | + | let found = conn | |
| 356 | + | .query_row( | |
| 357 | + | "SELECT 1 FROM sync_deferred \ | |
| 358 | + | WHERE scope = ?1 AND state = 'deferred' AND attempts < ?2 LIMIT 1", | |
| 359 | + | rusqlite::params![scope, MAX_ATTEMPTS], | |
| 360 | + | |_| Ok(()), | |
| 361 | + | ) | |
| 362 | + | .optional()?; | |
| 363 | + | Ok(found.is_some()) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | #[cfg(test)] | |
| 367 | + | mod tests { | |
| 368 | + | use super::*; | |
| 369 | + | use crate::types::{ChangeOp, hlc_legacy_floor}; | |
| 370 | + | use std::collections::HashMap; | |
| 371 | + | ||
| 372 | + | fn db() -> Connection { | |
| 373 | + | let conn = Connection::open_in_memory().unwrap(); | |
| 374 | + | conn.execute_batch(DEFERRED_DDL).unwrap(); | |
| 375 | + | conn | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | fn pulled(table: &str, row_id: &str, seq: i64) -> PulledChange { | |
| 379 | + | PulledChange { | |
| 380 | + | entry: ChangeEntry { | |
| 381 | + | table: table.into(), | |
| 382 | + | op: ChangeOp::Insert, | |
| 383 | + | row_id: row_id.into(), | |
| 384 | + | timestamp: chrono::Utc::now(), | |
| 385 | + | hlc: hlc_legacy_floor(), | |
| 386 | + | data: Some(serde_json::json!({"id": row_id})), | |
| 387 | + | extra: serde_json::Map::default(), | |
| 388 | + | }, | |
| 389 | + | device_id: DeviceId::nil(), | |
| 390 | + | seq, | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | fn unapplied(table: &str, row_id: &str) -> Unapplied { | |
| 395 | + | Unapplied { | |
| 396 | + | table: table.into(), | |
| 397 | + | row_id: row_id.into(), | |
| 398 | + | cause: "constraint violation".into(), | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | fn batch(entries: &[PulledChange]) -> HashMap<RowKey, PulledChange> { | |
| 403 | + | entries | |
| 404 | + | .iter() | |
| 405 | + | .map(|p| (key_of(&p.entry), p.clone())) | |
| 406 | + | .collect() | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | fn deferred_outcome(rows: Vec<Unapplied>) -> ApplyOutcome { | |
| 410 | + | ApplyOutcome { | |
| 411 | + | deferred: rows, | |
| 412 | + | ..ApplyOutcome::default() | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | #[test] | |
| 417 | + | fn a_new_deferred_row_is_held_with_its_pulled_entry() { | |
| 418 | + | let conn = db(); | |
| 419 | + | let p = pulled("child", "c1", 7); | |
| 420 | + | settle( | |
| 421 | + | &conn, | |
| 422 | + | "", | |
| 423 | + | &deferred_outcome(vec![unapplied("child", "c1")]), | |
| 424 | + | &batch(&[p]), | |
| 425 | + | &HashSet::new(), | |
| 426 | + | ) | |
| 427 | + | .unwrap(); | |
| 428 | + | ||
| 429 | + | let held = load_retryable(&conn, "").unwrap(); | |
| 430 | + | assert_eq!(held.len(), 1); | |
| 431 | + | assert_eq!(held[0].entry.row_id, "c1"); | |
| 432 | + | assert_eq!(held[0].seq, 7, "the wire seq is preserved for ordering"); | |
| 433 | + | assert_eq!(counts(&conn, "").unwrap().deferred, 1); | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | #[test] | |
| 437 | + | fn a_retry_that_lands_clears_the_hold() { | |
| 438 | + | let conn = db(); | |
| 439 | + | let p = pulled("child", "c1", 7); | |
| 440 | + | let b = batch(&[p]); | |
| 441 | + | settle( | |
| 442 | + | &conn, | |
| 443 | + | "", | |
| 444 | + | &deferred_outcome(vec![unapplied("child", "c1")]), | |
| 445 | + | &b, | |
| 446 | + | &HashSet::new(), | |
| 447 | + | ) | |
| 448 | + | .unwrap(); | |
| 449 | + | ||
| 450 | + | let retried: HashSet<RowKey> = [("child".to_string(), "c1".to_string())] | |
| 451 | + | .into_iter() | |
| 452 | + | .collect(); | |
| 453 | + | settle(&conn, "", &ApplyOutcome::default(), &b, &retried).unwrap(); | |
| 454 | + | ||
| 455 | + | assert!(load_retryable(&conn, "").unwrap().is_empty()); | |
| 456 | + | assert_eq!(counts(&conn, "").unwrap().total(), 0); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn attempts_are_capped_and_the_entry_is_promoted_to_rejected() { | |
| 461 | + | let conn = db(); | |
| 462 | + | let p = pulled("child", "c1", 7); | |
| 463 | + | let b = batch(&[p]); | |
| 464 | + | let outcome = deferred_outcome(vec![unapplied("child", "c1")]); | |
| 465 | + | settle(&conn, "", &outcome, &b, &HashSet::new()).unwrap(); | |
| 466 | + | ||
| 467 | + | let retried: HashSet<RowKey> = [("child".to_string(), "c1".to_string())] | |
| 468 | + | .into_iter() | |
| 469 | + | .collect(); | |
| 470 | + | for _ in 0..MAX_ATTEMPTS { | |
| 471 | + | settle(&conn, "", &outcome, &b, &retried).unwrap(); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | assert!( | |
| 475 | + | load_retryable(&conn, "").unwrap().is_empty(), | |
| 476 | + | "a capped entry is no longer retried" | |
| 477 | + | ); | |
| 478 | + | let listed = list(&conn, "").unwrap(); | |
| 479 | + | assert_eq!(listed.len(), 1, "but it stays visible"); | |
| 480 | + | assert_eq!(listed[0].state, HoldState::Rejected); | |
| 481 | + | assert_eq!(listed[0].attempts, MAX_ATTEMPTS); | |
| 482 | + | assert!(!has_retryable(&conn, "").unwrap()); | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | #[test] | |
| 486 | + | fn requeue_gives_a_rejected_entry_one_more_run() { | |
| 487 | + | let conn = db(); | |
| 488 | + | let p = pulled("child", "c1", 7); | |
| 489 | + | settle( | |
| 490 | + | &conn, | |
| 491 | + | "", | |
| 492 | + | &ApplyOutcome { | |
| 493 | + | rejected: vec![unapplied("child", "c1")], | |
| 494 | + | ..ApplyOutcome::default() | |
| 495 | + | }, | |
| 496 | + | &batch(&[p]), | |
| 497 | + | &HashSet::new(), | |
| 498 | + | ) | |
| 499 | + | .unwrap(); | |
| 500 | + | assert!(load_retryable(&conn, "").unwrap().is_empty()); |
Lines truncated