fix(sync): converge channel sections across devices on the same identity - #6525
Open
wpfleger96 wants to merge 5 commits into
Open
fix(sync): converge channel sections across devices on the same identity#6525wpfleger96 wants to merge 5 commits into
wpfleger96 wants to merge 5 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.ParamReplaceOutcomeenum (Inserted/Duplicate/Stale) incrates/buzz-dbso the DB layer distinguishes a stored head, an idempotent duplicate (exact-id resubmit or replay of a soft-deleted coordinate), and a strictly-losingStalewrite.ingestnow repliesOK falseconflict: newer version existsforStale, so the client refetches the head and converges.DuplicatestaysOK true.Desktop
localStorage, advances the sync watermark — and skips publishing. This unifies with the relay'sOK falseconflict 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.localStorageoutbox 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.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-entryupdatedAttoward 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.created_atpast the relay's ±15-minute future-drift window and wedge every later publish.created_atis now clamped inside that window (now + 840s).