Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions npm/packages/rvf/dist/backend.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,27 @@ export declare class NodeBackend implements RvfBackend {
private resolveLabel;
/** Path to the sidecar mappings file. */
private mappingsPath;
/** Persist the string↔label mapping to a sidecar JSON file. */
/**
* Persist the string↔label mapping to a sidecar JSON file.
*
* `delete()` resolves string ids through this map and silently filters out
* anything unresolvable, so a lost or torn write turns every ingest since
* the last good save into an undeletable-by-id vector. Persistence is
* therefore NOT best-effort: the write is made atomic (temp file + rename,
* so a crash/ENOSPC mid-write can never leave partial JSON at `mp`) and a
* failure is surfaced rather than swallowed.
*/
private saveMappings;
/** Load the string↔label mapping from the sidecar JSON file if it exists. */
/**
* Load the string↔label mapping from the sidecar JSON file if it exists.
*
* A corrupt sidecar must NOT degrade to empty maps: `nextLabel` would reset
* to 1 and subsequent ingests would assign labels colliding with existing
* vectors (silent data corruption), and the next `saveMappings()` would
* overwrite the recoverable file. Instead the corrupt sidecar is quarantined
* (renamed aside so it is not clobbered) and a `SidecarCorrupt` error is
* raised so the caller learns string-id operations are unsafe.
*/
private loadMappings;
}
/**
Expand Down
100 changes: 63 additions & 37 deletions npm/packages/rvf/dist/backend.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion npm/packages/rvf/dist/backend.js.map

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion npm/packages/rvf/dist/errors.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export declare enum RvfErrorCode {
BackendNotFound = 65280,
BackendInitFailed = 65281,
StoreClosed = 65282,
InvalidOptions = 65283
InvalidOptions = 65283,
SidecarWriteFailed = 65286,
SidecarCorrupt = 65287
}
/**
* Custom error class for all RVF operations.
Expand Down
5 changes: 5 additions & 0 deletions npm/packages/rvf/dist/errors.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion npm/packages/rvf/dist/errors.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions npm/packages/rvf/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"scripts": {
"build": "tsc",
"test": "jest",
"smoke:idmap": "node scripts/smoke-idmap-durability.mjs",
"bench": "tsx bench/index.ts",
"typecheck": "tsc --noEmit"
},
Expand Down
77 changes: 77 additions & 0 deletions npm/packages/rvf/scripts/smoke-idmap-durability.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Runtime smoke test for #635: the .idmap.json sidecar must be written
// atomically and must fail loud (not degrade to empty maps) on a corrupt load.
// Requires a stub @ruvector/rvf-node resolvable from dist/backend.js (the
// runner sets one up); skips if absent.
//
// Run: node scripts/smoke-idmap-durability.mjs (after `tsc` emits dist/)
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const dist = pathToFileURL(path.resolve('dist/index.js')).href;
const { RvfDatabase, RvfError, RvfErrorCode } = await import(dist);

let passed = 0;
const ok = (name) => { console.log(` ok - ${name}`); passed++; };

let nativeAvailable = true;
try { await import('@ruvector/rvf-node'); } catch { nativeAvailable = false; }
if (!nativeAvailable) {
console.log(' skip - #635 (no @ruvector/rvf-node stub resolvable; run via npm run smoke:idmap)');
process.exit(0);
}

const vec = () => new Float32Array([1, 0, 0, 0]);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rvf-idmap-'));
const target = path.join(tmp, 'store.rvf');
const sidecar = `${target}.idmap.json`;

// 1. Ingest with string ids -> sidecar written atomically (no leftover .tmp).
{
const db = await RvfDatabase.create(target, { dimensions: 4 }, 'node');
await db.ingestBatch([
{ id: 'a', vector: vec() },
{ id: 'b', vector: vec() },
]);
await db.close();

const sc = JSON.parse(fs.readFileSync(sidecar, 'utf-8'));
assert.strictEqual(sc.nextLabel, 3, 'two ids -> labels 1,2 -> nextLabel 3');
assert.ok(!fs.existsSync(`${sidecar}.tmp`), 'atomic write leaves no .tmp file');
ok('#635 ingest persists sidecar atomically (nextLabel=3, no .tmp)');
}

// 2. Reopen with a VALID sidecar restores state (nextLabel does NOT reset).
{
const db = await RvfDatabase.open(target, 'node');
await db.ingestBatch([{ id: 'c', vector: vec() }]);
await db.close();

const sc = JSON.parse(fs.readFileSync(sidecar, 'utf-8'));
assert.strictEqual(sc.nextLabel, 4, 'restored state -> new id gets label 3 -> nextLabel 4 (not reset to 2)');
assert.deepStrictEqual(Object.keys(sc.idToLabel).sort(), ['a', 'b', 'c']);
ok('#635 reopen restores maps; no label collision on next ingest');
}

// 3. A corrupt sidecar fails loud (SidecarCorrupt) and is quarantined, not
// overwritten with empty/colliding state.
{
fs.writeFileSync(sidecar, '{ this is not valid json ', 'utf-8');
await assert.rejects(
() => RvfDatabase.open(target, 'node'),
(e) =>
e instanceof RvfError &&
e.code === RvfErrorCode.SidecarCorrupt &&
!/fsync/i.test(e.message),
'open() on corrupt sidecar should throw SidecarCorrupt',
);
assert.ok(!fs.existsSync(sidecar), 'corrupt sidecar renamed away (not left to be overwritten)');
const quarantined = fs.readdirSync(tmp).filter((f) => f.includes('.idmap.json.corrupt-'));
assert.strictEqual(quarantined.length, 1, 'exactly one quarantine file created');
ok('#635 corrupt sidecar -> SidecarCorrupt + quarantined (no silent empty-map reset)');
}

fs.rmSync(tmp, { recursive: true, force: true });
console.log(`\nAll ${passed} checks passed.`);
Loading
Loading