Skip to content

Sub-100ms event-loop liveness for large initial sync (follow-up to #104) #111

Description

@AlexJeffcott

Background

Polly #104 closed against the fragmentation + observability + yield-only scope. That fix removed the silent-drop wedge (werift's 64 KiB cap dropping every 65 630-byte fragment) and added the inFlightSync snapshot field, the sync-progress event, and the receiver-side macrotask yield. The contract the ticket asked for is partially held:

  • Sentinel propagates end-to-end on a throttled in-process transport with a 5.5 MB seeded snapshot.
  • inFlightSync is observable mid-run.
  • Most of the receive-side sync time is spent off the wire-callback stack frame.

What is NOT held: the strict <100ms event-loop tick-gap bar during the bulk-apply step. The single applyChanges call for a multi-megabyte initial sync is one synchronous Automerge call. The existing setTimeout(0) yield moves dispatch off the wire frame but cannot split that call. The harness in examples/mesh-large-initial-sync/ reports a residual ~300 ms tick spike for a 5.5 MB snapshot and the example's NOTE line documents this.

Burden of proof

Same shape as #104. This ticket closes only when polly demonstrates with a captured run that:

  • The harness exercises the failing-shape diff (≥ 5 MB compacted snapshot, throttled transport, tick-gap probe running before sync starts).
  • The probe records NO tick > 100 ms during the sync window.
  • The sentinel still propagates end-to-end.
  • A falsification path (pre-fix-emulated mode) reproduces the residual spike and exits non-zero with a named reason that points at the apply step, not at a generic timeout.

Two candidate approaches

A. Receive-side change-batching apply

Intercept reassembled sync messages, decode the embedded changes array, apply them in batches of N changes with setTimeout yields between batches. Re-emit the synchroniser's outbound sync message only after the full chain has applied so the sender doesn't see partial state.

Risk: Automerge's sync state machine isn't designed to be paused mid-apply. The decode path is implementation-private; relying on it pins polly to an Automerge version. The synchroniser's own state-tracking would need to match the slow apply to avoid sending stale have heads while we're still applying.

B. Web Worker apply

Run the Automerge Repo in a Worker. The receive-side mesh adapter forwards inbound sync messages to the worker via postMessage; the worker applies them and posts state deltas back. The main thread never sees the multi-megabyte apply call.

Risk / scope: storage adapter bridging (the worker needs to own its own storage subsystem, or proxy through postMessage), state subscription bridging ($meshState consumers expect synchronous reads of the latest doc state), and the document handle lifecycle (whenReady, change) all need worker-aware variants. Tests and examples need to be redone for the worker target. Multi-week scope.

Approach (B) is more invasive but more durable; (A) is less invasive but more fragile against Automerge upstream churn.

What I will not accept as proof

Same exclusions as #104. In particular: "all unit tests pass" does not imply the contract is held; the load-bearing signal is the tick-gap probe running against the failing-shape diff.

Out of scope

Tracking

Follow-up to polly#104. The harness examples/mesh-large-initial-sync/ is the proof surface; the residual tick-gap spike it reports under the post-fix mode is the failure this ticket closes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions