Bank bulk-copy progress across blob-gap reconnects and repair missing blob files on copy re-delivery - #701
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces position-indexed durability watermarks for bulk-copy resume cursors via a new CopyCursorWatermark class, allowing replication to resume from the last successful walk position before a transient blob fault rather than restarting from scratch. It also adds configuration for blobGapReconnectMs to tune gap cycles separately from blob timeouts, along with comprehensive unit and integration tests. Feedback on the implementation highlights a potential performance bottleneck in CopyCursorWatermark.stageCursor where a loop checking for unsettled tags could be optimized from O(frameIndex - tail.index) to O(unsettled tags) by traversing the sorted doubly-linked list instead.
|
Docs companion for the new config keys: documentation#628 — Document replication.blobGapReconnectMs and bulk-copy cursor flush options. — Claude (Fable) |
|
Reviewed; no blockers found. The prior finding (non-ENOENT open failures returning |
|
Read the watermark class and the wiring. The things I'd flagged as the risky parts of this shape are handled: pass/anchor captured at decode time (with Three things from measuring the live cluster tonight. 1. Field evidence for review note 4 — and it says the pair must ship togetherNote 4 accepts "a zero-progress gap cycle is still unbounded churn if the record just past the banked cursor never heals at the source." On the kohls cluster that is not hypothetical, and I can now say how big it is. Latching faults on v3t sourced from yc0, 60-minute window: Four recurrences per blob per hour is one per 15 minutes — the 900s watchdog cadence. These are the same 23 blobs failing every cycle, i.e. a fixed population of permanently-damaged references, not fresh transient contention. (I had assumed the opposite in the issue and was wrong; this measurement is what corrected me.) The prediction that follows: #701 alone would advance each link's resume key to just before its first permanently-damaged blob and then stall there — banking real progress once, then repeating a cheap seek-resume cycle indefinitely. That is exactly note 4's failure mode, and it is guaranteed on this cluster rather than merely possible. It's also a strong argument for the sequencing you chose — harper#2177 is what makes the barrier position move on the next cycle rather than re-latch at the same key. Worth stating in the PR body that the pair is required, not merely convenient, for the field case. Corollary for the repair's cost note (harper#2177 note 5): the damaged population per source link is small and finite — ~23 here — so the probe-per-tie cost lands on a bounded set, not on every re-delivered row indefinitely. 2.
|
|
Follow-up with disk-level verification, and it refines one claim in the fix-pair comment on #699 ("#3's fault supply should mostly drain on its own once copies converge and stubs get repaired"). That drain has a topology hole, and this cluster sits exactly in it. The stubs are verified persistent, not transientPulled the recurring fileIds' paths from the forwarded error text and read them on yc0's disk: PENDING (0xfe) stubs, ~23 hours old, minted before the 5.2.2 upgrade, still referenced by live records (the fault lines name the record URLs). #481's "the re-stream overwrites this stub" is confirmed dead on disk, exactly as harper#2177's analysis says — re-streams mint fresh fileIds, so the stub file is never rewritten and the record's reference never moves. Both fault populations are fixed sets recurring at the watchdog cadence (every ~15 min):
Why the pair drains e9v's 217 but not yc0's 23harper#2177's repair fires on copy-apply identity ties — i.e. on nodes that receive a base copy. And usefully, the healing doesn't require the copy to complete: the walk applies (and repairs) re-delivered records past the barrier even while the cursor is clamped, so one post-fix walk over a damaged record heals it.
Consequence for the two links copying from yc0 (v3t←yc0, cd5←yc0): with banking, each converges up to the first of the 23 damaged keys and then latches there every cycle — the barrier re-latches at the same frame each reconnect, so the resume key freezes permanently at that key. Cheap cycles now (seek-resume, not full re-walk), but the links never exit copy mode, and these links need their copies to complete (their seq cursors are 5.5–7.2 days stale against 3-day retention — a copy that never finishes means they never leave copy mode at all). Note 4's accepted risk isn't an edge case on this cluster; it's the guaranteed steady state for the root source's links. Ways to close the hole (in rough order of leverage)
One prediction this makes falsifiable post-deploy: if the pair lands and the yc0-sourced links still cycle, the resume key in the new "Resuming interrupted copy … after key K" log line will be frozen at the same K every cycle, and K will be the record just before the first of the 23. That's the signature to look for before concluding anything else is wrong. |
9c0f1a9 to
f9d4b50
Compare
|
Design rework since the original draft (per Kris): the dangling-blob repair moved out of core's write path entirely — instead of reclassifying the copy-apply duplicate and rewriting the record, the receiver now streams the re-delivered bytes into the stored record's existing fileId (core |
…instead of discarding the walked prefix (#699) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: GPT-5 Codex <noreply@openai.com>
f9d4b50 to
d4b1b75
Compare
|
Independent verification of head Banking: confirmed fixed, independentlyMy sustained-fault repro ( The PR's own banking half also behaved: Bonus: in my fault-supply characterization, the receiver now logs the source's actual Blocking: the in-place repair fired 0 times in a clean run of this PR's own e2e
Banking worked; the repair never fired; two records ended permanently blob-less — the exact field class the pair exists to fix. The receiver's log shows all 4 banked resumes re-delivering the damaged span and zero The aperiodic injector makes schedules non-deterministic (your run: Candidates worth instrumenting first (all silent today):
Ask: a per-decline-class debug counter (or one line per class) before merge, then re-run the e2e wherever it failed. If the cause is (1), an async Early reconnect has no pacing floor under a moving fault supplyEmpirical, same harness both sides: 5.2.2 paced 4 cycles at the watchdog interval; this branch ran 27 copy starts in ~76 s — back-to-back banked cycles — re-streaming 1115 blobs in a 17 s quiet window. It converges (each cycle banks, which is the design), but on a busy mesh with continuous transient contention this is continuous re-stream churn with no floor, and each early reconnect aborts in-flight receives, minting receiver-side PENDING stubs — benign when the repair heals them on the next pass, except per the section above the repair can silently not fire. A modest floor (minimum bank advance per cycle, or a minimum interval between banked reconnects) would bound it. Kohls' fixed damaged sets bank nothing per cycle → still watchdog-paced → unaffected by this. Note 1's per-frame granularity note also composes with this: "a fault supply dense enough to hit every message banks nothing" → watchdog-paced (safe); it's the middle regime — faults every few messages — that cycles continuously. Scope observation (release-risk, not correctness)This PR now carries a second, separable change set: clone-attempt markers, the Minor
|
|
Drafted, validated fixes for the repair non-fire (patches below, apply with
On a resumed copy, the re-delivered span was applied by a previous connection, so cold entries are the norm in exactly the repair window. Warm cache (small table, same process, records applied moments ago) fires; cold cache silently never does. That's why it passed on the authoring machine and failed here — and it would under-fire in production on precisely the large tables that matter. Fix: await the entry in both places; only a resolved absence counts toward the window latch (so the latch still turns the probe off once the walk passes the span); every decline increments a per-class counter, logged once per connection at retire (debug). One existing unit test had encoded the bug — Validation (this branch, macOS, same harness as my earlier report):
--- a/replication/replicationConnection.ts
+++ b/replication/replicationConnection.ts
@@ -1015,20 +1015,33 @@
* uses the normal fresh-save path. A tie guarantees content identity, so bytes-for-bytes overwrite
* of the existing file is sound; positional pairing is sound for the same reason (identical
* structure). Never throws: any read/decode surprise returns null (normal path, no repair).
+ *
+ * `getEntry` may return a Promise (block-cache miss) — AWAITED here, never treated as absence: on a
+ * resumed copy the re-delivered span was applied by a PREVIOUS connection, so cold entries are the
+ * norm in exactly the window this function exists for. Callers run on the serialized message chain
+ * (before decode), where awaiting is safe. Every decline increments its class in `declines` so a
+ * field non-fire is attributable — a silent null is indistinguishable from "no damage" (#699's
+ * observability lesson, applied to the fix itself).
*/
export async function collectBlobRepairTargets(
tableDecoder: any,
id: any,
incomingVersion: number,
sourceNodeId: number | undefined,
- blobFileDamaged: (blob: any) => Promise<boolean | undefined> | boolean | undefined
+ blobFileDamaged: (blob: any) => Promise<boolean | undefined> | boolean | undefined,
+ declines?: Record<string, number>
): Promise<any[] | null> {
+ const decline = (reason: string) => {
+ if (declines) declines[reason] = (declines[reason] ?? 0) + 1;
+ return null;
+ };
try {
- if (sourceNodeId === undefined) return null;
- const existing = tableDecoder?.getEntry?.(id);
- if (!existing || typeof existing.then === 'function') return null;
- if (existing.version !== incomingVersion) return null;
- if ((existing.nodeId ?? 0) !== sourceNodeId) return null;
+ if (sourceNodeId === undefined) return decline('no-source-node');
+ let existing = tableDecoder?.getEntry?.(id);
+ if (existing && typeof existing.then === 'function') existing = await existing;
+ if (!existing) return decline('no-stored-entry');
+ if (existing.version !== incomingVersion) return decline('version-mismatch');
+ if ((existing.nodeId ?? 0) !== sourceNodeId) return decline('node-mismatch');
const blobs: any[] = [];
findBlobsInObject(existing.value, (blob: any) => {
blobs.push(blob);
@@ -1038,10 +1051,10 @@
- if (blobs.length !== 1) return null;
- return (await blobFileDamaged(blobs[0])) === true ? [blobs[0]] : null;
+ if (blobs.length !== 1) return decline(blobs.length === 0 ? 'no-file-blobs' : 'multi-blob');
+ return (await blobFileDamaged(blobs[0])) === true ? [blobs[0]] : decline('healthy');
} catch {
- return null;
+ return decline('error');
}
}@@ call site (~:4961) @@
- const existsLocally = (() => {
- try {
- const entry = tableDecoder?.getEntry?.(id);
- return entry != null && typeof entry.then !== 'function';
- } catch {
- return false;
- }
- })();
- if (existsLocally) {
+ // AWAIT a Promise-returning getEntry (block-cache miss) rather than treating it as
+ // absence: the re-delivered span was applied by a PREVIOUS connection, so its entries are
+ // cold in exactly this window — a sync-only check reads the whole span as missing, which
+ // both skips every repair AND latches the window off after 8 records (observed as
+ // inPlaceRepairs=0 with the damage intact). Awaiting is safe here: this runs on the
+ // serialized message chain, before decode. Only a RESOLVED absence counts toward the
+ // miss-run latch, so the latch still turns the probe off once the walk passes the span.
+ let storedEntry: any = null;
+ try {
+ storedEntry = tableDecoder?.getEntry?.(id);
+ if (storedEntry && typeof storedEntry.then === 'function') storedEntry = await storedEntry;
+ } catch {
+ storedEntry = null;
+ }
+ if (storedEntry != null) {
repairWindowMissRun = 0;
repairTargets = await collectBlobRepairTargets(
tableDecoder,
id,
auditRecord.version,
localSourceNodeId,
- blobFileMissingOrIncompleteAsync
+ blobFileMissingOrIncompleteAsync,
+ repairDeclines
);
} else repairWindowMissRun++;Plus: Separately: a floor on the banked reconnect — measured, and it's a genuine trade, not a winI prototyped the pacing floor I suggested earlier (
The floor cuts reconnect/stub-minting churn 4x — but re-streamed bytes go up, because a floored cycle keeps walking the unbankable tail that your immediate reconnect deliberately truncates. Your design is byte-cheaper; the floor is socket/abort-cheaper. With the repair actually firing (above), the stub-minting half of my original concern is largely self-healing, which weakens the floor's case. So: the floor diff exists and passes everything, but I'd treat it as optional — your call whether reconnect-rate pathology on a busy mesh (~21k reconnects/link/day at 4s cycles) is worth the extra bytes. Happy to drop it. One residual from my earlier report stands either way: my sustained-fault regression test still shows continuous cycling on this branch (7–27 copy starts where 5.2.2 had 4 watchdog-paced ones) — that's inherent to walk-time-paced healing under a fault supply that never dries, not a defect; I'll adapt that test's oracle once the floor decision settles. |
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
|
I addressed the measured non-fire and the remaining parity/safety gaps:
The exact-head external review retries are currently degraded by repeated Claude HTTP 529 overloads, so the PR stays draft and the review-need footer remains fail-closed at 4. — GPT-5 Codex |
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Resolve the core submodule conflict to the latest head of HarperFast/harper#2177. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
|
Warning Superseded in two places — see the correction below. The "Scope" section at the end is wrong: this PR carries no clone-readiness changes, so there is nothing to split. And the uncertainty-policy finding, while a real mechanism, is self-limiting in measurement and is not a blocker. The rest stands, and the code findings are fixed by Reviewed The uncertainty policy can wedge a link on the same shape as #699Note 1 says this "can retry indefinitely under persistent file-descriptor or I/O pressure." Tracing it, the loop is tighter than "retry indefinitely" suggests — it's self-latching, and the copy cursor stops for the whole connection:
The part that concerns me is the interaction of notes 1 and 4. Individually each is a defensible call. Together, a transient environmental condition (fd exhaustion is usually transient and usually correlated across a worker) is converted into a per-connection barrier that only a reconnect clears — and the reconnect immediately re-enters it. That's the #699 shape with a different trigger: bounded per cycle, zero net progress across cycles. Two things make it worse than the old behavior it replaces. Under 5.2.2 an unprobeable file wasn't specially handled, so the record just fresh-saved and the walk continued (dangling reference, but forward progress). And fd pressure is precisely what a blob-dense copy with I don't think "hold" is wrong — I think "hold forever on a condition that isn't about this blob" is. Options, cheapest first:
Worth noting Smaller
Scope, restating onceThis is 1,316 additions across banking + repair + clone-readiness + config. The banking and repair halves I've now reviewed closely and measured. The clone half — Happy to re-run my #699 reproductions against this head if that's useful before merge — they're independent of this implementation and caught the repair non-fire last time. — Claude (Fable), reviewing for Joseph |
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
|
Correction to my review above, plus measurements against 1. Scope — I was wrong; disregard that section entirelyI said this PR carries clone-readiness changes ( 2. The uncertainty-policy wedge — real mechanism, overstated severityI called it self-latching and treated it as the thing to resolve before merge. I've now measured it instead of reasoning about it. On With Your two commits fix all three findings
What the fix does — 1000 records, a fault injected every 7th blob save
D1 is dead. Resume keys climb monotonically — 4→7→10→16→21→27→…→81 — rather than sitting frozen. One aside worth recording: What it doesn't fix — relevant to the kohls rollout, not to mergingA source-held PENDING stub is never healed by this pair: receiver-side repair rewrites the receiver's file and cannot touch the source's stub. I planted 3 byte-identical PENDING stubs on the source at 400 records and ran both gap positions:
In both, the cursor freezes, the copy never completes, and it falls back to the Two things I want to be clear are not criticisms of this PR:
The operational consequence is only that the damaged source blobs still have to be cleaned as part of the rollout — the fix pair won't heal them, and any peer copying from a node holding them will stay in copy mode indefinitely. Worth a follow-up issue for source-side reclassification or peer-rotating re-request; happy to file it. VerdictApproving. Beyond the above I checked: no wire-format change and no persisted-cursor shape change (same four fields, source vars renamed to per-frame), so a rolling upgrade and a rollback are both safe; the typecheck adds zero new errors (the 30 present are pre-existing on main, and |
|
Triage of the three outstanding bot findings, verified against Live: the
|
|
Warning Retracted — see the consolidated position below. The ENOENT split is a preference, not a blocker: the fd-pressure premise behind it does not hold (this path holds ~1 concurrent descriptor per connection, and @kriszyp — short version of the two long comments above, so the ask is in one place. One blocking item. Revert the open-failure widening from return (openErr as { code?: string })?.code === 'ENOENT' ? true : undefined;and restore a The reason we'd rather not defer it: it's a regression against 5.2.2 introduced inside this series, and its failure mode is a latched barrier with reconnect cycling and no cursor progress — i.e. indistinguishable from #699 itself, on the workload most likely to trigger it. Two lines, on a path that already has the semantics. Nothing else is blocking. The two other outstanding bot findings are both already fixed in your branch (details in the comment above) — no action needed on either. CI is green across all six cluster shards, and I re-ran my #699 reproductions on One item for the rollout plan rather than this PR: a source-held PENDING stub is never healed by this pair — receiver-side repair rewrites the receiver's file, not the source's. I measured both a head-of-walk and a tail-of-walk gap; in both the cursor freezes and the copy never completes, falling back to the I'll re-run the reproductions once the ENOENT change is in. |
|
@kriszyp — retracting the "one blocking item" framing from my comment above. I checked the reachability arithmetic I should have checked before calling it blocking, and it doesn't support the severity. Sorry for the churn; this is the position I'll hold. The fd-pressure premise doesn't hold upThe blocker argument (mine and the bot's) rests on "this PR adds a probe open plus a repair open per duplicate frame, so the trigger is correlated with the workload." That conflates open rate with concurrent fd count, and exhaustion is about the latter:
That's on the order of one extra descriptor per connection — three or so on a 4-node cluster. Not an exhaustion driver. EMFILE would have to originate elsewhere in the process, and at that point the node has larger problems than this branch. Notably Which makes your tradeoff defensible, and possibly the better oneBoth policies fail only under that same unlikely trigger, so the question is which consequence you'd rather have:
That's exactly what your commit message on The one thing I'd still ask forRestore a return (await blobFileDamaged(blobs[0])) === true ? [blobs[0]] : decline('healthy');so an unanswerable probe is counted as VerdictBack to approving. To summarize the whole review in one place, since I've moved around:
|
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
* fix(replication): classify undecodable records + hold on unknown table id (#537) The receive-apply path wrapped record decode in a bare catch that logged and skipped EVERY decode failure, advancing the resume cursor past it without any classification or metric — silently sealing the skipped range and, for the genuinely-unrecoverable missing-structure case (harper#1163), never surfacing it. Two receive-path failures are now handled by their true nature: 1. Decode failure = PERMANENT (skip + surface). Transient conditions are handled upstream; every known decode source is root-caused, so one reaching this catch is unrecoverable old-version/corrupt data no re-copy heals (verified: the replication decoder is a raw StructonPackr that THROWS "Could not find typed structure" on an absent structure, and the sender always sends TABLE_FIXED_STRUCTURE before the dependent record — so a throw means the structure is genuinely absent, not merely unsynced). classifyReplicationDecodeError routes the missing-structure subclass to the same `decode-missing-structure` metric core's local-read path fires (alertable, concise for the #537 flood), everything else to a lazy error log. Both skip + advance. 2. Unknown table id = TRANSIENT (hold, don't seal). tableDecoders is populated only by TABLE_FIXED_STRUCTURE, which precedes the first record over an ordered WS, so a missing decoder means a missed structure sync or un-propagated schema (#1497) — recoverable on reconnect. The old code logged, fell through, and double-threw on tableDecoder.decoder inside the catch, escaping it and wedging the leg. Now it holds: close + reconnect + resume from the durable cursor (which re-sends TABLE_FIXED_STRUCTURE), without advancing past the record. This also makes tableDecoder provably non-null in the decode catch. Adds classifyReplicationDecodeError + unit coverage. harper-pro-only. Cross-model reviewed (Codex + Gemini + Harper-domain adjudication); reproduced against the harper#1163 divergence cluster harness (4/4 pass, mode-A throw confirmed, gap permanent, fix surfaces it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(replication): add poisoned-cursor characterization + decode-drop component tests (#537) Two tests for the decode-classify fix landed in the parent commit: Test 1 — Integration: poisonedCopyCursorDataLoss.test.mjs Characterizes the cursor-trust symptom the #537 decode-drop exploited. A receiver node presents a copyCursor with afterKey='row-0500' (injected via HARPER_TEST_INJECT_COPY_CURSOR_JSON) against a 1000-row source. The leader trusts the cursor, skips rows row-0001..row-0500, and delivers only the tail; B ends up with 500 rows and believes itself current — the gap is permanent. copyStartTime is set to 24h in the future to suppress audit replay (which would otherwise backfill the skipped range and hide the symptom). Passes 1/1 (~24s). Marked as a characterization/regression-guard: this still- present cursor-trust behavior is correct for a legitimate interrupted copy; it only becomes a data-loss vector when the cursor is wrong. Adds a minimal test hook (maybeInjectCopyCursorForTest) following the existing HARPER_TEST_COPY_STALL_ONCE_DB / HARPER_TEST_REPLICATION_WEDGE_DB pattern. The hook only fires when the env var is set and never overrides a real cursor. Test 2 — Unit (component fallback): decodeDropMissingStructure.test.mjs Drives the real StructonPackr encode/decode path (not synthetic errors) to confirm the full classification chain for a genuine missing-structure failure: StructonPackr encode auto-learns struct 0 → decode with empty typedStructs throws "Could not find typed structure 0" → isMissingStructureError → true → classifyReplicationDecodeError → 'skip-missing-structure'. Also pins DECODE_MISSING_STRUCTURE_METRIC = 'decode-missing-structure'. Passes 6/6 (< 5ms). Does NOT cover: full cluster end-to-end (no real replication frames), the readAuditEntry + getValue binary path (direct decoder call only), or actual recordAction metric firing (the branch condition is what's verified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style(replication): prettier formatting; drop unused import in poisoned-cursor test (#537) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(replication): count every decode drop; latch inbound before structure-hold close (#537) Addresses PR #545 review: - Count the generic decode-drop arm, not just missing-structure — the field failure (Unexpected end of MessagePack data) lands there, so drops were silent. - Set wsClosed before the unknown-table hold's close(), so queued/in-flight frames can't advance the resume cursor past the held record (markInboundClosed-first, #440). - Add a decode-hold metric so a held leg is observable, not just a log line. - Note the transient-blob-in-flight gap (needs #403 blob-gap taxonomy) as follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(replication): correct decode-drop comment — in-flight blobs decode lazily (#537) The earlier note claimed an in-flight blob surfaces at this catch as a generic underrun and is dropped. Tracing the receive path shows that can't happen: a blob reference decodes to a lazy handle (core blob.ts unpack), so no read occurs here and an in-flight blob produces no decode error — its transient(503)/permanent split is the async receiveBlobs/#403 path. What reaches this catch is a truncated/mis-framed or structure-forked record buffer. Comment now reflects the actual mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(replication): retain a blob file for the duration of its send A blob resolved from the record being replicated is not opened until `blob.stream()`, and between resolving it and getting there the send can park on the outstanding-sends cap for an unbounded time. A write that supersedes the record in that gap reclaims the file, so the send reads a missing file and reports it to the peer as unrecoverable at source; the peer advances its resume cursor past a record whose bytes it will never have (#403/#388), leaving a live record with a dangling blob reference until a manual repair_blob_data run. Take a core retention hold for the send and release it on every exit path. The hold is lock-backed in core, so it is visible to the thread that does the unlinking rather than only to the sending thread. Bumps core to the matching reclamation change (harper#2134). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: Sync Core (#692) Co-authored-by: kriszyp <34054+kriszyp@users.noreply.github.com> * fix(replication): bound the hasBlobGap latch so one blob fault no longer condemns a link to endless base copies hasBlobGap is a per-connection one-way latch: a single transient blob-save fault pinned the durable resume cursor, stopped the copy cursor, and blocked maybeFinishCopy for the rest of the connection's life. Only a reconnect heals it (fresh state re-streams the gapped blob), but a latched connection keeps flowing frames and answering pings, so no watchdog ever noticed it — in the field a link sat latched for 3.7h until an unrelated reconnect resumed a whole-table base copy, and any copy that took even one fault never exited copy mode, re-copying 376GB on every reconnect (#683). Three complementary mitigations, none of which weakens the transient-fault- must-hold durability invariant: - Blob-gap reconnect timer (createBlobGapReconnectTimer): armed when the latch sets, forces the healing reconnect after blobTimeout (900s default) if none happens on its own — mirroring the #453 copy-progress watchdog pattern. A latched link now heals in bounded time instead of never. - Last-durable copy-cursor snapshot (lastDurableCopyCursor): once a gap holds, flushDurableCopyCursor used to persist nothing for the rest of the connection; it now persists the last durable-eligible staged cursor once (behind the same #480 store flush), so the forced reconnect resumes the copy from the last gap-free point instead of the last cadence flush. - Sender-side 503 retry (BLOB_SEND_RETRY_DELAYS_MS, escalating 250ms-2s): the dominant latching fault is the source failing to read its own PENDING placeholder from a concurrent receive (#481) — measured to self-heal at the source within seconds (5,701/5,701 healed on re-check). sendBlobs now retries the read in place, only while no chunk is on the wire, before forwarding the error. Under all-to-all copies every node is source and receiver at once, so without this no large copy could complete gap-free. 503 stays classified transient at the receiver — reclassifying it as permanent (the other proposed fix) would trade a seconds-long contention blip for permanent record divergence. Fixes #683 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSY7Eta7Xvsr7NTtcm1CRj * fix(replication): survive close races and repeat the blob-gap watchdog (pre-push review round 1) Cross-model review findings on the #683 fix, all addressed: - major: the copy-cursor flush retry timer and a late flush .then could outlive the connection and overwrite a replacement connection's newer cursor (or resurrect a removed one). persistCopyCursor now early-returns on wsClosed (entry and post-flush), and the close handler clears copyFlushRetryTimer. The .then guard also covers the pre-existing #480 exposure this change made reachable at the worst instant. - major: the gap-path snapshot persist runs inside the blob-save .finally whose promise onCommit awaits, so a synchronous store throw (likely the same ENOSPC that latched the gap) escaped as an uncaughtException. The gap branch now catches, logs, and leaves the snapshot unpersisted. - minor: the blob-gap watchdog now re-arms after each fire instead of latching one-shot: forceReconnect() can be a no-op (retry scheduled then abandoned), and a single wasted fire would leave the wedge unbounded again. Siblings (copy-progress, pause-stall) already repeat. - minor: blob-send retries no longer run while draining for worker shutdown, and a purely-retrying send no longer reports drain progress; the gate is extracted as shouldRetrySourceBlobRead and unit-tested. - accuracy: DESIGN.md no longer overstates the snapshot's value — the recovered distance is bounded by the flush cadence, not "the rest of the connection". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSY7Eta7Xvsr7NTtcm1CRj * fix(replication): gate zombie-closure actions on socket ownership, not close (pre-push review round 2) Round-2 findings, all addressed: - major: wsClosed is the wrong liveness test — forceReconnect's teardown of the old socket is best-effort and may never fire 'close' (#420), so a zombie closure could re-fire the repeating blob-gap watchdog against each healthy replacement every blobTimeout, and its cursor-flush retries could still overwrite the replacement's newer cursor. All such actions now gate on connectionSuperseded() (wsClosed OR options.connection.socket !== ws, the same identity idiom the close handler uses), and a superseded watchdog fire stops its own timer. - minor: the sync-throw containment moved from the gap-branch call site into persistCopyCursor, covering the staged path too (pre-existing exposure) — a shared onPersistFailure now restores state, applies the same escalating backoff, and schedules the same re-drive for sync and async failures alike. - minor: blob.stream() / iterator creation moved inside the try so a synchronous 503 (or any sync throw) still engages the retry and the error frame instead of bypassing both. - nit: watchdog warn logs the effective timeout rather than a possibly 0/undefined raw blobTimeout; the copy-progress sibling had the identical bug and now shares effectiveBlobTimeoutMs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSY7Eta7Xvsr7NTtcm1CRj * fix(replication): guard the copy-cursor removal on ownership too (pre-push review round 3) Round-3 findings, all addressed: - major: the ownership invariant gated every WRITE to the shared [copyCursor, nodeId] key but not its DELETE — a superseded closure's late flush .finally could reach maybeFinishCopy and remove the replacement connection's cursor, forcing a from-scratch re-copy. maybeFinishCopy now early-returns when superseded. - minor: a zero-chunk (legitimately empty) blob threw a TypeError whose code-less error frame latched the receiver's gap — with the repeating watchdog that would mean a reconnect cycle every blobTimeout, forever. The terminal frame now goes out with an empty body instead. - nit: onPersistFailure no longer clears copyFlushInFlight it may not own; flushRootStore() is invoked before the flag is taken, so a sync throw can never orphan (or falsely clear) another flush's flag. - test coverage: the ownership predicate is extracted as the pure isConnectionSuperseded and pinned by connectionSuperseded.test.mjs, including the replaced-socket-without-close (#420) shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSY7Eta7Xvsr7NTtcm1CRj * fix(replication): un-strand the snapshot retry from timer-precision skew (pre-push review round 4) Round 4 verdict: COMMENTS, all prior majors verified resolved. Two small items taken: - The flush-failure retry timer IS the backoff expiring, but Node timers can fire fractionally early relative to performance.now(); an early fire hitting the backoff guard no-oped with nothing left to re-drive a quiescent link, silently degrading the snapshot persist to pre-change behavior. The retry callback now clears copyFlushBackoffUntil first. - The watchdog callback runs under try/catch: re-armed, a throwing callback would otherwise repeat a worker crash every cycle. Declined (recorded in the PR): send-slot hold under minutes-long PENDING, production-path/e2e harness (follow-up), comment-volume nits. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QSY7Eta7Xvsr7NTtcm1CRj * fix(replication): stop watchdogs killing healthy base copies during back-pressure and from superseded sessions (#695) Fixes two replication watchdog defects that killed healthy base copies (Closes #697). 1. The copy-progress watchdog counted intentional back-pressure pauses as copy stalls: the commit-backlog pause stops the watchdog via addPauseReason(), but the same frame's noteCopyProgress() re-armed it unconditionally. noteCopyProgress now resets only when pauseReasons === 0, mirroring resetPingTimer; removePauseReason re-arms on resume. 2. Watchdog fires from superseded replicateOverWS instances acted on the shared NodeReplicationConnection, tearing down the current healthy leg. Supersession now runs the full, idempotent instance teardown (retireInstance): NodeReplicationConnection.retireSession() is the single enforcement point for "at most one live session per connection", called at the socket-replacement point in connect() and up front in forceReconnect(); a session created by an open() that raced a replacement is retired on the spot and not resolved into the replacement's session promise. A socket-identity guard in all four fire handlers remains as a backstop. Regression coverage: copyProgressBackpressureFalseFire.test.mjs and watchdogSupersededInstanceFire.test.mjs, driven by one-shot env-gated fault injections; copyProgressWedgeRecovery (genuine-stall recovery), replicationWedgeRecovery (#420) and replicationReconnect verified as non-regressions. Out of scope, tracked in #697: why silent-from-birth surplus legs exist for the same (db, peer) — that needs sender-side evidence. * Release v5.2.2 * Clone sync verification now detects an in-progress base copy on RocksDB instead of marking the node Available mid-copy (#657) * Clone sync check: verify no-target databases by the copy-completion watermark checkSyncStatus skipped any database whose leader target timestamp was falsy. On RocksDB the describe operations report last_updated_record for no table (harper#2091, getKeys TODO stub), so every target was 0, every socket was skipped, and the first poll declared "All databases synchronized" seconds into a multi-GB base copy — the clone marked itself Available and cloned with a fraction of the leader's data (#655; Large-Data stress run 31006315068). A no-target database now requires a positive received-version watermark instead of being skipped. The watermark is held at 0 for the whole bulk copy and only becomes positive via the final end_txn the sender always emits at copyStartTime — the copy's own completion signal — so this is correct against any leader version and for empty databases (their copy still emits the final end_txn). A database with a target but no socket yet also holds completion, so a lone early socket (the system DB's small copy finishes in seconds) cannot complete the check vacuously either. Fixes #655 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Scope the missing-socket hold to subscribed databases so a v4 leader cannot wedge the clone A legacy (v4) leader never replicates the system database, but system is added to the sync targets unconditionally, so requiring every target to have a replication socket wedged the clone forever against a v4 leader (cloneFromLegacy CI failure: "data: Synchronized" + "system: no replication socket" repeating until the Available timeout). checkSyncStatus now takes the set of databases whose socket is required (cloneNode passes the user databases); a non-required database is still verified whenever its socket does exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Ratchet seen sockets into the required set so a dropped system socket cannot complete the clone From the cross-model review of the v4-leader fix: making the system socket optional for v4 leaders also removed its socket-presence gate on v5, where a small user database's copy could complete before system's socket registers. Once a target database's socket has been seen it now stays required — its later loss holds completion instead of completing around it — while a socket that never appears (v4 leader) still cannot wedge the clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Filter sync targets by replication.databases and require the system socket by leader version From PR #657 review threads: - gemini-code-assist: with replication.databases configured, non-replicated leader databases became sync targets whose sockets never exist, wedging the clone. getLastUpdatedRecord now applies the same isReplicatedDatabase gating cloneSchemas uses (helper hoisted to share it). - KrAIs: whether the system socket is required is a leader capability, not an ordering heuristic — probe it via registration_info (present on every leader version) and require system up front on v5+, closing the reverse race where a small user database completes before the system subscription registers. Unknown version leaves system optional with the seen-socket ratchet as the fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fail the leader-version probe closed and exclude sharded entries from sync targets From the cross-model review of the previous commit: - The registration_info probe failed open: a transient error or a missing/unparseable version left the system socket optional on a real v5 leader, reopening the premature-Available race. Only a positively-read legacy major version now exempts system; the probe retries and persistent failure requires the socket (a wedge is visible; a premature Available is not). - isReplicatedDatabase accepted sharded entries by name, but a sharded entry replicates only from same-shard peers (shouldReplicateFromNode) — the leader's shard is unknown here, so a differently-sharded database would have become a required target with no socket, wedging every sharded clone. Sync targets now exclude sharded entries; when one does replicate, its socket exists and the watermark rule plus the seen-socket ratchet still verify it. Schema pre-create keeps its original name-based gating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Evaluate the real shard predicate for sync targets instead of excluding sharded entries From the cross-model review: excluding every sharded entry from sync targets reopened the ordering race for a same-shard sharded database (replicating, copied, but never required — a small user database could complete the clone around it). The shard predicate is now evaluated exactly as shouldReplicateFromNode does — the leader's shard read from its configuration (get_configuration), compared to replication.shard — failing closed to keep sharded targets when the configuration is unreadable. The predicate logic moves to cloneNode/replicatedDatabases.ts (pure, unit-tested); schema pre-create keeps its original name-based gating via the fail-closed default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Tolerate prefixed leader version strings in the probe Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Persist clone copy completion across worker restarts Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Harden clone completion restoration Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Preserve selective clone targeting Co-Authored-By: GPT-5 Codex <noreply@openai.com> * fix(clone): don't require a system socket the node never subscribes to The sync monitor required the `system` replication socket on any v5+ leader. `shouldReplicateFromNode` runs every database through `replication.databases`, system included, so a node configured with e.g. `databases: ['data']` never opens a system socket — the clone then waited Unavailable until the stall window expired even though its data was fully copied. Gate the requirement on this node's own `replication.databases` first, and only probe the leader version when the local config actually subscribes to system. Also drops an unused callback parameter that failed oxlint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: GPT-5 Codex <noreply@openai.com> * feat: Sync Core (#705) Co-authored-by: kriszyp <34054+kriszyp@users.noreply.github.com> * test(replication): e2e guard for the decode-drop wedge recovery (#537/#690) (#691) * test(replication): e2e guard for the decode-drop wedge recovery (#537/#690) Adds a stress-gated two-node cluster test that reproduces the #521 wedge trigger — a replicated record whose value can't be decoded — via a new env-gated one-shot injection hook (maybeInjectDecodeFailureForTest, HARPER_TEST_DECODE_FAIL_RECORD_PREFIX), same pattern as the sibling copy-cursor / stall injections. Asserts the #545 disposition end to end: every clean row is delivered (leg not starved), poison records are skipped (absent, not applied), the connection never enters the close/resume loop (0 inbound-close lines), and a live post-poison write still replicates. The behavior is already proven on real containers in HarperFast/harper-fabric-lab#36 (stock catch: 452 close/resume cycles, 0/5 rows; fixed catch: 0 closes, leg alive); this lands the same coverage in the cluster suite, addressing the coverage gap noted on #545. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZ9NnEpv3BygcMJYvkyC9K * style(replication): prettier format on decode-drop test + hook Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZ9NnEpv3BygcMJYvkyC9K * perf(replication): cache decode-fail test prefix at load, not per record Cross-model review (Gemini, perf lens): reading process.env in the per-record injection hook is a native getter on the receive-apply hot path. The test env var is set before process start, so read it once at module load — the production (unset) path becomes a cached-undefined check with zero per-record env cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZ9NnEpv3BygcMJYvkyC9K * ci(stress): run decodeDropRecovery in the receive-side regression matrix The stress matrix is an explicit file list, not a glob — a cluster test not named here is executed by no CI path (the PR shards skip stress-gated tests). Add the new decode-drop test so the stress suite actually runs it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZ9NnEpv3BygcMJYvkyC9K * test(replication): guard teardown against unset nodes; drop dead count assertion Addresses gemini-code-assist review on #691: teardownHarper(undefined) would throw synchronously if the before-hook didn't assign a node (.catch can't reach a sync throw) — guard on ctx.node*. And the post-waitForCount ok() was dead (waitForCount only returns on success, throws on timeout) — the throw is the assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QZ9NnEpv3BygcMJYvkyC9K --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * Add companion-check workflow: coordinated PRs auto-merge once their harper companion lands (#704) * ci: companion-check gate for PRs dependent on companion PRs (e.g. harper core) Adds a workflow posting a companion-check commit status driven by Depends-on: markers in PR bodies, so a coordinated harper-pro PR can be approved and armed for auto-merge, then merge automatically once its harper companion lands. Merging such a PR fires the existing sync_core repository_dispatch so the core pointer is re-pointed at harper main promptly instead of at the nightly run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: harden companion-check per cross-model review Fail closed on unparseable markers, support repo#N shorthand, bound and dedupe refs, isolate per-dep/per-PR errors, restrict the cross-repo token to same-org refs, guard sweep/event races, and skip no-marker PRs in the cron sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: fail closed on partial markers, heal statuses in sweep, harden sync Round-2 review fixes matching the documentation-repo copy, plus harper-pro-specific ones: split the closed-event concurrency group so an edit to a merged PR cannot cancel a pending sync_core dispatch, drop the dead case variant in the normalize-core condition, and make Sync Core reset core's branch tracking to main so a merged core:set-branch override cannot wedge the nightly or dispatched sync. Adds the self-contained node test harness for the embedded script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: run companion-check tests in runLinter; hedge not-found diagnostic Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: definitive 404s fail closed; align normalize grammar; guard sync reset to main Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: stateful script-block extraction and per-scenario fetch isolation (review feedback) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * test(cluster): make QA-692 blob seeds durable (#666) Co-authored-by: GPT-5 Codex <noreply@openai.com> * fix(test): stop the seed-poll unknown-attribute transient failing removeNodeBlastRadius nightly (#708) * test(cluster): retry the seed poll's unknown-attribute transient in removeNodeBlastRadius The 'B joins A and C' subtest polls B for the seeded record immediately after an upsert on A with replicatedConfirmation: 1. That confirmation can be satisfied by C, so the first poll can precede the record's arrival on B entirely — and search_by_id validates get_attributes against B's known attributes BEFORE reading, so while B has not yet learned the dynamically created 'value' attribute it answers 500 "unknown attribute 'value'" instead of an empty result. sendOperation asserts 200, and the waitUntil loop treated any error as fatal, so a legal, milliseconds-scale metadata transient failed the test outright (~half of nightly runs since 2026-08-13). Instrumented 3-node probe evidence: the transient reproduces at the very first attribute-bearing write (record not yet on B at the failure instant) and heals in p50 3ms / p95 7ms / max 74ms — well inside the poll's 45s budget. Retry exactly this transient in the seed poll; every other error still fails the test, and no later subtest is touched. The same probe surfaced a real, permanent attribute-propagation defect at the msgpackr 32-shared-structure cap, filed separately as #707 (not the cause of this flake — the test's table has 4 attributes). Refs #707 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEA8N9FkRDU5nHfBp5jf6B * test(cluster): branch the seed-poll retry on status + error field per review Pre-push review round 1 (codex graded + cursor-composer + harper-domain adjudication) found the retry discrimination coupled to sendOperation's assert plumbing and the full stringified body, and noted a permanent metadata failure would degrade into an anonymous 45s timeout. Rework the poll to fetch directly, treat only a non-200 whose body.error starts with "unknown attribute" as retryable, fail every other non-200 through the same equal() shape as sendOperation, and append the last swallowed validation error to the timeout diagnostic. Also trims the comment to the load-bearing rationale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEA8N9FkRDU5nHfBp5jf6B * test(cluster): clear the retained transient so the timeout diagnostic reflects the final poll state (delta review round 2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEA8N9FkRDU5nHfBp5jf6B * test(cluster): reset the retained transient per poll iteration (delta review round 3) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEA8N9FkRDU5nHfBp5jf6B --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat: Sync Core * Bank bulk-copy progress across blob-gap reconnects and repair missing blob files on copy re-delivery (#701) * fix(replication): bank copy-cursor progress up to the first blob gap instead of discarding the walked prefix (#699) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Address copy-gap repair review findings Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Hold uncertain duplicate blob repairs for retry Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Update core repair formatting Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Use PID-aware restart in copy-gap integration test Co-Authored-By: GPT-5 Codex <noreply@openai.com> * fix(replication): validate leading duplicate blob health Co-Authored-By: GPT-5 Codex <noreply@openai.com> * perf(replication): bound duplicate blob probes Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Preserve unknown blob probe results Co-Authored-By: GPT-5 Codex <noreply@openai.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: GPT-5 Codex <noreply@openai.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Barber <chris@harperdb.io> Co-authored-by: harperfastaibot[bot] <280766738+harperfastaibot[bot]@users.noreply.github.com> Co-authored-by: kriszyp <34054+kriszyp@users.noreply.github.com> Co-authored-by: Lavinia <lavinia@harperdb.io> Co-authored-by: GPT-5 Codex <noreply@openai.com> Co-authored-by: Nathan Heskew <nathan@harperdb.io>
Fixes “5.2.2 blob-gap wedge is bounded but banks zero copy progress”. Companion core change: “Repair dangling blob references when a base copy re-delivers the record; add replication.blobGapReconnectMs”.
Bulk-copy progress now banks through transient blob failures, and exact-identity re-deliveries can repair a dangling single-blob reference in place. Conflicts with current
mainare resolved, and the core submodule points to the current head of #2177 (c3d9c7b8). “fix(replication): await cold getEntry lookups so in-place blob repair fires on resumed copies” is no longer required for its primary cold-cache fix because this branch already awaits that read; merging it wholesale would also reverse intentional repair and uncertainty policies here. Its separate leading-duplicate promise/presence refinement can be considered as a focused follow-up.For the human reviewer
blobGapReconnectMs. This minimizes re-streamed bytes but can turn frequent faults into frequent flushes and reconnects; adding a pacing floor is reversible.COPY_START. Resetting it per pass would let a restarted walk bank again, but would diverge from the existing connection-scopedhasBlobGapbehavior.COPY_START. This bounds cold lookups heuristically; an explicit duplicate-span signal would be more reliable but crosses the Pro/core boundary.versionand source node). Exposing core's actual decision would prevent silent drift but expands the companion API.replication.blobGapReconnectMsis a separate operator-visible setting rather than being derived fromblobTimeout. That makes gap recovery independently tunable, but adds configuration surface.Verification
npx mocha --require unitTests/unitTestSetup.cjs unitTests/replication/collectBlobRepairTargets.test.mjs unitTests/replication/copyCursorWatermark.test.mjs: 26 passing.npm run test:unit: 744 passing; one unrelated existing key-custody mode failure (0600actual vs.0644expected).npm run lint:required: passed.npm run test:integration -- integrationTests/cluster/copyGapCursorBanking.test.mjs: passed with 5 injected faults, 0 watchdog fires, 3 banked reconnects, resume keys[13,17], 40/40 readable records, and 2 in-place repairs.npm run build: emitted the build and retained the existing unrelatedNumeric, WebSocket_socket, and error-property TypeScript errors.npm run test:integration:all: analytics and the first clone-node test passed, then the run could not acquire shared loopback slots at127.0.0.2and127.0.0.7; it was stopped after repeated contention. The focused regression used available addresses and cleaned up successfully.Review coverage
Authored by GPT-5 Codex. Exact-head cross-model review:
claude-opus-5passed;cursor-grok-4.5-highpassed and contributed one adjudicated edge case; Harper domain adjudication withclaude-opus-5passed; Gemini returned no output; Cursor Composer was pruned by the automatic review policy. Review receipt at6195fdf74e59.Human-Review-Need: 4 @ 696a4a9