Skip to content

fix(restore): buffer live events during restore to prevent state corruption - #639

Open
BraCR10 wants to merge 13 commits into
mainfrom
fix/restore-state-lost-after-relaunch-v2
Open

fix(restore): buffer live events during restore to prevent state corruption#639
BraCR10 wants to merge 13 commits into
mainfrom
fix/restore-state-lost-after-relaunch-v2

Conversation

@BraCR10

@BraCR10 BraCR10 commented Jul 9, 2026

Copy link
Copy Markdown
Member

Closes #584.

Re-implementation of #589, which never merged — it had a reviewed buffer-and-replay fix but fell too far behind main (Transport v2 touched the same files) to rebase cleanly. Same design, redone against current main.

Changes

  • Buffer live events during restore instead of dropping them (dedup marked them seen but never persisted them, so they were lost for good).
  • Works for both transports the app currently supports: v1 gift-wrap (kind 1059) and v2 NIP-44 direct (kind 14). Buffering happens before either is decrypted, so both go through the same path, no separate handling needed per version.
  • Flush the buffer after restore, success or error, clearing the dedup entry first so replay isn't blocked.
  • Fixed a gap fix: Prevent corrupting restored order state on relaunch #589 had: the buffer check now runs before the session-match check, so events for sessions not yet recreated mid-restore also get buffered instead of dropped.
  • Replayed events keep their real timestamp instead of processing time.
  • Synthetic restore messages now use a single restore-start timestamp instead of the order's created_at. That field is order-creation time, not current status, and can be null.
  • Tested with v1 and v2 nodes

Observations

Found an unrelated bug while testing on device: ChatRoomNotifier/ChatRoomsNotifier/DisputeChatNotifier crash with "used after dispose" during restore's _clearAll(). Predates this branch, reproduces on main too. Tracking separately.

Summary by CodeRabbit

  • Bug Fixes
    • Improved session recovery to buffer live DM events received during restore and replay them after recovery completes.
    • Restored items now use a single consistent ordering anchor for synthetic messages.
    • Fixed dispute timing by correctly treating message timestamps as milliseconds.
    • Improved restore “fiat was sent” behavior by deferring classification and reconciling after restore buffering flush.
    • Notification freshness checks now use message arrival time (receivedAt) to better handle reconnects.
  • Documentation
    • Updated session recovery documentation to reflect corrected buffering/replay and metadata.
  • Tests
    • Expanded restore, buffering, reconciliation, storage persistence, and freshness-gate regression coverage.

During the restore window, incoming events were dropped after being
marked as seen for dedup, permanently losing genuinely live state
changes (e.g. a counterparty payment arriving mid-restore). Buffer
them instead and replay once restore ends, on both the success and
error paths, clearing the dedup entry first so the replay isn't
self-blocked.

Also moves the restore-buffer check ahead of the session-match check
in _onData, closing a gap where an event for a session not yet
recreated mid-restore was still dropped instead of buffered.

Synthetic restore snapshot messages now anchor their ordering
timestamp to a single restore-start time instead of the daemon's
per-order created_at (which reflects order creation, not current
status, and can be null per protocol) or per-message "now", so live
events during the window sort correctly relative to the snapshot.

Closes #584. Supersedes the stale #589, whose reviewed buffer-and-
replay design (credit: Catrya) this reincorporates against current
main, extended to cover both v1 (gift wrap) and v2 (NIP-44) transport.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR updates session recovery with shared timestamp anchoring, live-event buffering and replay, deferred fiat-sent reconciliation, corrected freshness handling, testing hooks, and architecture documentation.

Changes

Session Recovery Fixes

Layer / File(s) Summary
Message persistence and freshness
lib/data/models/mostro_message.dart, lib/data/repositories/mostro_storage.dart, lib/features/order/..., test/data/..., test/features/order/...
Persists client arrival timestamps, uses them for freshness checks, preserves dispute timestamps, and adds regression coverage.
Restore anchoring and reconciliation
lib/features/restore/..., lib/features/order/notifiers/order_notifier.dart, test/features/restore/...
Uses one restore timestamp, defers fiat-sent checks until replay, and upgrades cooperative-cancel state after reconciliation.
Live event buffering and replay
lib/services/mostro_service.dart, test/services/...
Buffers events before session handling, preserves arrival order and timestamps, clears dedup reservations before replay, and adds restore-flow tests.
Recovery documentation and test wiring
docs/architecture/..., integration_test/test_helpers.dart, test/mocks.dart
Documents the recovery protocol and adds fake-service and mock-generation hooks.

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

Possibly related PRs

Suggested reviewers: catrya, andreadiazcorreia, chebizarro, arkanoider

Poem

I’m a rabbit guarding the restore queue,
Anchoring messages as timestamps do.
Live events wait, then hop back in line,
Fiat state reconciles just fine.
Recovery lands in order true! 🐇

Sequence Diagram(s)

sequenceDiagram
  participant RestoreService
  participant MostroService
  participant EventStorage
  participant OrderNotifier
  RestoreService->>MostroService: enable restore mode
  MostroService->>EventStorage: reserve and buffer live event
  RestoreService->>MostroService: complete restore
  MostroService->>EventStorage: delete dedup reservation
  MostroService->>MostroService: replay buffered events
  RestoreService->>EventStorage: reconcile fiatSent history
  RestoreService->>OrderNotifier: update restored order state
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 describes the main restore fix and matches the changeset.
Linked Issues check ✅ Passed The restore buffering, replay, and timestamp fixes address the lost order/dispute state after relaunch in #584.
Out of Scope Changes check ✅ Passed The notification and fiat-sent changes support the restore flow and tests, so no unrelated scope stands out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/restore-state-lost-after-relaunch-v2

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
lib/services/mostro_service.dart (1)

237-247: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Isolate per-event failures during replay.

_onData wraps only its decrypt/process block in try/catch; eventStore.deleteItem and the reservation lines run outside it. An exception on any single event aborts the loop, and since _restoreBuffer was already cleared (Line 240) the remaining buffered events are lost with no retry. Guarding each replay keeps one bad event from silently dropping the rest.

♻️ Guard each replayed event
     for (final event in events) {
-      await eventStore.deleteItem(event.id!);
-      await _onData(event);
+      try {
+        await eventStore.deleteItem(event.id!);
+        await _onData(event);
+      } catch (e, stack) {
+        logger.e('Restore: failed to replay buffered event ${event.id}',
+            error: e, stackTrace: stack);
+      }
     }
🤖 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/services/mostro_service.dart` around lines 237 - 247, The replay logic in
_flushRestoreBuffer() is aborting the whole batch when a single event fails,
because _restoreBuffer is cleared before processing and the per-event
delete/replay steps are not isolated. Update _flushRestoreBuffer() in
MostroService to handle each event independently, wrapping the deleteItem and
_onData(event) work for each item in its own try/catch so one failure does not
stop the remaining buffered events. Use the existing _flushRestoreBuffer,
_onData, and eventStorageProvider symbols to locate the replay path.
🤖 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 `@docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md`:
- Around line 611-622: The fenced pseudocode block in
SESSION_RECOVERY_ARCHITECTURE.md is missing a language specifier, causing the
docs lint MD040 warning. Update that fenced block to include an explicit
language tag such as dart or text, keeping the existing _onData(event)
pseudocode content unchanged.

---

Nitpick comments:
In `@lib/services/mostro_service.dart`:
- Around line 237-247: The replay logic in _flushRestoreBuffer() is aborting the
whole batch when a single event fails, because _restoreBuffer is cleared before
processing and the per-event delete/replay steps are not isolated. Update
_flushRestoreBuffer() in MostroService to handle each event independently,
wrapping the deleteItem and _onData(event) work for each item in its own
try/catch so one failure does not stop the remaining buffered events. Use the
existing _flushRestoreBuffer, _onData, and eventStorageProvider symbols to
locate the replay path.
🪄 Autofix (Beta)

❌ Autofix failed (check again to retry)

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

Plan: Pro

Run ID: 3acaad7e-abad-4b09-97d1-742eb5a2fa84

📥 Commits

Reviewing files that changed from the base of the PR and between 9bde08a and 948cf4d.

📒 Files selected for processing (4)
  • docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md
  • integration_test/test_helpers.dart
  • lib/features/restore/restore_manager.dart
  • lib/services/mostro_service.dart

Comment thread docs/architecture/SESSION_RECOVERY_ARCHITECTURE.md Outdated
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Autofix skipped. No unresolved CodeRabbit review comments with fix instructions found.

_flushRestoreBuffer() cleared the buffer before processing and looped
over events with no per-event error handling, so a single failure
(e.g. deleteItem) would abort the whole replay, silently dropping any
remaining buffered events for that restore. Wrap each event's
deleteItem + _onData call in its own try/catch so one failure is
logged and skipped without stopping the rest of the batch.
@BraCR10 BraCR10 changed the title fix: buffer live events during restore to prevent state corruption fix(restore): buffer live events during restore to prevent state corruption Jul 9, 2026
@BraCR10

BraCR10 commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

While reviewing MostroP2P/mostro#754, found that this PR's buffer mechanism also might help to close #586.

@codaMW already dug into #586 and found the real cause. The AddInvoice was arriving before the app had even finished recreating the session for that order, so MostroService could not match it to anything, dropped it with a warning, and never stored it at all.

Instead of trying to match a session the moment an event shows up, it now checks first whether restore is still running. If it is, the event just gets buffered, no session match attempted yet. Once restore is fully done, all sessions already exist and the flag is already false, so every buffered event gets replayed the same way live events normally are, the match succeeds, it gets stored, and the notifier updates the state like it should.

@MostroP2P MostroP2P deleted a comment from chatgpt-codex-connector Bot Jul 13, 2026
@BraCR10

BraCR10 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 023dbe71ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/services/mostro_service.dart
Codex flagged a race on PR #639: RestoreService checked local message
history for fiatSent/fiatSentOk before the restore buffer had a chance
to flush a same-order backfilled event, permanently misclassifying
cooperatively-canceled/disputed orders as no-fiat-sent. A seller in
that state loses the Release action even though the buyer already paid.

Fix: track affected orders during the restore loop instead of guessing
inline, then reconcile once in a finally block after MostroService's
buffer is provably drained via a new public flushRestoreBuffer().
@BraCR10

BraCR10 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d2f390b49

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/features/restore/restore_manager.dart
message.timestamp is already milliseconds everywhere it's set in this
codebase (mostro_service.dart, mostro_storage.dart, restore_manager.dart
all use millisecondsSinceEpoch). This code treated it as seconds and
multiplied by 1000 again, pushing Dispute.createdAt to roughly year
58488 for restored disputes.

Pre-existing since 973b017 (~9 months old), unrelated to the restore
buffer/fiat-sent work in this branch. Real-world impact was low
(createdAt is never rendered as text, only used to sort disputes when
more than one exists) but surfaced again in review on this branch's
restore path, so fixing it here.
@BraCR10

BraCR10 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cbdb6b43ec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/features/restore/restore_manager.dart
Comment thread lib/features/restore/restore_manager.dart
1. _restoreStartTime used raw millisecond precision, which could
   occasionally sort newer than a live event created in the same Nostr
   second (Nostr created_at is second-precision, never exact). Floored
   the anchor to just before the current second so it can never
   outrank a same-second live event.

2. _reconcileFiatSent replayed the tracked synthetic snapshot after the
   buffer flush, unconditionally overwriting anything newer the flush
   had just applied for the same order. An earlier version of this fix
   guarded the replay by comparing stored timestamps, but that left
   setFiatWasSent() running unconditionally while skipping the action
   remap it depends on — a live cooperativeCancelInitiated* event
   flushed before reconciliation would still get stuck on the
   NoFiat action variant. Replaced with a direct, idempotent correction
   (OrderNotifier.upgradeCooperativeCancelToFiatSent) that only touches
   the action field when it's still a stale NoFiat variant, regardless
   of what produced it.

Documented both as D5/D6 in SESSION_RECOVERY_ARCHITECTURE.md.
@BraCR10

BraCR10 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 541f0e4df1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/features/restore/restore_manager.dart
BraCR10 added 2 commits July 13, 2026 09:28
updateWith() re-synced Dispute.createdAt to match the wrapping
message's timestamp whenever they differed, not just when createdAt
was missing. For restored disputes this clobbers the real creation
time (sourced from orderDetail.createdAt when the synthetic message is
built) with _restoreStartTime, the restore ordering anchor — making
restored disputes sort as if freshly created. A dispute's creation
time is fixed and both message-construction paths (restore and live)
already resolve it properly before this code runs, so it should only
ever fill a genuinely missing value, never overwrite an existing one.
_onData reserves an event's dedup entry before checking for a matching
session, then silently drops the event if none exists — without
undoing that reservation. If restore() aborts before recreating every
session (the finally block flushes the buffer regardless of outcome),
any buffered event for a not-yet-recreated session gets dropped here
during replay, but its dedup entry survives. A later retry, even after
the session exists, would then see the event as already processed and
silently skip it — permanent data loss. Delete the dedup entry before
returning so a genuinely unprocessed event can still be picked up
later.
@BraCR10

BraCR10 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3feeda7712

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/services/mostro_service.dart
Comment thread lib/services/mostro_service.dart

@BraCR10 BraCR10 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ready for review

@grunch grunch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Strict review

The core design (buffer before the session-match check, flush on the true → false transition, single restore-start anchor) is sound, and the reasoning in SESSION_RECOVERY_ARCHITECTURE.md is genuinely good. I traced the whole path — _clearAll() → temp subscription → restore() → incremental saveSession()SubscriptionManager re-subscribe (the orders filter has no since, so the relay re-delivers the full history for every restored trade key) → buffer → flush → OrderNotifier.sync() — and the mechanism holds together.

Two things block for me, both because they reintroduce or extend #584-class loss outside the window the PR reasons about.


1. BLOCKING — an interrupted restore permanently loses every buffered event

_onData persists the dedup reservation before buffering:

await eventStore.putItem(event.id!, {...});   // persistent (sembast)

if (ref.read(isRestoringProvider)) {
  _restoreBuffer[event.id!] = event;          // in-memory only
  return;
}

If the process dies mid-restore — user kills the app, OOM, or mostroServiceProvider is disposed (node switch) — _restoreBuffer evaporates while the eventStore entries survive. On the next launch the relay re-delivers those exact events (no since in buildOrdersFilter), hasItem(id) returns true, and they are dropped forever. That is precisely the mechanism the PR description calls out: "dedup marked them seen but never persisted them, so they were lost for good." Restore is a multi-second, multi-round-trip operation, so this is not a theoretical window.

The fix is the same one already applied for the session-less path in 3feeda7 — release the reservation when buffering, and let the replay re-reserve it:

if (ref.read(isRestoringProvider)) {
  _restoreBuffer[event.id!] = event;
  await eventStore.deleteItem(event.id!);   // the in-memory map already dedups by id
  return;
}

No downside: a duplicate delivery from a second relay during restore re-enters, overwrites the same map key, and deletes the entry again. _flushRestoreBuffer already re-reserves via _onData. This also makes dispose() — which currently discards _restoreBuffer silently, without flushing or clearing — harmless instead of lossy.

2. BLOCKING — msg.timestamp ??= <protocol time> silently re-defines the 60-second freshness gate

This line changes the meaning of MostroMessage.timestamp app-wide, not just for replayed events. Before this PR the field came out of _onData as null and was stamped by MostroStorage.addMessage:

// lib/data/repositories/mostro_storage.dart:20
message.timestamp ??= DateTime.now().millisecondsSinceEpoch;   // arrival time

So timestamp meant "when we received it". It now means "when Mostro sent it". That is the right call for ordering — but AbstractMostroNotifier.subscribe() uses the same field as a liveness gate:

// abstract_mostro_notifier.dart:114
if (msg.timestamp != null &&
    msg.timestamp! > DateTime.now().subtract(const Duration(seconds: 60)).millisecondsSinceEpoch) {
  unawaited(handleEvent(msg, ...));   // notification, navigation, side effects
} else { /* only disputeInitiated* survives */ }

Failure scenario: the user is offline or backgrounded for five minutes; on reconnect the relay delivers a pay-invoice / fiat-sent / released gift wrap created three minutes ago. Before: timestamp = arrival = now → gate passes → notification and navigation fire. After: timestamp = three minutes ago → gate fails → state updates silently, no notification, no navigation. Relay backlog and offline reconnects are the normal case for a mobile Nostr client, so this suppresses real user-facing notifications.

The gate is a pre-existing wart, but this PR is what breaks it, and it needs a deliberate decision — either keep a separate receivedAt for liveness and use protocol time only for ordering, or convert the gate to compare against app-session start. As written it changes behavior far outside "restore" with nothing testing it.

Same field, same class of concern, in a money path: reconcileBondCancelAction() computes nowMs - latestCanceledTimestamp against the bond grace window, and bond_payout_helpers sorts by it. Behavior probably improves there (real cancel time beats arrival time), but it is an unflagged blast radius.


MEDIUM

3. Zero tests. The PR adds five @visibleForTesting hooks — onDataForTesting, flushRestoreBufferForTesting, restoreBufferForTesting, reconcileFiatSentForTesting, floorToPreviousSecondForTesting — and grep -rn ForTesting test/ returns nothing. They are used only by the FakeMostroService stubs in integration_test/test_helpers.dart. That is dead API surface on production classes, and the PR rewrites the ordering/dedup/timestamp semantics of the app's core message-ingress path with no unit coverage. At minimum: (a) event is buffered while isRestoring; (b) buffering happens even when no session matches; (c) flush clears the dedup entry so the replay is not swallowed; (d) a replayed v1 event keeps its inner rumor's created_at; (e) _reconcileFiatSent upgrades cooperativeCancelNoFiatByPeer when a fiatSent lands in the flush.

4. The doc describes logic that is not implemented. D6 in SESSION_RECOVERY_ARCHITECTURE.md states: "The tracked snapshot is only replayed if no stored message for that order is newer, so a buffered live update flushed on the same pass is never overwritten by the stale snapshot." _reconcileFiatSent() does no timestamp comparison and replays no snapshot — it calls setFiatWasSent() + upgradeCooperativeCancelToFiatSent(), which is safe only because the upgrade is guarded on the current action. The code is fine; the paragraph is fiction, and the doc is what the next reviewer will trust.

LOW

5. _activeFlush can hand back a stale future. flushRestoreBuffer() joins the in-flight flush, but _flushRestoreBuffer() snapshots _restoreBuffer.values once at the top, so anything buffered after that snapshot is not drained by the future the caller awaits. _operationInProgress currently prevents overlapping restores, so this is latent — but a while (_restoreBuffer.isNotEmpty) loop makes it structurally correct rather than accidentally correct.

6. deleteItem on session-less events costs a write cycle per redelivery. Every reconnect re-delivers events for trade keys that will never match, and each one now does putItemdeleteItemlogger.w. Correct, but checking the session before reserving would avoid the churn.

7. The buffer is unbounded and logs one logger.i per event. Because the orders filter has no since, the mid-restore re-subscribe re-delivers the entire history for every restored trade key — all of it lands in _restoreBuffer and emits an info line each. Worth logger.d plus a size counter. The naming and comments ("live events") also undersell what actually goes in there: it is the full re-delivered history plus live events.


flutter analyze is clean on the branch (the only failures in my env are missing generated mocks — build_runner breaks against analyzer 8.4.1 locally, unrelated to this PR), and CI is green.

Happy to re-review as soon as (1) and (2) are addressed; the rest can be follow-ups if you want to keep this moving.

BraCR10 added 3 commits July 13, 2026 18:22
…n gate

msg.timestamp now holds protocol send time (needed for ordering), which
silently broke the 60s live-notification/navigation freshness gate for
any message delivered late (offline reconnect, relay backlog). Adds a
receivedAt field stamped with true client arrival time, persisted
write-once via MostroStorage's existing dedup guard but excluded from
toJson() so it never reaches the outbound wire path.

Also fixes the restore buffer to preserve the original buffering instant
across redeliveries instead of re-stamping it at flush time.

Addresses grunch's BLOCKING review comment on PR #639.
Both live-notification/navigation gate checks in AbstractMostroNotifier
compared against msg.timestamp, which now holds protocol send time
instead of arrival time. Swapped both to receivedAt so a message
delivered late (offline reconnect) but genuinely new to this client
still triggers a notification.
Adds the test coverage grunch's review requested for the restore-buffer
work: buffering during isRestoring (with and without a matching
session), dedup clearing on flush so replay isn't swallowed, the v1
inner-rumor timestamp anchor, and _reconcileFiatSent upgrading a stale
cooperativeCancelNoFiatByPeer once a confirming fiat-sent message lands
in the flush.

Also adds the core regression coverage for this session's receivedAt
fix: the freshness gate must key off receivedAt (not protocol
timestamp) so a message with an old send time but a fresh arrival time
still notifies, MostroStorage's write-once guard must not let a
redelivery overwrite the original receivedAt, the restore buffer must
preserve the first-buffered receivedAtMs across redeliveries, and the
client's explicit receivedAt assignment must always win over any
wire-supplied value.

test/mocks.dart gains a generated mock for EventStorage so the ported
buffer tests can assert dedup-clearing order with verifyInOrder,
matching the mocking style already used for the sibling storage mocks.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

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

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

14-24: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Make addMessage atomic
lib/data/repositories/mostro_storage.dart:14-24 — Concurrent calls can both pass hasItem(id) before either put, so the later write can replace the first receivedAt. Wrap the existence check and insert in one transaction, and add a concurrent regression test.

🤖 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/repositories/mostro_storage.dart` around lines 14 - 24, The
addMessage method is vulnerable to concurrent writes because hasItem and put are
separate operations. Make the existence check and insert atomic within the
transaction mechanism provided by the storage layer, preserving first-write-wins
behavior for receivedAt, and add a regression test that invokes concurrent
addMessage calls for the same key and verifies only the first record is
retained.
🤖 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/data/repositories/mostro_storage.dart`:
- Around line 14-24: The addMessage method is vulnerable to concurrent writes
because hasItem and put are separate operations. Make the existence check and
insert atomic within the transaction mechanism provided by the storage layer,
preserving first-write-wins behavior for receivedAt, and add a regression test
that invokes concurrent addMessage calls for the same key and verifies only the
first record is retained.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d8b47c07-4383-4201-9855-a26ecc0fa72a

📥 Commits

Reviewing files that changed from the base of the PR and between 541f0e4 and 5f2789f.

📒 Files selected for processing (11)
  • integration_test/test_helpers.dart
  • lib/data/models/mostro_message.dart
  • lib/data/repositories/mostro_storage.dart
  • lib/features/order/models/order_state.dart
  • lib/features/order/notifiers/abstract_mostro_notifier.dart
  • lib/services/mostro_service.dart
  • test/data/repositories/mostro_storage_test.dart
  • test/features/order/notifiers/abstract_mostro_notifier_freshness_gate_test.dart
  • test/features/restore/restore_manager_reconciliation_test.dart
  • test/mocks.dart
  • test/services/mostro_service_test.dart
🚧 Files skipped from review as they are similar to previous changes (2)
  • integration_test/test_helpers.dart
  • lib/services/mostro_service.dart

hasItem and put were separate awaited calls, leaving a window where two
concurrent addMessage calls for the same key (e.g. the same event
redelivered by a second relay) could both pass the existence check
before either write landed, breaking the first-write-wins guarantee
receivedAt depends on. Wraps both inside a single db.transaction so the
check and write can't be interleaved by another call.
@BraCR10

BraCR10 commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Hello @grunch, both blocking issues you flagged are fixed:

Blocking 1: Applied the same dedup-release pattern you pointed out, the isRestoring branch now releases the eventStore reservation right after buffering, exactly like the session-less branch already did, so a redelivery after a crash mid-restore can re-enter instead of being dropped forever.

Blocking 2: Added a separate receivedAt field that tracks true client arrival time, independent from the protocol timestamp now used for ordering. Both freshness-gate checks in AbstractMostroNotifier compare against receivedAt, so a message delivered late after a reconnect still notifies correctly. receivedAt is never serialized to the wire (excluded from toJson()), only persisted locally, and the restore buffer now preserves the original buffering instant across redeliveries instead of re-stamping it at flush time.

Also added the test coverage you asked for on the buffer/dedup/reconciliation behavior, plus regression tests for the new receivedAt mechanism. While implementing this we found and closed one more gap, MostroStorage.addMessage's existence-check-then-write wasn't atomic, which could let a concurrent write for the same key slip past the write-once guarantee receivedAt depends on, now wrapped in a single transaction, with a concurrency test covering it.

Thanks for the thorough review — both findings were real and worth catching before merge.

"Message freshness check failed" logged at warning level read like an
error to a reviewer, when skipping a notification for a non-live
(e.g. restore-reconstructed) message is the expected, common case.
Reworded to "Notification skipped/eligible" and dropped to info level.
@BraCR10
BraCR10 requested a review from grunch July 14, 2026 23:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Order and dispute state lost after closing the app

2 participants