Skip to content

fix(crew-sync): unblock the outbox on server timeouts; keep retry ord… - #555

Merged
smj1860 merged 1 commit into
mainfrom
claude/pre-launch-codebase-audit-tpt4zu
Aug 3, 2026
Merged

fix(crew-sync): unblock the outbox on server timeouts; keep retry ord…#555
smj1860 merged 1 commit into
mainfrom
claude/pre-launch-codebase-audit-tpt4zu

Conversation

@smj1860

@smj1860 smj1860 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

…er (H5, H6)

H5 — a head-of-line block that could never dead-letter A Postgres statement timeout arrives as UploadDataError('… canceling statement due to statement timeout', '57014'). 57014 is not 22/23/42 and not PGRST, so it was not terminal; classification then fell through to the transport-message test, matched \btimeout\b, and returned 'network'. The network branch consumes no retry, never sets failed, and STOPS the drain — so one server-side timeout pinned the head of the outbox forever and blocked every later write on the device, while FailedSyncBanner (which filters on failed) showed nothing at all. A crew member could work a full shift, sync nothing, and only find out at logout.

A DATA error carrying a Postgres/PostgREST code demonstrably REACHED the server — the server produced the code — so it is now classified terminal or transient by that code and never falls through to the transport test. An uncoded timeout message still classifies as network, because nothing there proves it reached anyone.

Transport failures still never dead-letter: discarding a crew member's work because their signal is bad would be worse than the bug. But an invisible retry loop is its own failure, so past STALLED_NETWORK_ATTEMPTS consecutive transport failures the queue surfaces in an amber "still trying to sync" notice — no discard affordance, because nothing is lost; it just stops being invisible.

H6 — "Retry all" resurrecting a superseded write
Dead-lettering let the drain continue (correct — other records must not block), so later writes for the SAME record pushed on top of a gap. Clearing failed in place then replayed the stale payload as though it were newest: crew ticks an item, it dead-letters, they realise it isn't done and un-tick it (which pushes fine), they tap Retry all — and the server flips back to complete, with the next delta pull erasing the un-tick from the phone too. Same shape for inventory quantities and availability.

Dead-lettering now holds back that record's remaining queued mutations, scoped to (table, targetId) so every other record keeps draining. Ids are preserved, so a retry replays tick-then-un-tick in the order the crew member actually performed them. retryAllFailedMutations now reads orderBy('id') rather than a bare toArray(), so the re-queue order is deterministic rather than incidental.

Worth recording: the first version of the hold-back was itself wrong. The drain iterates a snapshot taken before the loop, so it pushed the very mutations that had just been held back — the tests caught it, not review. Fixed with a drain-scoped heldBack set; the durable state remains the row's failed flag.

Verification: tsc clean, 2814/2814 (+8), lint 201/202, ui-classes clean, build succeeds, chokepoints 0, ratchet steady. Both fixes were reverted independently to confirm the tests fail without them — H5's classification change breaks 2 tests when reverted, H6's cascade breaks 1.

Not covered here, and unchanged: this is unit-level. The offline paths still warrant a real device pass before relying on them.

Claude-Session: https://claude.ai/code/session_013Fu4FzR25EYR7kz64cyp6r

Summary by CodeRabbit

  • New Features
    • Added a “still trying to sync” notice when network operations repeatedly fail, while keeping sync status visible.
    • Failed sync items now preserve their original order, preventing later changes to the same record from overtaking earlier failures.
  • Bug Fixes
    • Improved handling of server timeouts and upload errors so temporary issues are retried appropriately.
    • Unrelated records can continue syncing when one record encounters a permanent failure.
  • Tests
    • Added coverage for retry limits, timeout handling, failure ordering, and stalled-sync behavior.

…er (H5, H6)

H5 — a head-of-line block that could never dead-letter
A Postgres statement timeout arrives as UploadDataError('… canceling
statement due to statement timeout', '57014'). 57014 is not 22/23/42 and
not PGRST, so it was not terminal; classification then fell through to
the transport-message test, matched \btimeout\b, and returned 'network'.
The network branch consumes no retry, never sets `failed`, and STOPS the
drain — so one server-side timeout pinned the head of the outbox forever
and blocked every later write on the device, while FailedSyncBanner
(which filters on `failed`) showed nothing at all. A crew member could
work a full shift, sync nothing, and only find out at logout.

A DATA error carrying a Postgres/PostgREST code demonstrably REACHED the
server — the server produced the code — so it is now classified terminal
or transient by that code and never falls through to the transport test.
An uncoded timeout message still classifies as network, because nothing
there proves it reached anyone.

Transport failures still never dead-letter: discarding a crew member's
work because their signal is bad would be worse than the bug. But an
invisible retry loop is its own failure, so past STALLED_NETWORK_ATTEMPTS
consecutive transport failures the queue surfaces in an amber "still
trying to sync" notice — no discard affordance, because nothing is lost;
it just stops being invisible.

H6 — "Retry all" resurrecting a superseded write
Dead-lettering let the drain continue (correct — other records must not
block), so later writes for the SAME record pushed on top of a gap.
Clearing `failed` in place then replayed the stale payload as though it
were newest: crew ticks an item, it dead-letters, they realise it isn't
done and un-tick it (which pushes fine), they tap Retry all — and the
server flips back to complete, with the next delta pull erasing the
un-tick from the phone too. Same shape for inventory quantities and
availability.

Dead-lettering now holds back that record's remaining queued mutations,
scoped to (table, targetId) so every other record keeps draining. Ids are
preserved, so a retry replays tick-then-un-tick in the order the crew
member actually performed them. retryAllFailedMutations now reads
orderBy('id') rather than a bare toArray(), so the re-queue order is
deterministic rather than incidental.

Worth recording: the first version of the hold-back was itself wrong. The
drain iterates a snapshot taken before the loop, so it pushed the very
mutations that had just been held back — the tests caught it, not review.
Fixed with a drain-scoped heldBack set; the durable state remains the
row's `failed` flag.

Verification: tsc clean, 2814/2814 (+8), lint 201/202, ui-classes clean,
build succeeds, chokepoints 0, ratchet steady. Both fixes were reverted
independently to confirm the tests fail without them — H5's classification
change breaks 2 tests when reverted, H6's cascade breaks 1.

Not covered here, and unchanged: this is unit-level. The offline paths
still warrant a real device pass before relying on them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Fu4FzR25EYR7kz64cyp6r
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fieldstay Ready Ready Preview Aug 3, 2026 9:51pm

Request Review

@greptile-apps greptile-apps 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.

smj1860 has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR classifies coded upload errors, exposes stalled network retries, orders failed-mutation retries, and prevents successor mutations from overtaking permanently rejected predecessors. Tests cover classification, retry thresholds, ordering, and unrelated-record draining.

Changes

Sync reliability

Layer / File(s) Summary
Failure classification and stalled state
lib/dexie/net.ts, app/crew/_components/failed-sync-banner.tsx
Coded server errors now receive transient or terminal classifications. The banner shows an amber stalled-sync notice after five online transport failures.
Ordered dead-letter draining
lib/dexie/helpers.ts, lib/dexie/syncService.ts
Failed mutations are retried by ID order. Later mutations for the same record are held back when an earlier mutation is permanently rejected.
Sync ordering validation
unit/dexie/sync-outbox-ordering.test.ts
Tests cover error classification, retry thresholds, dead-letter ordering, and continued draining for unrelated records.

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

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant SyncEngine
  participant DexieOutbox
  participant Server
  SyncEngine->>DexieOutbox: Read ordered mutation snapshot
  SyncEngine->>Server: Upload mutation
  Server-->>SyncEngine: Return permanent rejection
  SyncEngine->>DexieOutbox: Mark later same-record mutations failed
  SyncEngine->>SyncEngine: Skip held-back mutation IDs
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 summarizes the main changes: server-timeout handling and retry ordering in the crew sync outbox.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/pre-launch-codebase-audit-tpt4zu

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.

@smj1860
smj1860 merged commit cbb36a7 into main Aug 3, 2026
8 of 9 checks passed

@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.

🧹 Nitpick comments (1)
unit/dexie/sync-outbox-ordering.test.ts (1)

208-213: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the stalled-threshold test to exercise the actual drain path.

This test only checks that STALLED_NETWORK_ATTEMPTS is a small positive number. It does not confirm that repeated network failures in the drain actually raise networkRetryCount to that threshold while keeping the row !failed, which is the exact condition failed-sync-banner.tsx depends on to surface the stalled notice. Consider adding a test similar to the existing dead-letter test (lines 120-135) that pushes a TypeError/network failure STALLED_NETWORK_ATTEMPTS times and asserts networkRetryCount >= STALLED_NETWORK_ATTEMPTS with failed still false or undefined.

🤖 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 `@unit/dexie/sync-outbox-ordering.test.ts` around lines 208 - 213, Replace the
range-only test in the “stalled-outbox visibility threshold” suite with a
drain-path test modeled on the existing dead-letter test: enqueue a row, trigger
a TypeError/network failure exactly STALLED_NETWORK_ATTEMPTS times, then assert
networkRetryCount >= STALLED_NETWORK_ATTEMPTS while failed remains false or
undefined. Preserve the existing setup and use the actual drain behavior rather
than testing the constant’s numeric range.
🤖 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.

Nitpick comments:
In `@unit/dexie/sync-outbox-ordering.test.ts`:
- Around line 208-213: Replace the range-only test in the “stalled-outbox
visibility threshold” suite with a drain-path test modeled on the existing
dead-letter test: enqueue a row, trigger a TypeError/network failure exactly
STALLED_NETWORK_ATTEMPTS times, then assert networkRetryCount >=
STALLED_NETWORK_ATTEMPTS while failed remains false or undefined. Preserve the
existing setup and use the actual drain behavior rather than testing the
constant’s numeric range.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 35d779ac-de5e-492e-ac4a-3c7467218156

📥 Commits

Reviewing files that changed from the base of the PR and between 56a62e8 and 318b482.

📒 Files selected for processing (5)
  • app/crew/_components/failed-sync-banner.tsx
  • lib/dexie/helpers.ts
  • lib/dexie/net.ts
  • lib/dexie/syncService.ts
  • unit/dexie/sync-outbox-ordering.test.ts

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.

2 participants