Skip to content

Per-node write sequencing: move/create wait cycle, queued generic fields, OCC during a nested flush #3037

Closed

Description

@malibio

Overview

Three narrow write-path gaps in SharedNodeStore / SimplePersistenceCoordinator, found while reviewing #3033. None loses data in a common flow, and the first recovers on its own, but all three belong to the same class: per-node write sequencing.

1. Create/typed flush can wait on a move that waits on it (5 s stall)

  • Cause: sendPendingTypedFields() awaits a pending move before sending. It runs:
    • after each create path (generic closure, setNode closure, batch closure);
    • inside the typed write's own closure.
  • Create path: when a node's create is still running, indentNode falls through to a MOVE that calls flushNodeSaves([nodeId]), which waits for that same create. With typed fields staged, the create waits on the move and the move waits on the create. Only the 5 s timeout in flushAndWaitForNodes breaks the cycle.
  • Typed path: the same shape occurs when two quick Tab presses are followed by a checkbox click.
  • Existing precedent: the generic closure's own pending-move wait (before its update RPC) already has this pattern.
  • Direction: in the create paths, hand the staged fields to a new coordinator write (persist(nodeId, () => sendPendingTypedFields(nodeId), { mode: 'immediate' })) instead of flushing inline. That write queues behind the create, and the move captured the create's promise, not this one, so the cycle can't form. Apply the same review to the generic closure's wait.

2. A queued generic write's non-content fields can be replaced

The coordinator keeps one queued write per node, and the latest write wins regardless of mode. Staged typed fields survive this, but a generic write's capturedNonContentFields do not. The failure: typing, then a custom: property change via a shortcut, then more typing, all within one in-flight RPC — the property change is never sent to the server. Latest-wins is only safe for writes that carry full state.

  • Direction: stage generic non-content fields the way typed fields are staged, or merge them when a queued entry is replaced.

3. OCC during a typed flush inside another write, while the node is focused

  • Double toast, lost write: hydration is skipped, so the local version isn't refreshed. The generic write that ran the flush then conflicts again, raising a second version-mismatch toast, and its own change is lost.
  • Unsettled callbacks: the OCC handler's pendingTypedFields.delete(nodeId) drops any newly staged fields and their callbacks without firing either.
  • Direction: have sendPendingTypedFields() report OCC so the calling write can skip its own RPC, and fire onPersistError for any staged callbacks it drops.

Acceptance Criteria

  • No create or typed write waits on a move that waits on it; covered by a test with a pending create plus an indent plus a staged typed field
  • A generic non-content change queued behind an in-flight write survives a later queued write for the same node
  • An OCC during a flush inside another write raises one notification, and every dropped callback is settled

Related

Activity

  1. malibio commented on Sep 25, 2026

    @malibio
    CollaboratorAuthor

    Frontend baseline: 269 files / 5313 tests passed

  2. self-assigned this
    on Sep 25, 2026
  3. added a commit that references this issue on Sep 26, 2026
    03e16c7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingui

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions