-
Notifications
You must be signed in to change notification settings - Fork 10
refactor(cli): split managed Oxigraph supervisor ownership #2201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Jurij89
merged 12 commits into
integration/2052-system-record-sync
from
refactor/2163-oxigraph-supervisor
Aug 9, 2026
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
1e5181c
refactor(cli): split Oxigraph supervisor ownership
9c5dbf1
refactor(cli): split supervisor lifecycle operations
336f8cd
refactor(cli): centralize proven generation lifecycle
91884f1
refactor(cli): model supervisor lifecycle intents
b4e37ee
refactor(cli): centralize supervisor intent decisions
c4a32c3
test(cli): assert supervisor architecture edges
1f8b6c2
chore(storage): add an exports map and stop publishing daemon ownersh…
Jurij89 68acc2e
chore: retrigger review for supervisor ownership
0af278d
fix(storage): reconcile ownership boundary after rebase
246e0e6
refactor(cli): narrow supervisor operation dependencies
e77eb1a
fix(storage): update rebased ownership guard import
f01b16e
test(cli): narrow supervisor boundary invariant
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 8 additions & 3 deletions
11
packages/agent/test/managed-read-gate-through-agent-wrapper-v1.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
22
packages/cli/src/daemon/managed-oxigraph-ownership-bridge.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
| 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(), | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.