Record caching - #410
Conversation
Introduces CachingRocksDatabase, a RocksDatabase subclass that layers a per-instance WeakLRUCache on top of the process-wide VerificationTable. Cache freshness is checked via entry.version (Harper's localTime timestamp) rather than a txnId, so reads hitting a fresh VT slot skip disk entirely. Primary RocksDB stores now use CachingRocksDatabase instead of RocksDatabase. DatabaseTransaction creates transactions with coordinatedRetry: true, so IsBusy conflicts are signalled as RETRY_NOW_VALUE (immediate retry, no backoff) rather than an ERR_BUSY rejection. The old quadratic-backoff retry handler is removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tests cover: - primaryStore is a CachingRocksDatabase (and RocksDatabase) instance - Basic read/write correctness - VT slot population after two reads enabling the fast-path - Third read returns value without DB access (VT fast-path) - Write clears VT slot so stale version cannot verify - Remove clears cache; subsequent read returns undefined - Transaction-scoped reads bypass cache and return correct value - Concurrent writes complete without error via coordinatedRetry - Write-then-read returns updated value, not stale cache Tests are skipped when HARPER_STORAGE_ENGINE=lmdb. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|
||
| getSync(id: any, options?: any): any { | ||
| if (options?.transaction) { | ||
| return super.getSync(id, options); |
There was a problem hiding this comment.
Untested branch (blocker): The if (options?.transaction) bypass is never exercised by the test suite. The test labelled "Read with a transaction bypasses the cache" passes context = {}, which has no transaction property — options?.transaction is falsy and the cache path is taken, not the bypass. Both legs of this production-vs-fallback split need coverage. An actual RocksTransaction (or a Harper DatabaseTransaction-backed context) needs to be passed to verify the bypass reaches super.getSync without touching the cache.
| if (commitResult === RETRY_NOW_VALUE) { | ||
| this.retries++; | ||
| harperLogger.debug?.('coordinated retry', transaction.id, this.retries); | ||
| return this.commit({ transaction }); |
There was a problem hiding this comment.
No application-level retry bound: this.retries++ is incremented on each RETRY_NOW_VALUE but never checked against a limit. The old ERR_BUSY handler had MAX_RETRIES = 40 (now dead code at line 23) that threw ServerError after 40 attempts. If @harperfast/rocksdb-js guarantees it will eventually stop returning RETRY_NOW_VALUE under any contention scenario, please document that guarantee. Otherwise, reinstate an application-level cap so sustained conflicts don't produce unbounded recursion.
Review: Record caching (PR #410)1.
|
Benchmarks six scenarios with 2,000 records (~80-byte values each):
1. getSync cold — first read, cache miss (both go to DB)
2. getSync soft VT miss — 2nd read: cache warm, expectedVersion passed,
DB read happens but slot is populated + FRESH returned
3. getSync VT fast-path — 3rd+ read: VT slot matches, no DB access
4. getSync hot key — repeated single-key reads
5. get (async) VT hit — async path with warm cache
6. putSync — write throughput (cache invalidation cost)
Each scenario reports ops/sec for plain RocksDatabase and CachingRocksDatabase
with a speedup ratio, printed as a formatted table.
Adds `npm run bench` script. Also fixes `test:unit:resources` to exclude
*.bench.js files so they don't run as part of the normal test suite.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…integration Renames CachingRocksDatabase to PrimaryRocksDatabase and moves all RocksDB-specific entry handling (previously done by handleLocalTimeForGets instance patches) into real class methods. Caching now operates at the getEntry level using full Entry objects with `version`, using verifyVersion/populateVersion for VT-based freshness checks rather than expectedVersion. handleLocalTimeForGets delegates to initStore() for PrimaryRocksDatabase instances via the isPrimaryRocksDatabase marker. All 10 new tests and 1720 existing tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rsion) call The rocksdb-js store.getSync/get natively accept expectedVersion: on a VT hit they return FRESH_VERSION_FLAG without touching disk; on a VT miss they read from the DB and auto-populate the VT slot. Replacing the two-step verifyVersion+populateVersion pattern with a single native call removes one round-trip per read on the warm cache path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nchmark - PrimaryRocksDatabase.getEntry now uses populateVersion option for cold reads (single native call, no separate populateVersion()) and merges options when passing expectedVersion so transaction snapshot is preserved - handleLocalTimeForGets: remove all isRocksDB branches — RocksDB stores now use the isPrimaryRocksDatabase early-return path exclusively; the remaining code is LMDB-only, so isRocksDB is always false there - Benchmark: use RecordEncoder + recordUpdater with Date.now()-range versions so values carry encoded version bytes and caching works correctly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| if (commitResult === RETRY_NOW_VALUE) { | ||
| this.retries++; | ||
| harperLogger.debug?.('coordinated retry', transaction.id, this.retries); | ||
| return this.commit({ transaction }); |
There was a problem hiding this comment.
No retry cap on the coordinatedRetry path.
MAX_RETRIES = 40 is still defined at the top of the file but is now unreachable dead code — nothing checks it. If RocksDB keeps returning RETRY_NOW_VALUE (sustained write contention, edge-case in the native layer, etc.) this recurses without bound until stack exhaustion.
| return this.commit({ transaction }); | |
| if (commitResult === RETRY_NOW_VALUE) { | |
| this.retries++; | |
| if (this.retries > MAX_RETRIES) { | |
| throw new ServerError( | |
| `After ${MAX_RETRIES} retries, unable to commit transaction, transaction is in conflict with ongoing writes` | |
| ); | |
| } | |
| harperLogger.debug?.('coordinated retry', transaction.id, this.retries); | |
| return this.commit({ transaction }); | |
| } |
Review: Record caching (PR #410)2 blockers found. 1. No retry cap on the
|
| attributes: [ | ||
| { name: 'id', isPrimaryKey: true }, | ||
| { name: 'embedding', indexed: { type: 'HNSW' }, type: 'Array' }, | ||
| ], |
There was a problem hiding this comment.
Blocker — missing worker script
vectorIndex-thread.js does not exist anywhere in this repository (confirmed with git log and directory listing). Every Worker instantiation here will throw ENOENT immediately, causing the entire before() hook to fail and the test suite to error out without running.
Please add the missing worker script (it should live at unitTests/resources/vectorIndex-thread.js) that receives the { type: 'insert', start, count, dims } message and posts back a result/error reply.
| import { setTimeout as delay } from 'node:timers/promises'; | ||
| import { Transaction as RocksTransaction, type Store as RocksStore } from '@harperfast/rocksdb-js'; | ||
| import { Transaction as RocksTransaction, type Store as RocksStore, constants } from '@harperfast/rocksdb-js'; | ||
| const RETRY_NOW_VALUE = constants.RETRY_NOW_VALUE; |
There was a problem hiding this comment.
Nit — MAX_RETRIES (line 23) is now dead code
The constant MAX_RETRIES = 40 is still defined but no longer used — the ERR_BUSY backoff guard that consumed it was removed when switching to coordinatedRetry. Consider removing it, or reinstate it as a cap inside the new RETRY_NOW_VALUE branch to preserve the JS-side safety net if the native layer ever loops unexpectedly.
Review: Record caching (PR #410)What I traced
1.
|
…che coherency Brings the record-caching work up to date with main (~942 commits). Resolved conflicts in package.json, DatabaseTransaction.ts, RecordEncoder.ts, databases.ts, and vectorIndex.test.js. Kept main's structural improvements to the commit path (write filtering, abort try/catch, blob cleanup) while preserving record-caching's coordinated-retry (RETRY_NOW) path; dropped the obsolete ERR_BUSY quadratic-backoff handler per the feature design. Fixes exposed by the merge's test run: - PrimaryRocksDatabase.clearSync()/clear() now drop the per-instance WeakLRUCache, so a cleared record can no longer be served as a stale cache hit (was throwing "Record already exists"). - Type reconciliation against the merged rocksdb-js API: commitResolution typed Promise<number|void>|void for the RETRY_NOW signal; the primary store cast to RocksRootDatabase. Pairs with the matching @harperfast/rocksdb-js verification-table build (PR #526). All 551 resources unit tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Reviewed; no blockers found. |
The two record-caching-added files were never run through Prettier, failing the Format Check CI job. No behavioral change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ions Cross-model review (Codex) found that dropping the ERR_BUSY backoff handler in favor of coordinated retry left a gap: writes that reach save() without a prior getReadTxn() (immediate/publish/invalidate writes) create their RocksTransaction without coordinatedRetry, so on a write conflict they reject with ERR_BUSY rather than resolving RETRY_NOW — and with the backoff removed those rejections were no longer retried, failing spuriously under concurrent writes. Restore the ERR_BUSY backoff as a fallback. It coexists with the RETRY_NOW path: coordinated transactions resolve RETRY_NOW (handled in the resolve branch) and never reach the reject handler with ERR_BUSY; non-coordinated ones fall back to the backoff retry. Re-adds the delay import and MAX_RETRIES. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…es are reclaimable PrimaryRocksDatabase.getEntry cached the full Entry via WeakLRUCache.set(). A raw Entry has neither a .deref (WeakRef) nor a .cache hook, so the LRFU expirer never reclaims it once it cycles out of the LRU stages — the backing Map grows unbounded (a leak). It now caches the record *value* via setValue (which WeakRef-wraps it, making it collectable) and recovers the Entry metadata (version, flags) via the existing value→Entry WeakMap while the value is live. This also matches weak-lru-cache's contract: setValue expects the value object, not Entry metadata — passing an Entry (which carries localTime) trips its "value is a Date, not a value object" guard on every write. Object-valued records get the full version fast path; primitive/empty values fall through uncached (still correct, just no fast path). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Harper's reads happen inside RocksTransactions, so the caching benchmark now reads through a per-sweep RocksTransaction in two new scenarios: transactional cold read (gated VT populate) and transactional VT fast-path. This exercises the Transaction::GetSync path and the snapshot-current fast-skip in vtPopulateIfSettled. Versions are written safely in the past so the single-accessible-version gate permits population while the read snapshot is open. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dependent rocksdb-js Verification Table PR (#526) landed and published as 2.2.0, so update the dependency from ^1.4.0 and reconcile main's drift. Conflict resolutions: - package.json: rocksdb-js ^2.2.0, lmdb 3.5.5 (main), keep weak-lru-cache ^1.2.2 - DatabaseTransaction.commit: keep coordinated RETRY_NOW_VALUE retry path while adopting main's robust onCommit error handling, ERR_TRY_AGAIN retry (#308), and capped backoff (MAX_RETRY_DELAY_MS) - RecordEncoder.handleLocalTimeForGets: keep the PrimaryRocksDatabase early-return (LMDB-only read-txn tracking) and fold in main's `if (this.isDone) return` guard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llision between benchmark steps Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…durable-session test `subscribe with QoS=1 and reconnect with non-clean session` sequenced connect/ disconnect/publish steps with hard-coded `delay(10)`/`delay(50)` calls instead of waiting for the actual server-side state to settle. `client.endAsync()` only resolves once the local socket closes — it does not wait for the broker to finish tearing down the durable session — so reconnecting with the same clientId immediately after could race the broker (issue #1138 flagged this file as the #2-worst offender for this pattern). Under CI load the delays were occasionally too short, causing the intermittent "got 0 messages"/mocha timeout flakes seen on #410 and #1476. Replace the disconnect-then-reconnect delays with a wait on the broker's own `disconnected` event (already exposed via `server.mqtt.events`) for the matching clientId, and replace the pre-disconnect delay with a wait for the client's outgoing PUBACK. Also await the first offline publish (it was fire-and-forget while the other two were awaited), so all three publishes are durably committed before the reconnecting client asserts on redelivery. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Merge of main bumped @aws-sdk/lib-storage, globals, and prettier in package.json but left the auto-merged lockfile on the old resolved versions, breaking npm ci. Regenerate to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ports) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eserve options on retry
Fix 1: The RETRY_NOW_VALUE resolve branch had no cap, unlike the ERR_BUSY reject path
which throws at MAX_RETRIES for non-sourceApply transactions and warns periodically for
sourceApply. Mirror that policy for the coordinated-retry path.
Fix 2: `isRetry` was derived from the lifetime `this.retries > 0` in save(), so a
reused DatabaseTransaction that had a conflict in batch A would stamp isRetry=true on
a FRESH RocksTransaction for batch B, causing RocksTransactionLogStore.put to skip
writing the txn-log entries for batch B — silently dropping its replication. Fix: set
`transaction.isRetry = true` at the RETRY_NOW and ERR_BUSY retry sites on the SPECIFIC
native transaction being retried, and remove the save() derivation. Also reset
`this.retries = 0` in the post-commit reset block so a reused DatabaseTransaction
starts fresh for its next batch.
Fix 3: Retry re-entries called `this.commit({ transaction })`, dropping caller options
(notably `options.flush`). Change all retry sites to `{ ...options, transaction }`.
Thread `options` into save() as a 4th parameter so the immediateCommit path can also
forward them to the recursive commit call.
Fix 4: Update the stale comment at the `transaction.commit() as Promise<void>` cast
which claimed coordinatedRetry was not passed. getReadTxn now passes it, so the
RETRY_NOW sentinel IS handled by the resolve callback above.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t-in for primary stores
Fix 5: resolveIndexFormat's emptiness probe used `getKeys({ start: 0, end: Infinity,
limit: 1 })`, which only scanned numeric keys. HNSW stores also hold Symbol/array keys
(e.g. entryPoint, [KEY_PREFIX, pk]) that sort BELOW 0 and string-pk safeKeys that sort
ABOVE Infinity. Corruption path: a delete-all left only safeKey orphan mappings after a
cleanup, the numeric-only scan reported empty, the store was re-opened as 'versioned'
over legacy values, and re-inserts were misdecoded. Fix: use `getKeys({ limit: 1 })` —
no start/end so any surviving key marks non-empty.
Fix 6: openRocksDatabase previously constructed PrimaryRocksDatabase with cache and
verificationTable defaulting ON for every non-dupSort store — the __dbis__ catalog,
root containers, internal catalog stores, and non-HNSW indexes all got a WeakLRUCache
and VT for zero benefit (no versioned entries → no cache hits, only per-write
cache.delete() overhead). Caching is now opt-in: PrimaryRocksDatabase requires explicit
`cache: true` and it is passed only at (a) table primary store open sites in initStores
and table(), where it is the PR's intended target; and (b) custom-object index stores
(isCustomObjectIndex) in openIndex, where the VT is required for versioned HNSW
traversal regardless of format (the format is resolved after opening).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rt-circuit getRange Fix 7a: getEntry's when-callback already sets entryMap for any entry it returns — #withEntry sets it for entries with METADATA, and the version+cache block sets it for versioned entries. The identical sets in getSync() and get() after calling getEntry() are redundant (running up to 3× per read). Remove them. Fix 7b: getRange()'s map() callback was a pure passthrough (return entry) when !hasRecordEncoder. Return the iterable directly before building the wrapper, avoiding the per-entry function call overhead on the cold path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…explicit cache:true Fix 8: Add weak-lru-cache entry to dependencies.md per AGENTS.md requirement that every dependency have a rationale. Rationale: same WeakLRUCache library lmdb-js uses for its CachingStore; weak-reference LRU so cached records are GC-reclaimable; powers the PrimaryRocksDatabase record cache. Update bench: PrimaryRocksDatabase now requires explicit `cache: true` (fix 6 flipped the default). Update the caching bench case to pass it explicitly so the benchmark continues measuring the intended cache-on vs cache-off comparison. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in getEntry Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… vs ERR_TRY_AGAIN Resolve the sole conflict in resources/DatabaseTransaction.ts: keep both complementary fixes in the non-coordinated ERR_BUSY/ERR_TRY_AGAIN commit-retry handler. Main's ERR_TRY_AGAIN fresh-transaction replay (#1696) runs first, then record-caching's explicit retry-site isRetry stamp runs after the possible swap so the fresh replay transaction is also marked (else it re-stages audit/change-feed log entries on recommit). The old save()-site isRetry assignment that record-caching removed (leak fix) is confirmed NOT reintroduced. Also align the ERR_BUSY subtest in sourceApplyConflictRetry.test.js with record-caching's coordinatedRetry convergence: a coordinated source-apply transaction resolves commit() with the RETRY_NOW_VALUE sentinel on conflict rather than rejecting with ERR_BUSY, so the commit spy now records the resolved value and the subtest accepts that sentinel (or a raw ERR_BUSY/ ERR_TRY_AGAIN rejection on the uncoordinated path) as the detected-and-retried conflict, while still requiring a later genuine commit to succeed.
…rop sweep, non-txn write settle, snapshot TOCTOU, backdated-version gate) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The review fix that moved isRetry stamping to the conflict-retry sites removed the retries>0 derivation in save() that replayLogs relied on (via retries=1) to suppress txn-log re-appends. Replay then re-appended every replayed write into the log it was iterating and never converged — silent boot hang after a crash (CI shard 5, all runtimes). Key the suppression off the explicit isReplay marker instead of the retries side-channel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d cross-worker coherence anchors The HarperFast#410 record-caching integration test's "4-worker" suite actually ran single-worker: HARPER_WORKER_COUNT is not a Harper env var (it's a test-file convention translated into config.threads.count elsewhere), and the harness forces --THREADS_COUNT=1, so cross-worker cache invalidation -- the load-bearing correctness promise of the per-worker WeakLRUCache + shared VerificationTable -- was never exercised. Fix the existing suite to configure threads via config.threads.count with a runtime worker-count guard (assertMultiWorker) so it can't silently regress to a no-op, and add two promoted exploratory-QA anchors: - cross-worker coherence under update/delete/hot-key churn - cached point-read vs uncached scan/query coherence Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces CachingRocksDatabase, a RocksDatabase subclass that layers a
per-instance WeakLRUCache on top of the process-wide VerificationTable.
Cache freshness is checked via entry.version (Harper's localTime timestamp)
rather than a txnId, so reads hitting a fresh VT slot skip disk entirely.
Primary RocksDB stores now use CachingRocksDatabase instead of RocksDatabase.
DatabaseTransaction creates transactions with coordinatedRetry: true, so
IsBusy conflicts are signalled as RETRY_NOW_VALUE (immediate retry, no
backoff) rather than an ERR_BUSY rejection. The old quadratic-backoff retry
handler is removed.