diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 979ba6c19a..4e7fa39822 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -120,7 +120,7 @@ jobs: - name: Set up the qualified SQLite runtime uses: actions/setup-node@v6 with: - node-version: "22.18.0" + node-version: "22.22.3" - name: Qualify the TypeScript Effect core run: | @@ -165,7 +165,7 @@ jobs: - name: Set up the qualified Node.js runtime uses: actions/setup-node@v6 with: - node-version: "22.18.0" + node-version: "22.22.3" cache: npm cache-dependency-path: | package-lock.json @@ -225,9 +225,17 @@ jobs: run: | npm ci --ignore-scripts npm run typecheck:control-plane - # The public minimum is qualified for every TypeScript control-plane - # provider, including SQLite statement finalization. - node --no-warnings --experimental-sqlite --experimental-strip-types --test tests/control_plane_ts/*.test.ts + # Default File/control-plane compatibility stays on the public minimum. + # SQLite requires a WAL-fixed embedded driver; its admission rejection + # runs here, while full SQLite suites run on the qualified runtime. + tests=() + for test in tests/control_plane_ts/*.test.ts; do + case "$test" in + */sqlite_authority_store.test.ts|*/local_authority_provider.test.ts|*/authority_provider_parity.test.ts|*/sqlite_capacity.test.ts) continue ;; + esac + tests+=("$test") + done + node --no-warnings --experimental-sqlite --experimental-strip-types --test "${tests[@]}" node-forward-compatibility: needs: changes @@ -486,7 +494,7 @@ jobs: - name: Set up the qualified SQLite runtime uses: actions/setup-node@v6 with: - node-version: "22.18.0" + node-version: "22.22.3" - name: Run SQLite CLI integration run: python -m pytest -q tests/control_plane/test_sqlite_authority_cli.py diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index 6de929c984..262fefd957 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -1161,12 +1161,15 @@ acceptance; it is not completion of lane L. Its head pointer is bounded and operation/cursor lookups are indexed, but it retains full historical projections and counts a covering index for continuity. That count grows with history; current/accessed-row digests are checked, not every historical payload per read. -The published fixed-4-KiB microbenchmark lacks the 64-KiB matched profile, p99, -RSS, logical-WAL-write, recovery and elapsed-soak evidence required above. It -must not be reported as meeting the <=2 history-growth ratio or the ten-day -target. Node 22.18 is the public minimum and current SQLite qualification runtime; -preserve the Node 24 primary lane and Node 26 non-blocking forward probe until -the supported profile changes explicitly. +The qualification entrypoint now separates a small rehearsal from an explicit +64-KiB 10k/100k storage axis, with p99/counts, cold CLI, RSS and a +passed/failed/missing ledger. Unavailable logical/WAL traffic, full-domain, +large-history recovery and elapsed-soak evidence remain holds; runner completion +cannot claim the <=2 growth budget or ten-day qualification. See the +[SQLite qualification commands](../../reference/sqlite-authority-store.md#reproduce-validation). +The public minimum remains Node 22.18 for File; SQLite additionally requires +synchronous finalization and the WAL-reset fix, with Node 22.22.3/SQLite 3.51.3 +as the reference. Node 24 stays primary and Node 26 stays a non-blocking probe. **Migration decision points.** Before the first existing-Goal cutover, freeze one exact source lineage/revision under the authority writer fence, import a diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index e1602677f3..8c9c498538 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -914,10 +914,14 @@ fencing/export 演练与 maintainer review 都通过才可晋升。发布紧凑 **当前证据位置。** #4121 对应第一个节点,仍待维护者接受,不代表 lane L 完成。 其 head pointer 有界,operation/cursor 查询有索引,但保留完整历史 projection,连续性 校验还会统计覆盖索引,因此该成本随历史增长。它验证当前及访问到的 row digest, -不是每次读取都审计全部历史 payload。已发布的固定 4 KiB 微基准尚缺上述 64 KiB 匹配 -profile、p99、RSS、逻辑 WAL 写入、恢复及自然时间 soak 证据,不能宣称满足 <=2 的历史 -增长比值或十天目标。Node 22.18 是公开最低版本,也是当前 SQLite 资格化 runtime;支持 -profile 明确变化前,继续保留 Node 24 主 runtime 与 Node 26 非阻塞 forward probe。 +不是每次读取都审计全部历史 payload。资格入口现在区分小型 rehearsal 与显式 +64 KiB 10k/100k 存储轴,记录 p99/样本数、cold CLI、RSS 和 passed/failed/missing +账本。逻辑/WAL 流量、完整领域负载、大历史恢复和自然时间 soak 的缺口仍阻止晋升, +工具跑完不等于通过 <=2 增长预算或十天资格。参见 +[SQLite 验证命令](../../reference/sqlite-authority-store.md#reproduce-validation)。 +公开 Node 最低版本 22.18 继续用于 File;SQLite 另需同步 finalization 与 WAL-reset +修复,参考组合为 Node 22.22.3/SQLite 3.51.3。Node 24 主 runtime 与 Node 26 +非阻塞 forward probe 保持原合同。 **迁移决策点。** 首次迁移已有 Goal 前,先在 authority writer fence 下冻结精确的源 lineage/revision,导入完整权威快照与保留证明,并独立比较原始 receipt 字段、operation/ diff --git a/docs/reference/sqlite-authority-store.md b/docs/reference/sqlite-authority-store.md index a5acafa574..31f4012267 100644 --- a/docs/reference/sqlite-authority-store.md +++ b/docs/reference/sqlite-authority-store.md @@ -72,11 +72,25 @@ outside this slice's qualification boundary. ## Explicit selection Use an isolated qualification runtime and an empty, unpromoted goal. Set -`RUNTIME_ROOT` to that runtime's absolute directory. SQLite qualification uses the public minimum Node 22.18 runtime. The provider checks that -`DatabaseSync.close()` finalizes prepared statements synchronously before -opening an authority file; older experimental drivers are rejected. In -older Node 22 releases leave closed database handles alive until GC on Windows -and are not supported for this provider. The module loads SQLite only after opt-in. +`RUNTIME_ROOT` to that runtime's absolute directory. The SQLite qualification +reference is **Node 22.22.3 with SQLite 3.51.3**. The provider checks the actual +embedded SQLite version and synchronous prepared-statement finalization before +creating or opening an authority database. Record both `sqlite_version()` and +`sqlite_source_id()`; the Node version alone is insufficient. + +SQLite 3.51.3 and later 3.x releases contain the +[WAL-reset concurrency fix](https://www.sqlite.org/wal.html#the_wal_reset_bug). +The fixed 3.44.x (3.44.6+) and 3.50.x (3.50.7+) backport lines are also admitted +when their driver finalizes statements on close. Unknown version strings or +unverified vendor backports fail closed. Passing these prerequisites does not +qualify the complete D2 profile. + +This intentionally rejects SQLite runtimes previously accepted by the +statement-only probe, including vulnerable drivers shipped with older Node 22 +releases. The public Node minimum remains 22.18 for the default File path; +SQLite requires the additional fix. No provider selection changes and no +fallback to File occur when an explicitly selected SQLite runtime is rejected. +The optional driver is still loaded only after opt-in. From the repository checkout, preview selection: @@ -170,23 +184,93 @@ external service access, cross-host synchronization or promotion authority. ## Reproduce validation +Use the qualified Node executable on PATH, including the Python CLI's managed +Effect runtime. The runner records the actual Node/SQLite/source identity. + ```sh npm ci --ignore-scripts npm run typecheck:control-plane node --no-warnings --experimental-sqlite --experimental-strip-types --test \ tests/control_plane_ts/sqlite_authority_store.test.ts \ - tests/control_plane_ts/local_authority_provider.test.ts + tests/control_plane_ts/local_authority_provider.test.ts \ + tests/control_plane_ts/sqlite_runtime_admission.test.ts \ + tests/control_plane_ts/sqlite_capacity.test.ts python -m pytest -q tests/control_plane/test_sqlite_authority_cli.py +node -e "require('node:fs').mkdirSync('.local', {recursive:true})" +node --no-warnings --experimental-sqlite --experimental-strip-types \ + examples/coordination/sqlite-capacity.ts --profile rehearsal --cli \ + --output .local/sqlite-rehearsal.json node --no-warnings --experimental-sqlite --experimental-strip-types \ - examples/coordination/sqlite-capacity.ts + examples/coordination/sqlite-capacity.ts --profile matched-64k --cli \ + --output .local/sqlite-matched-64k.json ``` -The tests exercise real SQLite, independent writer processes, CAS competition, -original-receipt replay, lost responses, interrupted head publication, schema -rejection, persistent selection, native CLI read/update and planning replay after -Markdown deletion, and archive acknowledgement against the selected store. -The capacity command uses a disposable database with a fixed 4 KiB live -payload, 10k/100k commits, and 100 samples per read workload. It emits measured -latency percentiles and database bytes, then deletes only its temporary -database. These accelerated measurements do not satisfy the separate ten-day -soak, retention, disk-exhaustion, restore or promotion gates. +The no-argument default intentionally replaces the former 4 KiB/100k run with +a small `rehearsal`; full capacity now requires an explicit profile. The default +`rehearsal` creates 100/1,000 commits and checks runner execution, +independent invariants and cleanup; it cannot satisfy formal performance +budgets. The explicit `matched-64k` profile creates separate 10k/100k databases, +serially, with exactly 64 KiB native synthetic projection JSON and at most 4 KiB +of new event/receipt JSON per commit. Each fill write is followed by three head +reads and two indexed historical receipt reads. Both formal groups sample the +last 1,000 commits and their corresponding reads, plus 200 scan-100 samples. +This one-Todo storage axis isolates history growth; it is not the complete +multi-agent/lease/capture workload. + +`--cli` adds 20 formal samples (three in rehearsal) for complete CLI mutation, +status and quota, using fresh Python processes and a newly started managed +Effect runtime for each sample. Shutdown occurs outside the timed interval in +the isolated fixture. `--python` chooses the Python executable. These figures +include process startup but do not drop the OS file cache. Cold Node-only load +and warm actual-provider calls are separate. The provider's normal per-call +connection open/close remains inside warm timing. CLI mutations happen after +the fixed-history measurement; their extra commits are reported separately. + +Reports carry p50/p95/p99 and counts, parent-process RSS, application request +JSON bytes and separate DB/WAL/SHM sizes at the target history. Resource-usage +peak RSS is process-lifetime across both groups; sampled axis RSS is separate, +and CLI child RSS is not measured. Application bytes, final files, SQLite +logical writes, cumulative WAL traffic and physical device writes are different +metrics. The unavailable write-traffic and pure busy-wait metrics remain +`missing`; a final WAL size of zero proves no cumulative-write bound. + +Each axis reserves 5 GiB free space, caps its database at 16 GiB and checks a +2,400-second fill budget. All data are generated in a new temporary directory; +there is no flag to select an existing Goal/runtime for writes. Keep generated +reports in ignored local storage. Failure results survive in the report and +exit nonzero; omitted or incomplete evidence never becomes a pass. An exit +zero with `status=incomplete` means the requested measurements ran, not that +D2 qualified. Formal budget failures must remain visible without changing the +workload or thresholds to obtain a green report. + +The real-process regressions exercise SIGKILL before and after business COMMIT, +lost-response receipt readback, exact head/event/receipt/scan equivalence and +SQLite `max_page_count` exhaustion. These are small disposable-database tests, +not power-loss, operating-system ENOSPC or large-history recovery qualification. +The source uses the shared retained-journal snapshot contract. No checkpoint, +retention deletion, restore-incarnation change or migration format is added. + +### Qualification holds / 资格保留项 + +The report's `passed` rows apply only to their named axis and sample counts. +`failed` measurements remain failed; `missing` rows include cumulative storage +writes, pure lock wait, steady-state RSS proof, the full domain profile, 1 MiB +and 300k headroom, 24-hour consumer lag, large-history recovery, fenced +backup/restore, supported upgrades/rollback, OS/runtime coverage and a real +>=10-day soak. Those holds still block profile promotion. Accelerated volume +never substitutes for elapsed time, and running this command starts no soak. + +SQLite 资格参考使用 Node 22.22.3/SQLite 3.51.3;打开前同时检查实际 WAL 修复版本 +和 statement 关闭行为。公开 Node 最低版本 22.18 继续用于默认 File 路径。显式 +SQLite 选择遇到不合格 runtime 会拒绝,不会改默认 provider 或静默回退。 + +默认无参数命令从旧的 4 KiB/100k 改为小型 `rehearsal`,只验证工具和不变量; +正式 64 KiB、10k/100k 对照必须显式选择 +`matched-64k`。`--cli` 分开记录完整 CLI 冷启动与 warm store,返回分位数、样本数、 +RSS 和文件大小;没有量到的累计 WAL/逻辑写入和纯锁等待保持 missing。 +应用 JSON 字节不能替代底层写入量,WAL 最终归零不能证明没有写入放大。 + +进程中断与 SQLite 容量注入在一次性合成数据库上运行,不等于断电、真实文件系统 +耗尽、长期 consumer backlog 或完整恢复验证。首批测量允许保留 failed/missing; +>=10 天自然时间 soak、迁移和晋升分别评审与授权。本入口不改变持久格式、Todo +语义、默认 provider 或任何活跃 Goal。 diff --git a/examples/coordination/sqlite-capacity-report.ts b/examples/coordination/sqlite-capacity-report.ts new file mode 100644 index 0000000000..f745ed58fa --- /dev/null +++ b/examples/coordination/sqlite-capacity-report.ts @@ -0,0 +1,92 @@ +/** Measurement semantics for the disposable SQLite capacity entrypoint. */ +export interface Latency { + n: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; +} + +export function latency(samples: readonly number[]): Latency { + if (!samples.length || samples.some(value => !Number.isFinite(value) || value < 0)) { + throw new Error("latency requires nonempty finite nonnegative samples"); + } + const sorted = [...samples].sort((left, right) => left - right); + const at = (p: number) => sorted[Math.ceil(sorted.length * p) - 1]!; + return {n: sorted.length, p50_ms: at(.5), p95_ms: at(.95), p99_ms: at(.99)}; +} + +export interface CapacityAxis { + target_commits: number; + completed_commits: number; + projection_json_bytes: number; + sample_window: number; + status: "passed" | "failed"; + warm: Record<"commit" | "head" | "receipt" | "scan_100", Latency> | null; + cold_node: Latency | null; + cold_cli: Record<"mutation" | "status" | "quota", Latency> | null; + application_request_json_bytes: number; + files_at_target: {database_bytes: number; wal_bytes: number; shm_bytes: number} | null; + sampled_peak_rss_bytes: number; + resource_peak_rss_bytes: number; + fill_seconds: number; + cli_commits: number; + cleanup_verified: boolean; + failure?: string; +} + +export interface QualificationRow { + id: string; + status: "passed" | "failed" | "missing"; + scope: string; + observed?: number; + budget?: number; + unit?: string; +} + +/** Thresholds come from RFC 7.2; a rehearsal cannot qualify the full profile. */ +export function capacityLedger(axes: readonly CapacityAxis[], formal: boolean): QualificationRow[] { + const rows: QualificationRow[] = []; + const valid = (value: Latency | undefined, samples: number): boolean => !!value && value.n === samples && + [value.p50_ms, value.p95_ms, value.p99_ms].every(n => Number.isFinite(n) && n >= 0) && + value.p50_ms <= value.p95_ms && value.p95_ms <= value.p99_ms; + const baseline = axes.find(axis => axis.target_commits === 10000); + const final = axes.find(axis => axis.target_commits === 100000); + const ready = formal && axes.length === 2 && baseline?.status === "passed" && final?.status === "passed" && + [baseline, final].every(axis => axis.completed_commits === axis.target_commits && + axis.projection_json_bytes === 65536 && axis.sample_window === 1000 && axis.cleanup_verified && + valid(axis.warm?.commit, 1000) && valid(axis.warm?.head, 3000) && + valid(axis.warm?.receipt, 2000) && valid(axis.warm?.scan_100, 200)); + rows.push({id: "matched_profile_execution", status: axes.some(axis => axis.status === "failed") ? "failed" : + ready ? "passed" : "missing", scope: "complete 64 KiB 10k/100k runs and declared sample counts"}); + const add = (id: string, value: number | undefined, budget: number, unit: "ms" | "ratio" | "delta_ms") => { + if (!ready || value === undefined || !Number.isFinite(value) || (value < 0 && unit !== "delta_ms")) { + rows.push({id, status: "missing", scope: "requires the complete matched 64 KiB 10k/100k profile"}); + } else rows.push({id, status: value <= budget ? "passed" : "failed", scope: "fixed 64 KiB storage axis", + observed: value, budget, unit}); + }; + for (const [key, budget] of [["commit", 100], ["head", 50], ["receipt", 50], ["scan_100", 250]] as const) { + add(`${key}_p95`, final?.warm?.[key].p95_ms, budget, "ms"); + } + for (const key of ["commit", "head", "receipt"] as const) { + const denominator = baseline?.warm?.[key].p95_ms; + add(`${key}_history_growth`, denominator && final?.warm ? final.warm[key].p95_ms / denominator : undefined, 2, "ratio"); + } + add("cold_cli_status_p95", valid(final?.cold_cli?.status, 20) ? final?.cold_cli?.status.p95_ms : undefined, 2000, "ms"); + add("cold_cli_mutation_increment_p95", valid(baseline?.cold_cli?.mutation, 20) && valid(final?.cold_cli?.mutation, 20) && baseline?.cold_cli && final?.cold_cli + ? final.cold_cli.mutation.p95_ms - baseline.cold_cli.mutation.p95_ms : undefined, 200, "delta_ms"); + const scope: Record = { + domain_workload: "eight agents, four writers, leases/capture/archive and the production-scale fixture remain separate", + cumulative_storage_writes: "application input bytes and final files cannot qualify logical writes, WAL traffic or the <=15x budget", + lock_wait_distribution: "no pure busy-handler timing is exposed by this node:sqlite driver", + steady_state_rss: "sampled RSS and per-process peak are observations, not a proof across steady-state windows", + large_history_recovery: "small fault regressions do not qualify bounded recovery of a 100k history", + payload_and_headroom: "1 MiB, 300k and bursts are not launched by this profile", + consumer_lag: "24-hour logical consumer backlog requires its own persisted-cursor test", + restore_upgrade_rollback: "fenced restore lineage and supported upgrade/rollback are not implemented by this harness", + elapsed_soak: "at least ten actual days require a separately authorized recoverable synthetic soak", + os_runtime_matrix: "one local run cannot qualify every supported OS and installed runtime", + promotion: "provider defaults, live migration and D3 remain separately gated", + }; + for (const [id, reason] of Object.entries(scope)) rows.push({id, status: "missing", scope: reason}); + return rows; +} diff --git a/examples/coordination/sqlite-capacity.ts b/examples/coordination/sqlite-capacity.ts index bd3e2beb38..9b491a487b 100644 --- a/examples/coordination/sqlite-capacity.ts +++ b/examples/coordination/sqlite-capacity.ts @@ -1,40 +1,234 @@ -/** Synthetic fixed-live-state qualification; no live goals or external services. */ -import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { performance } from "node:perf_hooks"; -import { SqliteAuthorityStore } from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +/** Disposable SQLite qualification. No live goal or caller-supplied runtime. */ +import assert from "node:assert/strict"; +import {createHash} from "node:crypto"; +import {spawnSync} from "node:child_process"; +import {existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, + statfsSync, statSync, writeFileSync} from "node:fs"; +import {cpus, platform, release, tmpdir, totalmem} from "node:os"; +import {delimiter, dirname, join, relative, sep} from "node:path"; +import {performance} from "node:perf_hooks"; +import {fileURLToPath} from "node:url"; +import {parseArgs} from "node:util"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import type {AuthorityStoreCommit} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {sqliteAuthorityRuntime} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; +import {selectLocalSqliteAuthority} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {engageLegacyCoordinationWriterFence} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +import {authorityProjectionFixture} from "../../tests/control_plane_ts/authority_projection_fixture.ts"; +import {capacityLedger, latency, type CapacityAxis, type QualificationRow} from "./sqlite-capacity-report.ts"; -const directory = await mkdtemp(join(tmpdir(), "loopx-sqlite-capacity-")); -const store = new SqliteAuthorityStore(directory, "synthetic-capacity"); -const projection = {goal_id: "synthetic-capacity", payload: "x".repeat(4096)}; -const percentile = (samples: number[]) => { - samples.sort((a, b) => a - b); - return {p50_ms: samples[Math.floor(samples.length * .5)], p95_ms: samples[Math.floor(samples.length * .95)]}; +const {values: options} = parseArgs({options: { + profile: {type: "string", default: "rehearsal"}, output: {type: "string"}, + python: {type: "string", default: "python3"}, cli: {type: "boolean", default: false}, +}}); +const goal = "sqlite-capacity", formal = options.profile === "matched-64k"; +const script = fileURLToPath(import.meta.url), repository = fileURLToPath(new URL("../../", import.meta.url)); +const sqlite = (() => { + try { return sqliteAuthorityRuntime(); } + catch (error) { + const ledger: QualificationRow[] = [{id: "runtime_admission", status: "failed", scope: "runtime prerequisites; no qualification database opened"}]; + const report = {schema_version: "loopx_sqlite_capacity_report_v1", profile: options.profile, + status: "failed", full_d2_qualified: false, axes: [], ledger, + reason: error instanceof Error ? error.message : "SQLite runtime admission failed"}; + const json = JSON.stringify(report, null, 2) + "\n"; + if (options.output) writeFileSync(options.output, json); + process.stdout.write(json); process.exit(1); + } +})(); // Fail before creating a qualification database. + +assert(["rehearsal", "matched-64k"].includes(options.profile), "profile must be rehearsal or matched-64k"); +const report: Record = { + schema_version: "loopx_sqlite_capacity_report_v1", profile: options.profile, + runtime: sqlite.info, source: sourceIdentity(), + host: {platform: platform(), release: release(), arch: process.arch, + logical_cpus: cpus().length, cpu_model: cpus()[0]?.model ?? "unknown", memory_bytes: totalmem(), + storage_medium: "not_captured"}, + workload: {projection_json_bytes: 65536, event_receipt_max_bytes: 4096, + fill_read_write_ratio: "5:1", read_mix: "three head, oldest receipt, deterministic middle receipt", + records: "one native synthetic Todo; fixed padding isolates history growth; not the full domain profile", + sampling: "last 1000 commits (or entire smaller rehearsal); nearest-rank quantiles", + cold_cli: options.cli ? "new Python process and newly started managed Effect runtime per sample; shutdown outside timing" : "not_requested", + cold_node: "new Node process and import plus first load; OS file cache is not dropped", + warm: "same process, actual provider opens and closes each connection"}, + durability: {journal_mode: "WAL", synchronous: "FULL", altered_for_measurement: false}, + budgets: {per_axis_fill_seconds: 2400, database_bytes: 16 * 1024 ** 3, minimum_free_bytes: 5 * 1024 ** 3}, + metric_limits: {logical_storage_writes: "missing", cumulative_wal_traffic: "missing", + pure_busy_wait: "missing", physical_device_writes: "missing", + rss_scope: "Node parent sampled per axis; resourceUsage peak is process-lifetime across both axes; CLI child RSS is not measured", + application_byte_scope: "fixed-history fill requests only; excludes CLI requests and storage/checkpoint work"}, + full_d2_qualified: false, }; -async function measure(operation: () => Promise) { - const samples: number[] = []; - for (let i = 0; i < 100; i++) { const start = performance.now(); await operation(); samples.push(performance.now() - start); } - return percentile(samples); +const axes: CapacityAxis[] = []; +for (const count of formal ? [10000, 100000] : [100, 1000]) { + const axis = await measureAxis(count); axes.push(axis); + if (axis.status === "failed") break; +} +const ledger = capacityLedger(axes, formal); +const sourceStable = (report.source as Record).source_tree_sha256 === sourceIdentity().source_tree_sha256; +if (!sourceStable) ledger.push({id: "source_stability", status: "failed", scope: "source changed while the profile was running"}); +Object.assign(report, {axes, ledger, source_stable: sourceStable, status: axes.some(axis => axis.status === "failed") || + ledger.some(row => row.status === "failed") ? "failed" : "incomplete"}); +const json = JSON.stringify(report, null, 2) + "\n"; +if (options.output) writeFileSync(options.output, json); +process.stdout.write(json); +if (report.status === "failed") process.exitCode = 1; + +function sourceIdentity(): Record { + const hash = createHash("sha256"); + const visit = (directory: string) => { + for (const entry of readdirSync(directory, {withFileTypes: true}).sort((a, b) => Buffer.compare(Buffer.from(a.name), Buffer.from(b.name)))) { + const path = join(directory, entry.name); + if (entry.isDirectory() && entry.name !== "__pycache__") visit(path); + else if (entry.isFile() && /\.(py|ts|json)$/.test(entry.name)) hash.update(relative(repository, path).split(sep).join("/")).update("\0").update(readFileSync(path)); + } + }; + visit(join(repository, "loopx")); + for (const path of [script, join(dirname(script), "sqlite-capacity-report.ts"), + join(repository, "tests/control_plane_ts/authority_projection_fixture.ts")]) hash.update(relative(repository, path).split(sep).join("/")).update("\0").update(readFileSync(path)); + const git = spawnSync("git", ["rev-parse", "HEAD"], {cwd: repository, encoding: "utf8"}); + return {git_head: git.status === 0 ? git.stdout.trim() : null, source_tree_sha256: hash.digest("hex"), + fingerprint_scope: "LoopX Python/TS/JSON source, capacity entrypoint/report and shared fixture; includes uncommitted source"}; } -try { - let revision: string | null = null; - let commits: number[] = []; - const reports = []; - for (let i = 1; i <= 100000; i++) { + +async function measureAxis(count: number): Promise { + const projection = authorityProjectionFixture(goal, [{todo_id: "todo_capacity", role: "agent", status: "open", + done: false, text: "Capacity 000000", archive_state: "active", claimed_by: "agent-a", task_class: "advancement_task"}], + [], "native", {handoff_mode: "soft_claim", capacity_padding: ""}); + const padding = 65536 - Buffer.byteLength(JSON.stringify(projection)); + assert(padding >= 0); projection.capacity_padding = "p".repeat(padding); + assert.equal(Buffer.byteLength(JSON.stringify(projection)), 65536); + const root = mkdtempSync(join(tmpdir(), "loopx-sqlite-capacity-")); + const runtime = join(root, "runtime"), state = join(root, "state.md"), registry = join(root, "registry.json"); + const directory = join(runtime, "authority", "sqlite-v0"); + const store = new SqliteAuthorityStore(directory, goal); + const axis: CapacityAxis = {target_commits: count, completed_commits: 0, projection_json_bytes: 65536, + sample_window: Math.min(1000, count), status: "failed", warm: null, cold_node: null, cold_cli: null, + application_request_json_bytes: 0, files_at_target: null, sampled_peak_rss_bytes: process.memoryUsage().rss, + resource_peak_rss_bytes: 0, fill_seconds: 0, cli_commits: 0, cleanup_verified: false}; + const commits: number[] = [], heads: number[] = [], receipts: number[] = []; + const timed = async (fn: () => Promise, samples?: number[]): Promise => { + const start = performance.now(), result = await fn(); samples?.push(performance.now() - start); return result; + }; + const environment = {...process.env, PATH: dirname(process.execPath) + delimiter + (process.env.PATH ?? ""), + NODE_OPTIONS: "--experimental-sqlite", TMPDIR: root, TMP: root, TEMP: root}; + let phase = "setup", managedRuntimeUsed = false; + const stopRuntime = () => { + if (!managedRuntimeUsed) return; + const child = spawnSync(options.python!, ["-c", + "from loopx.control_plane.effect_runtime import effect_runtime_result; effect_runtime_result('runtime.shutdown', {}, retry_safe=False)"], + {cwd: repository, env: environment, encoding: "utf8", timeout: 30000}); + assert.equal(child.status, 0, "isolated Effect runtime shutdown failed"); + managedRuntimeUsed = false; + }; + const cli = (args: string[], samples: number[]) => { + phase = `cold_cli_${args[0] === "todo" ? "mutation" : args[0]}`; + const start = performance.now(); managedRuntimeUsed = true; + const child = spawnSync(options.python!, ["-m", "loopx.cli", "--registry", registry, "--format", "json", ...args], + {cwd: repository, env: environment, encoding: "utf8", timeout: 60000, maxBuffer: 4 * 1024 ** 2}); + const elapsed = performance.now() - start; + if (child.status !== 0) process.stderr.write(`${phase}: process exited ${child.status ?? "without an exit code"}\n`); + assert.equal(child.status, 0, `cold CLI ${args[0]} failed`); + const result = JSON.parse(child.stdout) as Record; + assert.notEqual(result.ok, false, `cold CLI ${args[0]} rejected`); + samples.push(elapsed); stopRuntime(); return result; + }; + try { + assert.equal((await selectLocalSqliteAuthority(runtime, goal, true)).ok, true); + writeFileSync(state, "# Synthetic SQLite capacity goal\n\n## Objective\n\nValidate synthetic storage.\n\n## Agent Todo\n"); + writeFileSync(registry, JSON.stringify({schema_version: "0.1", common_runtime_root: runtime, goals: [{id: goal, + repo: root, state_file: "state.md", status: "active", domain: "synthetic-storage-qualification", + adapter: {kind: "read_only_project_map_v0", status: "connected-read-only"}, + coordination: {registered_agents: ["agent-a"]}}]})); + let revision: string | null = null; + phase = "matched_fill"; const start = performance.now(); - const result = await store.commitAuthority({expected_provider_revision: revision, operation_id: `op-${i}`, - next_projection: projection, events: [{kind: "synthetic"}], receipts: [{operation_id: `op-${i}`}]}); - if (result.status !== "applied") throw new Error(JSON.stringify(result)); - commits.push(performance.now() - start); revision = result.provider_revision; - if (i === 10000 || i === 100000) { - const bytes = (await Promise.all((await readdir(directory)).map(async name => (await stat(join(directory, name))).size))).reduce((a, b) => a + b, 0); - reports.push({operations: i, bytes, commit: percentile(commits), - head_read: await measure(async () => {if ((await store.loadAuthority()).status !== "loaded") throw new Error("head missing");}), - receipt_lookup: await measure(async () => {if ((await store.readReceipt("op-5000")).status !== "found") throw new Error("receipt missing");}), - scan_100: await measure(async () => {const page = await store.scanCommitted(String(i - 100), 100); if (page.status !== "page" || page.transactions.length !== 100) throw new Error("scan failed");})}); - commits = []; + for (let i = 1; i <= count; i++) { + const input: AuthorityStoreCommit = {expected_provider_revision: revision, operation_id: `op-${i}`, next_projection: projection, + events: [{kind: "synthetic", ordinal: i, data: "e".repeat(1800)}], + receipts: [{operation_id: `op-${i}`, ordinal: i, data: "r".repeat(1800)}]}; + assert(Buffer.byteLength(JSON.stringify(input.events)) + Buffer.byteLength(JSON.stringify(input.receipts)) <= 4096); + axis.application_request_json_bytes += Buffer.byteLength(JSON.stringify(input)); + const sample = i > count - axis.sample_window; + const result = await timed(() => store.commitAuthority(input), sample ? commits : undefined); + assert.equal(result.status, "applied"); if (result.status !== "applied") throw new Error("commit rejected"); + assert.equal(result.cursor, String(i)); revision = result.provider_revision; axis.completed_commits = i; + for (let j = 0; j < 3; j++) { + const loaded = await timed(() => store.loadAuthority(), sample ? heads : undefined); + assert.equal(loaded.status, "loaded"); if (loaded.status !== "loaded") throw new Error("head unavailable"); + assert.equal(loaded.cursor, String(i)); assert.deepEqual(loaded.head, projection); + } + for (const operation of [1, Math.max(1, Math.floor(i / 2))]) { + const read = await timed(() => store.readReceipt(`op-${operation}`), sample ? receipts : undefined); + assert.equal(read.status, "found"); if (read.status !== "found") throw new Error("receipt unavailable"); + assert.equal(read.receipts[0]?.ordinal, operation); + } + if (i % 100 === 0) { + axis.sampled_peak_rss_bytes = Math.max(axis.sampled_peak_rss_bytes, process.memoryUsage().rss); + const fs = statfsSync(root); + assert(performance.now() - start < 2400000, "axis wall budget exhausted"); + assert(statSync(store.path).size < 16 * 1024 ** 3, "database budget exhausted"); + assert(fs.bavail * fs.bsize > 5 * 1024 ** 3, "disk reserve exhausted"); + } + if (i % 10000 === 0) process.stderr.write(`SQLite capacity: ${i}/${count} commits\n`); + } + axis.fill_seconds = (performance.now() - start) / 1000; + phase = "scan"; + const scans: number[] = []; + for (let i = 0; i < (count >= 10000 ? 200 : 10); i++) { + const page = await timed(() => store.scanCommitted(count === 100 ? null : String(count - 100), 100), scans); + assert.equal(page.status, "page"); if (page.status !== "page") throw new Error("scan unavailable"); + assert.deepEqual(page.transactions.map(row => row.operation_id), Array.from({length: 100}, (_, k) => `op-${count - 99 + k}`)); + for (const row of page.transactions) assert.deepEqual(row.projection, projection); + } + axis.warm = {commit: latency(commits), head: latency(heads), receipt: latency(receipts), scan_100: latency(scans)}; + const bytes = (path: string) => existsSync(path) ? statSync(path).size : 0; + axis.files_at_target = {database_bytes: bytes(store.path), wal_bytes: bytes(store.path + "-wal"), shm_bytes: bytes(store.path + "-shm")}; + phase = "cold_node"; + const cold: number[] = [], samples = formal ? 20 : 3; + for (let i = 0; i < samples; i++) { + const before = performance.now(); + const child = spawnSync(process.execPath, ["--no-warnings", "--experimental-sqlite", "--experimental-strip-types", + "--input-type=module", "-e", + `import {SqliteAuthorityStore} from ${JSON.stringify(new URL("../../loopx/control_plane/coordination/sqlite_authority_store.ts", import.meta.url).href)}; + const result=await new SqliteAuthorityStore(process.argv[1],${JSON.stringify(goal)},{existingOnly:true}).loadAuthority(); + if(result.status!=="loaded")process.exit(1);`, directory], {encoding: "utf8", timeout: 30000}); + assert.equal(child.status, 0, "cold store process failed"); cold.push(performance.now() - before); } + axis.cold_node = latency(cold); + if (options.cli) { + phase = "cold_cli"; + const fence = await engageLegacyCoordinationWriterFence({schema_version: "loopx_legacy_coordination_writer_fence_engage_request_v0", + runtime_root: runtime, goal_id: goal, state_path: state, fence: {schema_version: "loopx_legacy_coordination_writer_fence_v0", + state: "engaged", goal_id: goal, fence_id: "capacity-fixture", source_version: "capacity-fixture", + source_projection_sha256: canonicalAuthoritySha256(projection), expected_shadow_provider_revision: revision}}); + assert.equal(fence.status, "applied"); + const mutation: number[] = [], status: number[] = [], quota: number[] = []; + for (let i = 0; i < samples; i++) { + const statusResult = cli(["status", "--goal-id", goal], status); + const index = statusResult.todo_index as {items?: {todo_id?: string}[]} | undefined; + assert(index?.items?.some(row => row.todo_id === "todo_capacity"), "status lost the canonical Todo"); + const quotaResult = cli(["quota", "should-run", "--goal-id", goal, "--agent-id", "agent-a"], quota); + const selected = quotaResult.selected_todo as {todo_id?: string} | undefined; + const summary = quotaResult.agent_todo_summary as {first_executable_items?: {todo_id?: string}[]} | undefined; + assert(selected?.todo_id === "todo_capacity" || summary?.first_executable_items?.some(row => row.todo_id === "todo_capacity"), + "quota lost the canonical Todo"); + const result = cli(["todo", "update", "--goal-id", goal, "--agent-id", "agent-a", "--todo-id", "todo_capacity", + "--text", `Capacity ${String(i + 1).padStart(6, "0")}`, "--update-operation-id", `cli-${i}`], mutation); + assert.equal(result.source_authority, "sqlite_v0"); axis.cli_commits++; + } + axis.cold_cli = {mutation: latency(mutation), status: latency(status), quota: latency(quota)}; + const final = await store.loadAuthority(); assert.equal(final.status, "loaded"); + if (final.status === "loaded") assert.equal(final.cursor, String(count + samples)); + } + axis.status = "passed"; + } catch (error) { + axis.failure = `${phase}: ${error instanceof Error ? error.name : "unknown failure"}`; + } finally { + try { stopRuntime(); } catch { axis.status = "failed"; axis.failure = "isolated_runtime_cleanup_failed"; } + axis.resource_peak_rss_bytes = process.resourceUsage().maxRSS * 1024; + try { rmSync(root, {recursive: true, force: true}); axis.cleanup_verified = !existsSync(root); } + catch { axis.status = "failed"; axis.failure = "temporary_database_cleanup_failed"; } } - process.stdout.write(JSON.stringify({node: process.version, platform: process.platform, journal: "WAL", synchronous: "FULL", live_payload_bytes: 4096, ten_day_soak: "not_evaluated", reports}, null, 2) + "\n"); -} finally { await rm(directory, {recursive: true, force: true}); } + return axis; +} diff --git a/examples/github-actions-runtime-smoke.py b/examples/github-actions-runtime-smoke.py index a12c49fe34..5fec61dd68 100644 --- a/examples/github-actions-runtime-smoke.py +++ b/examples/github-actions-runtime-smoke.py @@ -22,8 +22,8 @@ MINIMUM_NODE_VERSION = "22.18.0" MINIMUM_NODE_ACTION_VERSION = MINIMUM_NODE_VERSION FORWARD_NODE_VERSION = "26" -# SQLite conformance is qualified on the public minimum runtime. -SQLITE_NODE_VERSION = MINIMUM_NODE_ACTION_VERSION +# SQLite needs both synchronous statement finalization and the WAL-reset fix. +SQLITE_NODE_VERSION = "22.22.3" def declared_major(reference: str) -> str: diff --git a/loopx/control_plane/coordination/sqlite_authority_store.ts b/loopx/control_plane/coordination/sqlite_authority_store.ts index 854ff4a933..be3d7169bc 100644 --- a/loopx/control_plane/coordination/sqlite_authority_store.ts +++ b/loopx/control_plane/coordination/sqlite_authority_store.ts @@ -1,7 +1,7 @@ import {AuthorityJournalScan} from "./authority_journal_scan.ts"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync } from "node:fs"; -import { createRequire } from "node:module"; +import {sqliteAuthorityRuntime} from "./sqlite_runtime.ts"; import { dirname, join } from "node:path"; import type { DatabaseSync } from "node:sqlite"; @@ -17,28 +17,6 @@ const IDENTITY = /^sqlite:[0-9a-f]{32}$/; const REVISION = /^sqlite:([0-9a-f]{32}):([1-9]\d*)$/; const MAX_SEQUENCE = 9223372036854775807n; const ROW_COLUMNS = "CAST(cursor AS TEXT) AS sequence, operation_id, commit_digest, projection, events, receipts"; -const require = createRequire(import.meta.url); -let qualifiedSqlite: typeof import("node:sqlite") | undefined; - -function sqliteDriver(): typeof import("node:sqlite") { - if (qualifiedSqlite) return qualifiedSqlite; - let sqlite: typeof import("node:sqlite"); - try { sqlite = require("node:sqlite") as typeof import("node:sqlite"); } - catch { return protocol("SQLite authority requires a qualified node:sqlite runtime (Node 22.18.0 or newer)"); } - // Early experimental drivers defer statement finalization until GC, leaving - // closed file handles locked on Windows. Probe in memory before touching any - // authority path; never force GC or hide the leak with cleanup retries. - const probe = new sqlite.DatabaseSync(":memory:"); - const statement = probe.prepare("SELECT 1"); - probe.close(); - let finalized = false; - try { statement.get(); } - catch (error) { finalized = (error as NodeJS.ErrnoException).code === "ERR_INVALID_STATE"; } - if (!finalized) protocol("SQLite authority requires synchronous statement finalization on close; use Node 22.18.0 or newer"); - qualifiedSqlite = sqlite; - return sqlite; -} - // The retained transactions are also the durable projection outbox consumed by // scanCommitted. Keeping one row avoids a second copy/ACK authority. Retention // and compaction are deliberately not part of this provider-conformance slice. @@ -89,7 +67,7 @@ export class SqliteAuthorityStore implements AuthorityStore { } private open(write: boolean): DatabaseSync | null { - const sqlite = sqliteDriver(); + const {driver: sqlite} = sqliteAuthorityRuntime(); if (!write && !existsSync(this.path)) return null; if (write && this.existingOnly && !existsSync(this.path)) protocol("Selected SQLite authority database is missing"); if (write) mkdirSync(dirname(this.path), {recursive: true, mode: 0o700}); @@ -152,10 +130,12 @@ export class SqliteAuthorityStore implements AuthorityStore { // Positive unique integer cursors are exactly 1..head iff min=1 and // count=max=head. SQLite counts the compact covering operation-id index; // no retained projection/event/receipt payload is scanned here. + // Cast the scalar result, not the aggregate expression: otherwise SQLite + // cannot use its fast Count opcode and aggregates every retained row. const bounds = db.prepare(`SELECT (SELECT CAST(MIN(cursor) AS TEXT) FROM commits) AS first, (SELECT CAST(MAX(cursor) AS TEXT) FROM commits) AS last, - (SELECT CAST(COUNT(*) AS TEXT) FROM commits) AS count, + CAST((SELECT COUNT(*) FROM commits) AS TEXT) AS count, (SELECT CAST(cursor AS TEXT) FROM head WHERE singleton=1) AS head`).get()!; if (bounds.count === "0" && bounds.head === null) return null; if (bounds.first !== "1" || bounds.last !== bounds.count || bounds.head !== bounds.last) { diff --git a/loopx/control_plane/coordination/sqlite_runtime.ts b/loopx/control_plane/coordination/sqlite_runtime.ts new file mode 100644 index 0000000000..f48f8b6f9b --- /dev/null +++ b/loopx/control_plane/coordination/sqlite_runtime.ts @@ -0,0 +1,49 @@ +import { createRequire } from "node:module"; +import {AuthorityStoreProtocolError} from "./authority_store_codec.ts"; + +const require = createRequire(import.meta.url); + +export interface SqliteRuntimeInfo { + node_version: string; + sqlite_version: string; + sqlite_source_id: string; + synchronous_statement_finalization: boolean; +} + +/** Official SQLite 3 release lines containing the WAL-reset fix. */ +export function hasSqliteWalResetFix(version: string): boolean { + const match = /^(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})$/.exec(version); + if (!match) return false; + const [major, minor, patch] = match.slice(1).map(Number); + if (major !== 3) return false; + return minor > 51 || (minor === 51 && patch >= 3) || + (minor === 50 && patch >= 7) || (minor === 44 && patch >= 6); +} + +let cached: {driver: typeof import("node:sqlite"); info: SqliteRuntimeInfo} | undefined; + +/** Probe only an in-memory database before any authority path is created. */ +export function sqliteAuthorityRuntime(): {driver: typeof import("node:sqlite"); info: SqliteRuntimeInfo} { + if (cached) return cached; + let driver: typeof import("node:sqlite"); + try { driver = require("node:sqlite") as typeof import("node:sqlite"); } + catch { throw new AuthorityStoreProtocolError("SQLite authority requires node:sqlite; use the qualified Node 22.22.3 runtime. File authority uses the public Node minimum"); } + const db = new driver.DatabaseSync(":memory:"); + let version = "", sourceId = ""; + let statement: ReturnType; + try { + const row = db.prepare("SELECT sqlite_version() AS version, sqlite_source_id() AS source_id").get(); + version = String(row?.version ?? ""); sourceId = String(row?.source_id ?? ""); + statement = db.prepare("SELECT 1"); + } finally { db.close(); } + let finalized = false; + try { statement.get(); } + catch (error) { finalized = (error as NodeJS.ErrnoException).code === "ERR_INVALID_STATE"; } + if (!finalized || !hasSqliteWalResetFix(version)) { + throw new AuthorityStoreProtocolError(`SQLite authority runtime is not qualified (SQLite ${version}, synchronous statement finalization=${finalized}); require the WAL-reset fix in SQLite 3.51.3+, 3.50.7+ or 3.44.6+ and finalized statements on close. Use the qualified Node 22.22.3 runtime; File authority uses the public Node minimum`); + } + const info = {node_version: process.version, sqlite_version: version, sqlite_source_id: sourceId, + synchronous_statement_finalization: finalized}; + cached = {driver, info}; + return cached; +} diff --git a/tests/control_plane_ts/sqlite_authority_process.ts b/tests/control_plane_ts/sqlite_authority_process.ts index aa118817cc..a4cefec5b5 100644 --- a/tests/control_plane_ts/sqlite_authority_process.ts +++ b/tests/control_plane_ts/sqlite_authority_process.ts @@ -1,11 +1,33 @@ import { SqliteAuthorityStore } from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import { authorityStoreCommitFixture } from "./authority_store_conformance.ts"; +import {createRequire} from "node:module"; const [directory, operation, revision] = process.argv.slice(2); +if (operation === "crash-before" || operation === "crash-after" || operation === "capacity-full") { + // Instrument only the disposable child. Real SQL and process termination + // remain in the production entrypoint; no fault hooks enter product code. + const {DatabaseSync} = createRequire(import.meta.url)("node:sqlite"); + const original = DatabaseSync.prototype.exec; + let commits = 0, begins = 0; + DatabaseSync.prototype.exec = function(sql: string) { + if (sql === "BEGIN IMMEDIATE" && ++begins === 2 && operation === "capacity-full") { + const pages = this.prepare("PRAGMA page_count").get().page_count; + this.exec(`PRAGMA max_page_count=${pages}`); + } + if (sql === "COMMIT" && ++commits === 2 && operation.startsWith("crash-")) { + if (operation === "crash-before") process.kill(process.pid, "SIGKILL"); + const result = original.call(this, sql); + process.kill(process.pid, "SIGKILL"); return result; + } + return original.call(this, sql); + }; +} const store = new SqliteAuthorityStore(directory!, "goal"); -process.stdout.write("ready\n"); +await new Promise(resolve => process.stdout.write("ready\n", () => resolve())); for await (const _chunk of process.stdin) { - const result = await store.commitAuthority(authorityStoreCommitFixture(revision === "null" ? null : revision!, operation!, 1, 1)); + const input = authorityStoreCommitFixture(revision === "null" ? null : revision!, operation!, 1, 1); + if (operation === "capacity-full") input.next_projection.capacity_padding = "x".repeat(1024 * 1024); + const result = await store.commitAuthority(input); if (operation === "lost-response" && result.status === "applied") process.exit(23); process.stdout.write(`${JSON.stringify(result)}\n`); break; diff --git a/tests/control_plane_ts/sqlite_authority_store.test.ts b/tests/control_plane_ts/sqlite_authority_store.test.ts index 4997b00969..dc6e7c08fe 100644 --- a/tests/control_plane_ts/sqlite_authority_store.test.ts +++ b/tests/control_plane_ts/sqlite_authority_store.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; import { createRequire } from "node:module"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { SqliteAuthorityStore } from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import { authorityStoreCommitFixture, registerAuthorityStoreConformance } from "./authority_store_conformance.ts"; @@ -16,6 +16,86 @@ async function fixture(t: test.TestContext) { } registerAuthorityStoreConformance("SQLite", fixture); +test("SQLite head continuity uses exact fast counting instead of row aggregation", async t => { + const {store} = await fixture(t); + assert.equal((await store.storeIdentity()).status, "available"); + const {DatabaseSync} = createRequire(import.meta.url)("node:sqlite"); + const prepare = DatabaseSync.prototype.prepare; + let boundsSql = ""; + DatabaseSync.prototype.prepare = function(this: import("node:sqlite").DatabaseSync, sql: string) { + if (sql.includes("COUNT(*)")) boundsSql = sql; + return prepare.call(this, sql); + }; + try { assert.equal((await store.loadAuthority()).status, "missing"); } + finally { DatabaseSync.prototype.prepare = prepare; } + assert.notEqual(boundsSql, ""); + const db = new DatabaseSync(store.path); + try { + // Inspect the query captured from the production entrypoint. A covering + // index alone is insufficient: CAST around the aggregate disables SQLite's + // simple-count optimization and visits every retained operation row. + const instructions = db.prepare(`EXPLAIN ${boundsSql}`).all(); + assert(instructions.some((row: {opcode: string}) => row.opcode === "Count"), "head continuity needs SQLite's fast Count path"); + assert(!instructions.some((row: {opcode: string; p4: unknown}) => + row.opcode === "AggStep" && String(row.p4).startsWith("count(")), "COUNT must not use per-row aggregation"); + assert.deepEqual({...db.prepare(boundsSql).get()}, {first: null, last: null, count: "0", head: null}); + } finally { db.close(); } + let revision: string | null = null; + for (let i = 1; i <= 3; i++) { + const result = await store.commitAuthority(authorityStoreCommitFixture(revision, `count-${i}`, i, i)); + assert.equal(result.status, "applied"); if (result.status !== "applied") return; + revision = result.provider_revision; + } + const reader = new DatabaseSync(store.path); + try { + assert.deepEqual({...reader.prepare(boundsSql).get()}, {first: "1", last: "3", count: "3", head: "3"}); + reader.exec("DELETE FROM commits WHERE cursor=2"); + assert.deepEqual({...reader.prepare(boundsSql).get()}, {first: "1", last: "3", count: "2", head: "3"}); + reader.exec("BEGIN; PRAGMA defer_foreign_keys=ON; UPDATE commits SET cursor=9223372036854775807 WHERE cursor=3; UPDATE head SET cursor=9223372036854775807; COMMIT"); + assert.deepEqual({...reader.prepare(boundsSql).get()}, { + first: "1", last: "9223372036854775807", count: "2", head: "9223372036854775807", + }); + } finally { reader.close(); } + const rejected = await store.loadAuthority(); + assert.equal(rejected.status, "failed"); + if (rejected.status === "failed") assert.equal(rejected.reason_code, "provider_protocol_violation"); +}); + +for (const fault of ["crash-before", "crash-after", "capacity-full"]) { + test(`SQLite real-process ${fault} preserves the exact committed state and proof`, {timeout: 30000}, async t => { + const {store} = await fixture(t); + const initial = authorityStoreCommitFixture(null, "seed", 1, 1); + const seeded = await store.commitAuthority(initial); + assert.equal(seeded.status, "applied"); if (seeded.status !== "applied") return; + const child = spawnSync(process.execPath, ["--no-warnings", "--experimental-sqlite", "--experimental-strip-types", + fileURLToPath(new URL("./sqlite_authority_process.ts", import.meta.url)), dirname(store.path), fault, seeded.provider_revision], + {input: "go", encoding: "utf8", timeout: 15000}); + if (fault.startsWith("crash-")) { + assert.equal(child.stdout.trim(), "ready"); + assert.notEqual(child.status, 0); + if (process.platform !== "win32") assert.equal(child.signal, "SIGKILL"); + } else { + assert.equal(child.status, 0, child.stderr); + assert.equal(JSON.parse(child.stdout.split("\n")[1]!).status, "failed"); + } + const applied = fault === "crash-after"; + const attempted = authorityStoreCommitFixture(seeded.provider_revision, fault, 1, 1); + const expected = applied ? [initial, attempted] : [initial]; + const head = await store.loadAuthority(); assert.equal(head.status, "loaded"); + if (head.status !== "loaded") return; + assert.equal(head.cursor, String(expected.length)); + assert.deepEqual(head.head, expected.at(-1)!.next_projection); + const receipt = await store.readReceipt(fault); + assert.equal(receipt.status, applied ? "found" : "missing"); + if (receipt.status === "found") assert.deepEqual(receipt.receipts, attempted.receipts); + const scan = await store.scanCommitted(null, 10); assert.equal(scan.status, "page"); + if (scan.status !== "page") return; + assert.deepEqual(scan.transactions.map(row => ({operation_id: row.operation_id, projection: row.projection, + events: row.events, receipts: row.receipts})), expected.map(input => ({operation_id: input.operation_id, + projection: input.next_projection, events: input.events, receipts: input.receipts}))); + }); +} + for (const changedCursor of [1, 2]) { test(`SQLite validates historical receipt and scan lookahead at cursor ${changedCursor}`, async t => { const {store} = await fixture(t); diff --git a/tests/control_plane_ts/sqlite_capacity.test.ts b/tests/control_plane_ts/sqlite_capacity.test.ts new file mode 100644 index 0000000000..d0f76c24e1 --- /dev/null +++ b/tests/control_plane_ts/sqlite_capacity.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import {spawnSync} from "node:child_process"; +import {mkdtemp, readFile, rm} from "node:fs/promises"; +import {join} from "node:path"; +import {tmpdir} from "node:os"; +import {fileURLToPath} from "node:url"; +import test from "node:test"; +import {capacityLedger, latency, type CapacityAxis} from "../../examples/coordination/sqlite-capacity-report.ts"; + +test("latency has explicit nearest-rank tails and rejects absent or invalid samples", () => { + assert.deepEqual(latency([5, 1, 4, 2, 3]), {n: 5, p50_ms: 3, p95_ms: 5, p99_ms: 5}); + for (const samples of [[], [NaN], [Infinity], [-1]]) assert.throws(() => latency(samples)); +}); + +function axis(count: number): CapacityAxis { + const sample = (n: number) => ({n, p50_ms: 1, p95_ms: 2, p99_ms: 3}); + return {target_commits: count, completed_commits: count, projection_json_bytes: 65536, + sample_window: 1000, status: "passed", cleanup_verified: true, + warm: {commit: sample(1000), head: sample(3000), receipt: sample(2000), scan_100: sample(200)}, + cold_node: sample(20), cold_cli: {mutation: sample(20), status: sample(20), quota: sample(20)}, + application_request_json_bytes: 0, files_at_target: {database_bytes: 0, wal_bytes: 0, shm_bytes: 0}, + sampled_peak_rss_bytes: 0, resource_peak_rss_bytes: 0, fill_seconds: 0, cli_commits: 20}; +} + +test("budget failure remains failed; small rehearsals and unavailable metrics stay missing", () => { + const baseline = axis(10000), final = axis(100000); + final.warm!.head = {n: 3000, p50_ms: 1, p95_ms: 5, p99_ms: 6}; + const rows = capacityLedger([baseline, final], true); + assert.equal(rows.find(row => row.id === "head_history_growth")?.status, "failed"); + assert.equal(rows.find(row => row.id === "head_p95")?.status, "passed"); + assert.equal(rows.find(row => row.id === "cumulative_storage_writes")?.status, "missing"); + assert.equal(rows.find(row => row.id === "elapsed_soak")?.status, "missing"); + assert(capacityLedger([baseline, final], false).every(row => row.status === "missing")); + final.status = "failed"; + assert.equal(capacityLedger([baseline, final], true)[0]?.status, "failed"); +}); + +test("incomplete, wrong-size or malformed evidence cannot satisfy matched budgets", () => { + for (const change of [ + (value: CapacityAxis) => {value.completed_commits--;}, + (value: CapacityAxis) => {value.projection_json_bytes = 4096;}, + (value: CapacityAxis) => {value.warm!.head.n = 0;}, + (value: CapacityAxis) => {value.warm!.head.p95_ms = NaN;}, + (value: CapacityAxis) => {value.warm!.head.p50_ms = 99;}, + (value: CapacityAxis) => {value.cleanup_verified = false;}, + ]) { + const final = axis(100000); change(final); + assert.equal(capacityLedger([axis(10000), final], true).find(row => row.id === "head_p95")?.status, "missing"); + } + const final = axis(100000); final.cold_cli = null; + const rows = capacityLedger([axis(10000), final], true); + assert.equal(rows.find(row => row.id === "head_p95")?.status, "passed"); + assert.equal(rows.find(row => row.id === "cold_cli_status_p95")?.status, "missing"); +}); + +test("CLI latency improvement is retained as a signed difference", () => { + const baseline = axis(10000), final = axis(100000); + baseline.cold_cli!.mutation = {n: 20, p50_ms: 1, p95_ms: 8, p99_ms: 9}; + const row = capacityLedger([baseline, final], true).find(item => item.id === "cold_cli_mutation_increment_p95"); + assert.equal(row?.observed, -6); + assert.equal(row?.status, "passed"); +}); + +test("small capacity entrypoint exercises real SQLite and never claims a full qualification", {timeout: 60000}, async t => { + const directory = await mkdtemp(join(tmpdir(), "sqlite-capacity-report-")); + t.after(() => rm(directory, {recursive: true, force: true})); + const output = join(directory, "report.json"); + const child = spawnSync(process.execPath, ["--no-warnings", "--experimental-sqlite", "--experimental-strip-types", + fileURLToPath(new URL("../../examples/coordination/sqlite-capacity.ts", import.meta.url)), + "--profile", "rehearsal", "--output", output], {encoding: "utf8", timeout: 55000}); + assert.equal(child.status, 0, child.stderr); + const report = JSON.parse(await readFile(output, "utf8")); + assert.equal(report.full_d2_qualified, false); + assert.deepEqual(report.axes.map((row: CapacityAxis) => row.completed_commits), [100, 1000]); + assert(report.axes.every((row: CapacityAxis) => row.cleanup_verified && row.status === "passed")); + assert(report.ledger.every((row: {status: string}) => row.status === "missing")); + assert.equal(report.metric_limits.cumulative_wal_traffic, "missing"); + assert.equal(report.workload.cold_cli, "not_requested"); + assert.equal(report.runtime.sqlite_version.length > 0, true); +}); diff --git a/tests/control_plane_ts/sqlite_runtime_admission.test.ts b/tests/control_plane_ts/sqlite_runtime_admission.test.ts index b3f5edeaa7..f53382913e 100644 --- a/tests/control_plane_ts/sqlite_runtime_admission.test.ts +++ b/tests/control_plane_ts/sqlite_runtime_admission.test.ts @@ -5,15 +5,78 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRequire } from "node:module"; import test from "node:test"; +import {spawnSync} from "node:child_process"; +import {hasSqliteWalResetFix} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; import { SqliteAuthorityStore } from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +test("SQLite WAL admission follows the fixed upstream release lines", () => { + // Independent boundary cases from sqlite.org/wal.html#the_wal_reset_bug. + for (const version of ["3.44.6", "3.44.7", "3.50.7", "3.50.8", "3.51.3", "3.51.4", "3.52.0"]) { + assert.equal(hasSqliteWalResetFix(version), true, version); + } + for (const version of ["3.7.0", "3.44.5", "3.45.0", "3.47.2", "3.49.99", "3.50.6", "3.51.2", + "4.0.0", "3.51.3-vendor", "3.051.3", "3.51", "3.51.3.0", "", "invalid"]) { + assert.equal(hasSqliteWalResetFix(version), false, version); + } +}); + +test("a vulnerable SQLite version is rejected before authority paths are created", async t => { + const root = await mkdtemp(join(tmpdir(), "sqlite-version-admission-")); + t.after(() => rm(root, {recursive: true, force: true})); + const source = new URL("../../loopx/control_plane/coordination/sqlite_authority_store.ts", import.meta.url).href; + const script = ` + import assert from 'node:assert/strict'; + import Module,{createRequire} from 'node:module'; + import {existsSync} from 'node:fs'; + const require=createRequire(import.meta.url), sqlite=require('node:sqlite'), original=Module._load; + class VulnerableVersion extends sqlite.DatabaseSync { + prepare(sql) { + if(sql.includes('sqlite_version()'))return {get:()=>({version:'3.51.2',source_id:'synthetic-vulnerable-driver'})}; + return super.prepare(sql); + } + } + Module._load=function(id,...args){return id==='node:sqlite'?{...sqlite,DatabaseSync:VulnerableVersion}:original.call(this,id,...args)}; + const {SqliteAuthorityStore}=await import(${JSON.stringify(source)}); + const target=process.argv[1]+'/authority', store=new SqliteAuthorityStore(target,'goal'); + for(const result of [await store.storeIdentity(),await store.commitAuthority({operation_id:'must-reject',expected_provider_revision:null,next_projection:{},events:[],receipts:[]})]) { + assert.equal(result.status,'failed');assert.match(result.reason,/SQLite 3\\.51\\.2/); + } + assert.equal(existsSync(target),false); + `; + const child = spawnSync(process.execPath, ["--no-warnings", "--experimental-sqlite", "--experimental-strip-types", + "--input-type=module", "-e", script, root], {encoding: "utf8", timeout: 15000}); + assert.equal(child.status, 0, child.stderr); +}); + +test("default File authority never loads the optional SQLite driver", async t => { + const root = await mkdtemp(join(tmpdir(), "file-without-sqlite-")); + t.after(() => rm(root, {recursive: true, force: true})); + const source = new URL("../../loopx/control_plane/coordination/file_authority_store.ts", import.meta.url).href; + const sqliteSource = new URL("../../loopx/control_plane/coordination/sqlite_authority_store.ts", import.meta.url).href; + const script = ` + import assert from 'node:assert/strict';import Module from 'node:module'; + const original=Module._load; + Module._load=function(id,...args){if(id==='node:sqlite')throw Error('optional driver unavailable');return original.call(this,id,...args)}; + await import(${JSON.stringify(sqliteSource)}); + const {FileAuthorityStore}=await import(${JSON.stringify(source)}); + const store=new FileAuthorityStore(process.argv[1],'goal'); + const result=await store.commitAuthority({operation_id:'file-default',expected_provider_revision:null,next_projection:{goal_id:'goal'},events:[],receipts:[]}); + assert.equal(result.status,'applied');assert.equal((await store.loadAuthority()).status,'loaded'); + `; + const child = spawnSync(process.execPath, ["--no-warnings", "--experimental-strip-types", "--input-type=module", + "-e", script, root], {encoding: "utf8", timeout: 15000}); + assert.equal(child.status, 0, child.stderr); +}); + test("unqualified SQLite runtime fails before creating authority files", async t => { const directory = await mkdtemp(join(tmpdir(), "sqlite-admission-")); t.after(() => rm(directory, {recursive: true, force: true})); let supportsFinalization = false; + let fixedWal = false; try { const {DatabaseSync} = createRequire(import.meta.url)("node:sqlite"); const db = new DatabaseSync(":memory:"); + fixedWal = hasSqliteWalResetFix(String(db.prepare("SELECT sqlite_version() AS version").get()?.version)); const statement = db.prepare("SELECT 1"); db.close(); try { statement.get(); } @@ -22,13 +85,13 @@ test("unqualified SQLite runtime fails before creating authority files", async t const target = join(directory, "authority"); const store = new SqliteAuthorityStore(target, "goal"); const identity = await store.storeIdentity(); - if (supportsFinalization) { + if (supportsFinalization && fixedWal) { assert.equal(identity.status, "available"); // All statements must have been finalized at the public method boundary. await rm(target, {recursive: true}); } else { assert.equal(identity.status, "failed"); - if (identity.status === "failed") assert.match(identity.reason, /Node 22\.14/); + if (identity.status === "failed") assert.match(identity.reason, /Node 22\.22\.3/); assert.equal(existsSync(target), false); assert.equal((await store.commitAuthority({operation_id: "unsupported", expected_provider_revision: null, events: [], receipts: [], next_projection: {}})).status, "failed"); diff --git a/tests/control_plane_ts/todo_continuation.test.ts b/tests/control_plane_ts/todo_continuation.test.ts index bb20663c2f..2314b82ac8 100644 --- a/tests/control_plane_ts/todo_continuation.test.ts +++ b/tests/control_plane_ts/todo_continuation.test.ts @@ -5,6 +5,7 @@ import {join} from "node:path"; import {spawnSync} from "node:child_process"; import test from "node:test"; import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {sqliteAuthorityRuntime} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {TODO_DOMAIN_ITEM_SCHEMA, TODO_DOMAIN_READ_RECORD_SCHEMA, TODO_DOMAIN_RECORD_CONTRACT} from "../../loopx/control_plane/coordination/coordination_state_contract.ts"; @@ -341,12 +342,12 @@ test("cross-agent handoff: agent-a prepares, agent-b adopts", async t => { } }); -function nodeVersionAtLeast(major: number, minor: number): boolean { - const [maj, min] = process.versions.node.split(".").map(Number); - return maj > major || (maj === major && min >= minor); +function sqliteSkipReason(): string | undefined { + try { sqliteAuthorityRuntime(); return undefined; } + catch { return "requires a WAL-fixed SQLite runtime with finalized statements"; } } -test("cross-agent handoff works with SQLite authority", {skip: !nodeVersionAtLeast(22, 18) ? "requires Node 22.18+" : undefined}, async t => { +test("cross-agent handoff works with SQLite authority", {skip: sqliteSkipReason()}, async t => { const root = await mkdtemp(join(tmpdir(), "loopx-continuation-sqlite-")); t.after(() => rm(root, {recursive: true, force: true})); // Set up SQLite authority diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 71b0b27ac9..e000d4451a 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -67,6 +67,8 @@ "loopx/control_plane/work_items/task_lease_lifecycle.ts", "loopx/control_plane/work_items/task_lease_acquire_cli.ts", "examples/nokv-authority-store/live-qualification.ts", + "examples/coordination/sqlite-capacity.ts", + "tests/control_plane_ts/sqlite_capacity.test.ts", "tests/control_plane_ts/effect_program.test.ts", "tests/control_plane_ts/monitor_metadata.test.ts", "tests/control_plane_ts/effect_runtime_errors.test.ts",