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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to the DKG V9 node are documented here. The format is based

## [Unreleased]

### Removed — `oxigraph-worker` backend support

- **The embedded `oxigraph-worker` backend is retired.** The storage package no longer exports or registers the worker-thread adapter, `createTripleStore({ backend: "oxigraph-worker" })` fails with an actionable migration error, and CLI config validation refuses explicit `store.backend: "oxigraph-worker"` before daemon boot. Block-less configs now resolve to the daemon-managed `oxigraph-server` default; if a legacy `store.nq` file exists, daemon boot requires `DKG_ACCEPT_STORE_RESET=1` so operators acknowledge the fresh-store cutover.

### Fixed — `dkg init` on monorepo checkouts + oxigraph-server default for adapter/MCP setups (#960)

- **V10 publish/update allowance recovery now translates raw ethers custom-error data before `TooLowAllowance` classification** (`packages/chain/src/evm-adapter.ts`): the shared populate-and-sign recovery gate decodes raw provider revert data first, so a fresh node can force one re-approval and retry when RPC allowance reads lag behind a mined TRAC approval.
Expand Down
188 changes: 4 additions & 184 deletions bench/store-read-latency.bench.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { defineSuite } from 'esbench';
// Import the store classes + types from their SPECIFIC source modules rather
// than the storage barrel: the barrel also re-exports GraphManager /
Expand All @@ -8,7 +6,6 @@ import { defineSuite } from 'esbench';
// clean checkout. These adapter modules depend only on `oxigraph` + the local
// triple-store types, so the bench needs nothing beyond the storage build.
import { OxigraphStore } from '../packages/storage/src/adapters/oxigraph.ts';
import { OxigraphWorkerStore } from '../packages/storage/src/adapters/oxigraph-worker.ts';
import type { Quad, QueryResult } from '../packages/storage/src/triple-store.ts';
import { GET_TOTAL_TRIPLES_SPARQL, parseRdfInt } from '../packages/cli/src/daemon/metrics-queries.ts';
import { benchAsyncWithHooks } from './support/esbench-case-hooks.ts';
Expand All @@ -24,33 +21,11 @@ import { benchAsyncWithHooks } from './support/esbench-case-hooks.ts';
* - the production `getTotalTriples` `COUNT(*)` aggregate the 30s metrics
* collector runs (`packages/cli/src/daemon/lifecycle.ts`).
*
* Backends (env `DKG_BENCH_STORE_BACKENDS`, comma-separated):
* - `inprocess` — `OxigraphStore`, no build step required.
* - `worker` — `OxigraphWorkerStore`, the PRODUCTION backend; requires
* `pnpm --filter @origintrail-official/dkg-storage build` first (it spawns a
* compiled worker artefact).
* Default: `inprocess`, plus `worker` automatically when its compiled artefact
* is present (so a built tree / CI measures both; an unbuilt tree degrades to
* `inprocess`-only instead of erroring).
*
* CONTENTION — the `(under write load)` cases — is measured ONLY on the `worker`
* backend, by design. The production read-starvation is a property of the
* single-writer oxigraph WORKER, whose message queue serialises a read behind
* queued writes — exactly what an out-of-process MVCC server (#938) relieves.
* The in-process `OxigraphStore` runs its insert/query work SYNCHRONOUSLY on one
* thread, so a read and a write can never truly overlap; a same-thread "writer"
* would only measure event-loop interleaving (not store contention) and could
* report misleadingly idle reads. So `inprocess` runs the idle read-latency
* baselines only.
*
* Store sizes via env `DKG_BENCH_STORE_SIZES` (default `1k,50k`).
*/

type Backend = 'inprocess' | 'worker';

interface ReadStore {
insert(quads: Quad[]): Promise<void>;
delete(quads: Quad[]): Promise<void>;
query(sparql: string): Promise<QueryResult>;
close(): Promise<void>;
}
Expand All @@ -63,13 +38,6 @@ const READ_LIMIT1 = `SELECT ?s WHERE { GRAPH <${GRAPH}> { ?s ?p ?o } } LIMIT 1`;
// synthetic data lives in a named graph, so the `GRAPH ?g` branch carries the scan.
const READ_TOTAL_TRIPLES = GET_TOTAL_TRIPLES_SPARQL;

// Bounded write churn: the background writer repeatedly inserts then deletes a
// fixed batch in a region disjoint from the pre-populated base, so it generates
// sustained write work WITHOUT drifting the store size (which would otherwise
// confound the getTotalTriples-under-load measurement).
const CHURN_BATCH = 50;
const CHURN_OFFSET = 1_000_000_000;

const STORE_SIZES: Record<string, number> = { '1k': 1_000, '10k': 10_000, '50k': 50_000, '200k': 200_000 };
const INSERT_CHUNK = 1_000;

Expand Down Expand Up @@ -111,46 +79,6 @@ function makeQuads(count: number, offset: number): Quad[] {
return quads;
}

// The compiled worker artefact sits next to the storage dist build; its presence
// means the `worker` backend can be constructed without throwing.
function workerArtifactAvailable(): boolean {
try {
return existsSync(fileURLToPath(new URL('../packages/storage/dist/adapters/oxigraph-worker-impl.js', import.meta.url)));
} catch {
return false;
}
}

function resolveBackends(): Backend[] {
const raw = process.env.DKG_BENCH_STORE_BACKENDS?.trim();
if (!raw) {
if (workerArtifactAvailable()) return ['inprocess', 'worker'];
// Loud, because the worker backend is the one this regression is about — a
// silent inprocess-only run would look like it measured the production path.
console.warn(
'[store-read-latency] worker backend SKIPPED (compiled adapter missing) — ' +
'measuring `inprocess` idle read latency ONLY, NOT the production write-contention path. ' +
'Run `pnpm --filter @origintrail-official/dkg-storage build` (or `pnpm bench:store-read`, ' +
'which builds it) to include the worker backend.',
);
return ['inprocess'];
}
const known = new Set<Backend>(['inprocess', 'worker']);
const requested = raw.split(',').map((p) => p.trim().toLowerCase()).filter(Boolean);
for (const b of requested) {
if (!known.has(b as Backend)) {
throw new Error(`Unknown DKG_BENCH_STORE_BACKENDS entry "${b}". Expected: inprocess, worker`);
}
}
if (requested.includes('worker') && !workerArtifactAvailable()) {
throw new Error(
'DKG_BENCH_STORE_BACKENDS requested "worker", but the compiled adapter is missing. ' +
'Run `pnpm --filter @origintrail-official/dkg-storage build` first.',
);
}
return requested.length > 0 ? (requested as Backend[]) : ['inprocess'];
}

function resolveStoreSizeLabels(): string[] {
const raw = process.env.DKG_BENCH_STORE_SIZES?.trim();
const labels = raw ? raw.split(',').map((p) => p.trim().toLowerCase()).filter(Boolean) : ['1k', '50k'];
Expand All @@ -162,15 +90,8 @@ function resolveStoreSizeLabels(): string[] {
return labels;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: Collapse the single-backend benchmark scaffolding

Why it matters
The current version keeps the shape of the removed worker comparison, including env parsing and an ignored parameter. That is small but unnecessary indirection, and it makes the benchmark look more configurable than it is.

Suggestion
Now that the worker backend is retired, delete the backend abstraction rather than leaving a one-value mode system. The benchmark can be a direct in-process Oxigraph read-latency suite until another real backend comparison exists.

}

function createStore(backend: Backend): ReadStore {
// `worker` availability is gated in resolveBackends(), so by the time we get
// here the compiled adapter is present.
return backend === 'worker' ? new OxigraphWorkerStore() : new OxigraphStore();
}

export default defineSuite({
params: {
backend: resolveBackends(),
storeSize: resolveStoreSizeLabels(),
},
baseline: {
Expand All @@ -185,63 +106,20 @@ export default defineSuite({
warmup: 1,
},
async setup(scene) {
const backend = scene.params.backend as Backend;
const sizeLabel = scene.params.storeSize as string;
const quadCount = STORE_SIZES[sizeLabel];

const store = createStore(backend);
const store: ReadStore = new OxigraphStore();

const churn = makeQuads(CHURN_BATCH, CHURN_OFFSET);
let writerActive = false;
let writerDone: Promise<void> | undefined;
let writerError: unknown;

const stopWriter = async (): Promise<void> => {
if (!writerDone) return;
writerActive = false;
const done = writerDone;
writerDone = undefined;
// The loop captures its own failures into `writerError`, so this never rejects.
await done;
// Remove the churn batch so a half-applied cycle (an insert without its
// matching delete) can't leak into the next iteration's getTotalTriples count.
try {
await store.delete(churn);
} catch {
/* store may be mid-teardown */
}
// Fail fast if the writer died: a stopped writer must never let an
// `(under write load)` case record a successful (effectively idle) sample.
if (writerError !== undefined) {
const err = writerError;
writerError = undefined;
throw new Error(`background writer died during the under-load iteration: ${errorText(err)}`);
}
};

// ONE ordered teardown for the scene: stop the writer and await its loop
// BEFORE closing the store, so an in-flight insert/delete can never race a
// closed store/worker. esbench may run scene teardown hooks concurrently, so
// all ordering lives inside this single callback. Registered up-front so the
// store is still closed (and any worker thread terminated) even if the
// population below throws.
// Registered up-front so the store is still closed even if population or a
// benchmark case throws.
scene.teardown(async () => {
let stopErr: unknown;
try {
await stopWriter();
} catch (err) {
stopErr = err; // capture; still close the store below
}
// `close()` is the only place the worker thread is joined, so a failure
// here is a real teardown bug — surface it (log + reject) rather than
// swallow it and let a broken worker benchmark look green.
try {
await store.close();
} catch (closeErr) {
console.error(`[store-read-latency] store.close() failed (worker thread may not have joined): ${errorText(closeErr)}`);
console.error(`[store-read-latency] store.close() failed: ${errorText(closeErr)}`);
throw closeErr;
}
if (stopErr !== undefined) throw stopErr;
});

// Pre-populate the base graph the reads scan.
Expand All @@ -256,63 +134,5 @@ export default defineSuite({
benchAsyncWithHooks(scene, 'read getTotalTriples (idle)', async () => {
assertCountAtLeast(await store.query(READ_TOTAL_TRIPLES), quadCount, 'read getTotalTriples');
}, {});

// Contention is meaningful only on the worker backend (see file docstring):
// the in-process store is single-threaded + synchronous, so reads and writes
// cannot truly overlap. Skip the `(under write load)` cases for it rather
// than report a misleading same-thread number.
if (backend !== 'worker') return;

// Background writer for the worker-backend `(under write load)` cases,
// scoped to each loaded iteration (not to case-execution order):
// - `beforeIteration` (startWriter) AWAITS one full insert/delete cycle so
// writes are provably queued in the worker before the measured read.
// - a writer that dies is recorded in `writerError` and surfaced as a
// FAILED case — startWriter throws if it died before the first cycle,
// the workload throws if it died mid-measurement, and stopWriter throws
// if it died just after — so a broken writer can never silently degrade
// into an idle read.
// - `afterIteration` (stopWriter) stops it and clears the churn batch.
const startWriter = async (): Promise<void> => {
if (writerActive) return;
writerActive = true;
writerError = undefined;
let signalFirstCycle!: () => void;
const firstCycle = new Promise<void>((resolve) => { signalFirstCycle = resolve; });
writerDone = (async () => {
try {
let signalled = false;
while (writerActive) {
await store.insert(churn);
await store.delete(churn);
if (!signalled) { signalled = true; signalFirstCycle(); }
}
} catch (err) {
writerError = err;
} finally {
// Unblock the barrier even if the first cycle threw, so a writer
// failure surfaces (below) instead of hanging the benchmark.
signalFirstCycle();
}
})();
await firstCycle;
if (writerError !== undefined) {
throw new Error(`background writer failed before its first write cycle: ${errorText(writerError)}`);
}
};

benchAsyncWithHooks(scene, 'read LIMIT 1 (under write load)', async () => {
assertNonEmptySelect(await store.query(READ_LIMIT1), 'read LIMIT 1');
if (writerError !== undefined) {
throw new Error(`background writer died during the measurement: ${errorText(writerError)}`);
}
}, { beforeIteration: startWriter, afterIteration: stopWriter });

benchAsyncWithHooks(scene, 'read getTotalTriples (under write load)', async () => {
assertCountAtLeast(await store.query(READ_TOTAL_TRIPLES), quadCount, 'read getTotalTriples');
if (writerError !== undefined) {
throw new Error(`background writer died during the measurement: ${errorText(writerError)}`);
}
}, { beforeIteration: startWriter, afterIteration: stopWriter });
},
});
4 changes: 4 additions & 0 deletions docs/UPGRADE_TO_RC17.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Upgrading to `v10.0.0-rc.17` — land it clean

> **Historical note:** this guide describes the rc.17 storage behavior. Current
> v10 builds have retired `oxigraph-worker`; use `oxigraph-server` or an
> external SPARQL backend for daemon storage.

**Audience:** DKG **node operators** (edge + core) on V10 Testnet (Base
Sepolia) upgrading from any pre-rc.17 build (rc.12 → rc.16), plus anyone
spinning up a node for the first time on rc.17.
Expand Down
4 changes: 2 additions & 2 deletions docs/use-dkg/storage-sparql-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ doc_type: how-to

# Using an external SPARQL store (Oxigraph server, etc.)

The DKG node can use any **SPARQL 1.1 Protocol**–compliant store you run yourself, instead of its default daemon-managed local Oxigraph server (or the embedded `oxigraph-worker` fallback). That gives you:
The DKG node can use any **SPARQL 1.1 Protocol**–compliant store you run yourself, instead of its default daemon-managed local Oxigraph server. That gives you:

- **Real on-disk persistence** (e.g. Oxigraph server with RocksDB)
- **Larger graphs** without holding everything in the Node process
Expand Down Expand Up @@ -91,4 +91,4 @@ await agent.start();

New installs default to a **daemon-managed local Oxigraph server** (`store.backend: "oxigraph-server"`): `dkg init`, `dkg openclaw/hermes/mcp setup`, or accepting the wizard default writes this block. The daemon fetches the pinned `oxigraph` binary on first boot and runs it on loopback, giving MVCC concurrent reads and incremental RocksDB persistence.

If a config has **no** `store` block at all, the runtime falls back to the embedded in-process **`oxigraph-worker`** (a single-writer store that rewrites its on-disk N-Quads dump under `dataDir` on every flush) — fine for development and small nodes. For very large graphs or existing infrastructure, use `sparql-http` with an external store.
If a config has **no** `store` block at all, the runtime now uses the same daemon-managed **`oxigraph-server`** default. The old embedded **`oxigraph-worker`** backend has been retired; configs that still name it fail fast with a migration message. For very large graphs or existing infrastructure, use `sparql-http` with an external store.
1 change: 0 additions & 1 deletion packages/agent/src/dkg-agent-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,6 @@ export function applyDefaultLargeLiteralStorage(

export function isLocalOxigraphConfig(storeConfig: TripleStoreConfig): boolean {
return storeConfig.backend === 'oxigraph'
|| storeConfig.backend === 'oxigraph-worker'
|| storeConfig.backend === 'oxigraph-persistent';
}

Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/dkg-agent-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,7 +870,7 @@ export interface DKGAgentConfig {
}>;
dataDir?: string;
store?: TripleStore;
/** Triple store backend configuration (e.g. oxigraph-worker, blazegraph). If omitted, defaults to oxigraph-worker when dataDir is set. */
/** Triple store backend configuration (e.g. oxigraph-server runtime view, oxigraph-persistent, blazegraph). If omitted, dataDir agents use oxigraph-persistent. */
storeConfig?: TripleStoreConfig;
/** Out-of-line storage for large public SWM RDF literal object terms. Defaults on for local Oxigraph-backed dataDir stores. */
largeLiteralStorage?: LargeLiteralStorageConfig;
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/dkg-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,11 +503,11 @@ export class DKGAgent extends DKGAgentBase {
const { join } = await import('node:path');
const persistPath = join(config.dataDir, 'store.nq');
store = await createTripleStore({
backend: 'oxigraph-worker',
backend: 'oxigraph-persistent',
options: { path: persistPath },
largeLiteralStorage: defaultLargeLiteralStorage(config.dataDir, config.largeLiteralStorage),
});
log.info(ctx, `Persistent triple store (worker thread): ${persistPath}`);
log.info(ctx, `Persistent triple store: ${persistPath}`);
} else {
store = await createTripleStore({ backend: 'oxigraph' });
log.warn(ctx, `No dataDir — triple store is in-memory (data will be lost on restart)`);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/sync/responder/sync-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export function registerSyncHandler(params: RegisterSyncHandlerParams): void {
warnedPreDispatchCancellation = true;
logWarn(
createOperationContext('sync'),
'Sync responder is using a store backend whose query AbortSignal is pre-dispatch only; in-flight sync queries cannot release responder capacity until the synchronous store call returns. Use oxigraph-worker or an HTTP SPARQL backend for interruptible long-query cancellation.',
'Sync responder is using a store backend whose query AbortSignal is pre-dispatch only; in-flight sync queries cannot release responder capacity until the synchronous store call returns. Use an HTTP SPARQL backend for interruptible long-query cancellation.',
);
}
if (isWorkspace) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ hermesCmd
)
.option(
'--store <backend>',
'Triple-store backend (oxigraph | blazegraph | sparql-http). Validates the URL and persists the store block after setup.',
'Triple-store backend (oxigraph-server | oxigraph | blazegraph | sparql-http). Validates the URL and persists the store block after setup.',
)
.option(
'--store-url <url>',
Expand Down
9 changes: 4 additions & 5 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ program
.option('--role <role>', "Node role: 'edge' (default; personal laptop / behind NAT) or 'core' (24/7 relay / SLA)")
.option(
'--store <backend>',
'Pre-fill the triple-store backend prompt (oxigraph | blazegraph | sparql-http).',
'Pre-fill the triple-store backend prompt (oxigraph-server | oxigraph | blazegraph | sparql-http).',
)
.option(
'--store-url <url>',
Expand Down Expand Up @@ -436,9 +436,8 @@ program
chain: chainSection ?? existing.chain,
auth: { enabled: enableAuth, tokens: existing.auth?.tokens },
// Persist the chosen backend. `storeBlock === null` from the
// wizard means "use the local default" — we explicitly clear any
// existing block so re-running `dkg init` to switch from
// blazegraph back to oxigraph actually applies.
// wizard means "leave the store block omitted"; daemon boot treats
// that as the managed `oxigraph-server` default.
store: storeBlock ?? undefined,
};
await saveConfig(config);
Expand Down Expand Up @@ -475,7 +474,7 @@ program
const endpoint = o?.url ?? o?.queryEndpoint;
return `${storeBlock.backend}${endpoint ? ` (${endpoint})` : ''}`;
})()
: 'oxigraph (local default)'
: 'oxigraph-server (default)'
}`,
);
{
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ program
// remote store. Quad count = null is rare in practice (cached
// every 30 s on the daemon side) so when it shows up the
// operator should treat it as an alert, not a no-op.
const backend = s.storeBackend ?? 'oxigraph-worker';
const backend = s.storeBackend ?? 'oxigraph-server';
if (s.storeUrl) {
const quads = s.storeQuads == null ? 'UNREACHABLE' : `${s.storeQuads.toLocaleString()} quads`;
console.log(` Store: ${backend} (${s.storeUrl}) — ${quads}`);
Expand Down
Loading