fix(sync): re-seed HLC clock from stored rows so local edits win LWW
The repository clock started fresh at Hlc.zero on every launch and was never re-seeded from the database. After a restart, if the device wall clock lagged behind stamps already stored (e.g. rows imported from a peer whose clock ran ahead), a local edit was stamped *older* than the row it edited and silently lost last-writer-wins — so lot changes made on one device did not update when the backup was imported on another. Re-seed the clock lazily from the newest stored `updatedAt` before the first local stamp of the session, mirroring what importInventory already does with the incoming max. _stamp() is now async. Tests: add a lot-level LWW re-import test (the variety case was covered, the lot case was not) and a restart-regression test that reproduces the lagging-wall-clock scenario.
This commit is contained in:
parent
46ba85dad0
commit
2ea8d67cb4
2 changed files with 169 additions and 26 deletions
|
|
@ -210,6 +210,37 @@ void main() {
|
|||
expect(variety.lastAuthor, 'node-other');
|
||||
});
|
||||
|
||||
test('import overwrites a lot edited elsewhere (LWW)', () async {
|
||||
final ids = await populate();
|
||||
|
||||
// Fork: a second device imports this inventory, edits the lot…
|
||||
final json = codec.encode(await source.exportInventory());
|
||||
final otherDb = newTestDatabase();
|
||||
addTearDown(otherDb.close);
|
||||
final other = newTestRepository(otherDb, nodeId: 'node-other');
|
||||
await other.importInventory(codec.decode(json));
|
||||
await other.updateLot(
|
||||
lotId: ids.lotId,
|
||||
type: LotType.seed,
|
||||
harvestYear: 2025,
|
||||
quantity: const Quantity(kind: QuantityKind.cob, count: 9),
|
||||
storageLocation: 'cellar',
|
||||
);
|
||||
|
||||
// …and its export comes back: the newer edit wins here.
|
||||
final back = codec.encode(await other.exportInventory());
|
||||
final summary = await source.importInventory(codec.decode(back));
|
||||
expect(summary.updated, greaterThan(0));
|
||||
|
||||
final lot = await (sourceDb.select(
|
||||
sourceDb.lots,
|
||||
)..where((l) => l.id.equals(ids.lotId))).getSingle();
|
||||
expect(lot.harvestYear, 2025);
|
||||
expect(lot.quantityPrecise, 9);
|
||||
expect(lot.storageLocation, 'cellar');
|
||||
expect(lot.lastAuthor, 'node-other');
|
||||
});
|
||||
|
||||
test('after an import the local clock never stamps behind it', () async {
|
||||
await populate();
|
||||
final json = codec.encode(await source.exportInventory());
|
||||
|
|
@ -236,6 +267,54 @@ void main() {
|
|||
expect(Hlc.parse(written.updatedAt).compareTo(maxImported), greaterThan(0));
|
||||
});
|
||||
|
||||
test('a restarted repository re-seeds its clock from stored rows so local '
|
||||
'edits still win LWW (survives a lagging wall clock)', () async {
|
||||
// A peer device whose wall clock runs far ahead writes the inventory.
|
||||
final peerDb = newTestDatabase();
|
||||
addTearDown(peerDb.close);
|
||||
final peer = VarietyRepository(
|
||||
peerDb,
|
||||
idGen: IdGen(),
|
||||
nodeId: 'peer',
|
||||
nowMillis: () => 9000000000000, // ~year 2255
|
||||
);
|
||||
final varietyId = await peer.addQuickVariety(label: 'Maize');
|
||||
final lotId = await peer.addLot(varietyId: varietyId, harvestYear: 2024);
|
||||
final json = codec.encode(await peer.exportInventory());
|
||||
|
||||
// Our device (wall clock in the real, much earlier present) imports it.
|
||||
final myDb = newTestDatabase();
|
||||
addTearDown(myDb.close);
|
||||
const myWall = 1000; // far behind the imported stamps
|
||||
final importing = VarietyRepository(
|
||||
myDb,
|
||||
idGen: IdGen(),
|
||||
nodeId: 'mine',
|
||||
nowMillis: () => myWall,
|
||||
);
|
||||
await importing.importInventory(codec.decode(json));
|
||||
|
||||
// App restart: a brand-new repository instance over the same DB, whose
|
||||
// in-memory clock starts fresh at Hlc.zero and whose wall clock lags.
|
||||
final restarted = VarietyRepository(
|
||||
myDb,
|
||||
idGen: IdGen(),
|
||||
nodeId: 'mine',
|
||||
nowMillis: () => myWall,
|
||||
);
|
||||
final before = await _lotStamp(myDb, lotId);
|
||||
await restarted.updateLot(
|
||||
lotId: lotId,
|
||||
type: LotType.seed,
|
||||
harvestYear: 2030,
|
||||
);
|
||||
final after = await _lotStamp(myDb, lotId);
|
||||
|
||||
// The edit must out-stamp the imported row, or it would silently lose
|
||||
// last-writer-wins when this device shares its backup back.
|
||||
expect(Hlc.parse(after).compareTo(Hlc.parse(before)), greaterThan(0));
|
||||
});
|
||||
|
||||
test('unmatched species leaves speciesId null instead of dangling', () async {
|
||||
await populate();
|
||||
final json = codec.encode(await source.exportInventory());
|
||||
|
|
@ -250,3 +329,11 @@ void main() {
|
|||
expect(variety.speciesId, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
/// The packed HLC `updatedAt` currently stored for [lotId].
|
||||
Future<String> _lotStamp(AppDatabase db, String lotId) async {
|
||||
final lot = await (db.select(
|
||||
db.lots,
|
||||
)..where((l) => l.id.equals(lotId))).getSingle();
|
||||
return lot.updatedAt;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue