Skip to content

Commit cee5c13

Browse files
kriszypclaude
andcommitted
fix(storage): translate the LMDB compression descriptor for RocksDB stores
Harper's `storage.compression` - and the `compression` descriptor persisted on a table's primary attribute - describe LMDB's per-value compression: `{ startingOffset, threshold, dictionary }`. That value was passed straight through to every RocksDB store open. rocksdb-js ignored it until 2.6.0, which gives `compression` a real meaning (block/blob algorithm) and validates it. 2.6.1 accepts the descriptor object as "unset", but an unset `storage.compression` resolves to `''` and throws `Unsupported compression algorithm ""`, and the booleans throw as a malformed option. Every table open throws, taking component loading and the status table down with it. Translate at openRocksDatabase, the single point every RocksDB store opens, so re-opens of existing tables - whose persisted descriptor is LMDB-shaped - are covered along with new ones. `''`, `true` and the LMDB descriptor leave the option unset and take rocksdb-js's own default of lz4 (naming lz4 explicitly would throw on a build without it); `storage.compression: false` maps to 'none'; an algorithm name or { algorithm, level } passes through. The 2.6 binaries link lz4/zstd/bzip2 for the first time - 2.5.0 exported no `supportedCompression` and its binary carries no LZ4_compress/ZSTD_compress - so RocksDB stores were uncompressed until now. `false` therefore preserves today's behavior and the default (lz4) is the change worth a release note. main pinned `^2.5.0`, which already resolved 2.6.1 for the Docker image, which installs from a tarball and so resolves fresh from package.json rather than the published shrinkwrap. Pin `~2.6.1` so the dependency and the translation move together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c0483f5 commit cee5c13

4 files changed

Lines changed: 130 additions & 39 deletions

File tree

package-lock.json

Lines changed: 36 additions & 36 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@
177177
"@fastify/cors": "^11.2.0",
178178
"@fastify/static": "^9.1.3",
179179
"@harperfast/extended-iterable": "^1.0.1",
180-
"@harperfast/rocksdb-js": "^2.5.0",
180+
"@harperfast/rocksdb-js": "~2.6.1",
181181
"@harperfast/skills": "^1.10.8",
182182
"@turf/area": "6.5.0",
183183
"@turf/boolean-contains": "6.5.0",

resources/databases.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,35 @@ export const databaseEventsEmitter = new EventEmitter<DatabaseWatcherEventMap>()
176176
export const tables: Tables = Object.create(null);
177177
export const databases: Databases = Object.create(null);
178178

179+
/**
180+
* Harper's `storage.compression` config - and the `compression` descriptor persisted on a table's
181+
* primary attribute - describe LMDB's per-value compression: `{ startingOffset, threshold,
182+
* dictionary }`. RocksDB compresses whole SST blocks and blob files itself, and as of rocksdb-js
183+
* 2.6 the `compression` store option means that: an algorithm name, or `{ algorithm, level }`.
184+
* 2.6.1 accepts an object with no `algorithm` as "unset", but `''` (an unset `storage.compression`)
185+
* and the booleans still throw, so the config has to be translated before it reaches the store -
186+
* for re-opens of existing tables (whose persisted descriptor is LMDB-shaped) as much as new ones.
187+
*
188+
* Exported for unit test: every branch here is a startup path, and the ones that throw without it
189+
* are exactly the ones a table-level test cannot reach.
190+
*/
191+
export function resolveRocksCompression(compression: any) {
192+
if (compression == null) return undefined;
193+
// a blank string is how an unset `storage.compression` reaches here (it survives the `&&` in
194+
// getDefaultCompression), and rocksdb-js rejects it as an unknown algorithm rather than ignoring it
195+
if (typeof compression === 'string' && compression.trim() === '') return undefined;
196+
// already RocksDB-shaped ('lz4', { algorithm: 'zstd', level: 3 }, ...): pass through
197+
if (typeof compression === 'string') return compression;
198+
if (typeof compression === 'object' && compression.algorithm != null) return compression;
199+
// LMDB descriptor: compression is on, so take the rocksdb-js default (lz4 where the build
200+
// supports it) by leaving the option unset - naming lz4 explicitly would instead throw on a
201+
// build without it. `storage.compression: false` reaches here as false and disables it.
202+
return compression ? undefined : 'none';
203+
}
204+
179205
function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSort?: boolean }) {
180206
options.disableWAL ??= true;
207+
(options as any).compression = resolveRocksCompression((options as any).compression);
181208
// Apply read-only mode if enabled
182209
if (isReadOnlyMode()) {
183210
options.readOnly = true;
@@ -745,7 +772,9 @@ function initStores(
745772
}
746773
const dbiInit = createOpenDBIObject(!primaryAttribute.isPrimaryKey, primaryAttribute.isPrimaryKey);
747774
dbiInit.compression = primaryAttribute.compression;
748-
if (dbiInit.compression) {
775+
// threshold only applies to the LMDB descriptor shape; a RocksDB algorithm name has no
776+
// threshold, and assigning a property to a string primitive throws under strict mode
777+
if (dbiInit.compression && typeof dbiInit.compression === 'object') {
749778
const compressionThreshold =
750779
envGet(CONFIG_PARAMS.STORAGE_COMPRESSION_THRESHOLD) || DEFAULT_COMPRESSION_THRESHOLD; // this is the only thing that can change;
751780
dbiInit.compression.threshold = compressionThreshold;

unitTests/resources/databases.test.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ const assert = require('assert');
33
const { setupTestDBPath } = require('../testUtils');
44
const { existsSync, mkdirSync, writeFileSync } = require('node:fs');
55
const { dirname, join } = require('node:path');
6-
const { table, flushDatabases, dropDatabase, getDatabases, resetDatabases } = require('#src/resources/databases');
6+
const {
7+
table,
8+
flushDatabases,
9+
dropDatabase,
10+
getDatabases,
11+
resetDatabases,
12+
resolveRocksCompression,
13+
} = require('#src/resources/databases');
714
const { setMainIsWorker } = require('#js/server/threads/manageThreads');
815
const { RocksDatabase } = require('@harperfast/rocksdb-js');
916
const { beginRestore, completeRestore, RESTORE_META_DIR } = require('#src/dataLayer/restoreMarker');
@@ -54,6 +61,61 @@ describe('table() randomAccessFields directive', () => {
5461
});
5562
});
5663

64+
describe('table() compression', () => {
65+
before(function () {
66+
setupTestDBPath();
67+
setMainIsWorker(true);
68+
});
69+
70+
it('opens the primary store with lz4 under the default config', function () {
71+
const CompressionTable = table({
72+
table: 'CompressionDefault',
73+
database: 'test',
74+
attributes: [{ name: 'id', isPrimaryKey: true }],
75+
});
76+
if (!(CompressionTable.primaryStore instanceof RocksDatabase)) return this.skip();
77+
assert.strictEqual(CompressionTable.primaryStore.compression?.algorithm, 'lz4');
78+
});
79+
});
80+
81+
// The default config yields the LMDB descriptor object, which rocksdb-js 2.6.1 already accepts as
82+
// "unset" - so the table-level test above passes with or without the translation. The inputs that
83+
// actually throw are `''` (an unset storage.compression, rejected as an unknown algorithm) and the
84+
// booleans (rejected as a malformed option), and only a direct test reaches them.
85+
describe('resolveRocksCompression', () => {
86+
it("treats an unset storage.compression ('') as unset rather than an algorithm name", () => {
87+
assert.strictEqual(resolveRocksCompression(''), undefined);
88+
assert.strictEqual(resolveRocksCompression(' '), undefined);
89+
});
90+
91+
it('maps the LMDB descriptor and `true` to unset, taking the rocksdb-js default', () => {
92+
assert.strictEqual(resolveRocksCompression({ startingOffset: 32, threshold: 4036 }), undefined);
93+
assert.strictEqual(resolveRocksCompression(true), undefined);
94+
});
95+
96+
it('maps `storage.compression: false` to no compression', () => {
97+
assert.strictEqual(resolveRocksCompression(false), 'none');
98+
});
99+
100+
it('passes an already-RocksDB-shaped option through untouched', () => {
101+
assert.strictEqual(resolveRocksCompression('zstd'), 'zstd');
102+
const descriptor = { algorithm: 'zstd', level: 3 };
103+
assert.strictEqual(resolveRocksCompression(descriptor), descriptor);
104+
});
105+
106+
it('leaves an omitted option unset', () => {
107+
assert.strictEqual(resolveRocksCompression(undefined), undefined);
108+
assert.strictEqual(resolveRocksCompression(null), undefined);
109+
});
110+
111+
// the resolver runs on every store open, so a throw here fails startup rather than one table
112+
it('never throws, whatever shape the persisted attribute holds', () => {
113+
for (const input of [0, 1, [], [6], NaN, Symbol('x'), () => {}]) {
114+
assert.doesNotThrow(() => resolveRocksCompression(input));
115+
}
116+
});
117+
});
118+
57119
describe('schemaDefined backfill on replicas missing the flag', () => {
58120
const TABLE = 'SchemaDefinedBackfillTest';
59121
const DB = 'test';

0 commit comments

Comments
 (0)