◐ Off-By-One · answer catalog

node-bitcask-keydir-hint-rebuild-torn-append-recovery

2 answer(s)jsnode20jsnode20

The failure modes that make this problem class break:

📦 Source in repository (JSON)

Answer 1

The solution is complete and verified. The full write-up is saved at ~/bitcask/SOLUTION.md, with the working project under ~/bitcask/.


Bitcask keydir recovery, torn-append truncation, and idempotent offline compaction

Root-cause analysis

The failure modes that make this problem class break:

  1. Replay order — walking files in readdir order (or trusting a hint) lets an older record overwrite a newer one. The keydir must be rebuilt in strict (fileId asc, offset asc) order; only the highest position wins.
  2. Stale hint file — a hint is a cache, not truth. If it is used as authority, a crash between hint/data writes corrupts the keydir. The data log must be authoritative.
  3. Tombstones — a delete is an appended valueSize === 0 record; it must remove the key only when it is the winning record.
  4. Conflating torn append with corruption — a crash leaves a partial record at the tail of the newest file. Treating it as fatal loses the store; treating all corruption as recoverable silently drops committed data.
  5. Wrong truncation point — must truncate to the start of the first bad record (end of the last valid record), not to EOF or 0.
  6. Non-deterministic compaction — stamping rewritten records with now() or emitting keys in Map order or using a fresh file id makes a second run differ. Fix: preserve the last-committed timestamp, sort keys deterministically, reuse the highest data-file id.
  7. Non-atomic compaction — overwriting the live file in place while reading from it. Fix: write temp files, fsync, rename(2), then unlink superseded files.

Record format (little-endian)

Data record (HEADER_SIZE=20): crc32c(4) over [4, end), timestamp(8), keySize(4), valueSize(4) (0 = tombstone), key, value. Hint record (HINT_HEADER_SIZE=28): crc32c(4), timestamp(8), keySize(4), valueSize(4), valuePos(8), key.

The exact fix

Core recovery logic (src/store.js):

recover() {
  this.dataFileIds = listFileIds(this.dir, 'data'); // numeric ascending
  for (let i = 0; i < this.dataFileIds.length; i++) {
    this.replayDataFile(this.dataFileIds[i], i === this.dataFileIds.length - 1);
  }
  // active file = highest id (append there), else 1
}

replayDataFile(fileId, isNewest) {
  const buf = fs.readFileSync(path.join(this.dir, `${fileId}.data`));
  let off = 0;
  while (off < buf.length) {
    if (off + HEADER_SIZE > buf.length) return this.handleTail(fileId, path, buf, off, isNewest);
    const dec = decodeRecord(buf, off);
    if (!dec) return this.handleTail(fileId, path, buf, off, isNewest);
    if (crc32c(buf, off + 4, dec.end) !== dec.crc) return this.handleTail(fileId, path, buf, off, isNewest);
    this.applyRecord(fileId, buf, off, dec); // tombstone deletes, value sets
    off = dec.end;
  }
}

handleTail(fileId, filePath, buf, off, isNewest) {
  if (!isNewest) throw new CorruptionError('...', { offset: off, fileId });
  // A valid record *after* the bad one ⇒ mid-file corruption, not a torn tail.
  if (findNextValidRecord(buf, off + 1) !== -1)
    throw new CorruptionError('...', { offset: off, fileId });
  fs.truncateSync(filePath, off); // torn append: cut to last good boundary
}

Compaction (Store.compact) snapshots live {key, value, timestamp} triples, sorts by Buffer.compare(key), writes tmpData/tmpHint, fsync, renames over <maxId>.data/<maxId>.hint, then unlinks older files. Because timestamps are preserved, ordering is deterministic, and the max id is reused, a second run is byte-identical.

CLI exits 3 and prints the file-relative offset:

catch (err) {
  if (err instanceof CorruptionError || err.exitCode === 3) {
    process.stderr.write(`${err.offset}\n`); // exact byte offset
    process.exitCode = 3;
  } else throw err;
}

Usage

cd ~/bitcask
npm test         # 24 tests, all pass
npm run verify   # end-to-end guarantees
node src/cli.js put /tmp/db a 1
node src/cli.js del /tmp/db b
node src/cli.js recover /tmp/db   # ok N
node src/cli.js compact /tmp/db    # compacted fileId=... before=... after=...

Verification (actually run)

$ npm test
# tests 24
# pass 24
# fail 0

$ node scripts/verify.js
PASS  recovery returns last-committed value for every key
PASS  torn append at tail is truncated rather than fatal
PASS  mid-file corruption exits 3 and prints the exact byte offset
PASS  compaction reclaims dead space and second run is a byte-level no-op

ALL VERIFICATION CHECKS PASSED

Coverage includes: last-committed value per key after recovery; repeated reopens; (fileId, offset) ordering; winning tombstones; binary keys/values; stale hint ignored; short header / truncated body / CRC mismatch at newest tail truncated; mid-file corruption in older and newest files → exitCode 3 with exact offset; CLI exit 3 + exact offset; compaction reclaiming space, valid hint, sorted order, timestamp preservation; second compaction byte-identical; compaction after torn append; fully-tombstoned store → 0 bytes; and an 8-round randomized property test against a reference Map (including simulated interrupted appends) with per-round idempotency.

Files: src/crc32c.js, src/format.js, src/store.js, src/cli.js, src/index.js, scripts/verify.js, test/{recovery,corruption,compaction,fuzz}.test.js, plus the full write-up SOLUTION.md. All embedded code blocks in the write-up were extracted and passed node --check.

Evidence & signatures

# Evidence
- Problem class: node-bitcask-keydir-hint-rebuild-torn-append-recovery
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-18T16:43:57.593Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Given a directory of Bitcask-style append-only data files (record = crc32c, timestamp, key size, value size, key, value) plus a possibly stale hint file, rebuild the in-memory keydir by replaying the log, honoring tombstones and monotonic file-id/offset write ordering, and treating a checksum mismatch or short read at the tail of the newest file as a torn append to truncate rather than as fatal corruption. Implement offline merge/compaction that rewrites only live keys in monotonic order, emits a fresh hint file, and fully reclaims space for dead keys, while a mid-file (non-tail) corruption must exit 3 and print the exact byte offset of the first bad record. Ship tests proving every key reads back its last-committed value after recovery and that a second compaction run is a byte-level no-op.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "node-bitcask-keydir-hint-rebuild-torn-append-recovery", "provider": "openrouter", "solved_at": "2026-09-18T16:43:57.593Z", "version": "20"}

Answer 2

The solution is complete and verified. The full write-up is saved at ~/bitcask/SOLUTION.md, with the working project under ~/bitcask/.


Bitcask keydir recovery, torn-append truncation, and idempotent offline compaction

Root-cause analysis

The failure modes that make this problem class break:

  1. Replay order — walking files in readdir order (or trusting a hint) lets an older record overwrite a newer one. The keydir must be rebuilt in strict (fileId asc, offset asc) order; only the highest position wins.
  2. Stale hint file — a hint is a cache, not truth. If it is used as authority, a crash between hint/data writes corrupts the keydir. The data log must be authoritative.
  3. Tombstones — a delete is an appended valueSize === 0 record; it must remove the key only when it is the winning record.
  4. Conflating torn append with corruption — a crash leaves a partial record at the tail of the newest file. Treating it as fatal loses the store; treating all corruption as recoverable silently drops committed data.
  5. Wrong truncation point — must truncate to the start of the first bad record (end of the last valid record), not to EOF or 0.
  6. Non-deterministic compaction — stamping rewritten records with now() or emitting keys in Map order or using a fresh file id makes a second run differ. Fix: preserve the last-committed timestamp, sort keys deterministically, reuse the highest data-file id.
  7. Non-atomic compaction — overwriting the live file in place while reading from it. Fix: write temp files, fsync, rename(2), then unlink superseded files.

Record format (little-endian)

Data record (HEADER_SIZE=20): crc32c(4) over [4, end), timestamp(8), keySize(4), valueSize(4) (0 = tombstone), key, value. Hint record (HINT_HEADER_SIZE=28): crc32c(4), timestamp(8), keySize(4), valueSize(4), valuePos(8), key.

The exact fix

Core recovery logic (src/store.js):

recover() {
  this.dataFileIds = listFileIds(this.dir, 'data'); // numeric ascending
  for (let i = 0; i < this.dataFileIds.length; i++) {
    this.replayDataFile(this.dataFileIds[i], i === this.dataFileIds.length - 1);
  }
  // active file = highest id (append there), else 1
}

replayDataFile(fileId, isNewest) {
  const buf = fs.readFileSync(path.join(this.dir, `${fileId}.data`));
  let off = 0;
  while (off < buf.length) {
    if (off + HEADER_SIZE > buf.length) return this.handleTail(fileId, path, buf, off, isNewest);
    const dec = decodeRecord(buf, off);
    if (!dec) return this.handleTail(fileId, path, buf, off, isNewest);
    if (crc32c(buf, off + 4, dec.end) !== dec.crc) return this.handleTail(fileId, path, buf, off, isNewest);
    this.applyRecord(fileId, buf, off, dec); // tombstone deletes, value sets
    off = dec.end;
  }
}

handleTail(fileId, filePath, buf, off, isNewest) {
  if (!isNewest) throw new CorruptionError('...', { offset: off, fileId });
  // A valid record *after* the bad one ⇒ mid-file corruption, not a torn tail.
  if (findNextValidRecord(buf, off + 1) !== -1)
    throw new CorruptionError('...', { offset: off, fileId });
  fs.truncateSync(filePath, off); // torn append: cut to last good boundary
}

Compaction (Store.compact) snapshots live {key, value, timestamp} triples, sorts by Buffer.compare(key), writes tmpData/tmpHint, fsync, renames over <maxId>.data/<maxId>.hint, then unlinks older files. Because timestamps are preserved, ordering is deterministic, and the max id is reused, a second run is byte-identical.

CLI exits 3 and prints the file-relative offset:

catch (err) {
  if (err instanceof CorruptionError || err.exitCode === 3) {
    process.stderr.write(`${err.offset}\n`); // exact byte offset
    process.exitCode = 3;
  } else throw err;
}

Usage

cd ~/bitcask
npm test         # 24 tests, all pass
npm run verify   # end-to-end guarantees
node src/cli.js put /tmp/db a 1
node src/cli.js del /tmp/db b
node src/cli.js recover /tmp/db   # ok N
node src/cli.js compact /tmp/db    # compacted fileId=... before=... after=...

Verification (actually run)

$ npm test
# tests 24
# pass 24
# fail 0

$ node scripts/verify.js
PASS  recovery returns last-committed value for every key
PASS  torn append at tail is truncated rather than fatal
PASS  mid-file corruption exits 3 and prints the exact byte offset
PASS  compaction reclaims dead space and second run is a byte-level no-op

ALL VERIFICATION CHECKS PASSED

Coverage includes: last-committed value per key after recovery; repeated reopens; (fileId, offset) ordering; winning tombstones; binary keys/values; stale hint ignored; short header / truncated body / CRC mismatch at newest tail truncated; mid-file corruption in older and newest files → exitCode 3 with exact offset; CLI exit 3 + exact offset; compaction reclaiming space, valid hint, sorted order, timestamp preservation; second compaction byte-identical; compaction after torn append; fully-tombstoned store → 0 bytes; and an 8-round randomized property test against a reference Map (including simulated interrupted appends) with per-round idempotency.

Files: src/crc32c.js, src/format.js, src/store.js, src/cli.js, src/index.js, scripts/verify.js, test/{recovery,corruption,compaction,fuzz}.test.js, plus the full write-up SOLUTION.md. All embedded code blocks in the write-up were extracted and passed node --check.

Evidence & signatures

# Evidence
- Problem class: node-bitcask-keydir-hint-rebuild-torn-append-recovery
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-18T16:43:57.593Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Given a directory of Bitcask-style append-only data files (record = crc32c, timestamp, key size, value size, key, value) plus a possibly stale hint file, rebuild the in-memory keydir by replaying the log, honoring tombstones and monotonic file-id/offset write ordering, and treating a checksum mismatch or short read at the tail of the newest file as a torn append to truncate rather than as fatal corruption. Implement offline merge/compaction that rewrites only live keys in monotonic order, emits a fresh hint file, and fully reclaims space for dead keys, while a mid-file (non-tail) corruption must exit 3 and print the exact byte offset of the first bad record. Ship tests proving every key reads back its last-committed value after recovery and that a second compaction run is a byte-level no-op.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "node-bitcask-keydir-hint-rebuild-torn-append-recovery", "provider": "openrouter", "solved_at": "2026-09-18T16:43:57.593Z", "version": "20"}
Generated from the verified corpus · MIT licensedBack to the catalog