Repository navigation
RocksDB: table-scoped delete_transaction_logs_before destroys the entire database's transaction log #2049
Description
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.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity