Repository navigation
Anchor the replication base copy on a log-order barrier instead of a timestamp - #878
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a more robust mechanism for base-copy resume boundaries in replication by replacing timestamp-based anchors with log-order barriers. This change ensures that transactions committed during a bulk copy are correctly captured, preventing data loss. The implementation includes new utility functions for barrier management and anchor classification, along with comprehensive unit tests. I have reviewed the provided feedback and kept the suggestion regarding the withDatabase helper, as it correctly identifies a potential resource leak in the test cleanup logic.
| async function withDatabase(run) { | ||
| const dir = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 'copy-barrier-')); | ||
| const db = RocksDatabase.open(join(dir, 'db')); | ||
| try { | ||
| await run(db, db.useLog('local')); | ||
| } finally { | ||
| db.close(); | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| } |
There was a problem hiding this comment.
In the withDatabase helper, if RocksDatabase.open throws an error, the finally block is not entered, and the temporary directory created by mkdtempSync is leaked on disk. Wrapping the database initialization and log usage in nested try-finally blocks ensures that the temporary directory is always cleaned up properly.
| async function withDatabase(run) { | |
| const dir = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 'copy-barrier-')); | |
| const db = RocksDatabase.open(join(dir, 'db')); | |
| try { | |
| await run(db, db.useLog('local')); | |
| } finally { | |
| db.close(); | |
| rmSync(dir, { recursive: true, force: true }); | |
| } | |
| } | |
| async function withDatabase(run) { | |
| const dir = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 'copy-barrier-')); | |
| try { | |
| const db = RocksDatabase.open(join(dir, 'db')); | |
| try { | |
| await run(db, db.useLog('local')); | |
| } finally { | |
| db.close(); | |
| } | |
| } finally { | |
| rmSync(dir, { recursive: true, force: true }); | |
| } | |
| } |
References
- In test cleanup hooks (such as
afterorafterEach), guard cleanup functions against uninitialized or undefined variables to prevent synchronous errors from blocking subsequent cleanup steps and causing resource leaks.
702cb41 to
81fe24c
Compare
81fe24c to
c14f17b
Compare
…timestamp `copyStartTime` was `Date.now()`, captured before the key-order table walk and published as the follower's resume cursor. But a transaction's log key is fixed when the transaction is CREATED while its log batch is appended when it COMMITS, so the transaction log is written out of key order — measured at 47 inversions per 1000 concurrent transactions. A transaction created just before the anchor and committed during the copy therefore sits behind the anchor numerically and after it physically: the walk had already passed its row, and the post-copy tail's per-entry range predicate (`timestamp > start`) excluded it. Neither side delivered it, and nothing ever would. The leader now commits a `copyBarrier` entry before `COPY_START` and anchors on its transaction key. Everything still in flight commits — and so appends — after that entry, and the tail resumes with `exactStart` + `resumeAfterExactStart`, after which the range predicate stops consulting the key at all. The boundary iterable is built before the walk, because `getRange` resolves the position and maps the log file eagerly, so a long copy cannot outlive the barrier it is anchored to. The same append-order resume is used on reconnect whenever the cursor names a `copyBarrier` entry, so the anchor survives a disconnect between `COPY_COMPLETE` and the in-flight commit. An ordinary cursor keeps the timestamp range, so the default subscription path is unchanged and pays nothing. `COPY_ORDER_VERSION` is bumped to 2 and now versions what the anchor MEANS as well as the table order: a v1 cursor surrenders its anchor along with its walk position rather than carrying a timestamp anchor into a leader that cannot make it good, and a v2 cursor whose barrier has left the log restarts instead of resuming onto a guarantee nothing can keep. The anchor is validated on both peers before it reaches any state. Not covered, and stated in the PR: a relayed origin's log, where the barrier does not exist and the timestamp range still applies. Fixes #876 Dispatch-Task: hp-basecopy-inflight-write-loss Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HE8PfPhHn1o1Wss4uH3ULD
findCopyBarrierTable() duck-types Table.writeCopyBarrier() as available whenever the method exists on the class, but the method itself hard-throws when the table's actual primaryStore isn't RocksDB. STORAGE_IS_ROCKSDB is a config snapshot that can disagree with a table's real engine (observed here: it read true in a replication worker thread whose harper instance was actually configured for LMDB, because env.get(CONFIG_PARAMS.STORAGE_ENGINE) returned undefined in that context). Previously this mismatch bubbled the thrown Error up through COPY_START's handler into the outer catch, which closed the connection with 1008 and killed the whole subscription -- reproduced deterministically via integrationTests/cluster/fullyConnectedReplication.test.mjs's LMDB suite (4-node full mesh), where every base copy of a non-empty database hits this path. Every other "can't form a barrier" case in this feature already degrades to the pre-#876 wall-clock anchor instead of refusing (empty database, a core submodule predating writeCopyBarrier, a boundary that fails its probe). This makes writeCopyBarrier() failing at call time follow the same rule: catch, warn once, and fall through to Date.now() -- exactly the invariant the surrounding code already states in its own comments, just missing this one call site. Verified: fullyConnectedReplication.test.mjs 10/10 (both RocksDB and LMDB suites); RocksDB suite's per-instance logs confirm it still writes and uses real barriers (zero fallback warnings); LMDB suite's logs show the new catch firing and the connection surviving. addNodeFullCopy, copyResumeCursorGap, and directionalFlowReplication cluster tests still pass 1/1. harper-pro test:unit 1254/0. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adjudicated finding from the pre-push review of the prior commit: resumeAnchorKind is classified against the ORIGINAL copyResume, before the three guards above (order-version mismatch, invalid anchor, missing table) can clear it. Once cleared, a stale resumeAnchorKind === 'barrier' combined with the previous commit's new catch around writeCopyBarrier() lets a fresh-barrier-write failure fall through to treating Date.now() as if it named a real barrier entry -- something the prior commit's throw-and-close behavior made unreachable, but that catching the throw newly exposed. Gate the stale classification on copyResume still being present. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ier frame
Full CI (Cluster Integration Tests 6/6) failed after the rebase:
"holds 61 frames for 60 rows; the oracle maps frame k to row k and needs one
frame per row". Root cause: this test's before() hook does a plain add_node
join on RocksDB with an empty table, which is exactly the base-copy path this
PR changed -- it now commits one copyBarrier control frame into the joining
leader's local transaction log before any row is ever inserted (confirmed via
the run's own debug log: "bulk copy starting/complete" precedes the test's
insertRows calls). That shifts every later frame's index by one, breaking the
test's "frame k is row k" assumption -- an assumption that predates this PR
and was true only because no base copy had ever written into this log before.
Writing the barrier as a control entry through an arbitrary table (rather
than some log-level-only channel) is this PR's own already-adjudicated
design decision, shared with the existing lockBarrier/reloadMarker control
types in the same log -- not something to redesign from a rebase task. The
fix is at the right altitude: the oracle now filters to frames that actually
carry one of the test's own rows (the same marker-matching check it already
does per-frame, just applied as a filter instead of assumed by position), so
a leading control frame no longer breaks the count. A corrupted or missing
row still fails loudly, since the filtered count would then miss TOTAL.
Verified: the test passes twice in a row (clean re-runs, not the same
process), and its diagnostic line now reports the row-frame count ("of 60")
rather than the raw frame count. harper-pro test:unit 1254/0 unaffected.
Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adjudicated finding from the pre-push review of the prior commit: filtering to row-carrying frames alone would also hide a barrier-per-retry proliferation -- the reconnect-loop risk replication/DESIGN.md names as the reason fail-closed was withdrawn. Bound non-row frames to the one base-copy barrier this test's single add_node can produce, so that regression would fail this test again. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two issues the prior commit's review found in its own bound check: 1. It ran before the row-count check, so a corrupted/missing row (which also depresses the row-frame count) was misdiagnosed as barrier proliferation instead of as itself. Row-count now checked first. 2. The exact <= 1 bound is flake-prone: a legitimate reconnect between add_node and B's first cursor can mint a second barrier on retry, which is not the proliferation risk the bound exists to catch. Loosened to <= 3 -- a wedged retry loop mints one barrier per attempt and blows well past a couple of incidental early reconnects. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The pre-COPY_START probe pulled one entry via `getRange(...)[Symbol.iterator]().next()` and then dropped the iterator without calling `.return()`. Single-log getRange wires `return` through to the underlying log iterator specifically for early-exit cleanup; leaving it uncalled means every base copy that forms a barrier boundary leaves that handle open until GC. Wrap the probe pull in try/finally so it's released immediately. The multi-log aggregate branch of RocksTransactionLogStore.getRange has no `.return()` at all, so this only closes the gap on the single-log path; the aggregate path's lack of a teardown hook is a pre-existing, broader core limitation this PR doesn't touch. Found by an independent Gemini review pass. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The comment claimed callers fail closed on 'unreadable'. The only caller (the copy-resume anchor check at replicationConnection.ts:5972) only branches on `=== 'barrier'`; 'other' and 'unreadable' both fall through identically to degrading to the pre-#876 timestamp anchor, same as DESIGN.md's stated philosophy for every other unformed boundary. Doc didn't match code. Found by an independent Gemini review pass. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The backstop that clears a failed append-order auditLogIterable fell through to `await nextTransaction` instead of retrying right away. `nextTransaction` only resolves on a future commit; on an otherwise idle database, whatever the failed boundary couldn't deliver would sit stranded until an unrelated write happened to unstick it. Add a `continue` so the loop rebuilds an ordinary range from currentSequenceId immediately, the same way it would on a fresh pass. Found independently by both Codex and Gemini review passes. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same stale "fail closed" framing as the JSDoc fixed in the prior commit; the assertion itself (return value === 'unreadable') was already correct and unchanged. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI started failing after the rebase's rocksdb-js 2.9.1 -> 2.10.0 bump
(from main's Sync Core commits): all 1253+ tests passed, then the
process crashed with "Failed to flush database during close: ...
No such file or directory".
Root cause: two independent `process.on('exit', ...)` listeners race.
unitTestSetup.cjs registers first (via --require) and rmSync's the
whole shared STORAGE_PATH; RocksTransactionLogStore.ts's own exit
listener (registered later, whenever a test first imports it) then
tries to flush every still-known RocksDB log into directories that
listener just deleted. Node fires exit listeners in registration
order, so this ordering was always latent -- 2.10.0 apparently just
started throwing on it where 2.9.1 didn't.
The flush-on-exit hook exists for crash/replay coverage, which lives
in integrationTests (grep confirms zero unitTests reference
HARPER_NO_FLUSH_ON_EXIT); unit tests close their own databases
explicitly and don't need it. Setting the env var here, before any
test file can import RocksTransactionLogStore.ts, skips registering
that second listener entirely.
Reproduced and verified locally against the post-rebase
package-lock.json (my node_modules had gone stale after the rebase
bumped it; `npm ci` was needed to see the crash at all) -- 2/2 clean
runs after the fix, both crashing before it.
Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The existing case only checked a hypothetical 1-vs-2 version mismatch; a real pre-#876 leader's cursor has no copyOrder at all, and today's COPY_ORDER_VERSION is still 1, not 2 — add that case and stop implying the hypothetical one covers it. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit's new case asserted (undefined, 1) as "a real pre-barrier leader's cursor" — wrong: COPY_ORDER_VERSION is #421/#422's copy-table-order versioning, unrelated to and unchanged by this PR, so a pre-#876 leader already sends copyOrder:1 and is accepted, not rejected. undefined is a pre-#421 leader instead. Caught by codex in this task's own pre-push review round. Dispatch-Task: pr-maint-352723a0e8c43f4ff8e901f0fc934706 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
c14f17b to
a5de25d
Compare
…ng a barrier The copy no longer commits a copyBarrier entry to form its boundary: the last committed entry of the local log is already one. The log yields entries only up to its committed watermark, which rocksdb-js advances past a transaction only after its RocksDB commit succeeds and only across a contiguous prefix, so every transaction still in flight appends after that entry whatever its key. - findLastCommittedLogKey scans a widening key window back from now (1s, 1min, 1h, 1d, whole log) and takes the last entry yielded; an empty log gets an ordinary range from its start, which is already a boundary. - classifyResumeAnchor now asks only whether the anchor names an entry: an exact match resumes in append order, anything else keeps the timestamp resume, so pre-#876 Date.now() cursors are honoured unchanged. - Gate on auditStore.reusableIterable rather than the STORAGE_IS_ROCKSDB config snapshot, which misreports the engine in a worker. - Drops the core submodule bump (harper#2697 is no longer needed) and the txnlogTearReplication oracle loosening, which only tolerated barrier frames. - Restores the #737 DESIGN.md paragraph an earlier rebase of this branch lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quiet log made the widening window fall through to a whole-history scan on the copy's setup path. A range seeks by key and its first entry sits at the seek position, so bisecting on one pulled entry per probe finds the largest committed key in a bounded number of probes without reading history. Also drops the unit-test exit-flush override: main fixed the underlying exit ordering in #893, and the override masked that change's lifecycle test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…log sends the wall clock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| async function withDatabase(run) { | ||
| const dir = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 'copy-anchor-')); | ||
| const db = RocksDatabase.open(join(dir, 'db')); | ||
| try { | ||
| await run(db, db.useLog('local')); | ||
| } finally { | ||
| db.close(); | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } |
There was a problem hiding this comment.
Suggestion (non-blocking): RocksDatabase.open(...) runs outside the try, so if it throws, the temp dir created by mkdtempSync on the prior line is never cleaned up by the finally below. This is the same leak gemini flagged on this helper under its old name (copyBarrierResume.test.mjs, now-outdated thread) — it persisted through the rewrite into this new file. Nest the open call in its own try/finally:
| async function withDatabase(run) { | |
| const dir = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 'copy-anchor-')); | |
| const db = RocksDatabase.open(join(dir, 'db')); | |
| try { | |
| await run(db, db.useLog('local')); | |
| } finally { | |
| db.close(); | |
| rmSync(dir, { recursive: true, force: true }); | |
| } | |
| async function withDatabase(run) { | |
| const dir = mkdtempSync(join(process.env.TMPDIR || tmpdir(), 'copy-anchor-')); | |
| try { | |
| const db = RocksDatabase.open(join(dir, 'db')); | |
| try { | |
| await run(db, db.useLog('local')); | |
| } finally { | |
| db.close(); | |
| } | |
| } finally { | |
| rmSync(dir, { recursive: true, force: true }); | |
| } | |
| } |
|
Reviewed; no blockers found. |
The base copy published a resume cursor that was not a boundary in the log it resumes from, so a transaction in flight when a node joined could be delivered by neither the copy nor the audit tail. It was not delayed: it was never replicated to that peer, and nothing later repairs it. Full trace in #876.
Why a timestamp could never work here
Two orderings disagree, and the copy assumed they agreed:
So the log is written out of key order, measured at 47 inversions per 1000 concurrent transactions across 4 threads. Take a transaction created at
T-0.2and committed atT+0.02, against an anchor ofT. It sits behind the anchor numerically and after it physically. The walk had already passed its row, and the tail's per-entry predicate excluded it:timestamp > start, in rocksdb-js'sTransactionLog.query, where_findPositiononly seeks.The fix
Anchor on the latest committed entry of the
locallog, and resume past it in append order. Just beforeCOPY_START, the leader finds the latest committed key withfindLastCommittedLogKeyand sends it ascopyStartTime. Nothing is written to form the boundary, so this no longer needs a core change. The earliercopyBarrierentry and its companion harper PR, harper#2697, are dropped, and thecoregitlink is back onmain's.Why the last committed entry is a boundary. rocksdb-js yields entries only up to its committed-read watermark. That watermark advances past a transaction only after its RocksDB
Commit()returns ok (transaction.cpp, thecommitFinishedcall gated onstatus.ok()), and only across a contiguous prefix. So everything up to the anchor is visible to the walk, and every transaction still in flight appends after it, whatever its key. A real-log test pins that property.Finding the last entry without reading history. The log reads only forward, but a range seeks by key through each file's running-max index. The first entry it yields sits at the seek position, so the helper bisects on key, pulling one entry per probe: about 30 probes, and no scan. An earlier revision scanned from the last-flushed position, which found nothing after a memtable flush and fell back to the wall clock on most RocksDB copies. A widening-window variant could scan a quiet log's whole history. The cluster runs caught the first problem and review caught the second.
The tail resumes with
exactStart+resumeAfterExactStart(boundary range). Once the key matches, the predicate stops consulting the key at all. The range is built before the walk and proven with a throwaway pull under the same options.exactStartis scoped tolocalbystartByLog, so peer logs in the aggregate are untouched.A resumed copy tries the anchor as an exact entry first (
classifyResumeAnchor): an exact match resumes in append order, and anything else keeps the timestamp resume. That anything else covers a pre-#876Date.now()cursor, a purged entry and an empty-log copy.COPY_ORDER_VERSIONstays 1, so a rolling upgrade honours in-flight copies instead of restarting them.An empty log tails from its start with an ordinary range: everything it will yield commits later.
Gated on the store, not the config. The gate is
auditStore.reusableIterable === true && logName === 'local', notSTORAGE_IS_ROCKSDB. That flag is a config snapshot that misreports the engine in a worker thread, and it previously let an LMDB table reach a RocksDB-only call.Kept from earlier rounds. The anchor is validated on both peers (follower). The sender's cursor and
SENDING_TIME_POSITIONonly climb, since append order is not key order. A tail-time boundary failure rebuilds an ordinary range immediately. A degraded copy says so atwarn. Design note:replication/DESIGN.md.For the human reviewer
--mode planframing gate, because the owner chose the framing directly. The framing verdicts recorded below were against the barrier design and are kept as history.Date.now()to the follower (why). The leader's in-connection tail is correct: it reads from the log's start. A mid-copy disconnect on an empty log, though, resumes by timestamp and can miss a transaction that was in flight. A low sentinel such as1would close that, butshouldForceBaseCopyForRetentionreads it as purged history. It would then recopy an idle follower on every reconnect afterCOPY_COMPLETE. Closing it properly needs a mode flag in the cursor, the same durable-cursor work as Replication resume cursors need an append-order mode to survive a reconnect or a relayed origin #879.COPY_COMPLETEresumes by timestamp, becausematchesSubscriptionrequiresstartTimebelow an entry's key. Relayed-origin logs are not anchored either.returninsidefor…ofcalls the iterator'sreturn()(the language's IteratorClose step).findPositionByTimestamptakes adouble.main's Flaky: clone from a legacy v4 leader intermittently fails when audit-log forwarding throws RangeError converting a non-integer to BigInt #737 paragraph fromreplication/DESIGN.md, so it is restored. The unit-test exit-flush override is reverted, becausemainfixed its cause in Remove the unit-test root only after its databases are closed #893 and the override masked that change's lifecycle test. ThetxnlogTearReplicationoracle loosening is reverted, because it existed only to tolerate barrier frames.Earlier rounds (barrier design), kept for the record
better-alternative-existstwice. Round 1's alternative, reconstructing an in-flight floor fromregistryStatus(), was refuted by rocksdb-js assigningstartTimestampbefore it publishes the handle, and that design was replaced. Round 2's corrections were adopted: the pre-positioned iterator, validating the anchor on both peers, and keeping a resumed copy's original anchor.!excludedNodes, andexcludedis always an array, so the gate was never true. Every test passed against the inert path. That is why verification asserts on the absence of the fallback warning, not on a green test.matchesSubscriptiondoes not filter the copy's tail. It has one call site, gated on the per-originsubscribedNodeIds[id].timeRange, andauditSubscription.startTimeis written, never read, on that path.Verification
Defect and fix, against a real RocksDB log (
copyAnchorResume.test.mjs). A held-open transaction takes the lower key but appends last. The timestamp range drops it, while resuming past the last committed entry delivers it. The log is flushed first, so the anchor cannot depend on post-flush entries; this pins the regression the first revision had. A third test polls the log while 200 transactions commit, and asserts every yielded entry is already visible to a read.npm run buildandtsc --noEmitare clean.prettieris clean on touched files.oxlintshows only the pre-existingsubscriptionRequestwarning, on an untouched line.npm run test:unit: 1258 passing, 0 failing, 1 pending.Cluster integration, one file at a time on a private TMPDIR, with instance logs kept (
HARPER_INTEGRATION_TEST_LOG_DIR):addNodeFullCopycopyResumeCursorGapdirectionalFlowReplicationtxnlogTearReplication(oracle now back tomain's)fullyConnectedReplication(RocksDB + LMDB suites)Warn level is logged in these runs, so zero fallback lines means each RocksDB copy took the log-order path. An earlier run of the same table is what exposed the flushed-log gap: 25 fallbacks on
fullyConnectedReplicationalone.Independent pre-push review, 4 rounds on this revision: 1 full round (codex + gemini + cursor-muse, domain adjudicator timed out) and 3 deltas. The final delta ran codex + gemini + cursor-muse + harper-domain and found nothing new. Everything left is an accepted-known limit or a decision listed above.
Fixes #876
Origin — the dispatch brief this PR was written from
Anchor the replication base copy on a log-order barrier instead of a timestamp
LIVE CONVERSATION about #878.
You are answering a person, in a thread, one turn at a time. Every turn:
each of your previous turns is in it. Read the PR/issue and the code as needed.
they are talking to you, and a status template is not an answer.
Each turn arrives as ASK (answer it, change nothing) or PERFORM (do it, then say what you did) —
the person chose which when they sent it, and the run's own prompt tells you which one this is.
Never infer it from the wording: an unrequested commit in the middle of a discussion and a polite
description of work that was supposed to happen are the two failures this exists to prevent.
Never mark a PR ready and never merge from this conversation.
Dispatch: task
chat-pr-harper-pro-878-kriszyp· queued by unknown · ran by claude/opus/high · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-muse,codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi; rounds=31; full=4 @ 716cea8
Human-Review-Need: 4 (decisions: empty-log-wall-clock-cursor, degrade-open-on-boundary-failure, reconnect-gap-deferred, no-cursor-version-bump, anchor-local-log-only) @ 716cea8