fix(db): re-key decoded ledger off volatile counter onto rec_ts (unrecoverable 1 Hz data loss) - #234
fix(db): re-key decoded ledger off volatile counter onto rec_ts (unrecoverable 1 Hz data loss)#234abdulsaheel wants to merge 2 commits into
Conversation
The strap resets its per-record `counter` to ~0 on every reboot, and `decoded_onehz` was `counter INTEGER PRIMARY KEY`. So a post-reboot record (counter=c, rec_ts=T2) REPLACE-evicted a still-present pre-reboot row (counter=c, rec_ts=T1), silently deleting T1's only decoded 1 Hz row. Because `raw_records` is dropped (not a live ledger), the decoded store is the sole system of record, making the eviction UNRECOVERABLE. No orphan-guard patch can restore an evicted row — the key itself has to change. Re-key both decoded tables onto record time: - decoded_onehz PK -> rec_ts; `counter` demoted to a NOT NULL forensic column (+ index), still the keyset-cursor tiebreak (never fires now rec_ts is unique). - decoded_rr PK -> (rec_ts, beat_index); rr_ts_ms kept as the beat timestamp. - Write path per second: REPLACE decoded_onehz(rec_ts,...); DELETE decoded_rr by rec_ts; insert the beats. Parent and child now share the rec_ts key, so the counter-based orphan guard and the prune orphan-sweep are deleted — a shrinking beat count can no longer strand stale high-index beats. Caller audit (every counter-identity query rewritten to rec_ts): - decodedRrByCounterRange -> decodedRrByRecTsRange (a clean PK range read; drops the degraded counter-span fallback + truncation counter that only existed to paper over the reboot reset). - derive_prepare.addDecodedPage groups RR by rec_ts, not counter (a counter reuse within a page had mis-joined two seconds' beats). - deleteDays / pruneDecodedBeforeRecTs / export copyRawRange / importFromDb all select decoded_rr by rec_ts; import derives rec_ts from rr_ts_ms for legacy (counter-keyed, no rec_ts) backups. Migration v33 (`_rekeyDecodedStoreByRecTs`): rebuilds BOTH decoded tables FROM THE EXISTING decoded tables only (never from the dropped raw_records — that would zero the store), rename-aside, deterministic newest-wins by rec_ts, idempotent, pure INSERT..SELECT so the iOS 999-var limit never applies. The frozen v11/v17/v19 steps are made schema-adaptive so the ladder still completes. NOTE: base is origin/main at schemaVersion 31; PR #231 (pending) bumps to 32, so this uses 33 — a trivial schemaVersion rebase is expected when they merge.
|
Warning Review limit reached
Next review available in: 1 minute You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Reviewer Guide 🔍(Review updated until commit 161ded7)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Explore these optional code suggestions:
|
|
@coderabbitai review |
|
|
@coderabbitai review |
|
|
@coderabbitai review |
The v33 re-key touches frozen migration steps (v11/v17/v19), but no ladder test seeded a genuinely OLD counter-keyed decoded store. The riskiest path is a user installed at v19..31: raw_records is already dropped by then, so the rekey is the SOLE copy of their 1 Hz data with no raw-backfill safety net. Seed that exact origin/main schema at v31, run the real ladder, and assert every second/beat survives, counter is preserved as the forensic column, the PK moved to rec_ts, and no temp tables leak.
|
|
Persistent review updated to latest commit 161ded7 |
PR Code Suggestions ✨No code suggestions found for the PR. |
|
Superseded by #235 — consolidated into the single |
User description
The bug (highest-severity: silent, unrecoverable data loss)
The strap resets its per-record
counterto ~0 on every reboot, anddecoded_onehzwascounter INTEGER PRIMARY KEY(+UNIQUE(rec_ts)) writtenwith
ConflictAlgorithm.replace. So a post-reboot record (counter=c,rec_ts=
T2) REPLACE-evicted a still-present pre-reboot row (counter=c,rec_ts=
T1) — silently deletingT1's only decoded 1 Hz row.raw_recordsis dropped (db.darthas multipleDROP TABLE ... raw_records;it is not a live ledger), so the decoded store is the sole system of record →
the eviction is unrecoverable. An in-code comment near the old orphan guard
already documented this exact hazard. No orphan-guard patch can restore an evicted
row — the only fix is to change the key.
The fix: re-key both decoded tables onto record time
decoded_onehzPK →rec_ts.counterdemoted to aNOT NULLforensiccolumn with an index; it's still the keyset-cursor tiebreak (never fires now
that
rec_tsis unique).decoded_rrPK →(rec_ts, beat_index)(was(counter, beat_index)).rr_ts_ms(=rec_ts*1000) stays as the per-beat timestamp the compute workerreads.
REPLACE decoded_onehz(rec_ts,…);DELETE FROM decoded_rr WHERE rec_ts=?; insert the beats. Parent and child now share therec_tskey, so the counter-based_queueOrphanGuardand the pruneorphan-sweep are deleted — they only ever papered over the counter-key
mismatch, and a shrinking beat count can no longer strand stale high-index beats.
Caller audit (every counter-identity query rewritten — a counter join now
over-deletes / mis-reads since
counteris no longer unique)decodedRrByCounterRange→decodedRrByRecTsRange: a clean, bounded PKrange read. Drops the degraded counter-span fallback +
decodedRrFallbackTruncationscounter, which existed only because a reboot-straddling page read
counter >= high AND counter <= low= zero rows (whole page's RR silently lost).derive_prepare.addDecodedPagegroups RR byrec_ts, notcounter(acounter reused within a page had mis-joined two seconds' beats).
deleteDaysandpruneDecodedBeforeRecTs, theexport
copyRawRange, and theimportFromDbdecoded branch all selectdecoded_rrbyrec_ts. Import derivesrec_tsfromrr_ts_msfor legacy(counter-keyed, no
rec_ts) backups, so old exports still restore.sessionHrStats/sessionHrSamplesBySession) already joinedby
rec_ts— unchanged.Migration (v33,
_rekeyDecodedStoreByRecTs)the tempting DROP + backfill-from-
raw_records(that table is dropped, so itwould zero the store: total loss). Rename-aside, deterministic newest-wins
by
rec_ts, idempotent (guards a re-run leaving_v33temp tables).INSERT … SELECT(server-side, zero host-boundvariables), so the iOS
SQLITE_MAX_VARIABLE_NUMBER(999) never applies — nochunking needed.
rebuild is skipped when the store is already rec_ts-keyed; v19 converts before
its rec_ts-keyed backfill) so the whole ladder still completes.
Invariants preserved
Durable ledger committed atomically before the trim ACK (
commitSyncBatch'ssingle-transaction shape is unchanged beyond the key); decoded pruned only after
the covering day is derived;
raw_archivenever pruned; live streams neverpersisted; day labels LOCAL; iOS value-returning PRAGMAs via
rawQuery,migrations stay in-
openDatabase.Tests
(
db_p0_fixes_test,db_integrity_test,db_storage_hygiene_test,db_paged_import_export_test,local_persistence_test).(rec_ts=T1, counter=5)then post-reboot(rec_ts=T2, counter=5)→ bothdecoded_onehzrows survive (pre-fix,T1was evicted), plus a shrink check that re-offloading a second with fewer beats
strands none.
(Pre-existing UI/widget test failures are an unrelated Flutter-SDK/phosphor
incompatibility in
lib/theme/theme.dart, present onmain.)PR Type
Bug fix, Tests
Description
Re-keys
decoded_onehzPK from volatilecountertorec_ts, eliminating silent unrecoverable 1 Hz data loss on strap rebootRe-keys
decoded_rrPK from(counter, beat_index)to(rec_ts, beat_index); removes counter-based orphan guard and orphan sweep, replacing with a singleDELETE … WHERE rec_ts = ?before each beat insertAdds schema migration v33 (
_rekeyDecodedStoreByRecTs) that rebuilds both decoded tables in-place; replacesdecodedRrByCounterRangewithdecodedRrByRecTsRangeand updates all callers (derivation engine, derive_prepare, deleteDays, prune, export, import)Updates all tests to use the new rec_ts-keyed API and adds regression coverage for the reboot-counter-reuse eviction scenario
Diagram Walkthrough
File Walkthrough
3 files
Re-key decoded tables to rec_ts; add v33 migration; remove orphanguardSwitch RR page fetch from counter range to rec_ts rangeGroup RR beats by rec_ts instead of counter in page accumulator5 files
Update orphan and prune tests to rec_ts-keyed schemaRewrite reboot-counter and import regression tests for rec_ts keyUpdate orphan check and remove fallback truncation counter testReplace counter-index hygiene tests with rec_ts PK index testsSwitch RR read assertion to decodedRrByRecTsRange API