From 393fc78a79614bb3ceeeeacf691d90fa3479a782 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:34:59 +0200 Subject: [PATCH] fix(sync): keep a superseded snapshot until every device has read past it --- .../2026-10-29-fix-sync2-gc-manifest-ack.md | 2 ++ .../aedev/flow/sync2/engine/GarbageCollection.kt | 8 +++++--- .../github/aedev/flow/sync2/GarbageCollectionTest.kt | 11 +++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 .github/nightly-notes/2026-10-29-fix-sync2-gc-manifest-ack.md diff --git a/.github/nightly-notes/2026-10-29-fix-sync2-gc-manifest-ack.md b/.github/nightly-notes/2026-10-29-fix-sync2-gc-manifest-ack.md new file mode 100644 index 000000000..38a6c21f2 --- /dev/null +++ b/.github/nightly-notes/2026-10-29-fix-sync2-gc-manifest-ack.md @@ -0,0 +1,2 @@ +## Fixed +- Linked devices: a device no longer gets stuck behind another one that cleaned up a snapshot it hadn't read yet (#1075) diff --git a/app/src/main/java/io/github/aedev/flow/sync2/engine/GarbageCollection.kt b/app/src/main/java/io/github/aedev/flow/sync2/engine/GarbageCollection.kt index 5c1f0d7c0..faf01e007 100644 --- a/app/src/main/java/io/github/aedev/flow/sync2/engine/GarbageCollection.kt +++ b/app/src/main/java/io/github/aedev/flow/sync2/engine/GarbageCollection.kt @@ -77,10 +77,12 @@ class Facts( private fun acknowledged(link: String?): List = manifests.filter { it.manifest.link == link && acked(it.did) >= it.seq } + /** A newer acknowledged snapshot covers [old], and every device read past [old] itself, so its removal leaves no gap. */ private fun superseded(old: Logged): Boolean = - acknowledged(old.manifest.link).any { newer -> - newer != old && old.manifest.vv.all { (did, tip) -> (newer.manifest.vv[did]?.seq ?: -1) >= tip.seq } - } + acked(old.did) >= old.seq && + acknowledged(old.manifest.link).any { newer -> + newer != old && old.manifest.vv.all { (did, tip) -> (newer.manifest.vv[did]?.seq ?: -1) >= tip.seq } + } /** How far every link's newest acknowledged snapshot covers [did]'s log. */ private fun covered(did: String): Long = diff --git a/app/src/test/java/io/github/aedev/flow/sync2/GarbageCollectionTest.kt b/app/src/test/java/io/github/aedev/flow/sync2/GarbageCollectionTest.kt index 7398336c7..977b2d1b6 100644 --- a/app/src/test/java/io/github/aedev/flow/sync2/GarbageCollectionTest.kt +++ b/app/src/test/java/io/github/aedev/flow/sync2/GarbageCollectionTest.kt @@ -71,6 +71,17 @@ class GarbageCollectionTest { assertTrue(plan(setOf("a", "b"), setOf("a"), mapOf("b" to mapOf("a" to 4L)), listOf(old, new)).manifests.isEmpty()) } + @Test + fun a_snapshot_stays_until_every_device_read_past_it_even_when_another_covers_it() { + // The Mac's manifest 573 is covered by the phone's acknowledged snapshot, but the phone has read the Mac's log only to 572. + val mac = snapshot("mac", 573, "mac" to 572, "phone" to 40) + val phone = snapshot("phone", 41, "mac" to 572, "phone" to 40) + val acks = mapOf("mac" to mapOf("phone" to 41L)) + val devices = setOf("mac", "phone") + assertTrue(plan(devices, setOf("mac"), acks + ("phone" to mapOf("mac" to 572L)), listOf(mac, phone)).manifests.isEmpty()) + assertEquals(listOf(mac), plan(devices, setOf("mac"), acks + ("phone" to mapOf("mac" to 573L)), listOf(mac, phone)).manifests) + } + @Test fun every_link_batches_name_needs_an_acknowledged_snapshot() { val acks = mapOf("b" to mapOf("a" to 9L))