Skip to content

readOnly:true open races a live writer's compaction and reports a healthy database as corrupt; no OpenAsSecondary alternative exists #812

Description

@kriszyp

Summary

RocksDatabase.open(path, { readOnly: true }) maps to rocksdb::DB::OpenForReadOnly. That call is not safe against a database another process is actively writing: it replays the MANIFEST to build the SST file list and then opens each file, holding no reference on any of them. A compaction in the writing process can unlink an input SST between those two steps, and the open then fails with a message that blames the MANIFEST:

Corruption: IO error: No such file or directory:
  While open a file for random read: <path>/000046.sst: No such file or directory
  The file <path>/MANIFEST-000005 may be corrupted.

Nothing is corrupt. That wording is RocksDB's generic text for a file named in the MANIFEST was not there when I opened it. The database is healthy; the reader simply raced a compaction.

Two problems, one root:

  1. The error misdiagnoses a healthy database as corrupt. This is the part that can cause harm: an operator who reaches for a read-only open during an incident, sees "MANIFEST may be corrupted", and starts a repair or a restore is acting on a false report against intact data.
  2. There is no safe alternative in this library. RocksDB's API for a follower reader that tolerates the primary deleting files is DB::OpenAsSecondary (plus TryCatchUpWithPrimary() to advance it). grep -rn "OpenAsSecondary\|secondary" src/ returns nothing — the option is not exposed at any layer.

Why this is not just a test concern

harper ships an operator-facing read-only mode. resources/databases.ts:364-368 sets options.readOnly = true whenever isReadOnlyMode() is true, which is driven by HARPER_READONLY=1 or a --readonly / --read-only argv flag. Nothing stops that being pointed at a data directory a live Harper is writing — "bring up a read-only instance against the live data dir and look at it" is a natural incident move, and it is exactly the case that races.

Reproduction

Deterministic-ish, no Harper needed:

  1. Open a database read-write and write enough to produce several SSTs.
  2. Start continuous write + flush() traffic so compactions run.
  3. From a second handle or process, loop RocksDatabase.open(path, { name, readOnly: true }) / close().

The open fails intermittently with the message above. Flushing immediately before the read-only open makes it far more likely, because a flush creates new L0 files and that is what triggers the L0→L1 compaction the open then races.

Observed in CI at scale: harper's integrationTests/database/delete-index-atomicity-rocksdb.test.ts opens a second read-only handle as a verification oracle, calls POST /Flush/ immediately before each reopen, and hit this in 7 of 15 Integration runs on harper main (~47%) within hours of landing — across Node v22, Node v26.5.0, Bun and the uWS HTTP variant, so it is not runtime-specific. That test is being changed separately to stop losing this race; this issue is about the library.

Affected

  • @harperfast/rocksdb-js 2.8.0 (current) and, as far as I can tell, every earlier version — the OpenForReadOnly call has no file-deletion protection at any point in its history.

Docs understate it

Neither the README nor the TSDoc warns that a read-only open is unsafe against a live writer. README says only that read operations are permitted and writes throw ERR_DATABASE_READONLY; src/store.ts:283-289 says the same. Neither mentions that the open is a point-in-time snapshot, nor that it can fail spuriously against a concurrent writer.

Suggested remedy

  1. Expose OpenAsSecondary — a secondary: true open mode (with its required secondary-instance path) plus a catchUpWithPrimary() binding for TryCatchUpWithPrimary(). This is the actual fix: secondary mode is designed for a live follower reader and tolerates the primary obsoleting files.
  2. Stop reporting a missing SST as MANIFEST corruption. When OpenForReadOnly fails with an IsIOError naming a missing .sst, surface a distinct, accurate error — something like ERR_CONCURRENT_COMPACTION / "a concurrent compaction removed a file this read-only open needed; retry, or open as a secondary" — instead of passing RocksDB's corruption wording through. Whatever else is decided, an operator should never be told a healthy database may be corrupt.
  3. Document the hazard on the readOnly option in both the README and the TSDoc: safe against a quiescent database, unsafe against a live writer, use secondary mode for that.

(1) and (2) are independent; (2) is small and worth doing even if (1) is deferred.

Activity

  1. added theissue type on Aug 31, 2026
  2. kriszyp commented on Sep 4, 2026

    @kriszyp
    MemberAuthor

    Measured the window this issue describes, in case a number is useful when the secondary-mode open lands.

    Two processes, one RocksDB directory, @harperfast/rocksdb-js 2.8.0, Linux x64, Node 24:

    • writer — 3,000 put()s then flush() in a loop (each flush lands a new L0 file, which is what keeps L0→L1 compaction running), plus one createCheckpoint() per iteration. ~2.4M puts / ~800 checkpoints per 25s.
    • reader — RocksDatabase.open(target, { name: 'cf1', readOnly: true }) followed by a short range scan, in a tight loop.
    reader target attempts failed opens
    the live directory 3,804 273 (7.2%)
    a checkpoint of it 5,804 0 (7 were my harness's own non-atomic cursor file, not the DB)

    So under continuous compaction roughly 1 in 14 read-only opens loses the race, and a checkpoint of the same database taken by the same writer never does.

    One detail worth adding to the issue's second point: on 2.8.0 the failure does not always carry the filename. All 273 surfaced as

    Database does not exist
    

    with no cause, which is the shape an operator is most likely to see and is arguably worse than the "MANIFEST may be corrupted" wording — it says the database is absent while it is sitting right there, healthy. The CI failure that sent me here (HarperFast/harper#2500) did carry the full Corruption: IO error: No such file or directory: ... 000021.sst text, so both messages come out of the same window.

    Downstream: HarperFast/harper#2500 works around this by having the fixture publish a checkpoint and reading that. That workaround goes away when this issue does.

    — Claude Opus 5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions