Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
56caa7c
Pin core to the record-lock phase-1 branch head (harper#2498)
kriszyp Sep 7, 2026
7321831
Cluster-wide record locks: the harper-pro transport for core's LockCo…
kriszyp Sep 7, 2026
cf6cebf
Read replication.recordLocks from the config tree, and fix two test-h…
kriszyp Sep 8, 2026
9369912
Address the pre-push review: test lifecycle, config warning, slot doc
kriszyp Sep 8, 2026
7e61610
Install the connection-down reader in start(), not at module load
kriszyp Sep 8, 2026
1c2a353
Record-lock cost baseline: the numbers the enablement gate calls for
Sep 9, 2026
47d0a25
Address the pre-push review: node-side section timing, pooled hot-key…
Sep 9, 2026
942a53d
Baseline: the request-minus-section gap is the commit plus request ov…
Sep 9, 2026
1cab4e2
Record locks: delegation transport for the amortized-ownership protoc…
kriszyp Sep 11, 2026
d9a8f28
Fix two more stale slot-13 references and a schema-list omission in D…
kriszyp Sep 13, 2026
2f588c2
Fail closed instead of crashing when recordLockConfig loads before boot
kriszyp Sep 13, 2026
2908e08
Pin core to harper#2498 head (operator-agreed home map)
kriszyp Sep 14, 2026
412673c
Record locks: the delegation cost measurement, and the two handoff ga…
kriszyp Sep 14, 2026
b9c4bd0
Design note: operator-agreed home map (harper-pro#825 inside #822)
kriszyp Sep 14, 2026
7de1929
Design note round 2: replace ack/activate with a per-node drain timer
kriszyp Sep 14, 2026
43d9530
Design note round 3: operator-timed activation, immediate quiesce-on-…
kriszyp Sep 14, 2026
4f10f61
Cluster record locks: implement harper-pro#825's operator-agreed home…
kriszyp Sep 14, 2026
59d859e
Address round-1 pre-push review: 3 blockers, sender gating, digest tr…
kriszyp Sep 14, 2026
7637a58
Fix round-2 pre-push blocker: ack timeout must fail a stage/activate,…
kriszyp Sep 14, 2026
a992ac3
Fix round-3 pre-push finding: settle the armed ack entry on a postMes…
kriszyp Sep 14, 2026
7a2ad19
Fix round-4 finding: the concurrent-increment test asserted freshness…
kriszyp Sep 14, 2026
833ed53
Fix round-5 finding: require near-total distinctness so the test stil…
kriszyp Sep 14, 2026
28c0cf1
Format: wrap the distinctness assert.ok to satisfy prettier
kriszyp Sep 14, 2026
2664b02
Docs: fix two accuracy findings from pre-push review (capability leve…
kriszyp Sep 14, 2026
10d3a54
Fix round-7 nit: crash-recovery skip cites harper#2498, not harper#2542
kriszyp Sep 14, 2026
d446d0a
Merge remote-tracking branch 'origin/main' into agent-fresh/pr-maint-…
kriszyp Sep 14, 2026
d755212
Design note: the harper-pro successor-freshness barrier (harper#2542)
kriszyp Sep 15, 2026
edc4c2c
Design note round 2: adopt the planning findings, fail recovery closed
kriszyp Sep 15, 2026
91c144c
Design note round 3: origin-keyed atomic publication, event-driven wa…
kriszyp Sep 15, 2026
bb9705f
Design note round 4: fences only from direct full-coverage streams
kriszyp Sep 15, 2026
6e516ca
Design note round 5: both halves against harper#2627's barrier entry
kriszyp Sep 15, 2026
b1a9fd9
Design note round 6: exact nonce barriers only, durable poison
kriszyp Sep 15, 2026
f02cf50
Design note round 7: record the append-order probe, adopt round 6's rest
kriszyp Sep 15, 2026
6bca86e
Successor freshness: prove every cross-origin handoff with a lockBarrier
kriszyp Sep 15, 2026
6269ae2
Docs: successor freshness landed, slot map, cost rows
kriszyp Sep 15, 2026
725bad0
Consume core's apply-failure listener when present; wildcard poison
kriszyp Sep 15, 2026
c364ea5
Cluster test: assert a barrier was applied and nothing was poisoned
kriszyp Sep 15, 2026
5c406e8
Fix pre-push review majors: poison durability, cross-thread poison, s…
kriszyp Sep 15, 2026
ffebc20
Read poison and reclone state from the store, not a per-thread cache
kriszyp Sep 15, 2026
062caa1
Fail closed when poison state cannot be read; poison unknown-table drops
kriszyp Sep 16, 2026
5294d42
Pin core at main (harper#2627 + #2630) and consume the apply-failure …
kriszyp Sep 16, 2026
d7bb233
Merge remote-tracking branch 'origin/main' into feat/record-lock-clus…
kriszyp Sep 16, 2026
fe45fa3
Design note: record_lock_bootstrap_generation
kriszyp Sep 16, 2026
c6a9872
Add record_lock_propose_homes, a read-only home-set proposal
kriszyp Sep 16, 2026
3cb1751
Return the §4.3 quiesce union, not just the new ring
kriszyp Sep 16, 2026
8ce73e0
Say what the proposal cannot know, and what a departing node becomes
kriszyp Sep 16, 2026
cb2340d
Docs: the design note now states both limits the response warns about
kriszyp Sep 16, 2026
a6cb5a4
Record locks: drain by recall so a membership change is not a ~6 minu…
kriszyp Sep 17, 2026
e0d9fae
Pin core at harper main now that #2663 has merged
kriszyp Sep 17, 2026
896e63a
Merge remote-tracking branch 'origin/main' into feat/record-lock-clus…
kriszyp Sep 17, 2026
a4920d3
Record locks: one operator call applies a home map across the cluster…
kriszyp Sep 17, 2026
b67f797
Cluster record locks: serve lock() on every worker at threads.count >…
kriszyp Sep 18, 2026
db5a9a2
Merge origin/main; pin core at harper main, which now carries harper#…
kriszyp Sep 18, 2026
9727464
Record locks: adjudicate PR #822's open review threads
kriszyp Sep 18, 2026
3152f56
Bench: count lockBarrier entries, the cost a cold handoff now actuall…
kriszyp Sep 18, 2026
132dfcb
Review round 20: drop a dead bench env var; stop advising a departing…
kriszyp Sep 18, 2026
9db67b3
Record locks: settle which nodes an activate goes to, and why not the…
kriszyp Sep 18, 2026
011f2b8
Record locks: let a quiesce relay outlive the drain it is carrying
kriszyp Sep 18, 2026
cedb049
Record locks: three round-32 findings in the operator-facing paths
kriszyp Sep 18, 2026
9db1700
Record locks: reject a non-finite stage drain budget too
kriszyp Sep 18, 2026
507a1cc
Record locks: hold MIN_DRAIN_BACKSTOP_MS to the same guard as its sib…
kriszyp Sep 18, 2026
c7ee3d5
Record locks: finish the digest-advice correction, and label the rela…
kriszyp Sep 18, 2026
51046d7
Record locks: state the transition relay's trust model and record the…
kriszyp Sep 18, 2026
e728ce5
Record locks: tell the operator enabling the feature about the accept…
kriszyp Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"server": "readonly",
"databases": "readonly",
"tables": "readonly",
"transaction": "readonly",
"createBlob": "readonly",
"lockdown": "readonly",
"threads": "readonly",
Expand Down
18 changes: 18 additions & 0 deletions benchmarks/recordLockExitCommit/doomedWorker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* The departed off-owner worker of harper-pro#865 ledger item 5: it staged writes under a relayed
* admission, unlocked, handed the commits to the engine, and is about to be terminated. Nothing is
* awaited — the state under test is a commit whose calling thread abandons it.
*/
import { parentPort, workerData } from 'node:worker_threads';
import { RocksDatabase } from '@harperfast/rocksdb-js';

const db = new RocksDatabase(workerData.path);
db.open();

const value = 'x'.repeat(workerData.valueBytes);
for (let i = 0; i < workerData.count; i++) {
if (workerData.mode === 'transaction') db.transaction(() => db.put(`contested-${i}`, { writer: 'doomed', i, value }));
else db.put(`contested-${i}`, { writer: 'doomed', i, value });
}

parentPort.postMessage({ handedOff: workerData.count, stalledAtHandoff: db.isWriteStalled?.() ?? null });
113 changes: 113 additions & 0 deletions benchmarks/recordLockExitCommit/exitCommit.probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* harper-pro#865 ledger item 5 / rocksdb-js#865: a worker's EXIT is counted as a completed fence on
* the ownerless-handoff path, and the argument for that is the process-wide native key lock. But a
* handle is revoked after `unlock()` has already returned the key (`core/resources/recordLock.ts`
* `revokeLease()`), so the key lock does not cover a staged write still in flight. The open question
* has been whether a commit already handed to the engine can outlive the thread that issued it, and
* land after a successor was admitted.
*
* npm run probe:record-lock-exit-commit
*
* Two things are measured, because they have different answers:
*
* 1. SURVIVAL — does an abandoned commit still land? The doomed worker hands `count` writes to the
* engine, is terminated, and the parent counts how many became visible.
* 2. ORDERING — can the doomed worker's write land AFTER a successor's write to the same key?
* That, not survival, is the lost update exit-as-fenced would allow. The parent writes every
* contested key as the successor and reports who won each one.
*
* `AWAIT_TERMINATE=0` starts the successor without awaiting `worker.terminate()`, because awaiting it
* would let the runtime drain the thread first — which is the assumption under test.
*
* A measurement, not a gate: `.probe.` keeps it out of the `*.test.*` globs, and nothing here asserts.
*/
import { Worker } from 'node:worker_threads';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { cpus, tmpdir, totalmem } from 'node:os';
import { performance } from 'node:perf_hooks';
import { join } from 'node:path';
import { RocksDatabase } from '@harperfast/rocksdb-js';

const COUNT = Number(process.env.RECORD_LOCK_EXIT_COUNT) || 4_000;
const VALUE_BYTES = Number(process.env.RECORD_LOCK_EXIT_VALUE_BYTES) || 131_072;
const TRIALS = Number(process.env.RECORD_LOCK_EXIT_TRIALS) || 5;
const AWAIT_TERMINATE = process.env.AWAIT_TERMINATE !== '0';
const MODES = (process.env.RECORD_LOCK_EXIT_MODES || 'put,transaction').split(',');
const OUT = process.env.RECORD_LOCK_EXIT_OUT || join(tmpdir(), `record-lock-exit-commit-${Date.now()}.json`);

async function trial(mode) {
const db = new RocksDatabase(join(mkdtempSync(join(tmpdir(), 'record-lock-exit-')), 'db'));
db.open();
const worker = new Worker(new URL('./doomedWorker.mjs', import.meta.url), {
workerData: { path: db.path, count: COUNT, valueBytes: VALUE_BYTES, mode },
});
const handed = await new Promise((resolve) => worker.once('message', resolve));

const terminating = worker.terminate();
if (AWAIT_TERMINATE) await terminating;
const terminatedAt = performance.now();

let survived = 0;
for (let i = 0; i < COUNT; i++) if (db.get(`contested-${i}`) !== undefined) survived++;

// The successor: admitted only because the exit was counted as a fence, writing the same keys.
const successorWrites = [];
for (let i = 0; i < COUNT; i++) successorWrites.push(db.put(`contested-${i}`, { writer: 'successor', i }));
await Promise.all(successorWrites);
const successorCommitMs = performance.now() - terminatedAt;

let doomedWon = 0;
for (let i = 0; i < COUNT; i++) if (db.get(`contested-${i}`)?.writer === 'doomed') doomedWon++;
db.close();

return {
mode,
handedOff: handed.handedOff,
stalledAtHandoff: handed.stalledAtHandoff,
survivedTerminate: survived,
doomedWonTheKey: doomedWon,
successorWon: COUNT - doomedWon,
successorCommitMs: Number(successorCommitMs.toFixed(3)),
};
}

const trials = [];
for (const mode of MODES) for (let i = 0; i < TRIALS; i++) trials.push(await trial(mode));

console.log(
`\n${COUNT} contested keys x ${VALUE_BYTES} B, ${TRIALS} trial(s) per mode, awaitTerminate=${AWAIT_TERMINATE}, Node ${process.version}\n`
);
console.log('| mode | survived terminate | doomed won | successor won | successor commit ms |');
console.log('| ----------- | ------------------ | ---------- | ------------- | ------------------- |');
for (const t of trials)
console.log(
`| ${t.mode.padEnd(11)} | ${`${t.survivedTerminate} / ${COUNT}`.padEnd(18)} | ${String(t.doomedWonTheKey).padEnd(10)} | ${String(t.successorWon).padEnd(13)} | ${String(t.successorCommitMs).padEnd(19)} |`
);
const inversions = trials.reduce((total, t) => total + t.doomedWonTheKey, 0);
console.log(
`\nabandoned commits that still landed: ${trials.reduce((total, t) => total + t.survivedTerminate, 0)} of ${trials.length * COUNT}` +
`\nordering inversions (a dead worker's write over a successor's): ${inversions} of ${trials.length * COUNT}\n`
);

writeFileSync(
OUT,
`${JSON.stringify(
{
machine: {
cpu: cpus()[0]?.model,
cores: cpus().length,
memoryGiB: Math.round(totalmem() / 1024 ** 3),
platform: `${process.platform}-${process.arch}`,
node: process.version,
},
ranAt: new Date().toISOString(),
keysPerTrial: COUNT,
valueBytes: VALUE_BYTES,
awaitTerminate: AWAIT_TERMINATE,
trials,
},
undefined,
'\t'
)}\n`
);
console.log(`raw: ${OUT}`);
50 changes: 50 additions & 0 deletions benchmarks/recordLockRelay/coordinatorWorker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* The coordinating worker's side of `transport.bench.mjs`: answers an acquire over the direct
* worker-to-worker port and over `notify()`, so the two transports are measured against the same
* handler doing the same work.
*
* `busyMs` is the point of the file. A relayed acquire is answered from the owner's event loop, so
* it waits out whatever turn that worker is already in — the spin loop is a stand-in for the owner
* serving its own requests, which is the normal state of a worker at `threads.count > 1`.
*/
import { parentPort, workerData } from 'node:worker_threads';
import { performance } from 'node:perf_hooks';
import { RocksDatabase } from '@harperfast/rocksdb-js';

const db = new RocksDatabase(workerData.path);
db.open();

const peer = workerData.port;
let admissionId = 1;
let busyMs = 0;

const mint = (message) => ({
requestId: message.requestId,
database: message.database,
table: message.table,
key: message.key,
round: { tsR: Date.now(), mintedMono: performance.now(), admissionId: admissionId++ },
session: workerData.session,
});

const spin = () => {
if (busyMs > 0) {
const until = performance.now() + busyMs;
while (performance.now() < until);
}
setImmediate(spin);
};
setImmediate(spin);

peer.on('message', (message) => {
if (message.type === 'load') {
busyMs = message.busyMs;
peer.postMessage({ type: 'load-ack', requestId: message.requestId });
} else if (message.type === 'acquire') {
peer.postMessage({ type: 'acquire-reply', ...mint(message) });
}
});

db.on('lock-acquire', (message) => db.notify('lock-acquire-reply', mint(message)));

parentPort.postMessage({ ready: true });
Loading
Loading