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
10 changes: 10 additions & 0 deletions .github/workflows/system-record-managed-ownership.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ jobs:
- name: Typecheck the ownership gate harness
run: pnpm typecheck:live:system-record-managed-ownership

- name: Typecheck barrier type contracts (issue 2179)
# Compiles the runtime spec's expectTypeOf assertions
# (store-control-barrier-contract-v1.test.ts) and the compile-only
# negative contracts (store-control-barrier-contract-v1.typetest.ts,
# which vitest never executes). Type assertions only PROVE anything
# under a compiler: the package tsconfig includes only src/ and vitest
# does not typecheck, so without this lane they are decorative.
run: pnpm --filter @origintrail-official/dkg-storage run typecheck:type-contracts

- name: Storage unit conformance
run: |
pnpm --filter @origintrail-official/dkg-storage exec vitest run \
Expand Down Expand Up @@ -168,6 +177,7 @@ jobs:
test/changelog-store.test.ts \
test/graph-write-gen.test.ts \
test/system-record-decorator-apply-outcomes-v1.test.ts \
test/system-record-managed-coordinator-v1.test.ts \
test/store-control-barrier-contract-v1.test.ts \
test/store-priority-scheduler.test.ts \
test/store-scheduler-system-record-admission.test.ts \
Expand Down
1 change: 1 addition & 0 deletions packages/storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "tsc",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck:type-contracts": "tsc --noEmit -p tsconfig.typetests.json",
"clean": "rm -rf dist tsconfig.tsbuildinfo"
},
"dependencies": {
Expand Down
13 changes: 2 additions & 11 deletions packages/storage/src/adapters/sparql-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,17 +783,8 @@
// when the controller was BUILT would seal a generation that has since
// been replaced.
//
// Keep the published string callback intact for external/legacy
// coordinator composition. Production lifecycle calls use the
// domain-owned typed methods below, which translate to scheduler keys
// only at this adapter boundary.
barrier: (purpose, transition) =>
externalStorePriorityScheduler.runControlBarrier(
this,
purpose,
transition,
barrierGeneration(),
),
// The only barrier managed composition has (#2179) — rationale on
// SystemRecordLaneControllerTypedDepsV1.
typedBarrier: (kind, transition) =>
runTypedBarrier(SYSTEM_RECORD_BARRIER_KEYS_V1[kind], transition),
setAdmissionActive: (active) => { this.systemRecordAdmissionActive = active; },
Expand Down Expand Up @@ -1428,7 +1419,7 @@
async countQuads(graphUri?: string, options?: QueryOptions): Promise<number> {
const sparql = graphUri
? `SELECT (COUNT(*) AS ?c) WHERE { GRAPH <${escapeUri(graphUri)}> { ?s ?p ?o } }`
: `SELECT (COUNT(*) AS ?c) WHERE { { ?s ?p ?o } UNION { GRAPH ?g { ?s ?p ?o } } }`;

Check notice on line 1422 in packages/storage/src/adapters/sparql-http.ts

View workflow job for this annotation

GitHub Actions / SPARQL scalability lint

sparql-scale-lint R2 graph-var-scan

All-variable triple inside GRAPH ?var enumerates every graph × every triple (the #1597 listGraphs-storm shape). Bind the graph (VALUES/exact IRI), bind a term, or use a FILTER EXISTS existence probe. [pre-existing (grandfathered; fix when touched)] To acknowledge: "sparql-scan-allow: R2 -- <why this is bounded>"

Check notice on line 1422 in packages/storage/src/adapters/sparql-http.ts

View workflow job for this annotation

GitHub Actions / SPARQL scalability lint

sparql-scale-lint R1 unscoped-all-var-scan

All-variable triple pattern with no graph scope scans the ENTIRE store. Scope it to an exact named graph, bind at least one term, or add LIMIT (without ORDER BY). [pre-existing (grandfathered; fix when touched)] To acknowledge: "sparql-scan-allow: R1 -- <why this is bounded>"
const r = await this.query(sparql, {
...options,
source: options?.source ?? 'sparql-http.countQuads',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
createSystemRecordLaneControllerV1,
type SystemRecordApplyOutcomeV1,
type SystemRecordChildHandoffV1,
type SystemRecordLaneBarrierV1,
type SystemRecordLaneControllerTypedDepsV1,
type SystemRecordLaneControllerV1,
type SystemRecordLaneExecutionBindingV1,
type SystemRecordLaneTypedBarrierV1,
Expand All @@ -27,7 +27,10 @@ export interface ManagedSystemRecordCoordinatorOptionsV1 {
proof: unknown,
childGeneration: string,
) => Promise<SystemRecordApplyOutcomeV1>;
readonly barrier: SystemRecordLaneBarrierV1;
/**
* The ONLY barrier the managed path accepts — no string member exists here
* by design; rationale on {@link SystemRecordLaneControllerTypedDepsV1}.
*/
readonly typedBarrier: SystemRecordLaneTypedBarrierV1;
readonly setAdmissionActive: (active: boolean) => void;
}
Expand All @@ -44,7 +47,11 @@ export function createManagedSystemRecordCoordinatorV1(
updateEndpoint: options.updateEndpoint,
resolveClient: options.resolveClient,
});
return createSystemRecordLaneControllerV1({
// The deps literal is typed as the typed-only shape, so the managed path
// resolves the single factory's typed overload: no string member exists
// here to fall back to, and adding one is a type error pinned in the
// typecheck lane.
const typedDeps: SystemRecordLaneControllerTypedDepsV1 = {
lease: options.lease,
handoff: options.handoff,
executor: {
Expand All @@ -53,8 +60,8 @@ export function createManagedSystemRecordCoordinatorV1(
applyVerifiedSettlementBound: (proof, binding, registerRecovery) =>
atomicExecutor.execute(proof, binding, registerRecovery),
},
barrier: options.barrier,
typedBarrier: options.typedBarrier,
setAdmissionActive: options.setAdmissionActive,
});
};
return createSystemRecordLaneControllerV1(typedDeps);
}
4 changes: 4 additions & 0 deletions packages/storage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export {
type SystemRecordDeferralReasonV1,
type SystemRecordLaneActivationV1,
type SystemRecordLaneControllerDepsV1,
// The shared deps base is deliberately NOT published: it is a factoring
// device, not a valid factory input (no barrier of either kind), and a
// public name for an incomplete shape is compatibility burden without use.
type SystemRecordLaneControllerTypedDepsV1,
type SystemRecordLaneControllerV1,
type SystemRecordLaneSessionV1,
type SystemRecordLaneStateV1,
Expand Down
19 changes: 18 additions & 1 deletion packages/storage/src/store-priority-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,16 @@ export type StoreQueuedAdmissionV1 = StoreAdmissionV1 & {
readonly mode: Exclude<StoreAdmissionMode, 'control-barrier'>;
};

/**
* @deprecated The `run()` control-barrier admission is the same unsound
* purpose-string contract as {@link StorePriorityScheduler.runControlBarrier}:
* the run() generic `T` is chosen by each caller while coalescing is keyed by
* `(storeId, purpose-string)`, so a coalesced caller receives the first
* transition's value under its own `T`. Use
* {@link StorePriorityScheduler.runTypedControlBarrier} with a key from
* `createStoreControlBarrierKeyV1`. Removed, together with `runControlBarrier`,
* at the next allowed breaking version boundary.
*/
export type StoreControlBarrierAdmissionV1 = StoreAdmissionV1 & {
readonly mode: 'control-barrier';
};
Expand Down Expand Up @@ -1080,7 +1090,14 @@ export class StorePriorityScheduler extends ObservableScheduler {
* callers that already use the historical free-form string contract.
*
* @deprecated Use {@link runTypedControlBarrier} with a key created by
* `createStoreControlBarrierKeyV1`.
* `createStoreControlBarrierKeyV1` — one key per transition, created once at
* module scope, binds every coalescing caller to that key's result type.
* First-party code no longer calls this method: managed composition
* structurally omits the string barrier (its deps shape has no such
* member). It is removed, together with the `'control-barrier'` `run()`
* admission mode, at the next allowed breaking version boundary. Until
* then behavior is unchanged: both entry points share the coordinator, so
* coalescing, timeout, sealing, quiescence and metrics are identical.
* @param timeoutMs Overrides the default bound for this transition.
*/
runControlBarrier<T>(
Expand Down
133 changes: 133 additions & 0 deletions packages/storage/src/system-record-lane-controller-contract-v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* The system-record lane controller-barrier CONTRACT (#2179): the barrier
* vocabulary, the controller deps shapes, and the typed/legacy normalizer.
* Separated from the materializer so the compatibility boundary can be read
* without navigating lane-session mechanics; the materializer re-exports
* everything here, so its public surface is unchanged.
*/
import type { ManagedOxigraphOwnershipLeaseV1 } from './managed-oxigraph-ownership-v1-internal.js';
import type { SystemRecordMaterializationEpochRotationV1 } from './system-record-materialization-epoch-contract-v1.js';
// Type-only, deliberately: the value-import graph stays acyclic (the
// materializer imports the normalizer VALUE from this module; this module
// imports only TYPES back).
import type {
Comment thread
Jurij89 marked this conversation as resolved.
SystemRecordChildHandoffV1,
SystemRecordTransactionExecutorV1,
} from './system-record-materializer-v1.js';

/**
* Run a lifecycle transition as an exclusive section over the whole store.
*
* Every transition here can invalidate the client or the child, so ordinary
* store traffic must be sealed and drained around it — otherwise a request that
* was already in flight is cut off mid-exchange when the child is stopped, and
* turns into a transport failure or an ambiguous write rather than
* backpressure. The adapter backs this with the scheduler's control barrier;
* tests supply a recording pass-through.
*
* The transition MUST NOT re-enter the store scheduler: it owns the store
* exclusively for the duration, so scheduled work issued from inside it waits
* for a barrier that cannot commit until the work drains. That is why the
* handoff steps are limited to supervisor calls, owned-client drains and
* synchronous cache invalidation.
*/
export type SystemRecordLaneBarrierV1 = <T>(
purpose: string,
transition: () => Promise<T>,
) => Promise<T>;

export interface SystemRecordLaneBarrierResultsV1 {
readonly enable: void | SystemRecordMaterializationEpochRotationV1;
readonly disable: void;
readonly shutdown: void;
readonly recovery: void;
}

export type SystemRecordLaneBarrierKindV1 = keyof SystemRecordLaneBarrierResultsV1;

/** Additive typed lifecycle path; adapters may translate kinds to scheduler keys. */
export type SystemRecordLaneTypedBarrierV1 = <K extends SystemRecordLaneBarrierKindV1>(
kind: K,
transition: () => Promise<SystemRecordLaneBarrierResultsV1[K]>,
) => Promise<SystemRecordLaneBarrierResultsV1[K]>;

/**
* Dependencies shared by every controller composition route. The legacy and
* typed entry points differ ONLY in how the barrier is supplied; everything
* else lives here exactly once, so a future shared dependency (a tracing
* hook, a lifecycle signal) is added in one place and reaches both routes or
* neither.
*/
export interface SystemRecordLaneControllerSharedDepsV1 {
/** The supervisor-issued live ownership lease. Captured, never accepted per-call. */
readonly lease: ManagedOxigraphOwnershipLeaseV1;
readonly handoff: SystemRecordChildHandoffV1;
readonly executor: SystemRecordTransactionExecutorV1;
/**
* Adapter-owned admission latch, driven by the lifecycle's physical state.
* `true` is published synchronously before enable can enqueue its barrier;
* `false` is published only after disable physically commits or the lane is
* terminally unavailable. Merely constructing the controller never calls it.
*/
readonly setAdmissionActive?: (active: boolean) => void;
}

export interface SystemRecordLaneControllerDepsV1 extends SystemRecordLaneControllerSharedDepsV1 {
/**
* Required, not optional. An optional barrier is one that gets forgotten:
* this capability shipped once with a barrier implemented, exported and
* tested but with zero production callers, so the enable path stopped the
* child while ordinary requests were still in flight. Required-ness is the
* compile-time guard against that recurring, which is why retiring the
* string contract does NOT make this member optional before the break.
*
* @deprecated The purpose-string contract cannot carry a sound result type:
* coalescing is keyed by a runtime string while each caller picks a static
* `T`, so a later same-purpose caller receives the first promise under its
* own `T`. Supply {@link typedBarrier}; migrate string barriers to
* `runTypedControlBarrier` with keys from `createStoreControlBarrierKeyV1`.
* First-party composition no longer passes through this interface at all —
* the managed coordinator calls the factory's typed overload with
* {@link SystemRecordLaneControllerTypedDepsV1}, which has no string member
* to fall back to. This member is removed at the next allowed breaking
* version boundary, not before: the removal is source-incompatible for
* external composers.
*/
readonly barrier: SystemRecordLaneBarrierV1;
/**
* When supplied it is ALWAYS used and the string callback above is never
* invoked; the fallback exists only for external composers that predate
* typed keys.
*/
readonly typedBarrier?: SystemRecordLaneTypedBarrierV1;
}

/**
* Typed-only controller deps — the CANONICAL home of the #2179 rationale;
* everywhere else carries at most a one-line pointer here.
*
* The purpose-string barrier contract is unsound: coalescing is keyed by a
* runtime string while each caller picks a static result type, so a later
* same-purpose caller receives the first promise under its own type. This
* shape therefore has NO string `barrier` member — structurally absent, not
* deprecated — so first-party composition cannot regress onto that contract
* without editing this interface, and a type-contract pin fails the
* typecheck lane if a string member is ever re-added. `typedBarrier` being
* required is the same can't-forget guard that `barrier`'s required-ness
* gives external composers on the legacy shape, which keeps the string
* contract alive only until its breaking-version removal.
*/
export interface SystemRecordLaneControllerTypedDepsV1
extends SystemRecordLaneControllerSharedDepsV1 {
readonly typedBarrier: SystemRecordLaneTypedBarrierV1;
}

/** Typed deps pass through; legacy deps wrap `barrier`. Proven by `in`-narrowing, no cast. */
export function normalizeControllerBarrierV1(
deps: SystemRecordLaneControllerDepsV1 | SystemRecordLaneControllerTypedDepsV1,
): SystemRecordLaneTypedBarrierV1 {
if (!('barrier' in deps)) return deps.typedBarrier;
return deps.typedBarrier ??
((kind, transition) => deps.barrier(`system-record.${kind}`, transition));
}

Loading
Loading