Skip to content

fix(notify): make the fire-once dedupe guard cross-isolate safe#145

Merged
abdulsaheel merged 3 commits into
OpenStrap:mainfrom
dannymcc:fix/fired-keys-cross-isolate
Jul 26, 2026
Merged

fix(notify): make the fire-once dedupe guard cross-isolate safe#145
abdulsaheel merged 3 commits into
OpenStrap:mainfrom
dannymcc:fix/fired-keys-cross-isolate

Conversation

@dannymcc

@dannymcc dannymcc commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Problem

A same-day insight notification — reproduced with "Low readiness today" — re-fires repeatedly and re-alerts every time it's dismissed. The dedupeKey (\$date:readiness) is stable, so the fire-once guard added in #137 (issue #136) is being defeated across isolates, not bypassed by a changing key.

Derivation runs in two isolates, both of which call NotificationCenter.emit()FiredKeyStore:

  1. ForegroundAppState._afterDrain kicks a light derive on every BLE drain, and the foreground service keeps this isolate alive for days while backgrounded.
  2. WorkManager backgroundderivationDispatcher runs engine.run(heavy: true) (→ _runNotifications) every ~15 min, even app-closed.

NotificationCenter's _synchronized lock only orders emits within one isolate. The store defeated its own guard two ways across them:

  • Stale read cache — legacy SharedPreferences loads a snapshot once and never re-reads disk. The long-lived foreground isolate loaded prefs before today's key existed, so after the background isolate recorded it, hasFired() still read the stale snapshot as "not fired" → re-alert.
  • Lost-update clobber — keys lived in one shared list mutated via read-modify-write (getStringList → add → setStringList). With no cross-process lock, whichever isolate wrote last rewrote the whole list from its own stale copy, dropping the other's keys — including a just-recorded one, which then re-fired.

The existing tests are single-isolate against one in-memory mock, so they stayed green while this shipped.

Fix

FiredKeyStore is now cross-isolate safe:

  • reload() before every read so a record from the other isolate is seen.
  • One independent bool flag per key (notif_fired:\$dedupeKey) instead of a shared list. Native SharedPreferences merges single-key writes, so concurrent records of different keys can't clobber each other and a repeat record is idempotent.
  • Bounded growth via _prune() — date-prefixed flags older than a 14-day window are dropped (replacing the old FIFO cap). Undated keys (e.g. alarm_fired:<epoch>) are rare and left alone.

The in-isolate emit lock is unchanged; its comment is corrected to scope it to a single isolate.

Tests

test/notification_dedupe_test.dart is updated to the per-key model and adds coverage for retention pruning and undated-key retention. Behavioural dedupe/gating/concurrency tests are unchanged in intent.

Note: a unit test can't span isolates (one in-process mock store), so this can't be regression-tested end-to-end in the suite — the fix removes the two mechanisms (stale-cache read, shared-list clobber) that the multi-isolate race depended on.

Summary by CodeRabbit

  • Bug Fixes
    • Made notification “fire once” deduplication atomic across isolates to prevent duplicate deliveries.
    • Prevented one notification’s dedupe record from affecting others.
    • Improved dedupe handling so permission denial or presentation failures don’t permanently consume the key.
    • Added automatic cleanup of old date-tagged dedupe entries (while keeping undated ones).
    • Migrates previous fired-notification history so already-fired notifications aren’t re-delivered.

Derivation runs in two isolates — the long-lived foreground pass (kept
alive for BLE) and the WorkManager background pass — and both call
NotificationCenter.emit() → FiredKeyStore. The guard was defeated across
them, so a same-day insight (e.g. "Low readiness today") re-fired
repeatedly and re-alerted after every dismissal:

- Stale read cache: legacy SharedPreferences loads a snapshot once and
  never re-reads disk, so the foreground isolate never saw a key the
  background isolate had recorded, and hasFired() re-reported "not fired".
- Lost-update clobber: keys lived in one shared list mutated via
  read-modify-write; with no cross-process lock, the last writer rewrote
  the whole list from its stale copy and dropped the other isolate's keys.

FiredKeyStore now reload()s before every read and stores one independent
bool flag per key instead of a shared list, so neither isolate can mask
or clobber the other's record. Growth is bounded by pruning date-prefixed
flags older than a 14-day window (replacing the old FIFO cap). The
in-isolate emit lock is unchanged; its comment now scopes it correctly.

Tests can't span isolates, so the previous suite passed while the bug
shipped; the dedupe tests are updated to the per-key model and cover
retention pruning and undated-key retention.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: be919049-5f0f-4e81-aa76-c4f3f3f7b67c

📥 Commits

Reviewing files that changed from the base of the PR and between b206eee and 72696a5.

📒 Files selected for processing (2)
  • lib/notify/fired_keys.dart
  • test/notification_claim_atomic_test.dart
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/notification_claim_atomic_test.dart
  • lib/notify/fired_keys.dart

📝 Walkthrough

Walkthrough

FiredKeyStore now uses atomic database-backed claims with a per-key SharedPreferences fallback, legacy migration, release-on-failed-presentation, and date-based pruning. The database schema, notification emission flow, and tests were updated accordingly.

Changes

Notification deduplication

Layer / File(s) Summary
Database claim ledger and schema
lib/data/db.dart
Adds the notif_fired table, schema upgrade and repair support, atomic claim APIs, release and lookup helpers, legacy seeding, pruning, and schema health validation.
Store protocol and migration
lib/notify/fired_keys.dart
Replaces FIFO list storage with database claims and refreshed per-key preference flags, including release, legacy migration, date parsing, and retention pruning.
Emit integration and behavioral coverage
lib/notify/notification_center.dart, test/notification_claim_atomic_test.dart, test/notification_dedupe_test.dart
Uses atomic claims during emission, releases keys when presentation fails, and tests database atomicity, migration, fallback behavior, independent keys, and retention rules.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • OpenStrap/edge#137: Related notification deduplication changes using FiredKeyStore and NotificationCenter.

Sequence Diagram(s)

sequenceDiagram
  participant NotificationCenter
  participant FiredKeyStore
  participant LocalDb
  participant presentSink
  NotificationCenter->>FiredKeyStore: claim(dedupeKey)
  FiredKeyStore->>LocalDb: atomic claim
  LocalDb-->>FiredKeyStore: winner or duplicate
  FiredKeyStore-->>NotificationCenter: claim result
  NotificationCenter->>presentSink: present notification
  presentSink-->>NotificationCenter: shown or failed
  NotificationCenter->>FiredKeyStore: release when not shown
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: making notification fire-once deduplication safe across isolates.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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/notify/fired_keys.dart`:
- Around line 55-70: Make FiredKeyStore’s same-key claim atomic across isolates
by replacing the separate hasFired/recordFired protocol around hasFired and
recordFired with a transactional or locked storage operation that checks and
records the key as one unit. In lib/notify/notification_center.dart lines 30-35,
update the integration so it relies on the atomic claim rather than assuming the
existing protocol provides cross-isolate fire-once behavior. In
test/notification_dedupe_test.dart lines 217-234, add a barrier-controlled
multi-isolate regression test asserting that concurrent claimants for the same
key result in only one presentation.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9adf8c29-764b-464e-9b16-3995a732ef74

📥 Commits

Reviewing files that changed from the base of the PR and between db356e6 and 1abd914.

📒 Files selected for processing (3)
  • lib/notify/fired_keys.dart
  • lib/notify/notification_center.dart
  • test/notification_dedupe_test.dart

Comment thread lib/notify/fired_keys.dart Outdated
Comment on lines +55 to +70
Future<bool> hasFired(String dedupeKey) async {
final p = await SharedPreferences.getInstance();
final keys = p.getStringList(_prefsKey);
return keys != null && keys.contains(dedupeKey);
await _reload(p);
return p.getBool('$_prefix$dedupeKey') ?? false;
}

/// Record [dedupeKey] as fired. Bounded FIFO: the newest key is appended and
/// the oldest evicted once past [maxKeys]. A key already present is left
/// untouched — a repeated hit must NOT re-fire and must NOT refresh its
/// recency (otherwise a hot duplicate could keep a stale key pinned forever).
/// Record [dedupeKey] as fired — one independent flag, so a concurrent record
/// of a different key from the other isolate can't clobber it. Recording the
/// same key again is a harmless idempotent no-op. Prunes stale flags after.
Future<void> recordFired(String dedupeKey) async {
final p = await SharedPreferences.getInstance();
final keys = List<String>.of(p.getStringList(_prefsKey) ?? const <String>[]);
if (keys.contains(dedupeKey)) return;
keys.add(dedupeKey);
if (keys.length > maxKeys) {
keys.removeRange(0, keys.length - maxKeys);
}
await p.setStringList(_prefsKey, keys);
// Reload first so both the write and the prune below act on the latest
// cross-isolate state (and so _prune enumerates the other isolate's keys).
await _reload(p);
await p.setBool('$_prefix$dedupeKey', true);
await _prune(p);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make the fired-key claim atomic across isolates.

Two isolates can both reload and read false, then both call presentSink before either reaches setBool. Per-key flags prevent different-key clobbers, but do not enforce fire-once for the same key.

  • lib/notify/fired_keys.dart#L55-L70: replace the separate read/record protocol with an atomic cross-isolate claim operation backed by transactional/locked storage.
  • lib/notify/notification_center.dart#L30-L35: do not claim FiredKeyStore provides cross-isolate fire-once until the atomic claim exists.
  • test/notification_dedupe_test.dart#L217-L234: add a barrier-controlled, multi-isolate regression test proving only one same-key claimant can present.
📍 Affects 3 files
  • lib/notify/fired_keys.dart#L55-L70 (this comment)
  • lib/notify/notification_center.dart#L30-L35
  • test/notification_dedupe_test.dart#L217-L234
🤖 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/notify/fired_keys.dart` around lines 55 - 70, Make FiredKeyStore’s
same-key claim atomic across isolates by replacing the separate
hasFired/recordFired protocol around hasFired and recordFired with a
transactional or locked storage operation that checks and records the key as one
unit. In lib/notify/notification_center.dart lines 30-35, update the integration
so it relies on the atomic claim rather than assuming the existing protocol
provides cross-isolate fire-once behavior. In test/notification_dedupe_test.dart
lines 217-234, add a barrier-controlled multi-isolate regression test asserting
that concurrent claimants for the same key result in only one presentation.

@abdulsaheel

Copy link
Copy Markdown
Collaborator

/review

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

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

TOCTOU race remains

The cross-isolate TOCTOU window is narrowed but not closed. hasFired does a reload+read, and recordFired does a separate reload+write. Between those two calls in NotificationCenter.emit, the other isolate can still complete its own recordFired after this isolate's hasFired returns false but before this isolate's setBool runs. The result is both isolates see "not fired", both call present, and both record — the exact race the PR claims to fix. The per-key flag makes the clobber harmless (both writes are idempotent), but the double-present (double alert) is not prevented. The in-isolate _synchronized lock in NotificationCenter only serializes within one isolate; it cannot prevent the other isolate from interleaving between hasFired and recordFired. This is the same TOCTOU shape that §4.6 of AGENTS.md flags as a recurring pattern ("check-then-record dedupe without the lock"). The fix eliminates the lost-update clobber and the stale-cache miss, which are real improvements, but the fundamental cross-isolate double-fire window for a key that neither isolate has recorded yet remains open.

Future<bool> hasFired(String dedupeKey) async {
  final p = await SharedPreferences.getInstance();
  await _reload(p);
  return p.getBool('$_prefix$dedupeKey') ?? false;
}

/// Record [dedupeKey] as fired — one independent flag, so a concurrent record
/// of a different key from the other isolate can't clobber it. Recording the
/// same key again is a harmless idempotent no-op. Prunes stale flags after.
Future<void> recordFired(String dedupeKey) async {
  final p = await SharedPreferences.getInstance();
  // Reload first so both the write and the prune below act on the latest
  // cross-isolate state (and so _prune enumerates the other isolate's keys).
  await _reload(p);
  await p.setBool('$_prefix$dedupeKey', true);
  await _prune(p);
}
UTC vs local in tests

dayOffset uses DateTime.now() (local wall clock) and calls .toIso8601String().substring(0, 10). On a machine in a UTC-negative timezone near midnight, DateTime.now() and DateTime.now().add(Duration(days: -N)) can produce a date one day off from what _leadingDate in FiredKeyStore._prune computes (which also uses DateTime.now()). More concretely, the prune cutoff is DateTime.now().subtract(Duration(days: retentionDays)) compared with d.isBefore(cutoff) using DateTime.tryParse (which returns a midnight-local DateTime). The test seeds dayOffset(-(retentionDays + 5)) expecting it to be pruned, but if the two DateTime.now() calls straddle a second boundary the arithmetic is consistent. The real issue is that dayOffset uses .toIso8601String() which for a local DateTime includes no timezone offset in the date portion — this is fine — but it is not using day_label.dart helpers as required by AGENTS.md §3.7. This is a test-only violation, not production code, but it means the test could silently use a different date convention than the production prune logic if those helpers ever diverge.

String dayOffset(int days) =>
    DateTime.now().add(Duration(days: days)).toIso8601String().substring(0, 10);

Follow-up to the review on OpenStrap#145. Two gaps remained.

1. TOCTOU — the guard was still check-then-record

Reload-before-read and per-key flags fix a stale read and a lost update, but
neither makes the guard atomic: hasFired() and recordFired() are two separate
operations, so the foreground derive isolate and the WorkManager one can both
read "not fired" before either writes, and both present. Fresher reads shrink
that window; they cannot close it. SharedPreferences has no primitive that can.

SQLite does. `notif_fired(key PRIMARY KEY)` turns the guard into one atomic
INSERT OR IGNORE: for a given key exactly one caller in the process ever sees
changes() == 1, and only that caller presents. emit() now claims instead of
checking, and releases the claim on every path where the present did not
actually happen (permission denied, OS error, a throw) — preserving the
existing "record only after a real present" semantics, which a claim-first
protocol would otherwise break.

The in-isolate emit lock stays: it still serialises overlapping emits here.
It is just no longer what enforces fire-once, and its comment says so.

If the DB is unusable (torn down mid-background pass, absent in a unit test),
claim falls back to the per-key prefs flags — the OpenStrap#145 behaviour, narrow window
and all. The fallback deliberately does NOT assume "already fired": a broken DB
must never silently mute every notification on the device.

2. No migration off the legacy shared list

OpenStrap#145 changed the on-disk shape without carrying the old `notif_fired_keys` list
over, so every key that had already fired on the day of the upgrade would have
fired a second time, and the dead list would have sat in prefs forever. It is
now drained into the claim table on first use and deleted; its absence is the
already-migrated marker, so the pass is idempotent and costs one cached read.

Tests

notification_claim_atomic_test.dart runs the real LocalDb via sqflite ffi:
concurrent same-key claimants (no in-isolate lock — the cross-isolate shape)
yield exactly one winner, release makes a key claimable again, a denied or
throwing present does not consume it, legacy keys do not re-fire, and retention
prunes dated claims while leaving undated ones. Verified to have teeth: swapping
claim() back to check-then-record fails the concurrency test.

notification_dedupe_test.dart is unchanged in intent and now documents that it
covers the degraded fallback (no sqlite factory registered). Its date helper
uses dayLabelOf, matching the store's own cutoff and the local-day convention.

DB schema 25 -> 26, purely additive. No analytics output changes, so
kAlgoVersion is untouched.
@abdulsaheel

Copy link
Copy Markdown
Collaborator

Pushed b206eee addressing the review, plus one gap the bots didn't catch.

1. The TOCTOU race (CodeRabbit 🟠 / PR-Agent "TOCTOU race remains") — fixed properly.

Both reviews landed on the same real thing: reload-before-read and per-key flags fix a stale read and a lost update, but hasFired()recordFired() is still two operations, so both derivation isolates can read "not fired" before either writes. Fresher reads shrink that window; they can't close it, and SharedPreferences has no primitive that can.

SQLite does. New notif_fired(key PRIMARY KEY) table makes the guard a single atomic INSERT OR IGNORE — exactly one caller in the process ever observes changes() == 1, and only that caller presents. emit() now claims rather than checks.

The one thing an atomic claim breaks if you're not careful is the existing "record only after a real present" rule (a permission-denied no-op must not consume the day's only chance to fire). So a claim we don't spend is released — on denial, on OS error, and on a throwing present.

The in-isolate lock stays; it still serialises overlapping emits here. It's just no longer what enforces fire-once, and the comment now says that instead of claiming FiredKeyStore's reload+per-key flags do it.

Degraded mode. If the DB is unusable (torn down mid-background pass, absent in a plain unit test) claim falls back to this PR's per-key prefs flags — narrow window and all. It deliberately does not assume "already fired": an unusable DB must never silently mute every notification on the device.

2. Missing migration off the legacy list — fixed.

Not flagged by either bot: the PR changed the on-disk shape without carrying notif_fired_keys over. Every key that had already fired on the day of the upgrade would have fired one extra time, and the dead list would have sat in prefs forever. It's now drained into the claim table on first use and deleted; its absence is the already-migrated marker, so the pass is idempotent.

On the multi-isolate regression test CodeRabbit asked for. A unit test can't spawn the WorkManager isolate — but it doesn't need to. Both isolates hit the same SQLite file, and the claim's correctness is a property of the statement, not of who calls it. Calling claim() concurrently without NotificationCenter's lock reproduces exactly the interleaving the other isolate produces. test/notification_claim_atomic_test.dart does that against a real DB via sqflite ffi, and I verified it has teeth: swapping claim() back to check-then-record fails it (+0 -1).

Also covered there: release makes a key claimable again, a denied and a throwing present don't consume it, legacy keys don't re-fire, retention prunes dated claims and leaves undated ones. notification_dedupe_test.dart is unchanged in intent and now documents that it covers the degraded fallback; its date helper moved to dayLabelOf per the second PR-Agent note (local-day convention, same one the store's cutoff uses).

DB schema 25 → 26, purely additive. No analytics outputs change, so kAlgoVersion is deliberately untouched.

Full suite: 605 pass. The 3 failures in history_screens_redesign_test.dart are pre-existing on this branch (ambiguous "Heart rate"/"HRV" widget finders) — confirmed by stashing this diff and re-running.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/data/db.dart (1)

90-126: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add a SQLite busy timeout for cross-isolate writes

claimNotifFired() can still hit SQLITE_BUSY immediately when the foreground and WorkManager isolates contend for the WAL file. _guardedWrite() only retries closed-handle failures, so lock contention drops straight into the degraded SharedPreferences path. Adding PRAGMA busy_timeout here would let the second writer wait briefly instead of bypassing the atomic DB claim.

🤖 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 90 - 126, Add a non-fatal PRAGMA busy_timeout
configuration in the _open() onConfigure callback, alongside the existing
synchronous and cache_size pragmas, so competing isolate writes wait briefly for
the WAL lock. Execute it through the same guarded performance-pragma handling
and preserve database opening even if the setting fails; do not modify
claimNotifFired() or _guardedWrite().
🧹 Nitpick comments (1)
test/notification_claim_atomic_test.dart (1)

62-110: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Hardcoded near-term dates will start colliding with the 14-day retention prune.

_dayOffset() (Lines 38-40) exists for exactly this, but most tests hardcode '2026-07-25' instead. Since every successful claim() triggers LocalDb.pruneNotifFired(_cutoffLabel()), once "today" is more than retentionDays past that literal (mid-August 2026), these keys become prunable mid-test — some assertions would then only keep passing by coincidence (via the prefs-mirror fallback / reconciliation branch), rather than actually testing the intended behavior.

Suggest swapping the hardcoded literals for '${_dayOffset(0)}:...' (or similar) throughout this file so the suite stays meaningful regardless of when it runs.

Also applies to: 156-187

🤖 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/notification_claim_atomic_test.dart` around lines 62 - 110, Replace
hardcoded date prefixes such as '2026-07-25' throughout the notification claim
tests, including the atomic claim tests and the additional cases around lines
156-187, with the existing _dayOffset(0) helper while preserving each key
suffix. Ensure all claim and persistence assertions use current-day keys so
retention pruning cannot affect the intended behavior.
🤖 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/notify/fired_keys.dart`:
- Around line 113-130: Update the reconciliation branch in the claim method
around the p.getBool('$_prefix$dedupeKey') check so it relinquishes only the
newly acquired DB claim without removing the existing prefs marker. Do not call
the public release(dedupeKey), since it clears both stores; use a DB-only
release mechanism or equivalent targeted deletion, while preserving the false
return and keeping the degraded-mode evidence intact.

---

Outside diff comments:
In `@lib/data/db.dart`:
- Around line 90-126: Add a non-fatal PRAGMA busy_timeout configuration in the
_open() onConfigure callback, alongside the existing synchronous and cache_size
pragmas, so competing isolate writes wait briefly for the WAL lock. Execute it
through the same guarded performance-pragma handling and preserve database
opening even if the setting fails; do not modify claimNotifFired() or
_guardedWrite().

---

Nitpick comments:
In `@test/notification_claim_atomic_test.dart`:
- Around line 62-110: Replace hardcoded date prefixes such as '2026-07-25'
throughout the notification claim tests, including the atomic claim tests and
the additional cases around lines 156-187, with the existing _dayOffset(0)
helper while preserving each key suffix. Ensure all claim and persistence
assertions use current-day keys so retention pruning cannot affect the intended
behavior.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 61704945-a8e0-4616-a13e-6c78b7ab1772

📥 Commits

Reviewing files that changed from the base of the PR and between 1abd914 and b206eee.

📒 Files selected for processing (5)
  • lib/data/db.dart
  • lib/notify/fired_keys.dart
  • lib/notify/notification_center.dart
  • test/notification_claim_atomic_test.dart
  • test/notification_dedupe_test.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/notification_dedupe_test.dart

Comment thread lib/notify/fired_keys.dart
…claim

Real bug, caught in review of the previous commit. When a DB claim won but the
prefs mirror already read true — the state a fallback claim leaves behind, i.e.
"the DB was down when this key fired" — the reconciliation branch called the
public release(), which clears BOTH stores. That erased the mirror: the only
evidence of the degraded-mode fire. Both stores then read "not fired", so the
next derive pass won the claim cleanly and re-alerted, reopening this PR's bug
in exactly the recovered-from-outage path the comments call out as expected.

A mirror that already reads true means the key HAS fired, so the row we just
inserted is the truth arriving late. Keep it and back off. Undoing the insert
(releasing only the DB row) also stops the immediate re-fire, but repeats the
insert/delete dance on every subsequent pass and leaves the claim table
permanently ignorant of a fire it should own. Reconciling forward converges
after one pass: from then on the DB alone loses the claim.

release() is unchanged and still clears both — that is correct for its actual
job, "the present did not happen, forget it everywhere". The bug was reusing it
for reconciliation, which is the opposite intent.

Regression test drives the on-disk state a degraded claim leaves behind and
asserts three consecutive passes all lose, then asserts the claim table has
absorbed the fire. Verified to fail on the previous commit at pass 1.
@abdulsaheel
abdulsaheel merged commit 8aebe08 into OpenStrap:main Jul 26, 2026
2 checks passed
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.

2 participants