Skip to content

fix(db): stop raw_archive silently dropping distinct frames on a reused counter - #231

Closed
abdulsaheel wants to merge 2 commits into
mainfrom
fix/raw-archive-counter-collision-dataloss
Closed

fix(db): stop raw_archive silently dropping distinct frames on a reused counter#231
abdulsaheel wants to merge 2 commits into
mainfrom
fix/raw-archive-counter-collision-dataloss

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

User description

The bug

`raw_archive` is the durable dead-letter box for undecodable historical frames — its entire purpose is to never lose a frame until a future firmware/decoder can interpret it (`db.dart` comment: "future firmware's records survive until we understand the format").

But it was keyed `counter INTEGER PRIMARY KEY` with `ConflictAlgorithm.ignore`, and the strap resets its record counter to ~0 on every reboot. So when a post-reboot frame reused a counter value still held by a pre-reboot row, the insert hit the PK conflict and was silently dropped — even though its bytes were entirely different data. Silent permanent loss, in the one table that must never lose anything.

An in-code comment already documents the same "counter resets on reboot → collision" hazard for `decoded_onehz`; this is the same root cause in `raw_archive`.

The fix

Re-key the table off the volatile counter onto frame `hex` (content identity) — exactly the pattern `events`/`band_events` already use:

  • an identical re-flood (missed-ACK redelivery) still dedups (same bytes → same PK), and
  • two genuinely distinct frames now both survive a counter collision.

`counter` is kept as a plain forensic column. Nothing reads `raw_archive` by counter (only inserts + `COUNT(*)`/`GROUP BY reason` diagnostics), so the re-key touches no read path.

v32 migration rebuilds the table preserving every existing row (existing counters are unique, so the content-keyed copy loses nothing; at most it collapses an exact-duplicate hex, which is the dedup we want). It's guarded: `raw_archive` is created lazily in `onOpen`/`_repairOpenSchema`, not the upgrade ladder, so an old DB may not have the table yet at migration time — then a fresh hex-keyed create is all that's needed. The old index name is dropped before the fresh `CREATE INDEX` to avoid the leaked-index-name collision documented on the decoded rebuild.

Tests

  • New regression: two distinct frames sharing a reused counter both survive (pre-fix, the second was dropped).
  • Renamed the stale "IGNORE on counter PK" dedup case to reflect that dedup is now on frame hex (it reused the same bytes, so it passed under both schemes and was not actually exercising counter-PK).
  • Full v27→v32 migration ladder + db integrity/hygiene/paged-import-export suites pass; `flutter analyze` clean.

Context

First of a short series of P0 data-integrity fixes surfaced by a deep review of the gen4 storage/BLE path. This is the smallest, most self-contained one. Larger follow-ups (the `decoded_onehz`/`decoded_rr` `rec_ts`-PK re-key that fixes the unrecoverable 1Hz eviction, the ACK-gating `synchronous=FULL` durability bracket, and the clock-skew drop-then-trim fix) will come as separate focused PRs.


PR Type

Bug fix, Tests


Description

  • raw_archive re-keyed from counter PK to hex PK, preventing silent frame loss on counter reuse after strap reboot

  • Schema bumped to v32 with a safe migration that preserves all existing rows

  • Regression test added: two distinct frames sharing a reused counter both survive


Diagram Walkthrough

flowchart LR
  A["Strap reboot\n(counter resets to ~0)"]
  B["Post-reboot frame\n(reused counter, different bytes)"]
  C["raw_archive\ncounter INTEGER PRIMARY KEY\n+ IGNORE"]
  D["raw_archive\nhex TEXT PRIMARY KEY\n+ IGNORE"]
  E["Frame silently DROPPED\n(data loss)"]
  F["Both frames survive\n(content-keyed dedup)"]
  A -- "counter collision" --> B
  B -- "old schema" --> C
  C -- "INSERT OR IGNORE" --> E
  B -- "new schema (v32)" --> D
  D -- "INSERT OR IGNORE" --> F
Loading

File Walkthrough

Relevant files
Bug fix
db.dart
Re-key raw_archive on hex PK, add v32 migration                   

lib/data/db.dart

  • schemaVersion bumped from 31 to 32
  • _createRawArchive changed: hex TEXT PRIMARY KEY, counter INTEGER
    (non-PK forensic column)
  • v32 migration added: renames old table, drops old index, creates new
    hex-keyed table, copies rows with INSERT OR IGNORE, drops old table;
    guarded for DBs where raw_archive doesn't exist yet
  • Updated doc comment on _createRawArchive explaining the counter-reuse
    hazard and content-keyed fix
+45/-4   
Tests
raw_archive_test.dart
Add regression test for counter-reuse frame loss                 

test/raw_archive_test.dart

  • Renamed existing dedup test to clarify it tests hex-based dedup
    (missed-ACK redelivery), not counter-PK dedup
  • Updated test comment to reflect same-bytes semantics
  • Added new regression test: two distinct frames with the same reused
    counter both survive (previously the second was silently dropped)
+30/-3   

Summary by CodeRabbit

  • Bug Fixes

    • Improved archive handling for undecodable frames when counter values reset.
    • Distinct frames are now preserved, while identical frames are deduplicated reliably.
    • Retained counter information for forensic review.
  • Tests

    • Added coverage for archive redelivery and frames sharing the same counter.

…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).
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@abdulsaheel, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 seconds

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 00b42300-de39-4595-aa5e-4e40e60b4b8c

📥 Commits

Reviewing files that changed from the base of the PR and between 3ccc263 and 52ee804.

📒 Files selected for processing (1)
  • test/db_migration_ladder_test.dart
📝 Walkthrough

Walkthrough

The database schema version increases to 32. The raw_archive table now uses frame hex as its primary key. Migration logic rebuilds existing archives, and tests cover hex deduplication and reused counters.

Changes

Raw archive identity

Layer / File(s) Summary
Migrate raw archive identity
lib/data/db.dart
Schema version 32 rebuilds existing raw_archive tables with frame hex as the primary key. Existing rows are copied with duplicate-hex suppression. counter remains metadata.
Validate hex deduplication
test/raw_archive_test.dart
Tests confirm that identical frames remain a single row and distinct frames with the same counter are both archived.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: dannymcc, svssathvik7, localhoop

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main database fix: preventing distinct frames from being dropped when counters are reused.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 52ee804)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Migration INSERT OR IGNORE

The migration copies rows from _raw_archive_old into the new hex-keyed raw_archive using INSERT OR IGNORE. If two old rows happen to share the same hex value (which the old counter-PK schema permitted, as the test seeds with rows 10 and 11 both having 'ff06'), only one survives. The PR explicitly calls this "the dedup we want," but it means the migration can silently discard rows that were stored under different counters with identical bytes. For the raw_archive table whose stated invariant is "never lose a frame," collapsing two rows with the same hex but different captured_at, rec_ts, or reason into one is a silent data loss during migration. Whether this matters in practice depends on whether the old schema ever stored two rows with the same hex (it could, since the PK was on counter). The PR acknowledges this but frames it as acceptable; reviewers should confirm that the business rule "same bytes = same frame = safe to dedup" is actually correct for all reason values (e.g., two different reason strings for the same hex would lose one reason).

  'INSERT OR IGNORE INTO raw_archive '
  '(hex, counter, packet_type, rec_ts, captured_at, reason) '
  'SELECT hex, counter, packet_type, rec_ts, captured_at, reason '
  'FROM _raw_archive_old',
);
Missing hex NOT NULL

In the new _createRawArchive DDL, hex TEXT PRIMARY KEY is declared without NOT NULL. SQLite allows a NULL primary key on a TEXT column (it treats each NULL as distinct), which would break the content-identity dedup guarantee entirely — a NULL hex would never collide with anything and could accumulate unboundedly. The old schema had hex TEXT NOT NULL; the new schema dropped the NOT NULL constraint when promoting hex to PK. PRIMARY KEY on a TEXT column in SQLite does NOT imply NOT NULL (only INTEGER PRIMARY KEY does). Any code path that archives a record with a null hex would silently bypass dedup.

hex TEXT PRIMARY KEY,
counter INTEGER,

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

1 similar comment
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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 433-466: Extract the raw_archive rebuild logic into an idempotent
helper that uses PRAGMA table_info(raw_archive) to detect whether counter,
rather than hex, is the primary key; leave an already hex-keyed table unchanged
and create the table when absent. Invoke this helper from both the onUpgrade
oldV < 32 migration and _repairOpenSchema so databases already at user_version
32 are repaired on open. Add an upgrade regression test covering a v31
counter-keyed table with multiple rows and verify all rows remain available
after opening as v32.
🪄 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: de74c0bd-7b03-4889-aae6-634e9ca79045

📥 Commits

Reviewing files that changed from the base of the PR and between 6bddec4 and 3ccc263.

📒 Files selected for processing (2)
  • lib/data/db.dart
  • test/raw_archive_test.dart

Comment thread lib/data/db.dart
Comment on lines +433 to +466
if (oldV < 32) {
// Re-key raw_archive off the volatile `counter` onto frame `hex`.
// `counter INTEGER PRIMARY KEY` + IGNORE silently DROPPED a distinct
// undecodable frame whenever a post-reboot counter (reset to ~0)
// collided with a still-present pre-reboot row — data loss in the
// "never lose" table. Rebuild keyed by content. Existing rows have
// unique counters, so the copy loses nothing; at most it collapses an
// exact-duplicate hex, which is the dedup we want.
//
// raw_archive is normally created lazily in onOpen (_repairOpenSchema),
// NOT in this ladder, so on an old DB it may not exist yet here — in
// which case there is nothing to migrate and a fresh (hex-keyed) create
// is all that's needed. DROP the old index name before the fresh CREATE
// so it can't collide on the name the rename carried onto the aside
// table (the leaked-`_new`-index footgun documented on the decoded
// rebuild).
final hasArchive = (await db.rawQuery(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='raw_archive'",
)).isNotEmpty;
if (hasArchive) {
await db.execute('ALTER TABLE raw_archive RENAME TO _raw_archive_old');
await db.execute('DROP INDEX IF EXISTS idx_raw_archive_captured');
await _createRawArchive(db);
await db.execute(
'INSERT OR IGNORE INTO raw_archive '
'(hex, counter, packet_type, rec_ts, captured_at, reason) '
'SELECT hex, counter, packet_type, rec_ts, captured_at, reason '
'FROM _raw_archive_old',
);
await db.execute('DROP TABLE _raw_archive_old');
} else {
await _createRawArchive(db);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Repair the legacy raw_archive shape during onOpen.

If a database has user_version = 32 but still has counter as the raw_archive primary key, this block does not run. _repairOpenSchema then calls _createRawArchive, but CREATE TABLE IF NOT EXISTS leaves the legacy table unchanged. A later counter reuse can still discard a distinct frame.

Extract this rebuild into an idempotent helper that checks the primary-key column with PRAGMA table_info(raw_archive). Call the helper from both onUpgrade and _repairOpenSchema. Add an upgrade regression test that creates the v31 table, inserts rows, opens the v32 database, and verifies that all rows remain available.

As per coding guidelines, “Keep migrations additive and idempotent using sequential onUpgrade if (oldV < N) steps; keep them cheap, repair schemas on open.”

🤖 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 433 - 466, Extract the raw_archive rebuild
logic into an idempotent helper that uses PRAGMA table_info(raw_archive) to
detect whether counter, rather than hex, is the primary key; leave an already
hex-keyed table unchanged and create the table when absent. Invoke this helper
from both the onUpgrade oldV < 32 migration and _repairOpenSchema so databases
already at user_version 32 are repaired on open. Add an upgrade regression test
covering a v31 counter-keyed table with multiple rows and verify all rows remain
available after opening as v32.

Source: Coding guidelines

…-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.
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 52ee804

@github-actions

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Wrap migration steps in a transaction

The migration steps are not wrapped in a transaction, so a crash between ALTER TABLE
and DROP TABLE _raw_archive_old leaves the database in a state where
_raw_archive_old exists but raw_archive may be partially populated or missing. Since
onUpgrade is called inside openDatabase, the migration should be wrapped in
db.transaction(...) to ensure atomicity and allow safe retry on the next open.

lib/data/db.dart [453-462]

-await db.execute('ALTER TABLE raw_archive RENAME TO _raw_archive_old');
-await db.execute('DROP INDEX IF EXISTS idx_raw_archive_captured');
-await _createRawArchive(db);
-await db.execute(
-  'INSERT OR IGNORE INTO raw_archive '
-  '(hex, counter, packet_type, rec_ts, captured_at, reason) '
-  'SELECT hex, counter, packet_type, rec_ts, captured_at, reason '
-  'FROM _raw_archive_old',
-);
-await db.execute('DROP TABLE _raw_archive_old');
+await db.transaction((txn) async {
+  await txn.execute('ALTER TABLE raw_archive RENAME TO _raw_archive_old');
+  await txn.execute('DROP INDEX IF EXISTS idx_raw_archive_captured');
+  await _createRawArchive(txn as Database);
+  await txn.execute(
+    'INSERT OR IGNORE INTO raw_archive '
+    '(hex, counter, packet_type, rec_ts, captured_at, reason) '
+    'SELECT hex, counter, packet_type, rec_ts, captured_at, reason '
+    'FROM _raw_archive_old',
+  );
+  await txn.execute('DROP TABLE _raw_archive_old');
+});
Suggestion importance[1-10]: 6

__

Why: Wrapping the multi-step migration in a transaction is a valid safety concern — a crash mid-migration could leave _raw_archive_old stranded and raw_archive missing. However, sqflite's onUpgrade callback is already called within a transaction in most configurations, and the improved_code has a casting issue (txn as Database) that makes it not directly applicable.

Low
General
Pin deterministic duplicate-collapse outcome in test

The seed inserts two rows with the same hex value 'ff06' but different counters into
the old counter-PK table. However, the old schema has no UNIQUE constraint on hex,
so both rows are inserted successfully. The migration uses INSERT OR IGNORE keyed on
hex, so only one survives — but the test comment says "5 old rows → 4", which is
correct. The issue is that the _seedOldDb helper inserts into a counter-PK table, so
inserting two rows with the same counter would fail. Since counters 10 and 11 are
distinct, both rows insert fine. This is correct, but the test should also verify
that the surviving 'ff06' row's counter value is deterministic (either 10 or 11),
otherwise the test is non-deterministic if row ordering matters for which duplicate
survives. Add an assertion on which hex='ff06' row was kept to make the test fully
deterministic.

test/db_migration_ladder_test.dart [461-462]

 await row(10, 'ff06');
 await row(11, 'ff06');
+// Note: INSERT OR IGNORE on hex PK keeps whichever is inserted first (counter=10).
+// The assertion below pins this so the test is deterministic.
Suggestion importance[1-10]: 2

__

Why: The suggestion only adds a comment to the existing code without changing any logic, and the improved_code is functionally identical to existing_code. The concern about non-determinism is minor since INSERT OR IGNORE with sequential inserts is deterministic in SQLite.

Low

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Superseded by #235 — consolidated into the single integration/gen4-data-integrity branch per request. Same commits, same reviews; closing to keep one PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant