Skip to content
Draft
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
7 changes: 0 additions & 7 deletions dataLayer/harperBridge/ResourceBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,10 @@ import { table, getDatabases, database, dropDatabase, type Table } from '../../r
import insertUpdateValidate from './bridgeUtility/insertUpdateValidate.js';
import SearchObject from '../SearchObject.ts';
import {
OPERATIONS_ENUM,
VALUE_SEARCH_COMPARATORS,
VALUE_SEARCH_COMPARATORS_REVERSE_LOOKUP,
READ_AUDIT_LOG_SEARCH_TYPES_ENUM,
} from '../../utility/hdbTerms.ts';
import * as signalling from '../../utility/signalling.ts';
import { SchemaEventMsg } from '../../server/threads/itc.js';
import { asyncSetTimeout } from '../../utility/common_utils.ts';
import { transaction } from '../../resources/transaction.ts';
import type {
Expand Down Expand Up @@ -179,14 +176,10 @@ export class ResourceBridge extends BridgeMethods {
database: createSchemaObj.schema,
table: null,
});
return signalling.signalSchemaChange(
new SchemaEventMsg(process.pid, OPERATIONS_ENUM.CREATE_SCHEMA, createSchemaObj.schema)
);
}

async dropSchema(dropSchemaObj) {
await dropDatabase(dropSchemaObj.schema);
signalling.signalSchemaChange(new SchemaEventMsg(process.pid, OPERATIONS_ENUM.DROP_SCHEMA, dropSchemaObj.schema));
}

async updateRecords(updateObj) {
Expand Down
33 changes: 32 additions & 1 deletion dataLayer/restoreMarker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { tryFileLock, fileLockRelease } from '@harperfast/rocksdb-js';
* mutate the same directory concurrently.
*
* Restore metadata lives in an isolated `` `restore` `` directory *beside* the database directory
* (never inside it, since a restore purges the destination). Each database's two files are keyed by
* (never inside it, since a restore purges the destination). Each database's metadata files are keyed by
* a hash of the database directory name rather than being suffixed onto the name itself. That keeps
* them out of the database-name namespace — a legal database literally named `orders.restoring`
* would otherwise be mistaken for the restore marker of `orders`, and a 250-character name plus a
Expand All @@ -44,13 +44,16 @@ import { tryFileLock, fileLockRelease } from '@harperfast/rocksdb-js';
* successfully, while still holding the lock. Its *existence* means "a restore started and has
* not finished successfully". Its first line records the database directory name so the startup
* scan can map a marker back to the database it blocks without decoding the hashed key.
* - `<meta-dir>/<key>.open` — a short-lived mutex around root RocksDB opens and destroys. The main thread tracks
* managed worker holders and releases their token if the worker exits before it can clean up.
*/

// The backtick makes this an illegal database name (schemaRegex rejects `/` and backtick only), so
// it can never collide with a real database directory — see the module header.
export const RESTORE_META_DIR = '`restore`';
export const RESTORE_LOCK_SUFFIX = '.lock';
export const RESTORING_MARKER_SUFFIX = '.restoring';
const DATABASE_OPEN_LOCK_SUFFIX = '.open';

/**
* Directory holding the restore metadata for a database — the reserved `` `restore` `` sibling of
Expand Down Expand Up @@ -81,6 +84,34 @@ export function restoringMarkerPath(dbPath: string): string {
return join(restoreMetaDir(dbPath), restoreMetaKey(dbPath) + RESTORING_MARKER_SUFFIX);
}

function databaseOpenLockPath(dbPath: string): string {
return join(restoreMetaDir(dbPath), restoreMetaKey(dbPath) + DATABASE_OPEN_LOCK_SUFFIX);
}

/**
* Serialize root RocksDB opens and destroys for one database. rocksdb-js releases its registry
* entry before `DestroyDB`, so a concurrent reopen must wait until destruction is complete.
*/
export function acquireDatabaseOpenLock(dbPath: string, maxWaitMilliseconds = 30_000): number {
mkdirSync(restoreMetaDir(dbPath), { recursive: true });
let token = tryFileLock(databaseOpenLockPath(dbPath));
const startedAt = Date.now();
let sleeper: Int32Array;
while (token === 0) {
if (Date.now() - startedAt >= maxWaitMilliseconds) {
throw new Error(`Timed out acquiring RocksDB open lock for ${dbPath}`);
}
sleeper ??= new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
Atomics.wait(sleeper, 0, 0, 10);

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.

Main thread can block its own event loop for up to 30s here

What: acquireDatabaseOpenLock retries with a blocking Atomics.wait(sleeper, 0, 0, 10) in a tight loop (up to maxWaitMilliseconds, default 30s) with no yield back to the event loop between iterations. resources/databases.ts:363 calls this via acquireTrackedDatabaseOpenLock for every root open (!options.name) with no isMainThread exemption, unlike the message-tracking code right above it which is guarded by if (isMainThread)/if (!isMainThread).

The main thread does open root databases directly, not just workers: bin/run.tsstartHTTPThreads()loadRootComponents()getTables()/getDatabases() on initial boot, and — more concerningly — manageThreads.js's restartWorkers() (if (isMainThread) { ... await loadRootComponents(); }, lines ~456-471) re-runs this on every component/app deploy or reload, concurrently with worker threads restarting and opening the same runtime-created database for the first time. That's exactly the contention window this PR is designed to serialize.

Why it matters: If the main thread loses the race for this lock during a restart, it synchronously blocks in Atomics.wait for up to 30 seconds. The main thread is the process's IPC hub — manageThreads.js's message dispatch, the new onThreadExit handler that releases a worker's stranded open-lock token (the very mechanism this PR adds), and all other worker coordination stop dead for that window. A worker that dies while holding the lock can't be cleaned up until the main thread's own poll loop finishes, and no other IPC traffic (including HTTP routing setup, shutdown signals, etc.) can be serviced meanwhile. This risks reintroducing a wedge on the main thread instead of a worker.

Suggested fix: Skip the lock (or use a non-blocking/async wait, e.g. polling via setTimeout/microtask so the event loop keeps running) when isMainThread is true, mirroring the existing isMainThread branching already used for the message-tracking calls in this same function.

token = tryFileLock(databaseOpenLockPath(dbPath));
}
return token;
}

export function releaseDatabaseOpenLock(token: number): void {
fileLockRelease(token);
}

export type RestoreState = 'in-progress' | 'incomplete' | 'clear';

/**
Expand Down
12 changes: 10 additions & 2 deletions dataLayer/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ export async function createSchema(schemaCreateObject: any) {
// Await cross-worker propagation so the new schema is visible on every worker before
// returning success — otherwise describe_all on a lagging worker reports it missing (#1497).
await signalling.signalSchemaChange(
new SchemaEventMsg(process.pid, schemaCreateObject.operation, schemaCreateObject.schema)
new SchemaEventMsg(
process.pid,
schemaCreateObject.operation ?? hdbTerms.OPERATIONS_ENUM.CREATE_SCHEMA,
schemaCreateObject.schema
)
);

return schemaStructure;
Expand Down Expand Up @@ -189,7 +193,11 @@ export async function dropSchema(dropSchemaObject: any) {
// Await cross-worker propagation before returning success so no worker keeps serving the
// dropped schema (#1497).
await signalling.signalSchemaChange(
new SchemaEventMsg(process.pid, dropSchemaObject.operation, dropSchemaObject.schema)
new SchemaEventMsg(
process.pid,
dropSchemaObject.operation ?? hdbTerms.OPERATIONS_ENUM.DROP_SCHEMA,
dropSchemaObject.schema
)
);

let response = await server.replication.replicateOperation(dropSchemaObject);
Expand Down
21 changes: 7 additions & 14 deletions integrationTests/apiTests/terminology.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { startHarper, teardownHarper } from '@harperfast/integration-testing';
import { createApiClient } from './utils/client.mjs';
import { awaitJobCompleted, waitFor } from './utils/operations.mjs';
import { awaitJobCompleted } from './utils/operations.mjs';

// Resolve the CSV fixture path relative to this file so Harper can read it.
const SUPPLIERS_CSV = path.join(path.dirname(fileURLToPath(import.meta.url)), 'data/Suppliers.csv');
Expand Down Expand Up @@ -403,20 +403,16 @@ suite('Terminology aliases (database / primary_key)', (ctx) => {
});

test('drop_database with database param', async () => {
// The preceding drop_table signals syncSchemaMetadata across worker threads, which
// briefly reopens all schema RocksDB files including tuckerdoodle. If drop_database
// races that reopen it gets a "No locks available" LOCK error. Poll until the drop
// succeeds (workers release the lock quickly once their getDatabases pass finishes).
const r = await waitFor(() => client.req().send({ operation: 'drop_database', database: 'tuckerdoodle' }), {
until: (res) => res?.body?.message != null,
timeoutSeconds: 10,
});
assert.equal(r?.body?.message, "successfully deleted 'tuckerdoodle'", r?.text);
await client
.req()
.send({ operation: 'drop_database', database: 'tuckerdoodle' })
.expect((r) => assert.equal(r.body.message, "successfully deleted 'tuckerdoodle'", r.text))
.expect(200);
});

// ── async job operations ────────────────────────────────────────────────

test('create job_guy database and working table for job tests', async () => {
test('runtime-created database immediately starts a job', async () => {
await client
.req()
.send({ operation: 'create_database', database: 'job_guy' })
Expand All @@ -427,9 +423,6 @@ suite('Terminology aliases (database / primary_key)', (ctx) => {
.send({ operation: 'create_table', database: 'job_guy', table: 'working', primary_key: 'id' })
.expect((r) => assert.equal(r.body.message, "table 'job_guy.working' successfully created.", r.text))
.expect(200);
});

test('delete_records_before with database param starts job', async () => {
const r = await client
.req()
.send({
Expand Down
149 changes: 103 additions & 46 deletions resources/databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { _assignPackageExport } from '../globals.js';
import { getIndexedValues } from '../utility/lmdb/commonUtility.ts';
import * as signalling from '../utility/signalling.ts';
import { SchemaEventMsg } from '../server/threads/itc.js';
import { workerData } from 'worker_threads';
import { isMainThread, workerData } from 'worker_threads';

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.

medium

According to the repository style guide, Node builtins must use the node: prefix. Please update the import to use node:worker_threads.

Suggested change
import { isMainThread, workerData } from 'worker_threads';
import { isMainThread, workerData } from 'node:worker_threads';
References
  1. Node builtins use the node: prefix. These are constraints, not style choices. (link)

import harperLogger from '../utility/logging/harper_logger.ts';
const { forComponent } = harperLogger;
import * as manageThreads from '../server/threads/manageThreads.js';
Expand All @@ -43,14 +43,49 @@ import { resolveRocksMemoryConfig } from '../utility/rocksMemoryConfig.ts';
import { isProcessRunning } from '../utility/processManagement/processManagement.js';
import {
acquireRestoreLock,
acquireDatabaseOpenLock,
checkRestoreState,
releaseDatabaseOpenLock,
releaseRestoreLock,
restoreMarkerPresent,
scanBlockedRestores,
RESTORE_META_DIR,
type RestoreLock,
} from '../dataLayer/restoreMarker.ts';

declare const threads: { sendToThread(threadId: number, message: any): boolean };

const DATABASE_OPEN_LOCK_ACQUIRED = 'database_open_lock_acquired';
const DATABASE_OPEN_LOCK_RELEASED = 'database_open_lock_released';
const workerDatabaseOpenLocks = new Map<number, number>();

if (isMainThread) {
manageThreads.onMessageByType(DATABASE_OPEN_LOCK_ACQUIRED, (message, port) => {
workerDatabaseOpenLocks.set(message.token, port.threadId);
});
manageThreads.onMessageByType(DATABASE_OPEN_LOCK_RELEASED, (message) => {
workerDatabaseOpenLocks.delete(message.token);
});
manageThreads.onThreadExit((threadId) => {
for (const [token, ownerThreadId] of workerDatabaseOpenLocks) {
if (ownerThreadId !== threadId) continue;
releaseDatabaseOpenLock(token);
workerDatabaseOpenLocks.delete(token);
}
});
}

function acquireTrackedDatabaseOpenLock(path: string): number {
const token = acquireDatabaseOpenLock(path);
if (!isMainThread) threads.sendToThread(0, { type: DATABASE_OPEN_LOCK_ACQUIRED, token });
return token;
}

function releaseTrackedDatabaseOpenLock(token: number): void {
releaseDatabaseOpenLock(token);
if (!isMainThread) threads.sendToThread(0, { type: DATABASE_OPEN_LOCK_RELEASED, token });
}

/**
* Check if Harper is running in read-only mode.
* Read-only mode can be enabled via:
Expand Down Expand Up @@ -325,19 +360,24 @@ function openRocksDatabase(path: string, options: RocksDatabaseOptions & { dupSo
}
mkdirSync(path, { recursive: true });
}
let db: RocksRootDatabase;
if (options.dupSort) {
db = new RocksIndexStore(path, options).open() as any;
} else {
db = new PrimaryRocksDatabase(path, options).open() as unknown as RocksRootDatabase;
// the RocksDB put and remove return promises, which masks thrown errors in non-awaiting calls to put/remove,
// making them unsafe to replace LMDB methods, which will synchronously throw errors if there is a problem
db.put = db.putSync as any;
db.remove = db.removeSync as any;
(db.encoder as any).name = options.name;
const openLock = !isReadOnlyMode() ? acquireTrackedDatabaseOpenLock(path) : 0;
try {
let db: RocksRootDatabase;
if (options.dupSort) {
db = new RocksIndexStore(path, options).open() as any;
} else {
db = new PrimaryRocksDatabase(path, options).open() as unknown as RocksRootDatabase;
// the RocksDB put and remove return promises, which masks thrown errors in non-awaiting calls to put/remove,
// making them unsafe to replace LMDB methods, which will synchronously throw errors if there is a problem
db.put = db.putSync as any;
db.remove = db.removeSync as any;
(db.encoder as any).name = options.name;
}
db.env = {};
return db;
} finally {
if (openLock) releaseTrackedDatabaseOpenLock(openLock);
}
db.env = {};
return db;
}

const lmdbDatabaseEnvs = new Map<string, LMDBRootDatabase>();
Expand Down Expand Up @@ -1214,6 +1254,13 @@ function lockDatabaseForDrop(dbPath: string, databaseName: string, held: Restore
held.push(lock);
}

type DatabaseOpenLock = { dbPath: string; token: number };

function lockDatabaseOpenForDrop(dbPath: string, held: DatabaseOpenLock[]): void {
if (held.some((lock) => lock.dbPath === dbPath)) return;
held.push({ dbPath, token: acquireTrackedDatabaseOpenLock(dbPath) });
}

/**
* Delete the database
* @param databaseName
Expand All @@ -1229,53 +1276,63 @@ export async function dropDatabase(databaseName) {
// (before writing its marker), so both operations serialize on this one primitive rather than on
// a check-then-act marker probe. Released in the finally below.
const restoreLocks: RestoreLock[] = [];
const openLocks: DatabaseOpenLock[] = [];
try {
for (const tableName in dbTables) {
const table = dbTables[tableName];
rootStore = table.primaryStore.rootStore;
if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
lmdbDatabaseEnvs.delete(rootStore.path);
rocksdbDatabaseEnvs.delete(rootStore.path);
}
try {
for (const tableName in dbTables) {
const table = dbTables[tableName];
rootStore = table.primaryStore.rootStore;
if (rootStore instanceof RocksDatabase) {
lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
lockDatabaseOpenForDrop(rootStore.path, openLocks);
}
lmdbDatabaseEnvs.delete(rootStore.path);
rocksdbDatabaseEnvs.delete(rootStore.path);
}

for (const tableName in dbTables) {
databaseEventsEmitter.emit('dropTable', tableName, databaseName);
}
for (const tableName in dbTables) {
databaseEventsEmitter.emit('dropTable', tableName, databaseName);
}

if (databaseName === 'data') {
for (const tableName in tables) {
delete tables[tableName];
if (databaseName === 'data') {
for (const tableName in tables) {
delete tables[tableName];
}
delete tables[DEFINED_TABLES];
}
delete tables[DEFINED_TABLES];
}
delete databases[databaseName];
delete databases[databaseName];

databaseEventsEmitter.emit('dropDatabase', databaseName);
databaseEventsEmitter.emit('dropDatabase', databaseName);

if (rootStore) {
if (rootStore.status === 'open') {
if (rootStore) {
if (rootStore.status === 'open') {
if (rootStore instanceof RocksDatabase) {
rootStore.close();
rootStore.destroy();
} else {
await rootStore.close();
await unlink(rootStore.path);
}
}
} else {
rootStore = database({ database: databaseName, table: null });
// a tableless database resolves its root store here rather than in the loop above, so take
// the drop lock now (still before any destructive step)
if (rootStore instanceof RocksDatabase) {
lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
lockDatabaseOpenForDrop(rootStore.path, openLocks);
}
if (rootStore instanceof RocksDatabase) {
rootStore.close();
rootStore.destroy();
} else {
} else if (rootStore.status === 'open') {
await rootStore.close();
await unlink(rootStore.path);
}
}
} else {
rootStore = database({ database: databaseName, table: null });
// a tableless database resolves its root store here rather than in the loop above, so take
// the drop lock now (still before any destructive step)
if (rootStore instanceof RocksDatabase) lockDatabaseForDrop(rootStore.path, databaseName, restoreLocks);
if (rootStore instanceof RocksDatabase) {
rootStore.close();
rootStore.destroy();
} else if (rootStore.status === 'open') {
await rootStore.close();
await unlink(rootStore.path);
}
} finally {
for (const lock of openLocks) releaseTrackedDatabaseOpenLock(lock.token);
}

await deleteRootBlobPathsForDB(rootStore);
} finally {
for (const lock of restoreLocks) releaseRestoreLock(lock);
Expand Down
Loading
Loading