Skip to content
Merged
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
15 changes: 14 additions & 1 deletion .github/workflows/system-record-managed-ownership.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ jobs:
test/managed-backend-ownership-dispatch-v1.test.ts \
test/managed-terminal-read-through-index-v1.test.ts \
test/managed-warm-read-composition-v1.test.ts \
test/managed-oxigraph-public-error-contract-v1.test.ts \
test/managed-read-gate-decorator-coverage-v1.test.ts \
test/store-chain-participation-source-v1.test.ts \
test/system-record-controller-disposal-v1.test.ts \
Expand Down Expand Up @@ -214,10 +215,22 @@ jobs:
- name: CLI supervisor conformance
run: |
pnpm --filter @origintrail-official/dkg exec vitest run \
test/oxigraph-server.test.ts
test/oxigraph-server.test.ts \
test/oxigraph-server-module-boundary.test.ts
pnpm --filter @origintrail-official/dkg exec vitest run \
test/oxigraph-managed.test.ts

- name: Packed-tarball exports surface (issue 2165)
# The storage package owns this invariant; CI only orchestrates. The
# gate packs the real tarball, proves resolution (exports map admits
# exactly the public barrel and the documented internal entry), proves
# the SURFACE (no ownership symbol on the packed barrel; the internal
# entry really serves them), and mutation-checks itself both ways —
# map deleted → resolution gate must fail; mint re-exported from the
# barrel → surface gate must fail. See
# packages/storage/scripts/verify-pack-exports.mjs.
run: pnpm --filter @origintrail-official/dkg-storage run test:package-exports

- name: Run live managed-ownership gate
# Downloads and checksum-verifies the pinned v0.5.8 binary, then runs
# the predecessor matrix against a real server.
Expand Down
10 changes: 7 additions & 3 deletions devnet/issue-2052-managed-ownership/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ import { tmpdir } from 'node:os';
import {
SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH,
SYSTEM_RECORD_V1_STATE_GRAPH,
attachManagedOxigraphLeaseV1,
createManagedOxigraphOwnershipControllerV1,
createTripleStore,
type ManagedOxigraphSupervisorHandoffV1,
type TripleStore,
} from '@origintrail-official/dkg-storage';
// The gate exercises the daemon-internal ownership authority on purpose, so it
// uses the internal entry point the daemon itself uses (#2165).
import {
attachManagedOxigraphLeaseV1,
createManagedOxigraphOwnershipControllerV1,
type ManagedOxigraphSupervisorHandoffV1,
} from '@origintrail-official/dkg-storage/internal/managed-oxigraph-ownership-v1';
// Relative source import: the CLI package does not export this subpath, and the
// gate must use the SAME pinned-asset table production uses rather than
// restating a version or checksum that could silently drift from it.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

// Public recognition contract from the BARREL; authority plumbing from the
// internal entry. The split is the boundary this test rides on: the error is
// package API, the mint is not.
import {
GraphSetIndexStore,
ManagedOxigraphBackendUnownedError,
createTripleStore,
type TripleStore,
} from '@origintrail-official/dkg-storage';
import {
attachManagedOxigraphLeaseV1,
createManagedOxigraphOwnershipControllerV1,
createTripleStore,
type ManagedOxigraphOwnershipControllerV1,
type ManagedOxigraphSupervisorHandoffV1,
type TripleStore,
} from '@origintrail-official/dkg-storage';
} from './support/managed-oxigraph-ownership-v1.js';

import { createListContextGraphsCacheInvalidatingStore } from '../src/dkg-agent-base.js';

Expand Down
7 changes: 7 additions & 0 deletions packages/agent/test/support/managed-oxigraph-ownership-v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** Test-only authority bridge for the Agent package's managed read-gate integration test. */
export {
attachManagedOxigraphLeaseV1,
createManagedOxigraphOwnershipControllerV1,
type ManagedOxigraphOwnershipControllerV1,
type ManagedOxigraphSupervisorHandoffV1,
} from '@origintrail-official/dkg-storage/internal/managed-oxigraph-ownership-v1';
22 changes: 22 additions & 0 deletions packages/cli/src/daemon/managed-oxigraph-ownership-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* The daemon's ONE doorway to the storage package's internal ownership
* authority (#2165).
*
* `@origintrail-official/dkg-storage/internal/managed-oxigraph-ownership-v1`
* is a documented internal entry point, not semver-covered public API. That
* makes importing it an exceptional cross-package dependency, and exceptions
* should be auditable in one place rather than scattered until they read as
* ordinary architecture: every daemon module imports this bridge, so when the
* storage-side authority moves or narrows, exactly one file knows.
*
* Re-export ONLY what the daemon actually uses — this bridge is an audit
* surface, not a convenience barrel.
*/
export {
attachManagedOxigraphLeaseV1,
createManagedOxigraphOwnershipControllerV1,
type ManagedOxigraphOwnershipControllerV1,
type ManagedOxigraphOwnershipLeaseV1,
type ManagedOxigraphOwnershipSnapshotV1,
type ManagedOxigraphSupervisorHandoffV1,
} from '@origintrail-official/dkg-storage/internal/managed-oxigraph-ownership-v1';
2 changes: 1 addition & 1 deletion packages/cli/src/daemon/oxigraph-managed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
* can mint and which dies the instant the child does.
*/
import { join } from 'node:path';
import { attachManagedOxigraphLeaseV1 } from '@origintrail-official/dkg-storage';
import { attachManagedOxigraphLeaseV1 } from './managed-oxigraph-ownership-bridge.js';
import { ensureOxigraphBinary } from './oxigraph-binary.js';
import {
startOxigraphServer,
Expand Down
64 changes: 64 additions & 0 deletions packages/cli/src/daemon/oxigraph-server-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { type ChildProcess, spawn } from 'node:child_process';
import type {
ManagedOxigraphOwnershipLeaseV1,
ManagedOxigraphOwnershipSnapshotV1,
ManagedOxigraphSupervisorHandoffV1,
} from './managed-oxigraph-ownership-bridge.js';
import type { OxigraphMemoryLimits } from './oxigraph-launch-strategy.js';
import type { CgroupOomSnapshot } from './oxigraph-memory.js';

export interface OxigraphServerIo {
spawn: typeof spawn;
fetch: typeof globalThis.fetch;
/** Resolve the child or descendant PID that owns the listen socket. */
findListenOwnerPid: (
child: ChildProcess,
port: number,
host: string,
ownership?: 'child-only' | 'process-tree',
) => Promise<number | null>;
readCgroupOomSnapshot: (pid: number) => CgroupOomSnapshot | null;
readCgroupOomKill: (dir: string) => number | null;
}

export interface StartOxigraphServerOptions {
/** Absolute path to the verified `oxigraph` binary. */
binaryPath: string;
/** RocksDB storage directory (`--location`). */
location: string;
/** Bind host. Always loopback in production; overridable for tests. */
host?: string;
port: number;
log?: (msg: string) => void;
readyTimeoutMs?: number;
queryTimeoutS?: number;
readyIntervalMs?: number;
stopGraceMs?: number;
restartBackoffBaseMs?: number;
restartBackoffMaxMs?: number;
/** Maximum gap between the two clean-generation handoff halves. */
handoffAbandonMs?: number;
memoryLimits?: OxigraphMemoryLimits;
platform?: NodeJS.Platform;
io?: Partial<OxigraphServerIo>;
}

/** Read-only ownership surface handed to the rest of the daemon. */
export interface OxigraphServerOwnershipV1 {
readonly lease: ManagedOxigraphOwnershipLeaseV1;
snapshot(): ManagedOxigraphOwnershipSnapshotV1;
/** Recover only the caller-observed generation, coalescing equal requests. */
recoverGeneration(expectedGeneration: string): Promise<string>;
}

export interface OxigraphServerHandle {
readonly host: string;
readonly port: number;
readonly queryEndpoint: string;
readonly updateEndpoint: string;
readonly ownership: OxigraphServerOwnershipV1;
readonly supervisorHandoff: ManagedOxigraphSupervisorHandoffV1;
stop(): Promise<void>;
/** Synchronous best-effort SIGTERM for process-exit handlers. */
killSync(): void;
}
214 changes: 214 additions & 0 deletions packages/cli/src/daemon/oxigraph-server-supervisor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Supervised local Oxigraph server (Release 2, phase 2b lifecycle; used
* opt-in in 2a via `store.backend: 'oxigraph-server'`).
*
* The daemon owns one loopback-bound `oxigraph serve` child. Readiness is not
* enough to mint ownership: the spawned child must also be proven as the
* listener owner. Every lifecycle operation runs through one serialized lock,
* while focused operation modules own startup, revive/recovery, shutdown, and
* clean-generation handoff behavior.
*
* Oxigraph has no native authentication, so this managed mode is restricted
* to loopback. Downstream mutation capabilities bind to the proven child
* generation rather than a forgeable configuration flag.
*/
import { spawn } from 'node:child_process';

import { findListenOwnerPid } from './oxigraph-listen-port.js';
import { createOxigraphLaunchStrategy } from './oxigraph-launch-strategy.js';
import { readCgroupOomKill, readCgroupOomSnapshot } from './oxigraph-memory.js';
import type {
OxigraphServerHandle,
OxigraphServerIo,
StartOxigraphServerOptions,
} from './oxigraph-server-contract.js';
import { OxigraphSupervisorChildV1 } from './oxigraph-supervisor-child.js';
import { OxigraphSupervisorHandoffOperationsV1 } from './oxigraph-supervisor-handoff-operations.js';
import { OxigraphSupervisorGenerationV1 } from './oxigraph-supervisor-generation.js';
import {
normalizePositiveOxigraphIntegerV1,
OxigraphSupervisorTimersV1,
SerializedOxigraphLifecycleV1,
} from './oxigraph-supervisor-lifecycle.js';
import {
createOxigraphServerOwnershipViewV1,
createOxigraphSupervisorOwnershipV1,
} from './oxigraph-supervisor-ownership.js';
import { OxigraphSupervisorProbesV1 } from './oxigraph-supervisor-probes.js';
import { OxigraphSupervisorRecoveryOperationsV1 } from './oxigraph-supervisor-recovery-operations.js';
import { OxigraphSupervisorReviveBackoffV1 } from './oxigraph-supervisor-revive.js';
import { OxigraphSupervisorShutdownOperationsV1 } from './oxigraph-supervisor-shutdown-operations.js';
import { startOxigraphSupervisorV1 } from './oxigraph-supervisor-startup-operations.js';
import { OxigraphSupervisorStateV1 } from './oxigraph-supervisor-state.js';
import { invalidateExternalStoreQuadsCache } from './routes/status.js';

const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_READY_TIMEOUT_MS = 30_000;
const DEFAULT_READY_INTERVAL_MS = 500;
const DEFAULT_STOP_GRACE_MS = 5_000;
const DEFAULT_RESTART_BASE_MS = 1_000;
const DEFAULT_RESTART_MAX_MS = 30_000;

/** Compose and start one managed Oxigraph supervisor. */
export async function createOxigraphServerSupervisorV1(
Comment thread
Jurij89 marked this conversation as resolved.
opts: StartOxigraphServerOptions,
): Promise<OxigraphServerHandle> {
const launchStrategy = createOxigraphLaunchStrategy({
memoryLimits: opts.memoryLimits,
platform: opts.platform ?? process.platform,
parentPid: process.pid,
uid: typeof process.getuid === 'function' ? process.getuid() : -1,
});
const ioOverrides = opts.io ?? {};
const io: OxigraphServerIo = {
spawn: ioOverrides.spawn ?? spawn,
fetch: ioOverrides.fetch ?? globalThis.fetch,
findListenOwnerPid: ioOverrides.findListenOwnerPid ?? findListenOwnerPid,
readCgroupOomSnapshot: ioOverrides.readCgroupOomSnapshot ?? readCgroupOomSnapshot,
readCgroupOomKill: ioOverrides.readCgroupOomKill ?? readCgroupOomKill,
};
const markStoreDown = (): void => {
invalidateExternalStoreQuadsCache();
};
const log = opts.log ?? (() => {});
const host = opts.host ?? DEFAULT_HOST;
const { port } = opts;
const bind = `${host}:${port}`;
const base = `http://${host}:${port}`;
const queryEndpoint = `${base}/query`;
const updateEndpoint = `${base}/update`;
const readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
const readyIntervalMs = opts.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS;
const stopGraceMs = opts.stopGraceMs ?? DEFAULT_STOP_GRACE_MS;
const restartBase = opts.restartBackoffBaseMs ?? DEFAULT_RESTART_BASE_MS;
const restartMax = opts.restartBackoffMaxMs ?? DEFAULT_RESTART_MAX_MS;
const handoffAbandonMs = normalizePositiveOxigraphIntegerV1(opts.handoffAbandonMs)
?? Math.max(readyTimeoutMs, DEFAULT_STOP_GRACE_MS) * 2;
const queryTimeoutS = normalizePositiveOxigraphIntegerV1(opts.queryTimeoutS);

const ownership = createOxigraphSupervisorOwnershipV1({
endpointBound: host === DEFAULT_HOST,
queryEndpoint,
updateEndpoint,
});
const state = new OxigraphSupervisorStateV1();
const reviveBackoff = new OxigraphSupervisorReviveBackoffV1(restartBase, restartMax);
const lifecycle = new SerializedOxigraphLifecycleV1();
const runExclusive = <T>(section: () => Promise<T>): Promise<T> =>
lifecycle.run(section);
const timers = new OxigraphSupervisorTimersV1();

const child = new OxigraphSupervisorChildV1({
binaryPath: opts.binaryPath,
location: opts.location,
bind,
queryTimeoutS,
stopGraceMs,
io,
launchStrategy,
log,
maySpawn: () => state.maySpawnChild(),
});
const probes = new OxigraphSupervisorProbesV1({
host,
port,
queryEndpoint,
readyIntervalMs,
stopGraceMs,
io,
launchStrategy,
currentChild: () => child.current(),
childAlive: () => child.alive(),
});
const generation = new OxigraphSupervisorGenerationV1({
state,
ownership,
child,
probes,
reviveBackoff,
readyTimeoutMs,
readyIntervalMs,
});

const recovery = new OxigraphSupervisorRecoveryOperationsV1({
state,
ownership,
child,
generation,
timers,
reviveBackoff,
bind,
log,
markStoreDown,
runExclusive,
});
child.registerCurrentExitHandler((exited, code, signal) => {
if (!state.mayHandleChildExit()) return;
if (!child.consumeHandoffRetiring(exited)) ownership.invalidate('child-exit');
if (!state.shouldReviveExitedChild()) return;
markStoreDown();
const oomNote = child.classifyOomExit(exited, code, signal)
? ', OOM-killed by cgroup memory cap (or host OOM)'
: '';
recovery.scheduleRevive(
`server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}${oomNote})`,
);
});
const shutdown = new OxigraphSupervisorShutdownOperationsV1({
state,
ownership,
child,
probes,
timers,
bind,
log,
markStoreDown,
runExclusive,
});
const handoff = new OxigraphSupervisorHandoffOperationsV1({
state,
ownership,
child,
generation,
timers,
reviveLocked: () => recovery.reviveLocked(),
scheduleRevive: (reason) => recovery.scheduleRevive(reason),
proveManagedPortRelease: (exited, absoluteDeadlineMs) =>
shutdown.proveManagedPortRelease(exited, absoluteDeadlineMs),
beginTermination: () => shutdown.beginTermination(),
abandonMs: handoffAbandonMs,
bind,
log,
markStoreDown,
runExclusive,
});

await runExclusive(() => startOxigraphSupervisorV1({
beginTermination: () => shutdown.beginTermination(),
stopLocked: () => shutdown.stopLocked(),
child,
generation,
bind,
readyTimeoutMs,
log,
binaryPath: opts.binaryPath,
location: opts.location,
queryTimeoutS,
launchSummary: launchStrategy.logSummary(),
}));

const ownershipView = createOxigraphServerOwnershipViewV1(
ownership,
(expectedGeneration) => recovery.recoverGeneration(expectedGeneration),
);
return {
host,
port,
queryEndpoint,
updateEndpoint,
ownership: ownershipView,
supervisorHandoff: handoff.publicView(),
stop: () => shutdown.stop(),
killSync: () => shutdown.killSync(),
};
}
Loading
Loading