Skip to content

Add storage tiering: SST paths and blob file placement across volumes - #767

Draft
kriszyp wants to merge 2 commits into
mainfrom
kris/tiered-storage
Draft

Add storage tiering: SST paths and blob file placement across volumes#767
kriszyp wants to merge 2 commits into
mainfrom
kris/tiered-storage

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 8, 2026

Copy link
Copy Markdown
Member

Lets one database span volumes with different cost and performance — typically a fast local NVMe plus a larger, slower attached volume.

Two knobs, covering different files:

  • paths — an ordered list of { path, targetSize } mapped to RocksDB's db_paths, distributing SST files across volumes by LSM level. Kept on db_paths rather than cf_paths so one policy covers every column family in the database.
  • blobs.dir — the directory blob files live in. This is the only way to tier large values: db_paths does not affect blob files, whose paths RocksDB derives from cf_paths.front(). It needs a native build carrying the downstream blob_dir patch (Add RocksDB blob_dir patch so blob files can live on a separate volume rocksdb-prebuilds#13); the option is rejected at open otherwise, and the binding still compiles and behaves correctly against an unpatched RocksDB.

The remaining blob settings were previously hardcoded and are now configurable (enabled, minSize, garbage collection plus its age cutoff and force threshold), along with a process-wide blobCacheSize on RocksDatabase.config(). That last one matters more than it looks: RocksDB does not put blob values in the block cache, so without it every blob read is real I/O — the dominant cost once blob files are on slower storage.

Depends on HarperFast/rocksdb-prebuilds#13 — the blobs.dir tests skip until a prebuild carries the patch. Everything else works against the current prebuild.

Where to look

buildColumnFamilyOptions in src/binding/database/db_descriptor.cpp, and the reopen guard about 60 lines below it.

Putting the blob settings in that shared builder is load-bearing rather than tidiness. An earlier revision set them only on the open path, which left every named column family — which is every Harper table — on the hardcoded defaults, while an open stamped the caller's directory on the families listed on disk. The next reopen then threw on the mismatch, or with allowDirChange pointed the family at a directory its blob files were not in. The three named-column-family tests fail against that revision; I verified that by restoring the old behavior and re-running, not by reasoning about it.

The guard is the other thing worth reading. A blob file's directory is derived from the option every time it is opened — unlike an SST's MANIFEST-recorded path index — so reopening elsewhere strands the files rather than moving them, making every value at or above minSize unreadable. open() compares the request against the blob_dir persisted in the OPTIONS file and refuses the mismatch; blobs.allowDirChange acknowledges a completed offline relocation for the single open that performs the switch.

Verification

  • Tiered-storage suite: 19/19 against a locally built RocksDB v11.8.1 carrying the patch — SST placement, compaction spillover onto a later path, appending a path to an existing database, blob files on a separate volume, read back across a reopen, offline relocation, named column families, and warm-reopen rejection.
  • Fails-on-base: restoring the pre-fix column-family behavior makes 3 of the 4 named-family tests fail; they pass with the fix.
  • Unpatched build: rebuilt against the stock pinned prebuild — compiles clean, the blobs.dir suite skips via capability detection, everything else passes.
  • Full suite: 750 passed. The one failure is compression.test.ts > configure-rocksdb.mjs (build script), which shells out to the RocksDB provisioner; it fails only when deps/rocksdb is symlinked to a local source tree and passes against the stock prebuild. Not related to this change.
  • pnpm check clean.

Open items from cross-model review

  • blobs.dir is a database-wide policy here, not per-column-family. RocksDB models it per-CF, and this applies one request to every family in the database — the same shape as paths. A database with genuinely heterogeneous blob directories per family is not expressible, and the guard rejects one if it somehow exists. Flagged as the main judgment call.
  • The paths append-only invariant is documented but not enforced. A file's path index is what lands in the MANIFEST, so reordering or removing an entry points existing files at the wrong directory. db_paths/cf_paths sit in RocksDB's "not yet supported" serialization block, so there is nothing persisted to compare against without inventing a rocksdb-js-owned marker file — a new on-disk artifact I did not want to add unilaterally.
  • Backups flatten a tiered layout and the blob guard then gives a false all-clear, since the backup carries the OPTIONS file. Documented in docs/tiered-storage.md; not fixed.
  • An unpatched build opening a patched database reads blobs from the wrong directory. Loud per-read IO errors, not silent wrong data, but it makes the prebuild version a deployment constraint.

For the human reviewer

  1. Database-wide vs per-CF blob placement — chosen for symmetry with paths and because Harper wants one policy per database. The cost is that per-table blob placement is unavailable, and widening it later means deciding what a mixed database means on reopen. Reversible now, awkward once databases exist.
  2. The reopen guard throws rather than warns — a misconfiguration becomes an outage at open instead of silent data unavailability. I think loud is right for "your large values just became unreadable", but it does mean a bad config file takes the database down. blobs.allowDirChange is public API once shipped.
  3. allowDirChange is open-scoped and covers all families — left in a config file it permanently disables the guard. A one-shot marker would be tighter; cheap to change now, awkward after adoption.
  4. targetSize is required per entry — a default would be friendlier, but an omitted target silently means "spill immediately". Relaxing later is compatible; tightening is not.
  5. blobCacheSize defaults to 0, matching RocksDB. Given blob files are on by default at a 2KB threshold, that means every large-value read is real I/O today — a live performance choice, not just an API one. Trivially reversible.

Generated by Claude Opus 5.

Human-Review-Need: 4 @ 03d58b4

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces tiered storage support for RocksDB, allowing SST files to be distributed across multiple storage paths and large values to be decoupled into separate blob files. The changes span C++ bindings, TypeScript definitions, documentation, and tests. The review feedback correctly identifies a cross-platform path handling issue on Windows, where using std::filesystem in the C++ layer can corrupt non-ASCII paths due to ANSI code page round-tripping. It is recommended to resolve and normalize all paths in the JavaScript layer using node:path before passing them to the native binding.

Comment on lines +1562 to +1565
// Resolve now: RocksDB interprets a relative path against the process
// working directory at open time, so storing it raw would let the same
// string resolve to a different volume on a later open.
storagePath.path = std::filesystem::absolute(storagePath.path).lexically_normal().string();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On Windows, constructing a std::filesystem::path from a UTF-8 std::string and converting it back via .string() round-trips through the active code page (ANSI), which can corrupt non-ASCII paths. To prevent this and ensure robust cross-platform path handling, resolve and normalize the paths in the JavaScript layer using path.resolve() before passing them to the native binding, and remove the std::filesystem conversions here.

Suggested change
// Resolve now: RocksDB interprets a relative path against the process
// working directory at open time, so storing it raw would let the same
// string resolve to a different volume on a later open.
storagePath.path = std::filesystem::absolute(storagePath.path).lexically_normal().string();
// Paths are resolved to absolute and normalized form in the JavaScript layer.
References
  1. In C++ Node-API bindings, avoid round-tripping UTF-8 paths from N-API through 'std::filesystem::path' on Windows if they are subsequently converted to wide strings, as this re-encodes through the active code page and can corrupt non-ASCII paths. Use plain UTF-8 string concatenation instead, as '/' is a valid separator on all platforms.

Comment on lines +1599 to +1604
if (!result.dir.empty()) {
// Same reason as paths[].path: the persisted value is compared as a
// string on reopen, so a relative directory would compare equal while
// resolving somewhere else.
result.dir = std::filesystem::absolute(result.dir).lexically_normal().string();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the paths resolution, calling std::filesystem::absolute on Windows round-trips the UTF-8 path through the active code page, corrupting non-ASCII paths. Since we can resolve and normalize the path in the JavaScript layer using path.resolve(), we should remove the std::filesystem conversion here.

	// The directory is resolved to absolute and normalized form in the JavaScript layer.
References
  1. In C++ Node-API bindings, avoid round-tripping UTF-8 paths from N-API through 'std::filesystem::path' on Windows if they are subsequently converted to wide strings, as this re-encodes through the active code page and can corrupt non-ASCII paths. Use plain UTF-8 string concatenation instead, as '/' is a valid separator on all platforms.

Comment thread src/store.ts
Comment on lines 16 to 25
import {
constants,
NativeDatabase,
type NativeBlobOptions,
type NativeDatabaseOptions,
NativeIterator,
type NativeStoragePath,
NativeTransaction,
stats,
supportedCompression,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Import resolve from node:path to resolve and normalize the storage paths and blob directory in the JavaScript layer, avoiding active code page corruption on Windows when using std::filesystem in C++.

Suggested change
import {
constants,
NativeDatabase,
type NativeBlobOptions,
type NativeDatabaseOptions,
NativeIterator,
type NativeStoragePath,
NativeTransaction,
stats,
supportedCompression,
import { resolve } from 'node:path';
import {
constants,
NativeDatabase,
type NativeBlobOptions,
type NativeDatabaseOptions,
NativeIterator,
type NativeStoragePath,
NativeTransaction,
stats,
supportedCompression,

Comment thread src/store.ts
Comment on lines +1132 to +1133
paths: this.paths,
blobs: this.blobs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Resolve and normalize the paths and blobs.dir to absolute paths in the JavaScript layer using resolve(). This ensures that they are correctly compared as strings on reopen and avoids the Windows active code page corruption issue in the C++ layer.

			paths: this.paths?.map((p) => ({ ...p, path: resolve(p.path) })),
			blobs: this.blobs
				? {
						...this.blobs,
						dir: this.blobs.dir ? resolve(this.blobs.dir) : undefined,
					}
				: undefined,

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.51K ops/sec 40.80 39.39 570.348 0.113 122,557
🥈 rocksdb 2 10.79K ops/sec 92.72 89.60 31,220.775 1.23 53,929

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.87K ops/sec 34.64 33.47 535.117 0.104 144,328
🥈 rocksdb 2 11.42K ops/sec 87.53 85.25 548.026 0.051 57,123

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 25.22K ops/sec 39.65 36.48 1,871.918 0.292 126,091
🥈 rocksdb 2 16.15K ops/sec 61.90 54.77 1,106.36 0.122 80,770

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 375.76 ops/sec 2,661.24 65.60 17,401.563 9.09 752
🥈 lmdb 2 26.86 ops/sec 37,236.673 436.685 1,168,090.767 136.038 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 40.84K ops/sec 24.49 10.54 13,733.951 0.583 204,190
🥈 lmdb 2 441.20 ops/sec 2,266.539 195.593 15,992.316 1.39 2,207

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 746.20K ops/sec 1.34 1.17 4,651.887 0.196 3,731,004
🥈 lmdb 2 460.14K ops/sec 2.17 1.09 8,617.674 0.527 2,300,687

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 837.30 ops/sec 1,194.318 979.574 1,981.367 0.473 1,675
🥈 lmdb 2 1.14 ops/sec 879,460.579 829,821.734 939,990.263 2.85 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 23.21K ops/sec 43.09 29.65 572.111 0.578 46,420
🥈 lmdb 2 826.65 ops/sec 1,209.703 166.869 13,897.105 5.12 1,655

Results from commit fc4b4ca

Lets one database span volumes with different cost and performance — typically
a fast local NVMe plus a larger, slower attached volume.

Two knobs, covering different files:

- `paths`: an ordered list of { path, targetSize } mapped to RocksDB's
  db_paths, distributing SST files across volumes by LSM level. Left on
  db_paths rather than cf_paths so one policy covers every column family.
- `blobs.dir`: the directory blob files live in. This is the only way to tier
  large values — db_paths does not affect blob files, whose paths RocksDB
  derives from cf_paths.front(). Requires a native build carrying the blob_dir
  patch (ROCKSDB_HAS_CF_BLOB_DIR); the option is rejected at open otherwise,
  and the binding still compiles and behaves against an unpatched RocksDB.

The rest of the blob settings were previously hardcoded and are now
configurable: enabled, minSize, garbage collection and its age cutoff and force
threshold, plus a process-wide blobCacheSize on RocksDatabase.config(). That
last one matters more than it looks: RocksDB does not put blob values in the
block cache, so without it every blob read is real I/O — the dominant cost once
blob files are on slower storage.

All of it goes through buildColumnFamilyOptions, so both column-family creation
paths get it. An earlier revision set these only on the open path, which left
every named family — which is every Harper table — on the hardcoded defaults
while an open stamped the caller's directory on the families listed on disk;
the next reopen then threw on the mismatch. The three named-family tests here
fail against that revision.

Reopening with a different blobs.dir does not move the existing blob files, it
strands them (a blob file's directory is derived from the option every time,
unlike an SST's MANIFEST-recorded path index), making every value >= minSize
unreadable. Open compares the request against the directory persisted in
OPTIONS and refuses the mismatch; blobs.allowDirChange acknowledges a completed
offline relocation. A warm reopen asking for different paths or a different
blob directory is rejected the same way a conflicting compression request
already was, rather than being silently ignored.

Verified against a patched RocksDB (19/19, including blob files written to a
separate volume, read back across reopen, relocated, and applied to named
column families) and against the stock unpatched prebuild (blob_dir suite
skipped, everything else passing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/tiered-storage branch from 2a9d78e to 03d58b4 Compare August 12, 2026 21:03
The tiered-storage invariants were added as 11/12 while 11 was already taken
by the transaction-log frame invariant, so `oxfmt --check` failed the Check
job on the duplicate ordered-list numbering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant