Skip to content

Commit 2b9972e

Browse files
authored
Merge pull request #1140 from HarperFast/fix/1135-1136-txnlog-replay-corruption
fix(replay): recover from corrupt transaction-log frames instead of aborting startup
2 parents bce7e4d + 9d563fe commit 2b9972e

4 files changed

Lines changed: 207 additions & 45 deletions

File tree

integrationTests/server/replay-stress.test.ts

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,19 @@
88
* 1. Clean crash — replay must recover every row inserted before SIGKILL
99
* 2. Truncated tails — last bytes of each txnlog stripped (simulates a torn
1010
* write at the moment of crash); replay must still come back up
11-
* 3. Random byte flips — msgpack-shaped corruption sprinkled across every
12-
* txnlog; replay must finish without the CPU-spin regression that
13-
* replayLogsGuards.ts (commit d0190ff5a) fixed
11+
* 3. Corrupt length prefix — the first entry's declared length is forced to
12+
* overrun the log (a torn/corrupt frame). rocksdb-js's reader throws a bounded
13+
* RangeError for this; replay/broadcast must treat it as end-of-log and the
14+
* server must come back up rather than aborting startup (HarperFast/harper#1135)
1415
*
1516
* All three scenarios share one suite/ctx to avoid the schema-registry leak
1617
* that surfaces when multiple suites each call create_database in the same
1718
* test-runner process.
1819
*/
1920
import { suite, test, before, after } from 'node:test';
2021
import { ok, equal } from 'node:assert/strict';
21-
import { readdirSync, statSync, openSync, readSync, writeSync, truncateSync, closeSync } from 'node:fs';
22+
import { readdirSync, readFileSync, statSync, openSync, writeSync, truncateSync, closeSync } from 'node:fs';
2223
import { join } from 'node:path';
23-
import { setTimeout as sleep } from 'node:timers/promises';
2424

2525
import {
2626
startHarper,
@@ -29,6 +29,12 @@ import {
2929
type ContextWithHarper,
3030
type HarperContext,
3131
} from '@harperfast/integration-testing';
32+
import { constants } from '@harperfast/rocksdb-js';
33+
34+
// Transaction-log framing (all big-endian): a fixed-size file header, then a run of entries
35+
// each shaped [float64 timestamp][uint32 length][flags byte][length bytes of data]. So an
36+
// entry's declared length lives at entryStart + 8, and the byte there is its most-significant.
37+
const { TRANSACTION_LOG_FILE_HEADER_SIZE, TRANSACTION_LOG_ENTRY_HEADER_SIZE } = constants;
3238

3339
const DB = 'stress';
3440
const TABLES = ['orders', 'items', 'events'];
@@ -128,26 +134,30 @@ function truncateTail(path: string, bytes: number) {
128134
truncateSync(path, size - bytes);
129135
}
130136

131-
function flipBytes(path: string, count: number, seed: number) {
132-
const size = statSync(path).size;
133-
// Skip the 13-byte file header (4 token + 1 version + 8 ts) so we exercise
134-
// the per-entry decoder hardening, not file-open validation.
135-
const start = 13;
136-
if (size <= start + 32) return;
137+
function corruptLastEntryLength(path: string): boolean {
138+
// Force the *last* well-framed entry's big-endian uint32 length to overrun the log (top
139+
// byte → 0xff, ≥ 4 GB). The last entry sits in the unflushed tail that replay reads
140+
// (replay starts from the last-flushed position), so a flushed prefix isn't skipped over.
141+
const buf = readFileSync(path);
142+
let pos = TRANSACTION_LOG_FILE_HEADER_SIZE;
143+
let lastLengthPos = -1;
144+
while (pos + TRANSACTION_LOG_ENTRY_HEADER_SIZE <= buf.length) {
145+
if (buf.readDoubleBE(pos) === 0) break; // a zero timestamp marks end-of-log to the reader
146+
const lengthPos = pos + 8;
147+
const length = buf.readUInt32BE(lengthPos);
148+
const next = pos + TRANSACTION_LOG_ENTRY_HEADER_SIZE + length;
149+
if (length === 0 || next > buf.length) break; // ran past the end / already unframable
150+
lastLengthPos = lengthPos;
151+
pos = next;
152+
}
153+
if (lastLengthPos < 0) return false;
137154
const fd = openSync(path, 'r+');
138155
try {
139-
const buf = Buffer.alloc(1);
140-
let s = seed;
141-
for (let i = 0; i < count; i++) {
142-
s = (s * 1103515245 + 12345) & 0x7fffffff;
143-
const pos = start + (s % (size - start));
144-
readSync(fd, buf, 0, 1, pos);
145-
buf[0] ^= 0xa5;
146-
writeSync(fd, buf, 0, 1, pos);
147-
}
156+
writeSync(fd, Buffer.from([0xff]), 0, 1, lastLengthPos);
148157
} finally {
149158
closeSync(fd);
150159
}
160+
return true;
151161
}
152162

153163
async function crashAndRestart(ctx: ContextWithHarper, mutate?: (dataRootDir: string) => void) {
@@ -197,37 +207,27 @@ suite('Transaction log replay stress', (ctx: ContextWithHarper) => {
197207
}
198208
});
199209

200-
test('crash with random byte flips in txnlogs', async () => {
210+
test('crash with corrupt length-prefix recovers without aborting startup', async () => {
201211
for (const table of TABLES) {
202212
const records = [];
203213
for (let i = 0; i < 500; i++) records.push(makeRecord(200_000 + i));
204214
await op(ctx.harper, { operation: 'insert', database: DB, table, records });
205215
}
206-
const replayMs = await crashAndRestart(ctx, (dataRootDir) => {
207-
// User-DB only: flipping bytes inside system/ txnlogs corrupts
208-
// version-tracking and Harper aborts on next boot with an upgrade
209-
// error. That's a real failure mode but not what this test is about —
210-
// here we want to exercise replay's per-entry decode hardening on
211-
// records that replay genuinely needs to skip rather than reject the
212-
// whole boot.
213-
const files = listTxnLogFiles(dataRootDir, { userOnly: true });
214-
files.forEach((f, i) => flipBytes(f, 32, 0xcafe + i));
216+
let corrupted = 0;
217+
await crashAndRestart(ctx, (dataRootDir) => {
218+
// User-DB only: corrupting system/ txnlogs trips the upgrade-abort path on next
219+
// boot — a different failure mode than the framing corruption (#1135) under test.
220+
for (const f of listTxnLogFiles(dataRootDir, { userOnly: true })) {
221+
if (corruptLastEntryLength(f)) corrupted++;
222+
}
215223
});
216-
// Tighter than the 60s global cap. With the per-entry decode guard in place,
217-
// healthy startup is ~3s on this corpus. Without it, every corrupt entry
218-
// logs a stack trace inside the loop and startup balloons to ~50s. 20s
219-
// catches that regression while leaving CI headroom.
220-
ok(replayMs < 20_000, `replay took ${replayMs}ms — guard regression?`);
224+
// Fail loudly, not vacuously, if the framing ever changes and nothing gets corrupted.
225+
ok(corrupted > 0, 'expected to corrupt at least one user-DB txnlog');
226+
// Behavioral, not a wall-clock budget: crashAndRestart resolved → the server came back
227+
// up; a regression shows up as a failed restart, not a slow one. Confirm tables queryable.
221228
for (const t of TABLES) {
222229
const c = await countRows(ctx.harper, t);
223230
ok(typeof c === 'number' && c >= 0, `count on ${t} should be a number, got ${c}`);
224231
}
225-
// Confirm Harper isn't in a CPU-spin loop post-replay: rss should be
226-
// roughly stable after startup is reported done. The pre-fix bug pinned
227-
// a core forever and rss grew steadily under the spin.
228-
const rssBefore = ctx.harper.process.resourceUsage?.()?.maxRSS ?? 0;
229-
await sleep(500);
230-
const rssAfter = ctx.harper.process.resourceUsage?.()?.maxRSS ?? 0;
231-
ok(rssAfter - rssBefore < 200 * 1024, `rss grew by ${rssAfter - rssBefore}KB after replay`);
232232
});
233233
});

resources/RocksTransactionLogStore.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { TransactionLog, RocksDatabase, shutdown, type TransactionEntry } from '
22
import { ExtendedIterable } from '@harperfast/extended-iterable';
33
import { getIdOfRemoteNode } from './nodeIdMapping.ts';
44
import { Decoder, readAuditEntry, ENTRY_DATAVIEW, AuditRecord, createAuditEntry } from './auditStore.ts';
5+
import { endIteratorOnCorruptFrame } from './replayLogsGuards.ts';
56
import { isMainThread } from 'node:worker_threads';
67
import { EventEmitter } from 'node:events';
78
import { asBinary } from 'lmdb';
@@ -21,6 +22,13 @@ type TransactionLogIterator = Iterator<TransactionEntry | number> & {
2122
removeLog(logName: string);
2223
};
2324

25+
// Logs (once per log) when a corrupt frame ends a query iterator early; see
26+
// endIteratorOnCorruptFrame in replayLogsGuards.ts for why this is end-of-log, not a crash.
27+
function warnCorruptFrame(logName: string) {
28+
return (error: RangeError) =>
29+
harperLogger.warn(`Stopping transaction log "${logName}" at a corrupt entry during replay`, error);
30+
}
31+
2432
/**
2533
* Represents a transaction log store backed by RocksDB.
2634
* This class provides methods that conform to a standard store interface
@@ -190,7 +198,7 @@ export class RocksTransactionLogStore extends EventEmitter {
190198
log = this.rootStore.useLog(options.log);
191199
}
192200
}
193-
const queryIterator = log.query(options);
201+
const queryIterator = endIteratorOnCorruptFrame(log.query(options), warnCorruptFrame(log.name));
194202
iterable.iterate = () => queryIterator;
195203
} else {
196204
const onlyKeys = options.onlyKeys;
@@ -241,7 +249,7 @@ export class RocksTransactionLogStore extends EventEmitter {
241249
// condition of potentially missing an initial update
242250
queryOptions = { ...options, start: options.start ?? 0 };
243251
}
244-
iterators.push(log.query(queryOptions));
252+
iterators.push(endIteratorOnCorruptFrame(log.query(queryOptions), warnCorruptFrame(log.name)));
245253
}
246254
}
247255
latestUpdates = this.updates;

resources/replayLogsGuards.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,48 @@ export function classifyAuditEntryForReplay(
4040
if ((action & RECORD_BEARING_FLAGS) !== 0 && !hasRecord) return 'missing-record';
4141
return null;
4242
}
43+
44+
/**
45+
* Wraps a transaction-log query iterator so a corrupt/torn frame ends that log's iteration
46+
* cleanly instead of escaping as an uncaughtException. rocksdb-js throws a bounded RangeError
47+
* when an entry's framing is broken; framing loss means the next entry can't be located, so the
48+
* frame marks end-of-log (entries before it were already yielded) and startup replay /
49+
* replication broadcast continue. `onCorruptFrame` fires once, latched — kept a callback (not a
50+
* direct log) so this module stays out of the Harper module graph and is unit-testable.
51+
*/
52+
export function endIteratorOnCorruptFrame<T>(
53+
iterator: Iterator<T>,
54+
onCorruptFrame: (error: RangeError) => void
55+
): IterableIterator<T> {
56+
let stopped = false;
57+
return {
58+
[Symbol.iterator]() {
59+
return this;
60+
},
61+
next(): IteratorResult<T> {
62+
if (stopped) return { done: true, value: undefined };
63+
try {
64+
return iterator.next();
65+
} catch (error) {
66+
// Key on the class, not the message: the framing RangeError's wording is
67+
// version-dependent (1.4.2 added hex offsets). Anything else re-throws.
68+
if (!(error instanceof RangeError)) throw error;
69+
stopped = true;
70+
onCorruptFrame(error);
71+
return { done: true, value: undefined };
72+
}
73+
},
74+
// Forward early termination (for-of break/return/throw) so the source's cleanup runs;
75+
// mark stopped first. Current rocksdb-js implements neither — hence the protocol defaults.
76+
return(value?: any): IteratorResult<T> {
77+
stopped = true;
78+
if (typeof iterator.return === 'function') return iterator.return(value);
79+
return { done: true, value };
80+
},
81+
throw(error?: any): IteratorResult<T> {
82+
stopped = true;
83+
if (typeof iterator.throw === 'function') return iterator.throw(error);
84+
throw error;
85+
},
86+
};
87+
}

unitTests/resources/replayLogs.test.js

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ const assert = require('node:assert');
22

33
// The helper lives in a dependency-free module so the test doesn't need to bootstrap
44
// the full Resource/RocksDB module graph (which has a circular require chain).
5-
const { classifyAuditEntryForReplay, RECORD_BEARING_FLAGS } = require('#src/resources/replayLogsGuards');
5+
const {
6+
classifyAuditEntryForReplay,
7+
RECORD_BEARING_FLAGS,
8+
endIteratorOnCorruptFrame,
9+
} = require('#src/resources/replayLogsGuards');
610

711
// Regression tests for the unclean-shutdown replay guards. Without these, an audit log
812
// containing entries with corrupt MessagePack values caused replayLogs to write
@@ -59,3 +63,108 @@ describe('classifyAuditEntryForReplay', () => {
5963
assert.strictEqual(RECORD_BEARING_FLAGS, HAS_RECORD | HAS_PARTIAL_RECORD);
6064
});
6165
});
66+
67+
// Regression tests for HarperFast/harper#1135: the wrapper must turn a framing RangeError into
68+
// a clean end-of-log (so replay/broadcast don't abort the boot) and leave other errors alone.
69+
describe('endIteratorOnCorruptFrame', () => {
70+
it('yields entries up to a corrupt frame, then ends cleanly and reports it once', () => {
71+
let calls = 0;
72+
const source = {
73+
next() {
74+
calls++;
75+
if (calls === 1) return { done: false, value: 'a' };
76+
if (calls === 2) return { done: false, value: 'b' };
77+
throw new RangeError('declared length 1778384896 overruns the log (limit=5439)');
78+
},
79+
};
80+
const reported = [];
81+
const wrapped = endIteratorOnCorruptFrame(source, (error) => reported.push(error));
82+
83+
assert.deepStrictEqual([...wrapped], ['a', 'b']);
84+
assert.strictEqual(reported.length, 1);
85+
assert.ok(reported[0] instanceof RangeError);
86+
// Latched: stays done without re-invoking the source (no repeated reporting/spam).
87+
assert.deepStrictEqual(wrapped.next(), { done: true, value: undefined });
88+
assert.strictEqual(calls, 3);
89+
assert.strictEqual(reported.length, 1);
90+
});
91+
92+
it('does not swallow non-RangeError failures', () => {
93+
const source = {
94+
next() {
95+
throw new TypeError('boom');
96+
},
97+
};
98+
let reported = 0;
99+
const wrapped = endIteratorOnCorruptFrame(source, () => reported++);
100+
assert.throws(() => wrapped.next(), TypeError);
101+
assert.strictEqual(reported, 0);
102+
});
103+
104+
it('passes a normal exhaustion through without reporting a corrupt frame', () => {
105+
let calls = 0;
106+
const source = {
107+
next() {
108+
calls++;
109+
return calls === 1 ? { done: false, value: 1 } : { done: true, value: undefined };
110+
},
111+
};
112+
let reported = 0;
113+
const wrapped = endIteratorOnCorruptFrame(source, () => reported++);
114+
assert.deepStrictEqual([...wrapped], [1]);
115+
assert.strictEqual(reported, 0);
116+
});
117+
118+
it('delegates return()/throw() to the underlying iterator so early-exit cleanup runs', () => {
119+
let returnedWith;
120+
let threwWith;
121+
const source = {
122+
next() {
123+
return { done: false, value: 1 };
124+
},
125+
return(value) {
126+
returnedWith = value;
127+
return { done: true, value };
128+
},
129+
throw(error) {
130+
threwWith = error;
131+
return { done: true, value: undefined };
132+
},
133+
};
134+
const wrapped = endIteratorOnCorruptFrame(source, () => {});
135+
136+
assert.strictEqual(typeof wrapped.return, 'function');
137+
assert.deepStrictEqual(wrapped.return('cleanup'), { done: true, value: 'cleanup' });
138+
assert.strictEqual(returnedWith, 'cleanup');
139+
// after return(), the wrapper is latched done and never touches the source again
140+
assert.deepStrictEqual(wrapped.next(), { done: true, value: undefined });
141+
142+
assert.strictEqual(typeof wrapped.throw, 'function');
143+
const boom = new Error('boom');
144+
wrapped.throw(boom);
145+
assert.strictEqual(threwWith, boom);
146+
});
147+
148+
it('return()/throw() fall back to protocol defaults and latch when the underlying lacks them', () => {
149+
let nextCalls = 0;
150+
const source = {
151+
next() {
152+
nextCalls++;
153+
return { done: false, value: 1 };
154+
},
155+
};
156+
const wrapped = endIteratorOnCorruptFrame(source, () => {});
157+
158+
// return() defaults to done and latches without ever pulling the source again
159+
assert.deepStrictEqual(wrapped.return('x'), { done: true, value: 'x' });
160+
assert.deepStrictEqual(wrapped.next(), { done: true, value: undefined });
161+
assert.strictEqual(nextCalls, 0);
162+
163+
// throw() rethrows when the source can't handle it
164+
const boom = new Error('boom');
165+
assert.throws(
166+
() => endIteratorOnCorruptFrame({ next: source.next }, () => {}).throw(boom),
167+
(error) => error === boom
168+
);
169+
});
170+
});

0 commit comments

Comments
 (0)