Astra (gpt-6-astra, high effort) read the design above against the worktree and litestream v0.5.14. Twelve findings, six P1. I checked the cited source for 1, 2, 3 and 8 and they hold. The findings are pasted unedited below my dispositions.
What this changes
The design above is withdrawn as written. Slice A (cap, backoff, restarts rule) is unaffected and goes ahead on litestream-oom-284 alone. Slice B moves to its own branch and gets a revised design before more is built. The restarts alert is the one that would have caught the #284 incident, within about a minute of the loop starting.
Dispositions
| # | Finding | Disposition | |---|---|---| | 1 | LastSuccessfulSyncAt advances while WAL capture fails | Accepted. litestream's own bookkeeping answers "has what was captured been shipped", not "is the replica current with the database". Rev 2 says so in its definitions, takes capture health from DB.SyncDiagnostic for the embedded replicator, and never calls the box-wide binary "level" on position equality alone. | | 2 | Idle daily-class replica pages after 24h15m | Accepted. The replica monitor waits two ticks when idle. Rev 2 measures outstanding captured changes (local position against replica position), not time since the last sync. | | 3 | A nil Close() is not proof of a final sync | Accepted. unitRep.Close is a no-op and claimOwnedRep.Close returns nil once writers-stop has begun. The Parked state I built from stopHistory.replicaClosed is wrong for both and is removed until the owning replicator gives an explicit receipt. | | 4 | Max L0 TXIDs prove neither initialisation nor lineage | Accepted. Zero or missing positions are "unknown", never "level". | | 5 | A healthy subset can clear an alert for a database that became unreadable | Accepted. Rev 2 tracks state per database; an overdue database with no fresh reading stays overdue until a reading says level or it leaves the config. A checker that stops reporting gets its own signal. | | 6 | Host-only joins report the previous owner after a move | Accepted. A box publishes only for hosts with a route row in its own registry; claims-on needs writer identity in the observation and is recorded as an activation item, not built here. | | 7 | The two-minute gate discards the known outage age | Accepted. The earliest unresolved outage is kept until catch-up is observed against the position at recovery. | | 8 | Claims-on passes interval 0 whatever the class | Accepted. The replicator reports the interval it actually runs with. | | 9 | The parser cannot establish coverage for handwritten configs | Accepted. Only a generated config is checked; anything else is "coverage unknown". | | 10 | Pagination and bounded execution | Accepted. Library iterator, no metadata HEADs, bounded concurrency, per-database and per-run deadlines, explicit partial failures. | | 11 | restore-verify's hardening would break the checker | Accepted. AF_UNIX, a writable monitor directory, atomic replace. | | 12 | Wants= does not arm a running box | Accepted. The timer is armed fleet-wide like the relay, with ConditionPathExists=/etc/litestream.yml on the service. |
One thing rev 2 will add that the review did not ask for
restore-verify's currency check never judges a database that is written continuously (the 15-minute quiet rule). Keeping the previous drill's change counter per database would close that: a source whose counter moved between two drills while the replica's did not is stale, quiet or not. It is independent of litestream's bookkeeping, which is the property finding 1 says the cheap signal lacks.
---
Findings, unedited
- P1 —
LastSuccessfulSyncAt()can advance while committed changes remain unshipped.
Replica.Sync reads a local LTX position once, uploads through that position, then timestamps completion (Litestream replica.go:156). That position represents LTX files, not the current SQLite WAL (db.go:559). WAL capture runs independently and backs off on errors (db.go:2705).
Failure: after reaching TXID 100, WAL capture fails persistently while the application continues writing. Uploads see local=remote=100 and keep refreshing the timestamp. The embedded source reports fresh indefinitely. A box-wide process surviving two minutes has the same blind spot. Even without errors, changes captured during a long upload remain pending when the completion timestamp advances.
Smallest fix: measure successful WAL capture and upload separately. Associate freshness with the captured watermark and its observation time; require healthy capture before asserting “level.” For the binary, uptime alone cannot provide that evidence: add capture-health evidence or report position equality with freshness unknown.
- P1 — An idle daily-class replica can falsely page for almost a day.
The replica monitor waits for a tick at the top of its loop, then—when positions are equal—waits for another tick or notification (replica.go:338). The configured daily interval is 24 hours (activator.go:348).
Failure: a fully synchronized, unchanged daily database can refresh its successful-sync timestamp only every 48 hours. Subtracting one 24-hour interval makes it page after 24h15m, despite no missing changes, then clear at the next idle sync. Batched instances have the same doubled-idle-wait shape, though its five-minute excess alone does not cross the threshold.
Smallest fix: determine lag from outstanding captured changes, with zero lag when capture is healthy and positions match. Do not use time since the last uploader invocation as the sole lag measurement or compensate by widening the fixed alert thresholds.
- P1 — A nil
Close()result is not universally proof of a final sync.
The activator’s Rep abstraction has implementations with different semantics. unitRep.Close is an unconditional no-op (backend_unit.go:234). With claims enabled, claimOwnedRep.Close deliberately returns nil once writers-stop has begun, leaving replication cleanup to its owner (claimwakeassembly.go:821); the actual close completes separately (claimwakeassembly.go:504).
Failure: a claim-controlled stop records a clean parked replica before the final upload completes—or before that upload fails. A unit-backed tenant can similarly acquire a false embedded freshness reading merely by sleeping.
Smallest fix: require an explicit final-sync receipt from the owning replicator, scoped to its writer identity. Exclude unit-backed replication from the embedded source. Keep a proven parked replica at zero outstanding lag; retain its completion timestamp for display.
- P1 — Maximum L0 TXIDs prove neither initialization nor a consistent replica lineage.
Litestream itself requires a nonzero, equal position for SyncStatus.InSync (db.go:656). When remote is ahead, initialization removes local L0 files and downloads a remote baseline; a subsequent capture must reconcile that baseline with the actual database (db.go:1400).
More seriously, the existing snapshot-only experiment demonstrates equal local/remote L0 positions while a cold restore loses new writes: numbering restarts below the existing snapshot (facts_test.go:366).
Failure: an uninitialized database, a restored database awaiting reconciliation, or the demonstrated snapshot-only lineage satisfies “no local files beyond remote” without having a fresh recoverable replica.
Smallest fix: define explicit uninitialized, reconciling, and unsupported-lineage states. Require positive positions and successful initialization/capture before equality means level. Validate the snapshot/L0 anchor when enrolling or resetting a lineage. Treat unexplained gaps as unknown; do not require contiguous retained L0 history, because legitimate compaction removes it.
- P1 — A healthy subset can clear an alert for a database that became unreadable.
The design specifies the worst numeric reading but no coverage requirement ([design-290.md:28](design-290.md:28)). The existing evaluator clears on any supplied value below the clear line and leaves state untouched only for nil (alert.go:230).
Failure: database A is 30 minutes behind and fires. Its next LIST fails, while database B—or the other replicator—reports zero. Taking the maximum of available readings clears A’s alert without evidence of recovery. Conversely, a checker that never produces a reading never fires on a box without embedded replicas. Removing the last monitored database after an alert can leave the rule firing indefinitely.
Smallest fix: carry expected inventory and coverage alongside values. Known overdue evidence may fire with incomplete coverage; clearing requires recovery or explicit removal of the affected databases. Distinguish “not configured” from “configured but unreadable.” Give checker failure its own health signal while retaining the fixed nil-reading behavior.
- P1 — Host-only joins can report the previous owner’s freshness after a move.
The proposed model copies route_db_bytes, whose join simply overwrites values by hostname as boxes are traversed (apiinstances.go:597). Claims distinguish incarnation, installation stamp, writer session, box, boot, and controller (claimstop_writers.go:28).
Failure: after a move, the source still publishes its clean parked observation while the destination has unshipped writes. Both box snapshots are current; traversal order decides which hostname entry wins. Host reuse has the same problem.
Smallest fix: include writer/replica identity in each observation and select against current ownership. Expire observations on ownership changes. Preserve them across a process-only zero-downtime swap when the replicator identity stays unchanged. Forward the observation through claimOwnedRep; adding a method only to lease.Replicator leaves that wrapper invisible.
- P2 — The two-minute uptime gate measures restart timing, not recovery, and can discard the known outage age.
The proposed gate dates failure from its first observation and resumes L0 comparison after two minutes ([design-290.md:20](design-290.md:20)). LTX files receive their timestamps when capture writes them, potentially long after the application committed the changes (db.go:1941).
Failure: after an hour-long outage, the restarted replicator captures old WAL into newly dated L0 files. At two minutes, switching from outage age to those mtimes can clear the alert although those old changes remain unshipped. Separately, config restarts one minute before each five-minute sample can produce a continuous “not replicating” alarm even when replication catches up between restarts.
Smallest fix: use uptime as startup context, not proof. Preserve the earliest unresolved outage across restarts and capture until catch-up is established. Track boot/invocation identity and distinguish startup grace from failed recovery. Describe L0 mtime as capture time, not the exact oldest outstanding commit time.
- P2 — Claims-on replication currently uses a different interval from the route’s declared class.
The legacy path passes the class-derived interval (activator.go:2384). The claims path explicitly passes 0, selecting stream behavior (claimwakeassembly.go:492).
Failure: a claims-on route marked daily actually streams, but subtracting its declared 24-hour interval hides a stalled stream for a day.
Smallest fix: report the interval actually used by the replicator alongside its observation. This preserves the decision not to change the claims-on replication path.
- P2 — The generator’s parser cannot establish monitoring coverage for handwritten configurations.
ParseLitestreamDBs accepts only the literal prefix " - path: " and does not parse replica destinations (litestream.go:165). Litestream supports environment expansion, YAML parsing, directory entries, and custom metadata paths (main.go:622, main.go:705).
Failure: valid alternate indentation omits databases; quoted paths remain quoted; meta-path makes the hardcoded local directory wrong. A partial parse can appear to be complete coverage.
Smallest fix: parse the supported configuration semantically, including destination and metadata path, using the service’s environment. Explicitly reject unsupported configurations as unknown coverage rather than returning an empty or partial success.
- P2 — “One LIST per database” needs pagination and bounded execution.
Litestream’s S3 iterator uses a paginator and checks errors while fetching subsequent pages (replica_client.go:1404, replica_client.go:1494).
Failure: with more than one page of retained L0 objects, a literal single request misses the newest TXID and reports false lag. Unbounded pagination/retries across 1,000 databases can prevent publication before the 15-minute expiry, leaving only “no reading.”
Smallest fix: require complete pagination, propagate iterator errors, disable metadata HEAD requests, bound concurrency and per-database/run deadlines, and publish explicit partial failures. The baseline is 28,800 listing requests/day at 100 databases and 288,000 at 1,000 only when each listing fits one page; retries and additional pages increase it.
- P2 — Copying restore-verify’s hardening would prevent the checker from querying systemd or writing its result.
Restore-verify uses ProtectSystem=strict, has no writable /data/monitor exception, and permits only AF_INET AF_INET6 (carlos-restore-verify.service:35). The agent, which already invokes systemctl show, permits AF_UNIX (carlos-edge.service:51).
Failure: an otherwise reasonable copied unit cannot connect to the local system bus or publish replicas.json. The resulting missing readings do not page.
Smallest fix: specify AF_UNIX, a narrowly writable monitor directory with suitable ownership, and atomic result replacement. Keep database metadata read-only. Systemd can load the existing environment file before dropping privileges; granting the checker root is unnecessary.
- P2 — Adding
Wants=timerdoes not arm the timer on an already-running deployment.
Bootstrap explicitly starts selected timers and deliberately does not restart ordinary services (bootstrap.go:229). Litestream is outside its enabled/armed list (bootstrap.go:167).
Failure: an upgrade installs the new dependency and reloads systemd while litestream continues running. No start transaction pulls in the timer; monitoring remains absent until a later restart or reboot.
Smallest fix: add an idempotent rollout/adoption step that starts the timer when box-wide replication is configured. Keep the timer running through replicator failures; do not couple its lifetime to the failing service with BindsTo= or restart propagation.
Checked and found sound:
- Normal Litestream retention preserves the newest remote L0 file. Local cleanup targets files already represented remotely; ordinary compaction does not erase unshipped L0 evidence (db.go:2627, facts_test.go:466).
- The two-minute gate catches the reported four-second crash loop, provided its unresolved-failure timestamp survives successive starts.
- Upload errors do not advance the successful-sync timestamp. They reset the replica position and trigger retry/backoff; this correctly exposes upload failure, subject to the capture and idle-timing problems above.
- The 15-minute fire / 10-minute clear hysteresis is sound for complete, correctly calculated readings. An unconfigured source need not contribute a guessed zero.
- Clean sleeping replicas should retain zero outstanding lag. The
Parkedstate added during this review addresses the design’s literal timestamp-aging problem, though final-sync proof still needs finding 3’s correction. - The existing restore checks can remain unchanged.
This was source inspection only: no edits, builds, tests, or subagents. The worktree changed concurrently; I checked the arriving lease, activator, and alert changes before finishing.