max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+221 insertions,
-0 deletions
| @@ -1288,6 +1288,227 @@ | |||
| 1288 | 1288 | assert!(dropped >= 1); | |
| 1289 | 1289 | } | |
| 1290 | 1290 | ||
| 1291 | + | #[tokio::test] | |
| 1292 | + | async fn push_drains_past_the_batch_limit() { | |
| 1293 | + | let dir = tempdir(); | |
| 1294 | + | let (db, node) = device(&dir.join("big.db"), 21); | |
| 1295 | + | let server = FakeServer::default(); | |
| 1296 | + | ||
| 1297 | + | // One more than two full batches, so the drain has to come back for a | |
| 1298 | + | // third: a loop that stops after the first batch loses 501 rows and | |
| 1299 | + | // still reports a clean sync. | |
| 1300 | + | let total = PUSH_BATCH_LIMIT * 2 + 1; | |
| 1301 | + | { | |
| 1302 | + | let conn = db.open().unwrap(); | |
| 1303 | + | conn.execute("BEGIN", []).unwrap(); | |
| 1304 | + | for i in 0..total { | |
| 1305 | + | conn.execute( | |
| 1306 | + | "INSERT INTO note (id, name) VALUES (?1, 'n')", | |
| 1307 | + | [format!("r{i:04}")], | |
| 1308 | + | ) | |
| 1309 | + | .unwrap(); | |
| 1310 | + | } | |
| 1311 | + | conn.execute("COMMIT", []).unwrap(); | |
| 1312 | + | let pending: i64 = conn | |
| 1313 | + | .query_row( | |
| 1314 | + | "SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0", | |
| 1315 | + | [], | |
| 1316 | + | |r| r.get(0), | |
| 1317 | + | ) | |
| 1318 | + | .unwrap(); | |
| 1319 | + | assert_eq!(pending as usize, total, "the triggers logged every insert"); | |
| 1320 | + | } | |
| 1321 | + | ||
| 1322 | + | let pushed = push_scope(&db, &server, &schema(), node, SyncScope::Personal) | |
| 1323 | + | .await | |
| 1324 | + | .unwrap(); | |
| 1325 | + | assert_eq!(pushed as usize, total); | |
| 1326 | + | ||
| 1327 | + | { | |
| 1328 | + | let log = server.log.lock().unwrap(); | |
| 1329 | + | assert_eq!(log.len(), total, "every row reached the server"); | |
| 1330 | + | let mut seen: Vec<String> = log.iter().map(|(_, e)| e.row_id.clone()).collect(); | |
| 1331 | + | seen.sort(); | |
| 1332 | + | seen.dedup(); | |
| 1333 | + | assert_eq!( | |
| 1334 | + | seen.len(), | |
| 1335 | + | total, | |
| 1336 | + | "no row was sent twice in place of another" | |
| 1337 | + | ); | |
| 1338 | + | } | |
| 1339 | + | ||
| 1340 | + | let left: i64 = db | |
| 1341 | + | .open() | |
| 1342 | + | .unwrap() | |
| 1343 | + | .query_row( | |
| 1344 | + | "SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0", | |
| 1345 | + | [], | |
| 1346 | + | |r| r.get(0), | |
| 1347 | + | ) | |
| 1348 | + | .unwrap(); | |
| 1349 | + | assert_eq!(left, 0, "every row is marked pushed"); | |
| 1350 | + | ||
| 1351 | + | // A second drain has nothing to do and sends nothing. | |
| 1352 | + | let again = push_scope(&db, &server, &schema(), node, SyncScope::Personal) | |
| 1353 | + | .await | |
| 1354 | + | .unwrap(); | |
| 1355 | + | assert_eq!(again, 0); | |
| 1356 | + | assert_eq!(server.log.lock().unwrap().len(), total); | |
| 1357 | + | } | |
| 1358 | + | ||
| 1359 | + | /// A schema wide enough for one pull to produce all four outcomes at once. | |
| 1360 | + | fn mix_schema() -> SyncSchema { | |
| 1361 | + | SyncSchema::new(vec![ | |
| 1362 | + | SyncTable::full("parent", &["id", "name"]), | |
| 1363 | + | SyncTable::full("child", &["id", "parent_id"]), | |
| 1364 | + | SyncTable::full("cfg", &["key", "value"]) | |
| 1365 | + | .pk(&["key"]) | |
| 1366 | + | .exclude_where("{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\'"), | |
| 1367 | + | ]) | |
| 1368 | + | } | |
| 1369 | + | ||
| 1370 | + | fn mix_device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) { | |
| 1371 | + | let db = DbSource::path(path); | |
| 1372 | + | let conn = db.open().unwrap(); | |
| 1373 | + | conn.execute_batch( | |
| 1374 | + | "CREATE TABLE parent (id TEXT PRIMARY KEY, name TEXT); | |
| 1375 | + | CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id)); | |
| 1376 | + | CREATE TABLE cfg (key TEXT PRIMARY KEY, value TEXT);", | |
| 1377 | + | ) | |
| 1378 | + | .unwrap(); | |
| 1379 | + | conn.execute_batch(&mix_schema().migration_sql()).unwrap(); | |
| 1380 | + | (db, DeviceId::new(uuid::Uuid::from_u128(n))) | |
| 1381 | + | } | |
| 1382 | + | ||
| 1383 | + | /// Put an arbitrary entry on the fake server, including one with no payload. | |
| 1384 | + | fn serve_entry(server: &FakeServer, entry: ChangeEntry) { | |
| 1385 | + | server | |
| 1386 | + | .log | |
| 1387 | + | .lock() | |
| 1388 | + | .unwrap() | |
| 1389 | + | .push((DeviceId::new(uuid::Uuid::from_u128(0xAA)), entry)); | |
| 1390 | + | } | |
| 1391 | + | ||
| 1392 | + | #[tokio::test] | |
| 1393 | + | async fn pull_reports_all_four_outcome_counters() { | |
| 1394 | + | let dir = tempdir(); | |
| 1395 | + | let (db, node) = mix_device(&dir.join("mix.db"), 22); | |
| 1396 | + | let server = FakeServer::default(); | |
| 1397 | + | ||
| 1398 | + | // applied x4 | |
| 1399 | + | for i in 1..=4 { | |
| 1400 | + | serve( | |
| 1401 | + | &server, | |
| 1402 | + | "parent", | |
| 1403 | + | &format!("p{i}"), | |
| 1404 | + | serde_json::json!({"id": format!("p{i}"), "name": "p"}), | |
| 1405 | + | ); | |
| 1406 | + | } | |
| 1407 | + | // filtered x3: cfg's include predicate excludes the sync_ prefix. | |
| 1408 | + | for k in ["sync_a", "sync_b", "sync_c"] { | |
| 1409 | + | serve( | |
| 1410 | + | &server, | |
| 1411 | + | "cfg", | |
| 1412 | + | k, | |
| 1413 | + | serde_json::json!({"key": k, "value": "v"}), | |
| 1414 | + | ); | |
| 1415 | + | } | |
| 1416 | + | // rejected x1: no payload, so the same bytes always fail. | |
| 1417 | + | serve_entry( | |
| 1418 | + | &server, | |
| 1419 | + | ChangeEntry { | |
| 1420 | + | table: "parent".into(), | |
| 1421 | + | op: ChangeOp::Insert, | |
| 1422 | + | row_id: "p9".into(), | |
| 1423 | + | timestamp: Utc::now(), | |
| 1424 | + | hlc: crate::types::hlc_legacy_floor(), | |
| 1425 | + | data: None, | |
| 1426 | + | extra: serde_json::Map::default(), | |
| 1427 | + | }, | |
| 1428 | + | ); | |
| 1429 | + | // deferred x2: a parent that is not in this page and never will be. | |
| 1430 | + | for i in 1..=2 { | |
| 1431 | + | serve( | |
| 1432 | + | &server, | |
| 1433 | + | "child", | |
| 1434 | + | &format!("c{i}"), | |
| 1435 | + | serde_json::json!({"id": format!("c{i}"), "parent_id": "absent"}), | |
| 1436 | + | ); | |
| 1437 | + | } | |
| 1438 | + | ||
| 1439 | + | let out = pull_scope(&db, &server, &mix_schema(), node, SyncScope::Personal) | |
| 1440 | + | .await | |
| 1441 | + | .unwrap(); | |
| 1442 | + | ||
| 1443 | + | assert_eq!(out.applied, 4); | |
| 1444 | + | assert_eq!(out.filtered, 3); | |
| 1445 | + | assert_eq!(out.rejected, 1); | |
| 1446 | + | assert_eq!(out.deferred, 2); | |
| 1447 | + | assert!(out.changed_tables.contains("parent")); | |
| 1448 | + | assert!( | |
| 1449 | + | !out.changed_tables.contains("child"), | |
| 1450 | + | "nothing landed in child" | |
| 1451 | + | ); | |
| 1452 | + | assert_eq!(row_count(&db, "parent"), 4); | |
| 1453 | + | assert_eq!(row_count(&db, "child"), 0); | |
| 1454 | + | assert_eq!(row_count(&db, "cfg"), 0); | |
| 1455 | + | } | |
| 1456 | + | ||
| 1457 | + | #[test] | |
| 1458 | + | fn cleanup_changelog_returns_what_it_deleted() { | |
| 1459 | + | let dir = tempdir(); | |
| 1460 | + | let (da, _) = device(&dir.join("cleanup.db"), 23); | |
| 1461 | + | let conn = da.open().unwrap(); | |
| 1462 | + | conn.execute("DELETE FROM sync_changelog", []).unwrap(); | |
| 1463 | + | ||
| 1464 | + | // 4 pushed and old (deletable), 2 pushed and recent, 3 unpushed and old. | |
| 1465 | + | let insert = |row_id: &str, pushed: i64, ts: &str| { | |
| 1466 | + | conn.execute( | |
| 1467 | + | "INSERT INTO sync_changelog (table_name, op, row_id, data, pushed, timestamp) \ | |
| 1468 | + | VALUES ('note','INSERT',?1,'{}',?2,?3)", | |
| 1469 | + | rusqlite::params![row_id, pushed, ts], | |
| 1470 | + | ) | |
| 1471 | + | .unwrap(); | |
| 1472 | + | }; | |
| 1473 | + | let old = "2000-01-01T00:00:00.000Z"; | |
| 1474 | + | let now = "2999-01-01T00:00:00.000Z"; | |
| 1475 | + | for i in 0..4 { | |
| 1476 | + | insert(&format!("old{i}"), 1, old); | |
| 1477 | + | } | |
| 1478 | + | for i in 0..2 { | |
| 1479 | + | insert(&format!("new{i}"), 1, now); | |
| 1480 | + | } | |
| 1481 | + | for i in 0..3 { | |
| 1482 | + | insert(&format!("unp{i}"), 0, old); | |
| 1483 | + | } | |
| 1484 | + | ||
| 1485 | + | let before: i64 = conn | |
| 1486 | + | .query_row("SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 1487 | + | .unwrap(); | |
| 1488 | + | let removed = cleanup_changelog(&conn).unwrap(); | |
| 1489 | + | let after: i64 = conn | |
| 1490 | + | .query_row("SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 1491 | + | .unwrap(); | |
| 1492 | + | ||
| 1493 | + | assert_eq!(removed, 4, "only the old pushed rows go"); | |
| 1494 | + | assert_eq!( | |
| 1495 | + | removed, | |
| 1496 | + | (before - after) as u64, | |
| 1497 | + | "the returned count is the number of rows actually deleted" | |
| 1498 | + | ); | |
| 1499 | + | let unpushed: i64 = conn | |
| 1500 | + | .query_row( | |
| 1501 | + | "SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0", | |
| 1502 | + | [], | |
| 1503 | + | |r| r.get(0), | |
| 1504 | + | ) | |
| 1505 | + | .unwrap(); | |
| 1506 | + | assert_eq!(unpushed, 3, "an unpushed row is never pruned by age"); | |
| 1507 | + | ||
| 1508 | + | // Nothing left to prune, so a second pass reports zero. | |
| 1509 | + | assert_eq!(cleanup_changelog(&conn).unwrap(), 0); | |
| 1510 | + | } | |
| 1511 | + | ||
| 1291 | 1512 | // minimal temp-dir helper (no external dep) | |
| 1292 | 1513 | fn tempdir() -> std::path::PathBuf { | |
| 1293 | 1514 | let mut p = std::env::temp_dir(); |