Skip to content

RocksDB: table-scoped delete_transaction_logs_before destroys the entire database's transaction log #2049

Closed
HarperFast/documentation
#618

Description

@kriszyp

RocksDB: table-scoped delete_transaction_logs_before destroys the entire database's transaction log

Summary

On RocksDB, delete_transaction_logs_before({ database, table, timestamp }) silently ignores the table scope and purges the whole database's transaction/audit log. An operator asking to prune one table's history permanently destroys every sibling table's history in the same database. The operation reports success with an entries_deleted count reflecting the wider deletion, and there is no warning and no way to recover.

The parameter is annotated // lmdb only in the signature, so this is known-not-supported on RocksDB — but the code path doesn't reject it, it accepts it and does something far more destructive than what was asked.

Mechanism

dataLayer/harperBridge/ResourceBridge.ts:481-519:

async deleteTransactionLogsBefore(deleteObj: {
    schema?: string;
    database?: string;
    table?: string; // lmdb only
    timestamp: Date | number | string;
    cleanup_deleted_records?: boolean; // lmdb only
}): Promise<DeleteTransactionLogsBeforeResults> {
    ...
    const table = getTable(deleteObj);
    if (!table) {
        // no table, check if any of the tables are RocksDB
        // since all tables share the same transaction log store, we break after the first
        // RocksDB table is found
        const tables = getDatabases()[deleteObj.database];
        if (tables) {
            for (const table of Object.values(tables)) {
                if (table.primaryStore instanceof RocksDatabase) {
                    const deleted = table.primaryStore.purgeLogs({ before, includeEntryCounts: true });
                    ...
                    break;
                }
            }
        }
    } else if (table.primaryStore instanceof RocksDatabase) {
        const deleted = table.primaryStore.purgeLogs({ before, includeEntryCounts: true });
        ...
    }
    ...
}

The two RocksDB branches — "caller named a table" and "caller named no table" — do the identical thing. The comment in the second branch states why: all tables share the same transaction log store. So naming a table only selects which table's primaryStore handle is used to reach the shared per-database log store; the purge itself has no table dimension.

Per-table purging isn't merely unimplemented — it isn't expressible at the storage layer. rocksdb-js 2.5.0's PurgeLogsOptions is:

type PurgeLogsOptions = {
  before?: number;
  destroy?: boolean;
  includeEntryCounts?: boolean;
  name?: string;   // log name, e.g. "local" — not a table
};

There is no table/tableId filter, and the log is purged in whole-file units, which is fundamentally at odds with per-table granularity — a per-table purge would require rewriting log files rather than deleting them.

Measured

Purging one 5,000-row table in a database with three 5,000-row tables:

  • reported entries_deleted: 15000 (i.e. all three tables' entries)
  • after restart, the two sibling tables read 0 rows of history each (5,000 before)

Manager-verified by source read and an independent cold rerun (6/6 runs, identical numbers).

A detail that makes this worse in practice: a stale in-process cache keeps the sibling data appearing intact until restart, so an operator's immediate "did that do what I wanted?" check sees healthy tables. The loss surfaces on the next restart, decoupled in time from the action that caused it.

Why this is worse than an unsupported-parameter bug

The failure mode is unrecoverable destruction of data the operator never asked to delete, on an operation whose whole purpose is bounded deletion. Ignoring an unsupported scope parameter is normally benign; here it widens the blast radius of a destructive operation by the number of tables in the database.

Suggested direction

Short term, and I'd argue this is the whole fix: reject table on RocksDB with a 400 rather than silently widening the scope. The signature already documents it as LMDB-only; make the validator enforce what the comment claims. validation/transactionLogValidator.ts is the natural place, though it needs the storage engine in scope to make the decision.

If per-table pruning on RocksDB is wanted as a feature, that's a rocksdb-js change first (a table filter in PurgeLogsOptions, plus a story for whole-file purge granularity) and should be a separate issue — but the silent-widening behaviour should not wait on it.

Worth deciding alongside: whether cleanup_deleted_records (also annotated // lmdb only) has the same silent-ignore shape. It looks inert rather than destructive on the RocksDB path, but it's the same pattern in the same signature.

Provenance

Found by the qa-explorer exploratory QA loop. Source lines verified against main @ b852a722c; PurgeLogsOptions verified against the pinned @harperfast/rocksdb-js 2.5.0.

Activity

  1. added
    bugSomething isn't working
    area:storageStorage engine, LMDB/RocksDB, compaction
    area:operations-apiOperations API: handlers, validation, response shape
    on Aug 2, 2026
  2. self-assigned this
    on Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:operations-apiOperations API: handlers, validation, response shapearea:storageStorage engine, LMDB/RocksDB, compactionbugSomething isn't working

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions