diff --git a/package-lock.json b/package-lock.json index 25c783d..2fdb13d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tidesdb", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tidesdb", - "version": "0.4.0", + "version": "0.5.1", "license": "MPL-2.0", "dependencies": { "koffi": "^2.9.0" diff --git a/package.json b/package.json index e8b72a7..082aeea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tidesdb", - "version": "0.5.0", + "version": "0.5.1", "description": "TypeScript bindings for TidesDB - A high-performance embedded key-value storage engine", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/ffi.ts b/src/ffi.ts index 24b9708..35e62cb 100644 --- a/src/ffi.ts +++ b/src/ffi.ts @@ -171,6 +171,7 @@ export const tidesdb_flush_memtable = lib.func('int tidesdb_flush_memtable(tides export const tidesdb_is_flushing = lib.func('int tidesdb_is_flushing(tidesdb_column_family_t *cf)'); export const tidesdb_is_compacting = lib.func('int tidesdb_is_compacting(tidesdb_column_family_t *cf)'); export const tidesdb_backup = lib.func('int tidesdb_backup(tidesdb_t *db, char *dir)'); +export const tidesdb_checkpoint = lib.func('int tidesdb_checkpoint(tidesdb_t *db, const char *checkpoint_dir)'); // Comparator operations export const tidesdb_register_comparator = lib.func('int tidesdb_register_comparator(tidesdb_t *db, const char *name, void *fn, const char *ctx_str, void *ctx)'); diff --git a/src/tidesdb.test.ts b/src/tidesdb.test.ts index eda32e5..1e78533 100644 --- a/src/tidesdb.test.ts +++ b/src/tidesdb.test.ts @@ -474,6 +474,107 @@ describe('TidesDB', () => { }); }); + describe('Checkpoint', () => { + // Use relative paths for checkpoint tests to avoid a Windows bug in the + // C library's tidesdb_checkpoint_ensure_parent_dir: it fails to stat/mkdir + // the drive-letter component (e.g. "C:") of absolute paths. Other bindings + // (Go, Lua) also use relative paths. Both DB and checkpoint must be on the + // same volume for hard links, so the DB is also opened from a relative path. + let cpDb: TidesDB; + let cpDbDir: string; + + beforeEach(() => { + cpDbDir = `tidesdb-cp-test-${Date.now()}`; + cpDb = TidesDB.open({ + dbPath: cpDbDir, + numFlushThreads: 2, + numCompactionThreads: 2, + }); + }); + + afterEach(() => { + try { cpDb.close(); } catch { /* ignore */ } + fs.rmSync(cpDbDir, { recursive: true, force: true }); + }); + + test('create checkpoint of database', () => { + cpDb.createColumnFamily('test_cf'); + const cf = cpDb.getColumnFamily('test_cf'); + + // Insert data + const txn = cpDb.beginTransaction(); + txn.put(cf, Buffer.from('cp_key1'), Buffer.from('cp_value1'), -1); + txn.put(cf, Buffer.from('cp_key2'), Buffer.from('cp_value2'), -1); + txn.commit(); + txn.free(); + + // Create checkpoint (directory must NOT exist; the C library creates it) + const checkpointDir = `tidesdb-checkpoint-${Date.now()}`; + fs.rmSync(checkpointDir, { recursive: true, force: true }); + try { + cpDb.checkpoint(checkpointDir); + + // Verify checkpoint directory exists with contents + expect(fs.existsSync(checkpointDir)).toBe(true); + expect(fs.readdirSync(checkpointDir).length).toBeGreaterThan(0); + } finally { + fs.rmSync(checkpointDir, { recursive: true, force: true }); + } + }); + + test('checkpoint to existing non-empty directory throws', () => { + cpDb.createColumnFamily('test_cf'); + + // Create a non-empty directory + const checkpointDir = `tidesdb-checkpoint-nonempty-${Date.now()}`; + fs.mkdirSync(checkpointDir, { recursive: true }); + fs.writeFileSync(path.join(checkpointDir, 'dummy'), 'data'); + + try { + expect(() => cpDb.checkpoint(checkpointDir)).toThrow(); + } finally { + fs.rmSync(checkpointDir, { recursive: true, force: true }); + } + }); + + test('checkpoint can be opened as a database', () => { + cpDb.createColumnFamily('test_cf'); + const cf = cpDb.getColumnFamily('test_cf'); + + // Insert data + const txn = cpDb.beginTransaction(); + txn.put(cf, Buffer.from('cp_key'), Buffer.from('cp_value'), -1); + txn.commit(); + txn.free(); + + // Create checkpoint (directory must NOT exist; the C library creates it) + const checkpointDir = `tidesdb-checkpoint-open-${Date.now()}`; + fs.rmSync(checkpointDir, { recursive: true, force: true }); + try { + cpDb.checkpoint(checkpointDir); + + // Open the checkpoint as a separate database + const cpOpenDb = TidesDB.open({ + dbPath: checkpointDir, + numFlushThreads: 1, + numCompactionThreads: 1, + }); + + try { + const cpCf = cpOpenDb.getColumnFamily('test_cf'); + const readTxn = cpOpenDb.beginTransaction(); + const value = readTxn.get(cpCf, Buffer.from('cp_key')); + expect(value.toString()).toBe('cp_value'); + readTxn.free(); + } finally { + cpOpenDb.close(); + } + } finally { + fs.rmSync(checkpointDir, { recursive: true, force: true }); + } + }); + }); + describe('Statistics', () => { test('get column family stats', () => { db.createColumnFamily('test_cf'); diff --git a/src/tidesdb.ts b/src/tidesdb.ts index 9c68d31..1fecb0c 100644 --- a/src/tidesdb.ts +++ b/src/tidesdb.ts @@ -31,6 +31,7 @@ import { tidesdb_register_comparator, tidesdb_get_cache_stats, tidesdb_backup, + tidesdb_checkpoint, } from './ffi'; import { checkResult, TidesDBError } from './error'; import { ColumnFamily } from './column-family'; @@ -324,6 +325,18 @@ export class TidesDB { checkResult(result, 'failed to create backup'); } + /** + * Create a lightweight, near-instant snapshot of the database using hard links. + * Much faster than backup() as it uses hard links instead of copying SSTable data. + * @param dir Checkpoint directory path (must be non-existent or empty, same filesystem). + */ + checkpoint(dir: string): void { + if (!this._db) throw new Error('Database has been closed'); + + const result = tidesdb_checkpoint(this._db, dir); + checkResult(result, 'failed to create checkpoint'); + } + /** * Get statistics about the block cache. */