Skip to content

fix(sync): converge channel sections across devices on the same identity - #6525

Open
wpfleger96 wants to merge 5 commits into
mainfrom
wpfleger/channel-sections-sync-fixes
Open

fix(sync): converge channel sections across devices on the same identity#6525
wpfleger96 wants to merge 5 commits into
mainfrom
wpfleger/channel-sections-sync-fixes

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 21, 2026

Copy link
Copy Markdown
Member

Channel-section sidebar state (the sidebar's channel groupings) diverges between a user's dev build and installed DMG on the same identity: sections differ at app open, sometimes self-heal after minutes, sometimes never, and manual "kick" edits inconsistently force convergence. The root cause spans the relay's write-outcome signaling and four client-side sync gaps.

Relay

A NIP-33 parameterized-replaceable coordinate write that lost last-write-wins was reported to the client identically to an idempotent exact-id resubmit — OK true. The losing device recorded its stale content as successfully synced and never refetched, so the two devices stayed diverged.

  • Added a ParamReplaceOutcome enum (Inserted / Duplicate / Stale) in crates/buzz-db so the DB layer distinguishes a stored head, an idempotent duplicate (exact-id resubmit or replay of a soft-deleted coordinate), and a strictly-losing Stale write.
  • ingest now replies OK false conflict: newer version exists for Stale, so the client refetches the head and converges. Duplicate stays OK true.

Desktop

  • Adopt the winner on lost LWW. A local edit that lost whole-blob LWW at pre-publish time was silently republished as remote content while the UI kept showing the edit. The sync manager now adopts the winning remote head — writes it through to React state and localStorage, advances the sync watermark — and skips publishing. This unifies with the relay's OK false conflict path as one convergence mechanism. The pre-publish check compares the fetched head against a canonical (created_at, id) baseline frozen when the edit was queued, not the live watermark, so a remote observed during the debounce window that became head after the edit began is adopted rather than overwritten.
  • Durable outbox — never drop an edit. Edits made inside the 2s publish debounce were lost on quit or community switch. A localStorage outbox persists every edit synchronously and resumes it on next mount. Adopt clears the outbox so a superseded edit can never be replayed back into divergence.
  • Single-owner pending-edit convergence. Every remote arrival (bootstrap, live, periodic reconcile) defers to a pending local edit whose own debounced publish resolves via publish-or-adopt, so a passive remote can never clobber the optimistic edit or strand its outbox. Each pending edit carries a monotonic generation; a publish completion clears pending/outbox/retry state only via compare-and-swap on the generation it published, so an older in-flight publish cannot erase a newer edit queued mid-flight.
  • Canonical equal-timestamp tie-break. Same-second writes from two devices are ordered to match the relay/database (created_at DESC, id ASC — lowest event id wins), so the UI converges on the event the relay actually stored instead of the largest id it happened to see first. This applies uniformly across all four kind-30078 sidebar surfaces that share the comparator — channel sections, stars, mutes, and sort preferences. For the per-entry stores (stars, mutes) the merge step also resolves an equal per-entry updatedAt toward the canonical incoming event, so a stale value from a superseded larger-id event delivered first cannot survive and undo the winner; strictly-newer local per-entry edits still win.
  • Clamp published timestamps. A skewed remote head could push the published created_at past the relay's ±15-minute future-drift window and wedge every later publish. created_at is now clamped inside that window (now + 840s).
  • Reconciliation loop. Stale-at-open state waited for a reconnect event a healthy socket never fires. A single scheduler periodically refetches the head (steady 60s, bounded backoff on failure) and refreshes on window visibility, so divergence self-heals without a reconnect.

Duncan and others added 2 commits August 21, 2026 19:05
A NIP-33 parameterized-replaceable write that lost last-write-wins was
reported to the client the same way as an idempotent exact-id resubmit:
OK true. The losing device recorded its stale content as successfully
synced and never refetched, so channel-sections diverged silently between
devices on the same identity.

Add a ParamReplaceOutcome enum so the DB layer distinguishes an Inserted
head, an idempotent Duplicate (exact-id resubmit or replay of a
soft-deleted coordinate), and a Stale conflict (a distinct write dominated
by a newer head). ingest now replies OK false "conflict: newer version
exists" for Stale so the client refetches and converges; Duplicate stays
OK true.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Channel-section sidebar state diverged between a user's devices and
sometimes never self-healed. Four client-side gaps fed the divergence:

- A local edit that lost whole-blob LWW was silently republished as remote
  content while the UI kept showing the edit. Now the manager adopts the
  winning remote head (writes it through to state + storage, advances the
  watermark) and skips publishing, unifying with the relay's OK-false
  conflict path as one convergence mechanism.
- Edits made inside the 2s publish debounce were dropped on quit or
  community switch. A durable localStorage outbox persists every edit
  synchronously and resumes it on next mount; adopt clears the outbox so a
  superseded edit can never be replayed.
- A skewed remote head could push the published createdAt past the relay's
  future-drift window and wedge all later publishes. createdAt is now
  clamped inside that window.
- Stale-at-open state waited for a reconnect that a healthy socket never
  fires. A reconciliation loop periodically refetches the head (steady 60s,
  backoff on failure) and refreshes on window visibility.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 21, 2026 23:12
Duncan and others added 3 commits August 21, 2026 20:10
Three cross-layer races defeated the one-convergence-mechanism design:

- An older in-flight publish unconditionally cleared pending state on
  completion, erasing a newer edit queued mid-flight. Each pending edit now
  carries a monotonic generation; a completion clears pending/outbox/retry only
  via compare-and-swap on the generation it published.
- Hook-level remote application (bootstrap/live/periodic) cancelled the pending
  publish's timers without deciding supersession, stranding the durable outbox
  and clobbering the optimistic edit. applyRemote now defers entirely to a
  pending edit, whose own debounced publish converges via publish-or-adopt; the
  manager's adopt path clears pending before write-through so the winning remote
  still applies.
- The equal-timestamp tie-break kept the largest event id, opposite the
  relay/database canonical order (created_at DESC, id ASC → lowest id wins).
  applyRemote now applies a strictly-lower id and ignores ids >= the last
  applied, so the UI converges on the event the relay actually stored.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useChannelStars, useChannelMutes, and useChannelSortPreference carried the
same inverted equal-timestamp comparator as channel sections: applyRemote kept
the largest event id, opposite the relay/database canonical order (created_at
DESC, id ASC -> lowest id wins). Two devices writing the same second could
leave the UI showing an event the relay did not store.

Apply a strictly-lower id and ignore ids >= the last applied, matching the
sections fix and the relay winner across all four 30078 sidebar surfaces. Each
hook gains a regression test: larger-then-lower id delivery at equal timestamp,
lower-id store wins (mutation-checked - reverting >= to <= fails each).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two convergence holes one layer under the pass-1 fixes:

Sections: the pre-publish head check compared the fetched head against
the mutable lastRemoteCreatedAt, which a live event observed during the
debounce window already advanced to that same head — equality fell
through to publish and the local blob overwrote a remote that became
head after the edit was queued. Freeze a canonical head baseline
(created_at, id) at publishSections and compare the fetched head against
that generation baseline instead, adopting when the head advanced.

Stars/mutes: applyRemote admits the canonical lower-id winner but then
mergeStores resolved equal per-entry updatedAt as local/prev-wins, so a
stale larger-id value delivered first survived and undid the winner. Add
mergeApplyingRemote which resolves an entry-timestamp tie toward the
canonical incoming blob while keeping strictly-newer local entries.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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.

1 participant