offload data loss fixes - #235
Conversation
…ed counter raw_archive is the durable dead-letter box for undecodable frames — its whole purpose is to never lose a frame until we can decode it. But it was counter INTEGER PRIMARY KEY with IGNORE-on-conflict, and the strap resets its record counter to ~0 on every reboot. So a post-reboot frame that reused a still-present pre-reboot counter was silently DROPPED, even though its bytes were completely different data. Re-key the table off the volatile counter onto frame hex (content identity), exactly like events/band_events already do: an identical re-flood (missed-ACK redelivery) still dedups, but two genuinely distinct frames survive a counter collision. counter is retained as a plain forensic column. v32 migration rebuilds the table preserving every existing row (their counters are unique, so the content-keyed copy loses nothing). Guarded because raw_archive is created lazily in onOpen, not the ladder, so an old DB may not have it yet at migration time — then a fresh hex-keyed create is all that's needed. Adds a regression test: two distinct frames sharing a reused counter both survive (previously the second was lost).
… received-total signal) Emit an honest, observation-only frame-loss signal at HISTORY_END without touching the commit/ACK decision. The band's num_packets counts every frame it transmitted (all types); the correct completeness comparison is against totalTrafficPacketCount — the all-types received total — not the banked R24 subset (which fabricates a shortfall whenever console/event frames ride along un-banked). This is type-agnostic and interleaving-immune. New pure helper burstPacketShortfall() = expected - (received_all_types + dropped_this_burst): a POSITIVE result is frames the band sent that never reached us (true loss); zero is complete; negative is retries/dupes, not loss. Gate-dropped (RecordGate) records are added back so plausibility rejections never read as radio loss. At burst end we now log a "would-flag" line and stamp burst_shortfall into the existing mismatch ledger entry — LOG-ONLY. Commit-before-ACK, the verbatim token echo, and the OK/FAIL decision are all unchanged. This is groundwork so we can SEE true frame loss in telemetry before ever wiring a field-validated FAIL gate; a hard FAIL/re-flood path is deliberately NOT included here. Rejected alternative: gating on the per-revision counter gap — the counter is a GLOBAL flash-log index sliced per revision, so gaps are the normal state and would false-positive constantly. Adds pure unit tests covering benign interleaving (no false positive), true loss, the gate-dropped add-back, negative/retry case, and shortfall==0 == burstPacketCountMatches.
The DB runs WAL + synchronous=NORMAL, under which a commit is durable only at the next checkpoint, not at commit. commitSyncBatch persists the sync batch (raw_archive + samples + decoded + trim cursor) and returns; the caller then writes the BLE batch-ACK and the band trims its flash. A kernel panic / battery-yank AFTER the ACK but BEFORE the -wal is checkpointed lost those just-committed rows from the phone while they were already gone from the band. The commit-before-ACK ordering held; the durability did not. Raise durability to synchronous=FULL (fsync AT commit) for this one commit only, leaving every other path at NORMAL — they are all recomputable and FULL everywhere is brutally slow. synchronous is per-connection and cannot change mid-transaction, so it is set BEFORE db.transaction opens and reset to NORMAL in a finally (a leaked FULL would fsync every later write on the connection forever). Both the main and background-isolate drains funnel through commitSyncBatch, each on its own connection, so this single bracket covers both. PRAGMA synchronous returns no rows -> execute(), kept non-fatal like the open-time PRAGMAs. Adds a focused test (spies the FULL/NORMAL SQL bracket and reads resting PRAGMA synchronous) covering both a normal commit and a throwing one.
A positive burst shortfall means frames the band counted that we did not count as valid received traffic. CRC-failed frames also never enter currentBurstTrafficCount, so a positive shortfall can be missing OR corrupted traffic — it cannot by itself prove a frame never arrived. Soften the helper doc, the would-flag log text, and the test name accordingly. Wording-only; no behavior change (still log-only).
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.
The headless drain (background_sync.dart) is the iOS CoreBluetooth-restoration recovery path and runs in the MAIN isolate on the same shared _db connection — not a separate per-isolate connection as the prior comment claimed. The bracket is safe not because of isolation but because BandOwnership + the single-flight offload processor guarantee the two drains never overlap on one connection. Document that as the load-bearing invariant so a future concurrent caller does not silently defeat the FULL window.
…-PK DB The v32 migration (RENAME → drop-index → hex-PK create → INSERT OR IGNORE SELECT → drop-old) had no coverage — the archive test only exercises the fresh onCreate schema, and the ladder test never touched raw_archive. Seed a populated v31 counter-PK table, open it through the REAL ladder, and assert: distinct frames survive, an exact-duplicate hex collapses (5 rows → 4), a reused counter no longer drops a distinct frame (hex-PK proven end-to-end), and an identical re-flood still dedups on content.
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.
# Conflicts: # lib/data/db.dart # test/db_migration_ladder_test.dart
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe PR adds phone-clock-aware BLE history deferral and burst shortfall telemetry. It migrates decoded and raw archive storage from counter-based identity to timestamp or frame-content identity, updates compute and database flows, and adds migration, durability, and integrity tests. ChangesBLE reliability telemetry
Timestamp-keyed persistence
Estimated code review effort: 5 (Critical) | ~90 minutes Mergeability Score: 🟠 High · up to This change targets data-loss prevention, but the current implementation can still discard archived or decoded records, acknowledge data before it is durably persisted, or report successful setup after a connection has dropped. These high-impact correctness and recovery risks should be fixed before merging. Sequence Diagram(s)sequenceDiagram
participant BleEngine
participant Strap
participant ClockPolicy
BleEngine->>Strap: Read GET_CLOCK
Strap-->>BleEngine: Return strap timestamp
BleEngine->>ClockPolicy: Evaluate phoneClockSuspect
ClockPolicy-->>BleEngine: Return clock verdict
BleEngine->>BleEngine: Defer or resume history offload
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
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 |
…clock-skew P1) The plausibility gate used the phone wall clock as ground truth. If the phone clock ran >1 day slow (dead-battery reboot, bad NTP, manual set-back), the strap's correctly-stamped records read as 'implausibly future', got dropped, and a mixed-burst ACK then TRIMMED them off the band — silent, permanent loss. Option A (trust the strap's GET_DATA_RANGE window instead) can't work: that window is itself discarded via isCorruptFutureRtc against the same wrong phone clock, so it's unavailable exactly when needed. Fix (option D): don't drain-and-trim under an untrustworthy clock. Before each history refresh, read the strap RTC and compare; if it reads a PLAUSIBLE time but >1 day ahead of the phone (ClockPolicy.phoneClockSuspect), the phone clock is likely slow, so DEFER the offload — the strap retains every record until the clocks agree (the phone almost always self-corrects via NTP within minutes). SET_CLOCK is deliberately NOT issued in this case: pushing the strap back to the slow phone would corrupt a correct RTC. The strap-behind and unset-RTC cases are unchanged (still corrected forward by shouldSetClock); only the future-skew case defers. Exposes historyPausedForClock for the UI so the pause is visible. Adds ClockPolicy.phoneClockSuspect unit coverage (agree / future-skew / behind / unset boundaries).
|
Persistent review updated to latest commit 90f9588 |
|
@CodeRabbit review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/db_p0_fixes_test.dart (1)
396-432: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winAdd a shrinking-beat-count case to this import fixture.
The foreign export supplies three beats for
collideTsand the local row also has three, so everybeat_indexis replaced and the assertion on line 414 passes.The import path merges
decoded_rrwithINSERT OR REPLACEper row and performs no delete for the second, unlike_queueDecodedOneHz. A foreign export with fewer beats for a colliding second would leave the local high-index beats in place.See the consolidated comment on
lib/data/db.dartfor the root cause.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/db_p0_fixes_test.dart` around lines 396 - 432, Extend the import fixture around the collideTs case to cover a foreign export with fewer beats than the local second, while retaining the existing collision assertions. Assert that the imported beat set exactly matches the foreign beats and that no higher-index local beats remain, then keep the orphan and timestamp consistency checks intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1602-1622: Gate every history-start path on a session-bound,
completed GET_CLOCK response rather than the fixed delay and cached
_phoneClockSuspect flag: update _startHistoricalRefresh and the initial
connection flow around setClock/sendInit to await and apply the response before
any SET_CLOCK or historical-data trigger. Ensure delayed or missing responses
cannot proceed, preserve the defer behavior for a suspect phone clock, and add
regressions covering responses arriving after 120 ms and the first-connection
path.
In `@lib/compute/derivation_engine.dart`:
- Around line 1839-1849: Update the algorithm version constant kAlgoVersion from
62 to the next version, and add a changelog entry documenting the decoded RR
lookup change in the derivation engine so finalized days are recalculated with
RR data.
In `@lib/data/db.dart`:
- Around line 4041-4050: The import path in lib/data/db.dart lines 4041-4050
must replace each collided decoded_rr beat set rather than patching it: queue a
DELETE for every rec_ts represented by the page before its inserts, using the
same batch and transaction, and revise the comment to reflect that guard. Extend
test/db_p0_fixes_test.dart lines 396-432 so collideTs has fewer foreign beats
than local beats and assert only the foreign beat set remains.
- Around line 4041-4050: Update the decoded_rr legacy rec_ts derivation to
validate rr_ts_ms with the existing numeric-conversion approach used by
_PrepareAccumulator._num before converting it; only derive row['rec_ts'] for
values that are safely numeric, and avoid throwing for non-numeric strings
during the transaction.
- Around line 2537-2554: Update _queueDecodedOneHz to resolve recTs through the
existing _recTsFor fallback instead of using raw.recTs ?? decoded.tsEpoch, so an
explicit raw.recTs value of 0 falls back to decoded.tsEpoch before insertion
into decoded_onehz. Preserve nonzero stored timestamps unchanged.
In `@pubspec.yaml`:
- Around line 263-266: Update the sqflite_common dependency declaration used by
the ACK commit sync test to pin an exact version whose experimental
SqfliteDatabaseFactoryLogger constructor has been tested, or replace that
constructor usage with a stable logging mechanism. Keep the existing logger
symbols and test behavior otherwise unchanged.
In `@test/db_storage_hygiene_test.dart`:
- Around line 42-65: Update the test `rec_ts-range reads on decoded_rr are
served by the PK auto-index` to remove the assertion for the internal
`sqlite_autoindex_decoded_rr_1` name. Assert that the uppercased query-plan
detail contains `SEARCH`, does not contain `USE TEMP B-TREE`, and does not match
`SCAN TABLE DECODED_RR`, while preserving the existing planner-fallback
diagnostics.
---
Outside diff comments:
In `@test/db_p0_fixes_test.dart`:
- Around line 396-432: Extend the import fixture around the collideTs case to
cover a foreign export with fewer beats than the local second, while retaining
the existing collision assertions. Assert that the imported beat set exactly
matches the foreign beats and that no higher-index local beats remain, then keep
the orphan and timestamp consistency checks intact.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 8e60a82a-5d6e-44d5-99e5-580abc10e1b7
⛔ Files ignored due to path filters (1)
pubspec.lockis excluded by!**/*.lock
📒 Files selected for processing (16)
lib/ble/ble_engine.dartlib/compute/derivation_engine.dartlib/compute/derive_prepare.dartlib/data/db.dartlib/sync/sync_policy.dartpubspec.yamltest/ack_commit_sync_full_test.darttest/ble_engine_test.darttest/db_integrity_test.darttest/db_migration_ladder_test.darttest/db_p0_fixes_test.darttest/db_paged_import_export_test.darttest/db_storage_hygiene_test.darttest/local_persistence_test.darttest/raw_archive_test.darttest/sync_policy_test.dart
init seq4 is send_historical so every fresh connect drained + trimmed under the bad clock anyway, and the unconditional set_clock before it clobbered the strap rtc and made the gate always see agreeing clocks. read first, skip both if suspect.
PR Reviewer Guide 🔍(Review updated until commit a1a2097)Here are some key observations to aid the review process:
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
lib/ble/ble_engine.dart (2)
1639-1647: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDo not consume the backfill floor for a deferred refresh.
_triggerBackfillsets_lastBackfillAtbefore this method runs. This return path sends no historical request, but it leaves that timestamp set and makes_triggerBackfillreturntrue. A corrected phone clock can then remain blocked by the backfill floor.Make
_startHistoricalRefreshreport whether it sentSEND_HISTORICAL_DATA. Update_lastBackfillAtonly after that result is true. Add a regression for a deferred refresh followed by an immediate successful retry.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/ble/ble_engine.dart` around lines 1639 - 1647, Update _startHistoricalRefresh to return whether SEND_HISTORICAL_DATA was actually sent, returning false for the _phoneClockSuspect deferred path and true after dispatch. In _triggerBackfill, assign _lastBackfillAt only when _startHistoricalRefresh returns true, and add a regression covering a deferred refresh followed immediately by a successful retry.
2293-2307: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick winDo not correct the strap clock when the phone clock is suspect.
When Line 2300 sets
_phoneClockSuspectto true,ClockPolicy.shouldSetClock(dev, wall)is also true for the same drift. Line 2340 then callssetClock()and writes the bad phone time to a plausible strap RTC. Its readback can clear the flag before INIT starts history draining.Guard the automatic correction with
!_phoneClockSuspect. Add a connection regression that verifies a plausible strap clock more than one day ahead sends neitherSET_CLOCKnorSEND_HISTORICAL_DATA.Proposed fix
- if (ClockPolicy.shouldSetClock(dev, wall)) { + if (!_phoneClockSuspect && ClockPolicy.shouldSetClock(dev, wall)) {As per coding guidelines, “When adding or changing a capability, cover every call path.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/ble/ble_engine.dart` around lines 2293 - 2307, Guard the automatic strap-clock correction in the connection flow with !_phoneClockSuspect so a plausible strap RTC more than one day ahead of the phone is not overwritten; keep normal correction behavior when the phone clock is trusted. Add a connection regression covering this drift case and assert that neither SET_CLOCK nor SEND_HISTORICAL_DATA is sent, exercising the _phoneClockSuspect, ClockPolicy.shouldSetClock, and history-start paths.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1639-1647: Update _startHistoricalRefresh to return whether
SEND_HISTORICAL_DATA was actually sent, returning false for the
_phoneClockSuspect deferred path and true after dispatch. In _triggerBackfill,
assign _lastBackfillAt only when _startHistoricalRefresh returns true, and add a
regression covering a deferred refresh followed immediately by a successful
retry.
- Around line 2293-2307: Guard the automatic strap-clock correction in the
connection flow with !_phoneClockSuspect so a plausible strap RTC more than one
day ahead of the phone is not overwritten; keep normal correction behavior when
the phone clock is trusted. Add a connection regression covering this drift case
and assert that neither SET_CLOCK nor SEND_HISTORICAL_DATA is sent, exercising
the _phoneClockSuspect, ClockPolicy.shouldSetClock, and history-start paths.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 64d2fd1a-2f6d-41dd-bd9d-37e4b144d3f1
📒 Files selected for processing (2)
lib/ble/ble_engine.darttest/ble_engine_test.dart
a slow phone fixes itself over ntp in minutes, so if we're still disagreeing 12h later its the strap rtc thats off. stop deferring at that point and let the normal set_clock fix it.
|
Persistent review updated to latest commit 8ad8b8a |
- run the strap-clock correction on the raw read instead of nesting it inside acceptsClockRead. both gates key off kFutureMargin, so the one reading that means "the strap clock is ahead" could never reach the only path that fixes it: history un-deferred at grace expiry straight back onto an uncorrected fast rtc, where the record gate drops every future-stamped record and the offload banks nothing. the read is still refused as an alarm correlation, just not as a correction — set_clock writes real wall time either way and the retry budget is bounded at 3. - a failed SEND_HISTORICAL_DATA write returns false and clears _offloadActive now, instead of spending both floors and wedging the already-transmitting guard on a command that never left the phone.
|
Persistent review updated to latest commit c5745ae |
|
@coderabbitai review |
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
lib/ble/ble_engine.dart (2)
3315-3329: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftDo not accept an unrelated GET_CLOCK response for the awaited gate.
Line 3316 stores one unqualified completer. Lines 2370-2373 complete it for every
clock_epochresponse.setClock()also issues an unawaitedgetClock(), and the keep-alive path issues anothergetClock(). A delayed response to either request can satisfy this waiter before the request from_readClockresponds.A stale non-suspect response can therefore authorize
SEND_HISTORICAL_DATAbefore the current read identifies a slow phone clock. Serialize all GET_CLOCK requests through one response-aware path, or otherwise prevent prior outstanding responses from completing the history gate.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/ble/ble_engine.dart` around lines 3315 - 3329, The _readClock method currently accepts any clock_epoch response through the shared _clockReadPending completer, allowing responses from setClock or keep-alive GET_CLOCK requests to satisfy the history gate. Serialize all GET_CLOCK requests through a single response-aware mechanism, or associate responses with the originating request, and update _readClock plus the GET_CLOCK callers and response handler so only its own response can complete the waiter.
1493-1497: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winClear initialization offload state when INIT does not start history.
Line 1493 marks the offload active and Line 1496 spends the backfill floor before
sendInitverifies its writes.sendInitignores each_writeresult. If the final INIT packet fails, the engine returns from connection setup with_offloadActiveset. Later refreshes then stop at the active-offload guard, although the strap never received the history command.Make
sendInitreport write success. If INIT does not start history, clear_offloadActiveand restore the backfill floor.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/ble/ble_engine.dart` around lines 1493 - 1497, Update sendInit to report whether all INIT writes succeeded, and use that result in the connection setup flow around _setOffloadActive and _lastBackfillAt. When INIT does not successfully start history, clear _offloadActive and restore the previously consumed backfill floor so later refreshes can retry; preserve the existing state for successful history-starting INITs.test/ble_clock_gate_test.dart (1)
21-31: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftExercise the connection and refresh clock gates.
These helpers inject
Decodedvalues throughdebugAbsorbDecoded. The tests do not execute_readClock,_doConnect, or_startHistoricalRefresh. A delayed GET_CLOCK response, an unrelated pending GET_CLOCK response, or an INIT state transition failure can therefore pass this suite.Add transport-level regressions for the INIT and refresh paths. Verify that each path waits for the intended clock verdict before it sends historical data. As per coding guidelines, “When adding or changing a capability, cover every call path” and “Behavior changes, especially regressions involving ... synchronization ... must include regression tests.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/ble_clock_gate_test.dart` around lines 21 - 31, Extend the tests around newEngine, clockReply, and wallNow with transport-level scenarios that drive real INIT and historical refresh flows through the BleEngine connection path. Cover delayed GET_CLOCK replies, unrelated pending GET_CLOCK responses, and INIT state-transition failures, asserting that _doConnect and _startHistoricalRefresh wait for the matching clock verdict before sending historical data; avoid relying solely on debugAbsorbDecoded.Source: Coding guidelines
lib/data/db.dart (1)
1271-1275: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winPropagate
PRAGMA synchronous=FULLfailures before ACKWhen the FULL upgrade fails,
commitSyncBatchcontinues atNORMAL. A successful transaction then reports success, so the HISTORY_END handler can ACK data that a power loss may remove.Propagate the error while restoring
NORMALinfinally. Add a fault-injection test that asserts commit failure and ACK refusal.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/data/db.dart` around lines 1271 - 1275, Update commitSyncBatch so failures from PRAGMA synchronous=FULL are retained and propagated after attempting to restore NORMAL in a finally block, preventing a successful commit result and ACK. Add fault-injection coverage verifying the commit fails and the HISTORY_END handler refuses the ACK.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/data/db.dart`:
- Around line 4059-4063: In lib/data/db.dart lines 4059-4063, update the
decoded_rr normalization logic to skip the row with continue when rrTsMs is not
numeric before adding it to rows; retain the existing timestamp conversion for
numeric values. In test/db_p0_fixes_test.dart lines 405-437, add a legacy
decoded_rr fixture with a string rr_ts_ms and assert that valid rows still
import successfully.
---
Outside diff comments:
In `@lib/ble/ble_engine.dart`:
- Around line 3315-3329: The _readClock method currently accepts any clock_epoch
response through the shared _clockReadPending completer, allowing responses from
setClock or keep-alive GET_CLOCK requests to satisfy the history gate. Serialize
all GET_CLOCK requests through a single response-aware mechanism, or associate
responses with the originating request, and update _readClock plus the GET_CLOCK
callers and response handler so only its own response can complete the waiter.
- Around line 1493-1497: Update sendInit to report whether all INIT writes
succeeded, and use that result in the connection setup flow around
_setOffloadActive and _lastBackfillAt. When INIT does not successfully start
history, clear _offloadActive and restore the previously consumed backfill floor
so later refreshes can retry; preserve the existing state for successful
history-starting INITs.
In `@lib/data/db.dart`:
- Around line 1271-1275: Update commitSyncBatch so failures from PRAGMA
synchronous=FULL are retained and propagated after attempting to restore NORMAL
in a finally block, preventing a successful commit result and ACK. Add
fault-injection coverage verifying the commit fails and the HISTORY_END handler
refuses the ACK.
In `@test/ble_clock_gate_test.dart`:
- Around line 21-31: Extend the tests around newEngine, clockReply, and wallNow
with transport-level scenarios that drive real INIT and historical refresh flows
through the BleEngine connection path. Cover delayed GET_CLOCK replies,
unrelated pending GET_CLOCK responses, and INIT state-transition failures,
asserting that _doConnect and _startHistoricalRefresh wait for the matching
clock verdict before sending historical data; avoid relying solely on
debugAbsorbDecoded.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 7d27851b-20c8-4546-a9f5-aebb2baef067
📒 Files selected for processing (7)
lib/ble/ble_engine.dartlib/compute/derivation_engine.dartlib/data/db.dartpubspec.yamltest/ble_clock_gate_test.darttest/db_p0_fixes_test.darttest/db_storage_hygiene_test.dart
- _readClock now waits for ITS OWN reply. GET_CLOCK goes out from connect, from setClock's read-back and from the keep-alive, and the waiter was unqualified, so any of them could release it and hand the history gate a verdict from a request this read never made. protocol #28 surfaces the echoed request seq (inner[3]) and we match on it. the echo isn't confirmed against a capture, so it's a fast path only: an uncorrelated reply still counts after a 400ms grace, which keeps firmware that doesn't echo working the way it did instead of stalling every read for the full 3s. - sendInit reports whether every packet was written. seq4 IS the drain trigger, so a failed init meant no history was requested while offload state stayed armed and every later refresh bounced off the already-transmitting guard. connect hands the state and the floor back now. - import: drop a decoded_rr row whose rr_ts_ms isn't numeric instead of letting it reach the insert and fail NOT NULL, which aborted the whole restore. - synchronous=FULL is still best-effort — failing the commit there means never acking, never trimming, and an eternal re-flood on any platform that refuses the pragma. but it reads back now and logs + counts a downgrade, so it can't be silently wrong for the life of an install. - real transport-level tests for the clock gate: a reply 500ms late, an unrelated request's reply, firmware that never echoes, and a dropped SEND_HISTORICAL_DATA write. these drive _readClock and _startHistoricalRefresh over a stubbed gatt write rather than poking the response handler, which is where the ordering bugs actually live.
|
round 4 up. notes on the two i handled differently to the suggestion: synchronous=FULL — kept it non-fatal. failing the commit when the pragma is refused means never committing, so never acking, so never trimming: the strap re-floods the same backlog forever and sync is dead on any platform that rejects it. NORMAL under WAL still commits atomically — FULL narrows the power-loss window, it is not itself the safe-trim invariant. what was actually wrong is that it failed silently, so it reads the pragma back now and logs + counts a downgrade. the seq correlation — protocol#28 surfaces the echoed request seq so _readClock can tell its own reply from setClock's read-back and the keep-alive. worth flagging: that the strap echoes the seq the phone sent is a layout this codebase has always assumed and i could not find a hardware capture proving it. so it is a fast path, never a requirement — an uncorrelated reply still counts after a 400ms grace. on firmware that echoes the gate is exact; on firmware that does not it costs a blip and behaves as it did before, rather than stalling every read for the full 3s timeout and quietly turning the gate off. needs confirming on real hardware either way. also added the transport-level tests you asked for — they drive _readClock and _startHistoricalRefresh over a stubbed gatt write, covering a reply 500ms late, an unrelated request's reply, firmware that never echoes, and a dropped SEND_HISTORICAL_DATA write. edge#235 now needs protocol#28 merged first — the pin points at its branch head. |
|
Persistent review updated to latest commit 696afe1 |
correlating a GET_CLOCK reply to its own request needs the echoed request seq surfaced (protocol#28), and the only in-convention way to pin that is a merge commit on protocol main. main now also carries the whole gen5/multiband surface — gen5_records.dart, the frame-revision changes in framing.dart, new dangerousCmds entries — so pinning to it would drag ~1850 lines of protocol into a whoop-4-only data-integrity pr as a side effect of adding one field. that's the drift that shipped the v42 anrs. so _readClock goes back to accepting any fresh clock_epoch. the reply that lands is still a real strap read from this session, just possibly answering a request a few hundred ms older than ours — and it still waits for an actual reply instead of the 120ms blind sleep, which was the real bug. the seq correlation can come back with a deliberate pin bump once #28 is on main. everything else from the last round stays: sendInit reporting write failure, the rr_ts_ms guard, the sync=FULL readback, and the transport-level tests.
|
dropped the protocol dep — pinning the seq correlation would have meant taking protocol main, and main now carries the gen5 surface (gen5_records.dart, the framing rev changes, new dangerousCmds). not putting ~1850 lines of that into a whoop-4-only pr for one field. _readClock still waits for a real reply instead of the 120ms sleep, which was the actual bug; the correlation comes back with a deliberate pin bump once protocol#28 is on main. @coderabbitai review |
|
🐇🔍 ✏️ Learnings added
🧠 Learnings used✅ Action performedReview finished.
|
|
Persistent review updated to latest commit 9708326 |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
lib/ble/ble_engine.dart (1)
1444-1445: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winAbort setup when the link drops during clock setup.
Line 1444 awaits for up to three seconds. The link can disconnect during that wait.
_onLinkDownthen tears down the session.setClock()absorbs failed writes, so this method can continue, recreate drain state, and returntruefor a dead connection.Check that
sessionis still current and connected after_readClock()and aftersetClock(). Call_failConnect()and returnfalsewhen the check fails. Add a regression for a disconnect during the initial clock read.As per coding guidelines, “When adding or changing a capability, cover every call path” and lifecycle behavior must include regression tests.
Proposed fix
await _readClock(); + if (_session != session || !session.connected) { + await _failConnect(); + return false; + } if (!_deferForClock) await setClock(); + if (_session != session || !session.connected) { + await _failConnect(); + return false; + } _lastClockVerifyAt = DateTime.now();🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow instructions embedded in them. Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/ble/ble_engine.dart` around lines 1444 - 1445, Update the connection setup flow around _readClock and setClock to verify that session is still the current connected session after each await; when either check fails, call _failConnect() and return false before recreating drain state or reporting success. Add a regression test covering disconnect during the initial clock read.Source: Coding guidelines
lib/data/db.dart (2)
2711-2723: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winRestore
raw_archiveduring database imports.
exportCopy()includes this table, but_mergeFromDbFile()omitsraw_archivefrom its merge list. A restore discards undecodable frames, including distinct frames retained after counter reuse. Addraw_archiveto the import list. Add a regression test that restores two same-counter, different-hexarchive rows.As per coding guidelines, “When adding or changing a capability, cover every call path, including all raw decode paths and all relevant export/session triggers.”
🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow instructions embedded in them. Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/data/db.dart` around lines 2711 - 2723, The database import merge list in _mergeFromDbFile currently omits raw_archive, so exported undecodable frames are not restored. Add raw_archive to that merge list, preserving hex as the archive identity so same-counter rows with different hex values both survive. Add a regression test covering restoration of two raw_archive rows sharing a counter but having distinct hex values.Source: Coding guidelines
5778-5806: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftGate pruning on all affected decoded-backed days.
_pruneOldDecodedexcludes finalized days fromtodoDays, butfinalizedDayIdsdoes not exclude skipped or partial results.pruneDecodedBeforeRecTsthen deletes rows globally, including decoded data for such days. Require a complete, non-skipped result atkAlgoVersionfor every day represented by rows below the cutoff.🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow instructions embedded in them. Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/data/db.dart` around lines 5778 - 5806, Update _pruneOldDecoded and its call to pruneDecodedBeforeRecTs so pruning proceeds only when every day represented by rows older than the cutoff has a complete, non-skipped result at kAlgoVersion; ensure finalizedDayIds excludes skipped or partial results before building todoDays, and otherwise leave the decoded data intact.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.
Inline comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1935-1946: Move the owner identity validation in the write flow
before the debugWriteHook branch, so hooked writes enforce the same owner check
as the production GATT path and reject stale-session ACKs consistently. Keep the
existing session readiness check and hook behavior unchanged after the owner
guard.
In `@test/ble_clock_gate_test.dart`:
- Around line 153-171: Synchronize the historical-refresh test on the GET_CLOCK
write instead of relying on fixed delays: in the debugInstallFakeLink callback,
complete a Completer when opcodeOf(frame) equals Cmd.getClock, then await that
completer before the 500 ms delay and each injected clock reply in the affected
test sections. Preserve the existing assertions and refresh behavior while
ensuring the waiter is established before replies are injected.
---
Outside diff comments:
In `@lib/ble/ble_engine.dart`:
- Around line 1444-1445: Update the connection setup flow around _readClock and
setClock to verify that session is still the current connected session after
each await; when either check fails, call _failConnect() and return false before
recreating drain state or reporting success. Add a regression test covering
disconnect during the initial clock read.
In `@lib/data/db.dart`:
- Around line 2711-2723: The database import merge list in _mergeFromDbFile
currently omits raw_archive, so exported undecodable frames are not restored.
Add raw_archive to that merge list, preserving hex as the archive identity so
same-counter rows with different hex values both survive. Add a regression test
covering restoration of two raw_archive rows sharing a counter but having
distinct hex values.
- Around line 5778-5806: Update _pruneOldDecoded and its call to
pruneDecodedBeforeRecTs so pruning proceeds only when every day represented by
rows older than the cutoff has a complete, non-skipped result at kAlgoVersion;
ensure finalizedDayIds excludes skipped or partial results before building
todoDays, and otherwise leave the decoded data intact.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 87cc5ce3-e9bf-482d-a4fc-a39c010f8ce3
📒 Files selected for processing (7)
lib/ble/ble_engine.dartlib/compute/derivation_engine.dartlib/data/db.dartpubspec.yamltest/ble_clock_gate_test.darttest/db_p0_fixes_test.darttest/db_storage_hygiene_test.dart
- the test write seam skipped the readiness and owner checks it was standing in for, so a hooked write would have accepted a stale-session ACK the real one rejects. guards moved above the hook. - abort connect setup if the link drops during the clock read. _readClock waits up to 3s where this used to sleep 120ms, and setClock swallows failed writes, so setup could sail past a teardown, rebuild the drain state and return true for a dead link. - raw_archive was missing from the import merge list. exportCopy is a whole-db VACUUM INTO so those rows leave the device, and the restore dropped them — in the one table whose entire purpose is that a frame is never lost. keyed by hex, so two same-counter frames from different boots both come back. - transport tests synchronise on the GET_CLOCK write instead of a fixed sleep; the waiter has to exist before a reply is injected and a loaded box made that a coin flip.
_failConnect tears down whatever _session points at and releases the band claim, so if a newer _doConnect already took over, the stale invocation's bail-out would kill ITS link instead of its own.
|
round 5 fixed. two notes:
@coderabbitai review |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '=== Changed files ==='
git diff --name-status origin/main...HEAD
printf '%s\n' '=== BLE symbols ==='
ast-grep outline lib/ble/ble_engine.dart --items all --type function,method --match '_doConnect|_failConnect|_readClock|_startHistoricalRefresh|sendInit|_on.*Clock|burstPacketShortfall' --view expanded || true
printf '%s\n' '=== Database symbols ==='
ast-grep outline lib/data/db.dart --items all --type function,method --match 'commitSyncBatch|decodedRrByRecTsRange|insert.*Decoded|import|prune' --view expanded || true
printf '%s\n' '=== Relevant diff hunks ==='
git diff --unified=40 origin/main...HEAD -- lib/ble/ble_engine.dart lib/data/db.dart lib/compute/derivation_engine.dart lib/compute/derive_prepare.dart lib/sync/sync_policy.dart
printf '%s\n' '=== New and changed tests ==='
git diff --unified=25 origin/main...HEAD -- test/ble_clock_gate_test.dart test/ble_engine_test.dart test/ack_commit_sync_full_test.dart test/db_integrity_test.dart test/db_p0_fixes_test.dartLength of output: 239438 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '=== Numbered decoded-table import path ==='
nl -ba lib/data/db.dart | sed -n '3880,4055p'
printf '%s\n' '=== Numbered migration fixtures and decoded-import tests ==='
nl -ba test/db_p0_fixes_test.dart | sed -n '300,470p'
nl -ba test/db_migration_ladder_test.dart | sed -n '1,280p'
printf '%s\n' '=== All decoded_rr import/reset statements ==='
rg -n -C 4 "highestBeat|decoded_rr.*beat_index|DELETE FROM decoded_rr|tables = \[" lib/data/db.dartLength of output: 880 🏁 Script executed: #!/bin/bash
set -euo pipefail
numbered_range() {
local file="$1"
local start="$2"
local end="$3"
awk -v start="$start" -v end="$end" 'NR >= start && NR <= end { printf "%6d\t%s\n", NR, $0 }' "$file"
}
printf '%s\n' '=== Numbered decoded-table import path ==='
numbered_range lib/data/db.dart 3880 4055
printf '%s\n' '=== Numbered decoded-import regression test ==='
numbered_range test/db_p0_fixes_test.dart 300 470
printf '%s\n' '=== Numbered migration ladder coverage ==='
numbered_range test/db_migration_ladder_test.dart 1 280
printf '%s\n' '=== Decoded RR import/reset references ==='
rg -n -C 4 "highestBeat|decoded_rr.*beat_index|DELETE FROM decoded_rr|tables = \[" lib/data/db.dartLength of output: 36191
I found one restore-path issue:
✏️ Learnings added
🧠 Learnings usedYou are interacting with an AI system. |
|
Persistent review updated to latest commit a54033f |
|
Persistent review updated to latest commit a1a2097 |
bunch of data loss fixes on the offload path, all in one branch.
schema goes to 33.
Summary by CodeRabbit
Bug Fixes
Reliability