Skip to content

Commit 8fdb03a

Browse files
kriszypclaude
andcommitted
fix: repair setCompression's persist-retry and same-instance reopen bugs
Independent pre-push review (codex + gemini + grok + Harper-domain adjudication) of the full branch diff found two real correctness bugs in the setCompression feature this PR already carries (not introduced by the CI fix), both silently reverting a durably-requested compression change: - database.cpp: the no-op short-circuit compared only the LIVE GetOptions() value. After a SetOptions() call whose in-memory apply succeeded but whose OPTIONS-file persist failed (ENOSPC/EROFS/EIO -- the exact split the existing error message already describes), a retry at the same algorithm/level saw "already matches" and returned success without ever calling SetOptions() again, leaving the OPTIONS file stale forever. Added a per-CF compressionPersistDirty flag (ColumnFamilyDescriptor) that forces the retry through SetOptions() until a persist actually succeeds. - database.ts: a successful setCompression() never updated Store.compression, which Store.open() re-normalizes and reapplies on every open(). A close()+open() on the SAME RocksDatabase/Store instance therefore silently re-requested the ORIGINAL open-time codec, undoing a durably-persisted live change with no error. Also, smaller findings from the same review: - database.cpp: createRocksDBError() only assigns its out-param on success; napi_throw() could receive an uninitialized napi_value if error construction itself failed. Initialize to nullptr and skip the throw when unset (createRocksDBError's own NAPI_STATUS_THROWS_VOID calls already throw a fallback error in that case). - database.ts: setCompression's JSDoc claimed compact() reliably forces existing data onto the new codec; corrected to match the README's documented kIfHaveCompactionFilter caveat. - database.cpp: trimmed PR-narrative/reviewer-facing comment blocks added by the prior commit down to the load-bearing invariants. - compression.test.ts: three existing setCompression sequences left a database open on assertion/call failure (missing try/finally) -- wrapped them, since on Windows an open handle blocks the afterEach cleanup and can cascade into unrelated test failures. New regression coverage (test/compression.test.ts): a same-instance close()+open() proving the live change survives, and a permission- denied (read-only directory) failure injection proving a retry at the same algorithm actually re-persists instead of taking the shortcut. Both fail without the corresponding fix (verified by temporarily reverting each). Full suite: pnpm check clean; 722/722 passing tests, 2 pre-existing skips (Node, full run). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
1 parent ea890eb commit 8fdb03a

3 files changed

Lines changed: 62 additions & 54 deletions

File tree

src/binding/database/database.cpp

Lines changed: 36 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -823,33 +823,21 @@ napi_value Database::GetCompression(napi_env env, napi_callback_info info) {
823823
/**
824824
* Dynamically changes the compression algorithm (and optional level) in effect
825825
* for this database's column family, on an already-open database, without
826-
* closing/reopening anything.
827-
*
828-
* This is backed by `rocksdb::DB::SetOptions()`, which RocksDB documents
829-
* `ColumnFamilyOptions::compression` and `blob_compression_type` as "Dynamically
830-
* changeable" (deps/rocksdb/include/rocksdb/options.h,
831-
* deps/rocksdb/include/rocksdb/advanced_options.h) — empirically verified (see
832-
* PR description) that a live `SetOptions` call takes effect on the very next
833-
* flush/compaction output, while files already on disk keep their existing
834-
* compression until they are naturally rewritten by compaction (matching the
835-
* open-time `compression` option's documented semantics).
826+
* closing/reopening anything. Backed by `rocksdb::DB::SetOptions()`; takes
827+
* effect on the next flush/compaction output, existing files are untouched
828+
* until compaction rewrites them.
836829
*
837830
* `compression_opts` is supplied via RocksDB's nested-option string syntax
838-
* (`"level=N;"`) rather than as a full struct, so — per the `SetOptions()` doc
839-
* example in db.h ("{prepopulate_block_cache=kDisable;}") — only `level` is
840-
* touched; every other `CompressionOptions` sub-field (window_bits, strategy,
841-
* dictionary settings, ...) is left at its current live value. Omitting the
842-
* level resets it to `kDefaultCompressionLevel`, mirroring `applyCompression`
843-
* in db_descriptor.cpp (the open-time path) so a live change never leaves a
844-
* stale level behind when only the algorithm was requested.
831+
* (`"level=N;"`), which only touches `level` — every other `CompressionOptions`
832+
* sub-field is left at its current live value. Omitting the level resets it to
833+
* `kDefaultCompressionLevel`, mirroring `applyCompression` in db_descriptor.cpp
834+
* (the open-time path) so a live change never leaves a stale level behind when
835+
* only the algorithm was requested.
845836
*
846-
* RocksDB's own doc for `SetOptions()` (db.h) calls it "a slow call because a
847-
* new OPTIONS file is serialized and persisted for each call. Use only
848-
* infrequently." The motivating caller (Harper resolving each table's codec
849-
* from its own catalog on every boot) calls this once per column family, and
850-
* in the steady state almost every call requests the codec that is already
851-
* live — so a no-op short-circuit against the live `GetOptions()` makes that
852-
* sweep free instead of N blocking OPTIONS writes.
837+
* `SetOptions()` persists a new OPTIONS file on every call, so a no-op
838+
* short-circuit against the live `GetOptions()` avoids paying that cost when
839+
* the requested codec is already in effect (see compressionPersistDirty for
840+
* the one case that must still go through `SetOptions()`).
853841
*
854842
* @example
855843
* ```typescript
@@ -860,15 +848,9 @@ napi_value Database::GetCompression(napi_env env, napi_callback_info info) {
860848
napi_value Database::SetCompression(napi_env env, napi_callback_info info) {
861849
NAPI_METHOD_ARGV(2);
862850
UNWRAP_DB_HANDLE_AND_OPEN();
863-
// Pins the descriptor for the duration of this call: without it, a concurrent
864-
// close/shutdown on another env sharing this process-global DBDescriptor could
865-
// tear down the column family/DB out from under the SetOptions() call below
866-
// (the same cross-env teardown hazard AGENTS.md documents for other ops that
867-
// touch descriptor->db or a CF handle).
851+
// Pins the descriptor so a concurrent close/shutdown on another env sharing
852+
// this process-global DBDescriptor can't tear it down under SetOptions() below.
868853
ACQUIRE_OPERATIONS_LOCK();
869-
// SetOptions() persists a new OPTIONS file for the database, which is durable
870-
// mutation of on-disk metadata — not something a read-only handle may do,
871-
// consistent with every other mutating native op.
872854
THROW_IF_READONLY((*dbHandle)->descriptor, "Set compression failed: ");
873855

874856
NAPI_GET_STRING(argv[0], compressionName, "Compression algorithm is required");
@@ -902,12 +884,13 @@ napi_value Database::SetCompression(napi_env env, napi_callback_info info) {
902884

903885
rocksdb::ColumnFamilyHandle* cf = (*dbHandle)->getColumnFamilyHandle();
904886
rocksdb::Options current = (*dbHandle)->descriptor->db->GetOptions(cf);
905-
if (
906-
current.compression == *type && current.blob_compression_type == *type &&
907-
current.compression_opts.level == level
908-
) {
909-
// Already at the requested algorithm/level: skip the OPTIONS-file
910-
// serialization entirely rather than paying for a no-op write.
887+
bool alreadyLive = current.compression == *type && current.blob_compression_type == *type &&
888+
current.compression_opts.level == level;
889+
// A prior call may have applied this same algorithm/level in memory but failed
890+
// to persist it (see compressionPersistDirty's doc): the live options already
891+
// "match", but the OPTIONS file does not, so the no-op shortcut must not apply
892+
// until a SetOptions() call actually succeeds again.
893+
if (alreadyLive && !(*dbHandle)->columnDescriptor->compressionPersistDirty.load()) {
911894
NAPI_RETURN_UNDEFINED();
912895
}
913896

@@ -929,29 +912,33 @@ napi_value Database::SetCompression(napi_env env, napi_callback_info info) {
929912

930913
rocksdb::Status status = (*dbHandle)->descriptor->db->SetOptions(cf, newOptions);
931914
if (!status.ok()) {
932-
// DBImpl::SetOptions() applies the change to the live in-memory options
933-
// FIRST, then persists an OPTIONS file reflecting it — the returned status
934-
// is the persist step's alone. A failure here can therefore leave the live
935-
// column family already running the new algorithm while the durable OPTIONS
936-
// file still reflects the old one (e.g. ENOSPC/EROFS/EIO on the persist
937-
// write). Since this codebase treats the OPTIONS file as the ONLY
938-
// authoritative source of a CF's compression on a cold reopen
939-
// (db_descriptor.cpp), that split is exactly the divergence a caller must
940-
// be told about explicitly rather than left to discover after a restart.
915+
// DBImpl::SetOptions() applies the change in memory FIRST, then persists an
916+
// OPTIONS file -- the returned status is the persist step's alone, so a
917+
// failure (ENOSPC/EROFS/EIO) can leave the live CF already on the new
918+
// algorithm while the durable OPTIONS file (the only source of truth on a
919+
// cold reopen) still has the old one. Detect and report that split.
941920
rocksdb::Options liveAfterFailure = (*dbHandle)->descriptor->db->GetOptions(cf);
942921
bool appliedInMemoryOnly = liveAfterFailure.compression == *type &&
943922
liveAfterFailure.blob_compression_type == *type &&
944923
liveAfterFailure.compression_opts.level == level;
924+
// Record the split so a retry at the same algorithm/level cannot take the
925+
// no-op shortcut above and silently skip persisting it.
926+
(*dbHandle)->columnDescriptor->compressionPersistDirty.store(appliedInMemoryOnly);
945927
std::string msg = appliedInMemoryOnly
946928
? "Set compression failed to persist (the new compression is already active "
947929
"in memory, but the on-disk OPTIONS file was not updated -- a cold reopen "
948930
"of this column family will revert to the prior compression)"
949931
: "Set compression failed";
950-
napi_value error;
932+
// createRocksDBError() only assigns `error` on success; leave nothing to throw
933+
// if constructing the error itself fails partway (e.g. under memory pressure).
934+
napi_value error = nullptr;
951935
rocksdb_js::createRocksDBError(env, status, msg.c_str(), error);
952-
::napi_throw(env, error);
936+
if (error != nullptr) {
937+
::napi_throw(env, error);
938+
}
953939
return nullptr;
954940
}
941+
(*dbHandle)->columnDescriptor->compressionPersistDirty.store(false);
955942

956943
NAPI_RETURN_UNDEFINED();
957944
}

src/binding/database/db_descriptor.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,18 @@ struct ColumnFamilyDescriptor final {
550550
*/
551551
std::mutex userSharedBuffersMutex;
552552

553+
/**
554+
* Set when a `SetCompression()` call successfully applied its change to the
555+
* live in-memory options but failed to persist the OPTIONS file (e.g.
556+
* ENOSPC/EROFS/EIO). While set, `SetCompression()` must not skip the
557+
* `SetOptions()` call just because the live options already match the
558+
* request -- the live/durable split from the failed attempt is otherwise
559+
* unrecoverable by retry (a repeat call at the same algorithm would see
560+
* "already matches" and return success without ever writing OPTIONS).
561+
* Cleared on the next persist that succeeds.
562+
*/
563+
std::atomic<bool> compressionPersistDirty{false};
564+
553565
ColumnFamilyDescriptor(std::shared_ptr<rocksdb::ColumnFamilyHandle> column) : column(column) {}
554566

555567
~ColumnFamilyDescriptor() {

src/database.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { StatsAll, StatsDefault, StatsValue } from './stats.js';
1717
import {
1818
type ArrayBufferWithNotify,
1919
CompactOptions,
20+
type CompressionAlgorithm,
2021
type CompressionInfo,
2122
type CompressionOption,
2223
ITERATOR_STATE_BUFFER,
@@ -298,11 +299,11 @@ export class RocksDatabase extends DBI<DBITransactional> {
298299
*
299300
* This governs only *newly written* files (the next flush and any future
300301
* compaction output) going forward; SST and blob files already on disk
301-
* keep their existing compression until they are rewritten by a later
302-
* compaction. Use {@link RocksDatabase.compact} to force existing data to
303-
* pick up the new codec sooner. This is the live-mutation counterpart to
304-
* the open-time `compression` option — see the README's Compression
305-
* section for when to use each.
302+
* keep their existing compression until they are rewritten by compaction.
303+
* `compact()`'s default options skip already-bottommost files, so it does
304+
* not reliably force existing data onto the new codec — see the README's
305+
* Compression section for the caveats and for when to use each of this
306+
* and the open-time `compression` option.
306307
*
307308
* @example
308309
* ```typescript
@@ -318,6 +319,14 @@ export class RocksDatabase extends DBI<DBITransactional> {
318319
throw new TypeError('setCompression requires a compression algorithm');
319320
}
320321
this.store.db.setCompression(algorithm, compressionLevel);
322+
// Keep the Store's own compression option in sync so a later close()+open()
323+
// on this same instance re-requests the new codec instead of the option it
324+
// was constructed with, which would otherwise silently revert this change.
325+
const appliedAlgorithm = algorithm as CompressionAlgorithm;
326+
this.store.compression =
327+
compressionLevel === undefined
328+
? appliedAlgorithm
329+
: { algorithm: appliedAlgorithm, level: compressionLevel };
321330
}
322331

323332
/**

0 commit comments

Comments
 (0)