Skip to content

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

Open
wpfleger96 wants to merge 7 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 7 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. Publish cycles are serialized — at most one runs at a time; a newer edit queued mid-cycle defers and the in-flight cycle's completion re-drives it — so concurrent generations can never interleave over shared manager state. The per-edit baseline is owned by its generation, and a completing publish folds its own accepted head forward via canonicalMax, so a prior generation's own write is never mistaken for a competing remote and adopted away, and a stale generation cannot sign or publish after a newer edit exists (guarded both after the pre-publish fetch and immediately before signing).
  • 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 equal-updatedAt remote-wins merge is scoped to the canonical-supersession path only — when a lower-id event corrects an already-applied same-timestamp larger-id head — so a stale value from a superseded larger-id event delivered first cannot survive and undo the winner. Every other application (bootstrap, live, newer timestamp) keeps local-wins merge and does not cancel a pending publish, so a later same-second local click is never clobbered by an older remote entry that decrypts late; 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 5 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>
Each prior round patched one cross-generation interleaving and opened
another a layer deeper. Kill the race class structurally instead.

Sections: serialize publish cycles (one in-flight at a time; a newer edit
queued mid-cycle defers and the completion re-drives it). The per-edit
pre-publish baseline is frozen at queue time, so a genuine remote observed
during the debounce window still adopts, while our own accepted head is
folded forward via canonicalMax so a stale generation's own write is never
mistaken for a competing remote and adopted away. Dual generation guards in
doPublish (post-fetch and pre-publish) stop a stale generation signing or
publishing after a newer edit exists.

Stars/mutes: scope mergeApplyingRemote (remote-wins on entry-tie) and the
pending-publish cancel to fire only on a canonical supersession of an
already-applied same-timestamp larger-id head. Every other application
(bootstrap/live/newer-timestamp) keeps local-wins mergeStores and does not
cancel the pending publish, so a later same-second local click is no longer
clobbered by an older remote entry that decrypts late.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ions-sync-fixes

* origin/main: (22 commits)
  Downgrade mobile Huddles to audio protocol v2 (#6558)
  perf(desktop): make the Projects surface render-cheap (#6460)
  refactor(acp): clarify agent prompt sections (#6501)
  Add mobile Huddles voice MVP (#6056)
  feat(desktop-messages): keep agents addressed across messages (#6315)
  fix(desktop): remove Buzz entity link previews (#6512)
  fix(composer): preserve caret when inserting mentions mid-message (#6531)
  chore(deps): update rust crate async-trait to v0.1.92 (#6094)
  chore(deps): update dependency sonner to v2.0.8 (#6093)
  chore(deps): update rust crate http-body-util to v0.1.4 (#5452)
  chore(deps): update rust crate http to v1.4.2 (#5451)
  chore(deps): update rust crate futures-util to v0.3.33 (#5448)
  chore(deps): update rust crate futures to v0.3.33 (#5445)
  chore(deps): update dependency @tauri-apps/api to v2.11.1 (#5444)
  chore(deps): update ubuntu:24.04 docker digest to 561618e (#5442)
  chore(deps): update swatinem/rust-cache digest to 6323deb (#5441)
  fix(desktop): restore true zoom by scaling the root rem (#6514)
  chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec (#6517)
  feat(workflows): clarify workflow setup and activation (#6470)
  perf(desktop): stop the Projects fan refetching on re-entry and running after leave (#6458)
  ...

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
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