Skip to content

fix(p2p): replication reliability — durable checkpoints, reconnection, convergent LWW#54

Open
mads-jm wants to merge 7 commits into
mvpfrom
fix/replication-reliability
Open

fix(p2p): replication reliability — durable checkpoints, reconnection, convergent LWW#54
mads-jm wants to merge 7 commits into
mvpfrom
fix/replication-reliability

Conversation

@mads-jm

@mads-jm mads-jm commented Jul 16, 2026

Copy link
Copy Markdown
Owner

⚠️ P2P protocol change — needs-p2p-review. Per CLAUDE.md this was built under explicit human approval, with the standing condition that any protocol change is fully documented. Protocol behavior/semantics changed; the wire format, message types, and version id are byte-unchanged (no 1.0.01.1.x bump). Requires a human reviewer.

Short Description

Hardens RxDB-over-libp2p replication from happy-path to reliable across all five fragile surfaces the state-of-the-union flagged: durable checkpoints (no more full resync per launch), pull timeout/backoff + relay reconnection, skew-aware LWW with a convergent tie-break, backpressure-safe file-chunk sends, and the P2P test coverage that was entirely absent.

Background

The replication layer was architecturally sound but operationally brittle, with zero tests. The subtle one is the LWW tie-break: on an exact-millisecond updatedAt tie we fall back to a content key. The sender strips device-local fields (localFilePath, localFileSize, albumArtLocalPath, coverArtLocalPath) from the transmitted payload, but the receiver computed the key over existing.toJSON(), which kept them — so the two sides hashed different field sets and, because 'l' < 'n', the existing side always lost, swapping user content on every tie. That's a non-convergent oscillation and a direct violation of "identical on both peers". It's fixed by sourcing the device-local field list from a single shared constant (DEVICE_LOCAL_FIELDS in schemas.ts) consumed by both the sender-strip and the tie-break, so the two can't drift apart again.

What Has Changed

  • fix(p2p): persist replication checkpoints #40 Durable checkpointscheckpoint-store.ts (new) persists per-collection/peer checkpoints, killing the full-resync-every-launch behavior (was in-memory at p2p-service.ts:71). Path re-derived from platform userData conventions in the utility process
  • fix(p2p): replication timeout/backoff + reconnection #41 Timeout/backoff + reconnectionbackoff.ts (new); pull no longer resolves empty-on-timeout silently; relay-manager.ts gains backoff + reconnection and a getConnections-based heartbeat for dropped/half-open links (p2p-config.ts tunables)
  • fix(p2p): LWW timestamp parsing (replace string comparison) #42 Skew-aware convergent LWWlww.ts tie-break strips DEVICE_LOCAL_FIELDS from both sides before the content key (replication-handler.ts + useSessionReplication.ts share the constant)
  • fix(p2p): harden & test P2P fileshare v0 #47 Backpressure-safe file sendsfile-transfer.ts chunk writes respect stream backpressure (was unhandled at :463)
  • test(p2p): replication, handshake & turn-coordination tests (N13) #32 P2P tests — new suites: replication.test.ts, handshake.test.ts, relay-manager.test.ts, backoff.test.ts, checkpoint-store.test.ts, file-transfer.test.ts, plus a shared harness.ts; convergence regression cases in lww.test.ts (incl. one with all four device-local fields populated)
  • DocsRxDB-Replication.md updated for the new behavior and the tie-break field exclusion

Steps for Reviewing

  1. Wire-format claim first: read protocols/replication.ts and confirm no message type/shape/version changed — only behavior. ✅ inspector independently confirmed (no diff on message types / ipc-protocol)
  2. schemas.ts DEVICE_LOCAL_FIELDS → confirm it's the single source consumed by both useSessionReplication.ts (sender-strip) and lww.ts (tie-break). This is the convergence fix.
  3. lww.test.ts convergence cases → confirm they'd fail without the fix (the stored fixture now carries localFilePath, which is exactly what let the original bug through).
  4. checkpoint-store.ts → durability + graceful degradation to full resync if the userData path can't be derived.
  5. relay-manager.ts → reconnection/backoff/heartbeat.
  6. cd app && npm test — 254 pass. ✅ independently re-run; src/ typecheck clean. ✅

Checklist

  • Tests green (254/0), typecheck clean on src/; inspector verdict APPROVE after fix-pass
  • Wire format / message types / protocol version unchanged (independently verified)
  • Protocol behavior documented in RxDB-Replication.md (the standing P2P condition)
  • LWW stays the MVP strategy (CRDT is Phase 2, out of scope)
  • Human P2P review required (needs-p2p-review)
  • Lint: ESLint config doesn't load on base — see chore/fix-dev-env-lint

Closes #40
Closes #41
Closes #42
Closes #47
Closes #32


Comments

Carry-forwards (non-blocking):

  • Heartbeat detects dropped/half-open libp2p connections (the spec's named case) but not an app-level connected-but-dead peer — an active ping keepalive is the follow-up.
  • Checkpoint path derivation would miss if a packaged app name differs from WhatNext (degrades gracefully to full resync); main could inject the path later.

mads-jm added 6 commits June 27, 2026 11:15
Persist per-peer/per-collection replication checkpoints to a debounced JSON
file under userData so a relaunch resumes incrementally instead of
full-resyncing every collection (#40). Stop silently dropping changes on pull
timeout: a timed-out pull now rejects and does not advance the checkpoint, and
the responder advances to the newest returned doc's updatedAt rather than
wall-clock now. Act on the previously-dropped pull-response on the requester
side. Replace fixed 10s relay retries with exponential backoff + jitter, a
single alternate-relay fallback, and a liveness heartbeat for half-open links
(#41). Wire protocol id unchanged.

Covered by checkpoint-store, backoff, relay-manager and replication suites (#32).
Replace the brittle string comparison of updatedAt in replication-handler with
epoch-ms parsing (lww.ts). Unparseable/missing timestamps resolve to oldest
rather than throwing. Equal-timestamp ties are broken by a deterministic
contentKey projection that strips id, updatedAt/addedAt and RxDB-internal
underscore fields identically on both sides, so two peers comparing a
transmitted data payload against a full toJSON elect the SAME winner and
converge (acceptance #42). Covered by lww suite incl. a cross-peer
convergence test (#32).
Route serve-stream writes through a per-stream serialized queue that awaits the
libp2p stream drain signal before overrunning the write buffer, so a slow
reader paces the sender instead of triggering the stream reset that made >10MB
transfers flaky. Removes the standing backpressure TODO (#47). Covered by a
large-file (>10MB) reassembly + sha256 integrity test and a drain-wait unit
test (#32).
Add handshake capability-exchange tests (success, capability-mismatch surfaced
not rejected, empty-stream no-op) on the shared in-memory libp2p harness,
closing the last untested protocol path (#32).
Update the RxDB-Replication concept page to reflect durable checkpoints, the
non-silent pull-timeout policy, relay backoff/fallback/heartbeat, and
skew-aware LWW with the convergent tie-break. Records the explicit decision NOT
to bump the wire-protocol version (stays /whatnext/rxdb-replication/1.0.0) per
the epic's no-silent-bump constraint.
The sender strips device-local fields (localFilePath, localFileSize,
albumArtLocalPath, coverArtLocalPath) before transmission, but the
receiver computed the equal-timestamp content tie-break key over the
unstripped stored doc. On an exact-ms tie the existing-side key carried
localFilePath (which sorts before name), so the incoming version won on
both peers -- they swapped user content and oscillated indefinitely,
violating cross-peer convergence (AC #42).

Introduce a single DEVICE_LOCAL_FIELDS constant in schemas.ts consumed by
both the sender strip and lww.contentKey so the two field sets cannot
drift. Receiver-side only: wire format and protocol id are unchanged.
@github-actions

Copy link
Copy Markdown

⚠️ P2P Protocol Files Modified

This PR touches files in the P2P scope gate:

The P2P protocol is off-limits for fully autonomous agentic work without explicit human approval.

Before merging, a human reviewer must explicitly confirm:

  • Changes are intentional and understood
  • Backward compatibility with existing peers considered
  • No unintended side effects on session stability or replication

See [[workflow-story-to-pr]] §3 Agentic Scope Gates.

The Architect handoff notes are working artifacts of the agent cycle, not
project source. They were the only add/add conflict when integrating the
Wave-1 lanes, and they do not belong in the reviewable diff. Removing them
here keeps the PR diff to real changes; .claude/ scratch is gitignored on mvp.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant