Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
46b6f6f
Write a new table's primary catalog row last so a mid-create catalog …
kriszyp Aug 28, 2026
486e6da
Register the new table class only once its catalog is complete, and p…
kriszyp Aug 28, 2026
9439efd
Assert the mid-create scan never emits updateTable, the event replica…
kriszyp Aug 28, 2026
e1e0677
Roll back a create that fails before its primary row, and drop a stal…
kriszyp Aug 28, 2026
eb4e007
Give the mid-create scan test a positive control on catalog visibility
kriszyp Aug 28, 2026
e3595c0
Remove a failed create's catalog rows, and release the callbacks of a…
kriszyp Aug 28, 2026
017ea21
Arm the create rollback as soon as the primary store opens, and make …
kriszyp Aug 28, 2026
fcd045a
Report an incomplete catalog once per table and thread, and exercise …
kriszyp Aug 28, 2026
ba2c1b3
Release a class whose makeTable() throws, and keep the rollback and r…
kriszyp Aug 28, 2026
4e5277a
Bound the create reconcile to the table's rows, reset every row a cre…
kriszyp Aug 28, 2026
93b342e
Terminate the scan worker on every exit of the thread test
kriszyp Aug 28, 2026
6636e25
Drop two comments that narrate the line beneath them
kriszyp Aug 28, 2026
d9e0026
Settle a superseded cleanup pass when a higher-priority one preempts it
kriszyp Aug 28, 2026
492d463
Refuse to schedule a cleanup pass on a disposed class, and search the…
kriszyp Aug 28, 2026
bf65b06
Settle the cleanup pass when its root store is closed, and dispose th…
kriszyp Aug 28, 2026
393436a
Treat the primary-row write as the publish point, not the class regis…
Sep 1, 2026
104ff48
Release the class of a create that publishes but is never registered
Sep 1, 2026
7875401
Drop a test comment duplicated from the test above it
Sep 1, 2026
89445c5
Settle a superseded cleanup pass from the replacement's scan, not on …
Sep 1, 2026
e6ec3b8
Say why the awaiter list is snapshotted rather than restate the hando…
Sep 1, 2026
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
6 changes: 6 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ When `signalSchemaChange('schema-change')` fires at the start of `runIndexing`,

**Remediating a node damaged before this was enforced.** The fix is forward-only. A node that already lost an attribute's catalog descriptor still has `schemaDefined: true` persisted, so the handshake's honor-local guard refuses to re-add the attribute from any peer and `search_by_value` keeps failing with `unknown attribute`; the recurring `Schema for '<db>.<table>' is defined locally, but attribute '<name>: <type>' from '<node>' does not match local attribute which does not exist` warn is the only detection signal. Local schema authoring is now the only path permitted to write that descriptor back, so remediation is to re-declare the attribute locally on the damaged node — the `create_attribute` operation, or redeploying the component whose `@table` declares it.

## A table is invisible to catalog scans until its create is complete (`databases.ts` `table()` / `initStores`)

The additive-only rule above repairs the _consumer_ of a partial peer snapshot; this rule stops the snapshot from existing. Every worker thread has its own `Table` map, rebuilt by `resetDatabases()` → `initStores` scanning the `__dbis__` catalog whenever any schema-change ITC signal arrives — including one for an unrelated database. On RocksDB catalog rows are individual `putSync` writes and the cross-thread `update-attributes` lock is taken only by writers, so a scan that lands inside a `create_table` used to see the primary row with none or some of the attribute rows, build a `Table` whose `attributes` was that partial list, and emit `updateTable` for it — which harper-pro forwards to peers as a DB_SCHEMA announcement. On a peer whose replication thread had not loaded the table yet, the announcement was applied as an authoritative definition and deleted the locally declared attributes (harper-pro `replicationLoad` "unknown attribute 'name'"; the partial announcement is visible in the node log as `(Re)creating { ... attributes: [ { name: 'id', ... } ] }`).

`table()` therefore writes the primary-key descriptor (`<table>/` — the row `initStores` needs before it will load a table; a row carrying `isPrimaryKey` is accepted too, for pre-5.x catalogs, so the primary-key descriptor must stay the last row written whatever key it lands on) only after every attribute row, still under the exclusive lock, and registers the class in this worker's `databases` map immediately after it. That write is also where the rollback stops: the row is durable the moment the put returns — on LMDB the `finally` that releases the exclusive lock commits the create's write transaction whether or not an error is unwinding — so a create that throws past it (registering the class, persisting relationships) keeps every row it wrote, and undoing them would leave the primary-only catalog this rule exists to prevent. `initStores` skips — with a warn — a table that has attribute rows but no primary row. The catalog is either invisible or complete to every other thread, so no thread can build or announce a partial `Table`. A scan that finds the incomplete catalog also drops a class it still holds from a dropped same-name table, rather than serving that stale generation through the recreate. A create that throws before its primary row is registered nowhere, and `table()` releases what it had opened for the class (primary store, index stores, the audit delete-removal callback, and its storage-reclamation handler — `removeStorageReclamationHandler`, because a RocksDB column family shares its reclamation path with every other family in the database). LMDB already had this property because `exclusiveLock()` there is an environment-wide write transaction. Consequence for an interrupted create: orphan attribute rows, no primary row, and the column families opened before the crash (as before), so the table does not load at all instead of loading with whatever attributes had landed; re-running `create_table` writes the rows, reuses the families, and the new-table reconcile removes any orphan row the new definition does not declare. The guarantee holds only once every schema-creating node runs this code: an older peer still announces primary-first snapshots, so the receiver-side additive rule above stays necessary. Regression coverage: `unitTests/resources/createTableCatalogOrder.test.js` (write order, a create failing on either side of the publish point, and a second worker thread scanning the catalog while the create is paused).

## Audit-store `'committed'` notification batching (`transactionBroadcast.ts`)

The cross-thread subscription path (default `crossThreads`) drives every `Table.subscribe()` consumer. When the database's audit store emits `'committed'`, we walk the audit log via a reusable iterator and dispatch matching records to subscribers. Three properties of this path are easy to break and worth knowing about before changing it:
Expand Down
65 changes: 54 additions & 11 deletions resources/Table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ import { rebuildUpdateBefore } from './crdt.ts';
import { appendHeader } from '../server/serverHelpers/Headers.ts';
import fs from 'node:fs';
import { Blob, deleteBlobsInObject, findBlobsInObject, startPreCommitBlobsForRecord } from './blob.ts';
import { onStorageReclamation, getStorageSpaceStats } from '../server/storageReclamation.ts';
import {
onStorageReclamation,
getStorageSpaceStats,
removeStorageReclamationHandler,
} from '../server/storageReclamation.ts';
import { RequestTarget } from './RequestTarget.ts';
import harperLogger from '../utility/logging/harper_logger.ts';
import { throttle } from '../server/throttle.ts';
Expand Down Expand Up @@ -516,6 +520,10 @@ export function makeTable(options) {
let cleanupPriority = 0;
let lastCleanupInterval: number;
let cleanupTimer: NodeJS.Timeout;
let recordExpirationInterval: NodeJS.Timeout;
// a reclamation pass awaits a scheduled cleanup, which only settles from its timer
const pendingCleanupResolvers = new Set<() => void>();
let disposed = false;
// true once a table-level expiration/eviction/scanInterval has armed the periodic cleanup scan at setup
let expirationScanScheduled = false;
// set on the first expiring write so the unscheduled-expiration warning is evaluated at most once per table
Expand Down Expand Up @@ -564,9 +572,10 @@ export function makeTable(options) {
const MAX_PREFETCH_SEQUENCE = 10;
const MAX_PREFETCH_BUNDLE = 6;
if (audit) addDeleteRemoval();
onStorageReclamation(primaryStore.path, (priority: number) => {
const reclamationHandler = (priority: number) => {
if (hasSourceGet) return scheduleCleanup(priority);
});
};
onStorageReclamation(primaryStore.path, reclamationHandler);

class Updatable extends GenericTrackedObject implements RecordObject {
declare set: (property: string, value: any) => void;
Expand Down Expand Up @@ -5496,8 +5505,14 @@ export function makeTable(options) {
}
return Promise.all(promises);
}
/** Release everything makeTable() registered process-wide; the class must not be used afterwards. */
static cleanup() {
disposed = true;
clearTimeout(cleanupTimer);
settlePendingCleanup();
clearInterval(recordExpirationInterval);
deleteCallbackHandle?.remove();
removeStorageReclamationHandler(primaryStore.path, reclamationHandler);
}
static _readTxnForContext(context) {
return txnForContext(context).getReadTxn();
Expand All @@ -5519,9 +5534,14 @@ export function makeTable(options) {
}
);

TableResource.updatedAttributes(); // on creation, update accessors as well
if (expirationMs) TableResource.setTTLExpiration(expirationMs / 1000);
if (expiresAtProperty) runRecordExpirationEviction();
try {
TableResource.updatedAttributes(); // on creation, update accessors as well
if (expirationMs) TableResource.setTTLExpiration(expirationMs / 1000);
if (expiresAtProperty) runRecordExpirationEviction();
} catch (error) {
TableResource.cleanup();
throw error;
}
return TableResource;
function updateIndices(id: any, existingRecord: any, record: any, options?: any) {
let hasChanges;
Expand Down Expand Up @@ -6537,7 +6557,13 @@ export function makeTable(options) {
},
};
}
function settlePendingCleanup() {
for (const resolve of pendingCleanupResolvers) resolve();
pendingCleanupResolvers.clear();
}
function scheduleCleanup(priority?: number): Promise<void> | void {
// a reclamation run may still hold this class's handler after cleanup(); a promise here would never settle
if (disposed) return;
let runImmediately = false;
if (priority) {
// run immediately if there is a big increase in priority
Expand All @@ -6550,8 +6576,18 @@ export function makeTable(options) {
if (getWorkerIndex() === getWorkerCount() - 1) {
// run on the last thread so we aren't overloading lower-numbered threads
if (cleanupTimer) clearTimeout(cleanupTimer);
if (!cleanupInterval) return;
return new Promise((resolve) => {
if (!cleanupInterval) {
// no replacement pass is being scheduled, so nothing is left to settle a superseded one
settlePendingCleanup();
return;
}
// This pass adopts the awaiters of the pass whose timer it just cleared: they settle when
// this pass's scan completes, so a reclamation run is never told the storage was reclaimed
// before any scan ran. It has to run now, though — that run blocks its whole path on the
// promise, and the replacement's own slot can be a full interval out.
if (pendingCleanupResolvers.size > 0) runImmediately = true;
return new Promise<void>((resolve) => {
pendingCleanupResolvers.add(resolve);
const startOfYear = new Date();
startOfYear.setMonth(0);
startOfYear.setDate(1);
Expand All @@ -6564,6 +6600,7 @@ export function makeTable(options) {
? Date.now()
: Math.ceil((Date.now() - startOfYear.getTime()) / nextInterval) * nextInterval + startOfYear.getTime();
const startNextTimer = (nextScheduled) => {
if (disposed) return;
logger.trace?.(`Scheduled next cleanup scan at ${new Date(nextScheduled)}`);
// noinspection JSVoidFunctionReturnValueUsed
cleanupTimer = setTimeout(
Expand All @@ -6574,8 +6611,11 @@ export function makeTable(options) {
const rootStore = primaryStore.rootStore;
if (rootStore.status !== 'open') {
clearTimeout(cleanupTimer);
settlePendingCleanup();
return;
}
// snapshot: an awaiter that arrives during this scan belongs to the pass that supersedes it
const settling = [...pendingCleanupResolvers];
const MAX_CLEANUP_CONCURRENCY = 50;
const outstandingCleanupOperations = new Array(MAX_CLEANUP_CONCURRENCY);
let cleanupIndex = 0;
Expand Down Expand Up @@ -6655,7 +6695,10 @@ export function makeTable(options) {
} catch (error) {
logger.warn?.(`Error in cleanup scan for ${tableName}:`, error);
}
resolve(undefined);
for (const settle of settling) {
pendingCleanupResolvers.delete(settle);
settle();
}
cleanupPriority = 0; // reset the priority
})),
Math.min(nextScheduled - Date.now(), MAX_SET_TIMEOUT_MS) // make sure it can fit in 32-bit signed number
Expand All @@ -6674,10 +6717,10 @@ export function makeTable(options) {
// Periodically evict expired records, searching for records who expiresAt timestamp is before now
if (getWorkerIndex() === 0) {
// we want to run the pruning of expired records on only one thread so we don't have conflicts in evicting
setInterval(async () => {
recordExpirationInterval = setInterval(async () => {
// go through each database and table and then search for expired entries
// find any entries that are set to expire before now
if (runningRecordExpiration) return;
if (disposed || runningRecordExpiration) return;
runningRecordExpiration = true;
try {
const expiresAtName = expiresAtProperty.name;
Expand Down
1 change: 1 addition & 0 deletions resources/auditStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export function openAuditStore(rootStore) {
return {
remove() {
delete deleteCallbacks[tableId];
if (auditStore.tableStores[tableId] === table) delete auditStore.tableStores[tableId];
},
};
};
Expand Down
Loading
Loading