diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9ef2e8c..517ed20d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,10 @@ jobs: src/wiki/operations/__tests__/identity-precision.test.ts src/wiki/operations/__tests__/integrity.test.ts src/wiki/index/__tests__/maintenance.test.ts + src/graph/__tests__/candidate-process.test.ts + src/graph/__tests__/fingerprint-store-transactions.test.ts + src/graph/__tests__/store-statements.test.ts + src/graph/__tests__/engine-continuity-resources.test.ts - run: npm run build - name: Smoke test the packed install run: npm run test:hub:package diff --git a/.mex/ROUTER.md b/.mex/ROUTER.md index fa4f336f..557fbfde 100644 --- a/.mex/ROUTER.md +++ b/.mex/ROUTER.md @@ -16,7 +16,7 @@ edges: condition: when starting a task — check the pattern index for a matching pattern file - target: patterns/release-readme-visuals.md condition: when refreshing the release README, badges, community links, or architecture illustrations -last_updated: 2026-09-08 +last_updated: 2026-09-09 --- # Session Bootstrap @@ -116,8 +116,11 @@ Then read this file fully before doing anything else in this session. Ordinary Wiki creation/synthesis retain explicit provenance or capture the operation actor/time/session; completed plain creates replay without duplicate records. Legacy Team/Spec recovery bytes stay unchanged. This is local - implementation on `codex/0.8.1`; Settings heap calibration on the pinned runner remains an - explicit release gate alongside the existing release requirements. + implementation on `codex/0.8.1`. The three missing Settings heap limits are now + calibrated from retained pinned Linux run `34286120355`; a clean enforcing + run after correction remains a release gate. Evidence is in + `docs/design/settings-heap-calibration.json`. The separately accepted Graph + isolation timing calibration below changes only its five owned time limits. - Phase 5 narrows shared artifact I/O to exact bytes by default, with explicit checkout-neutral reads for canonical Team records. Wiki/local revisions remain exact through Inbox preview and recovery; legacy Timeline IDs remain stable @@ -132,6 +135,23 @@ Then read this file fully before doing anything else in this session. the isolated Windows changes. Runner verification is attached to the PR. - Explicit graph status, refresh, and isolated rebuild/recovery commands preserve the last trustworthy index behind one cross-process maintenance lease. +- Graph performance work is implemented separately on + `codex/0.8.1-graph-performance`: outer-owned fingerprint publication, fixed + statement reuse, smaller continuity/reference staging, and disposable Hub + candidate construction. The parent retains validation and publication; + ordinary CLI construction remains in process. Implementation, verification, + and actual Hub measurements are in + `docs/design/code-graph-performance-implementation.md`. This targets the + release branch and is not a published release. +- Corrected graph performance PR #180 run `34288560611` passed Node 22/24, + browser, and Windows/macOS portability checks. Two independent pinned runners + confirmed five material Graph timing failures. The product decision accepts + disposable-worker startup latency for a responsive Hub and compiler-memory + release after jobs. Only those five time limits are recalibrated from retained + pinned evidence in `docs/design/graph-maintenance-timing-calibration.json`; + all memory and other limits stay unchanged. A clean enforcing run on the new + calibrated head remains required. Local same-code memory/latency evidence is + retained in `docs/design/graph-isolation-diagnostic.json`. - Targeted graph get/query/impact consumers use one provenance-bound immutable snapshot and discard output if graph or exact source identity changes. - The graph half of Checkpoint 2 is working in the Project Hub: grouped symbol @@ -228,6 +248,17 @@ Then read this file fully before doing anything else in this session. - Public package-root exports for the provisional team contracts. **Known Issues:** +- Graph construction still rebuilds the full eligible corpus after source + changes and has no peak-memory quota. The branch's actual Hub probe peaked at + about 1,963 MiB combined RSS and retained multi-second pauses around initial + checks and validation/publication despite responsive compiler-phase polling. + Fatal parent exit may leave owned temporary artifacts. The historical + `docs/design/code-graph-resource-investigation.md` explains the bottleneck and + retained-memory experiments; neither it nor the implementation rules out all + native, slow, or repository-specific leaks. Process isolation can increase + aggregate peak memory even while reducing memory left in the surviving Hub. + Corrected PR #180 platform and browser suites passed; runtime release + enforcement remains pending the accepted five-leaf timing calibration. - Graph schema v4 is operational in this checkout, with two partially parsed source files and no failed files. Treat partial graph evidence as degraded and narrow or fall back to source discovery when needed. diff --git a/.mex/patterns/fresh-graph-hub-integration.md b/.mex/patterns/fresh-graph-hub-integration.md index 604f6e5c..89b8cbb4 100644 --- a/.mex/patterns/fresh-graph-hub-integration.md +++ b/.mex/patterns/fresh-graph-hub-integration.md @@ -13,12 +13,12 @@ edges: condition: "when changing Hub routes, sessions, safe projections, SSE, or jobs" - target: "context/architecture.md" condition: "when reviewing the current Graph-to-Hub architecture and boundaries" -last_updated: 2026-09-06 +last_updated: 2026-09-09 mex: id: mx_01M1M0CJKZF3ABC1PQREMA2HYR type: pattern status: promoted - revision: 4 + revision: 5 title: fresh-graph-hub-integration grounds_to: - node: function:9099fdd7e5562f7507cc7e80a6d67f1e @@ -57,7 +57,9 @@ or make Wiki availability appear real. 1. Bind one package-private repository adapter through [`createRepositoryGraphPort()`](mex://function:9099fdd7e5562f7507cc7e80a6d67f1e). Implement the frozen `GraphPort` by calling Lane A modules directly; do not add a package- - root export, raw SQLite callback, command escape hatch, or subprocess. + root export, raw SQLite callback, or public CLI subprocess. Production Hub + maintenance selects Lane A's private candidate process; read paths retain + their existing immutable-session contract. 2. Route every graph-derived response through the complete freshness handshake: inspect a stable `fresh` graph, adopt one inode-bound immutable SQLite session, read graph facts and hash-matched contained source, build the whole @@ -86,6 +88,10 @@ or make Wiki availability appear real. counts, retain the Hub generation/lease checks, and let Lane A's cross-process maintenance lock arbitrate Hub and CLI writers. Rebuild requires the browser confirmation step; neither operation runs during an ordinary read. + The parent owns the lease, candidate validation, publication and rollback. + The private child only constructs the candidate. Wait for process closure + before cleanup, terminate disconnected/cancelled children, and use a parent + lifeline independent of the busy compiler thread. Keep the public API fixed. 9. Derive Wiki availability independently from the registered adapter and its current health. Keep unavailable states honest, and never fill Graph or Wiki gaps with production fixtures. @@ -107,6 +113,14 @@ or make Wiki availability appear real. repair control. - Job progress messages can contain paths or source details. Persist phase and numeric counts only; discard the message. +- Graph counts describe parsed files. Keep persisted progress monotonic, label + those counts explicitly, and show a numeric bar only during parsing. The + final validation/publication phases do not inherit a 100% completion claim. +- Process isolation releases the child's working set when it exits; it is not + a peak RAM quota. Parent status inspections, copies and final publication + still include synchronous work. Measure actual HTTP/cancel latency as well + as aggregate resources. Fatal parent death stops the writer via its lifeline + but may leave private temporary artifacts; do not delete by a guessed prefix. - Successful graph maintenance invalidates cached Search, Code, Health, Jobs, Home, Overview, and capability queries. It does not authorize automatic maintenance later. diff --git a/.mex/patterns/release-performance-gate.md b/.mex/patterns/release-performance-gate.md index 776ee2ec..6d894390 100644 --- a/.mex/patterns/release-performance-gate.md +++ b/.mex/patterns/release-performance-gate.md @@ -11,12 +11,12 @@ edges: condition: "when the regression involves Hub routes, browser sessions, or jobs" - target: "patterns/safe-graph-snapshot-evolution.md" condition: "when changing Graph maintenance or corpus inspection" -last_updated: 2026-09-03 +last_updated: 2026-09-09 mex: id: mx_01M1M0CJNG4SW0WCJF3NB547HE type: pattern status: promoted - revision: 3 + revision: 6 title: release-performance-gate grounds_to: - node: function:5f86a557c717597b411a71a82c000ded @@ -87,9 +87,29 @@ calibration environment. pagination. - Cross-tab job discovery must be event-driven. Do not restore continuous polling to repair cache invalidation. -- Corpus byte caps prevent runaway allocation, but maintenance should also - release source bodies and parser state as each file or bounded compiler batch - completes. +- Corpus byte caps bound admitted input, not process RAM. Compiler dependencies, + AST/checker state, graph materialization and native storage can amplify that + input; measure peak working set separately from post-cleanup retained memory. + A low heap cap can abort extraction rather than make it memory-efficient. +- Synthetic fixture size is not repository scale. The current 48-file largest + fixture missed a real 708-file fingerprint write dominated by SQLite nested + savepoint bookkeeping. Separate fingerprint computation from persistence, + inspect native stacks, and verify both output equality and failure rollback. + See the [2026-09-09 investigation](../../docs/design/code-graph-resource-investigation.md). +- A maintenance child must be included in aggregate CPU/RSS measurements. + Measure real HTTP/cancel responsiveness and parent-owned temporary cleanup; + fatal heap exhaustion bypasses JavaScript `finally` blocks. Process isolation + alone is not a reduction in total work or peak memory. + The release sampler now sums Hub and observed descendants. RSS can count + shared pages twice; sampled CPU can miss short-lived children and final exit + work. The separate graph characterization exercises overlapping projects, + installed declarations, inferred JavaScript and actual executable edits; + it does not recalibrate the frozen release gate. +- Observe maintenance completion through the same bounded event subscription as + the Hub UI. Aggressive status polling opens extra SQLite readers once process + isolation makes the Hub responsive, adding observer work to the operation. + Keep POST-to-terminal elapsed time and child CPU/RSS included, retain the + absolute deadline, and record the observation method in report provenance. - Back-to-back confirmation processes on one hosted VM share CPU steal, throttling, and I/O contention. Keep the raw reports as artifacts, pass only a bounded retry decision between jobs, and make missing or same-allocation @@ -98,6 +118,25 @@ calibration environment. budget. Replacing it with a real lazy workbench should initially fail only that route's owned leaves; do not reinterpret the placeholder budget as a calibration result. +- A schema-valid pinned measurement report can fail enforcement because a new + route's owned budget leaves are missing. Verify its exact raw-report hash, + runner, commit, schema, and samples before using it to calibrate only those + leaves with the frozen formula; an operationally invalid report is not + calibration evidence. A hard `budget_missing` failure suppresses runtime + confirmation, so other first-pass crossings remain unconfirmed. Run ordinary + enforcement again after calibration instead of widening existing budgets or + treating the completed measurement as a green gate. PR #180's Settings-only + correction is recorded in `docs/design/settings-heap-calibration.json`. +- Process isolation can deliberately trade small-job latency for responsiveness + and shorter compiler-memory lifetime. Confirm the regression on independent + pinned runners, distinguish peak aggregate RSS from surviving-parent RSS, and + record explicit acceptance of the product tradeoff before recalibration. + Use the first healthy report and existing formula for only the accepted, + confirmed timing leaves; restore their old values in the frozen-budget hash + projection so every unrelated limit stays protected. A local same-code + comparison explains the cost but never supplies release calibration numbers. + PR #180 retains both runner attempts in + `docs/design/graph-maintenance-timing-calibration.json`. ## Verify @@ -120,6 +159,10 @@ raw samples show a real regression or stable shift. ## Update Scaffold +The 2026-09-09 revision records valid calibration versus successful enforcement, +and the explicitly accepted startup cost of graph process isolation. Existing +grounding fingerprints and `bodyHash` baselines are retained unchanged. + - [ ] Update `.mex/ROUTER.md` when the benchmark surface or pinned runner changes - [ ] Update `docs/design/release-performance-baseline.md` with the retained calibration - [ ] Extend this pattern when a new resource class or bundler trap is discovered diff --git a/.mex/patterns/safe-graph-snapshot-evolution.md b/.mex/patterns/safe-graph-snapshot-evolution.md index 0ad064c8..a1e5b41a 100644 --- a/.mex/patterns/safe-graph-snapshot-evolution.md +++ b/.mex/patterns/safe-graph-snapshot-evolution.md @@ -12,12 +12,12 @@ edges: condition: "when changing the graph data plane or its consumers" - target: "context/conventions.md" condition: "when verifying a graph implementation change" -last_updated: 2026-09-07 +last_updated: 2026-09-09 mex: id: mx_01M1M0CJP81C590FCKTSN5HA3Q type: pattern status: promoted - revision: 3 + revision: 4 title: safe-graph-snapshot-evolution grounds_to: - node: function:57e8797d70bfb28e3f0bb1d6e065a84b @@ -82,6 +82,17 @@ only to explicit maintenance workflows. 10. Normalize evaluator provenance field-by-field. Exclude only explicitly operational snapshot fields; malformed or future snapshot shapes must fail closed instead of disappearing from the semantic graph hash. +11. Use `upsertFingerprintsInOwnedTransaction(db, entries)` only where the caller + owns complete publication rollback and lets every failure reach it. Keep + `FingerprintStore.upsert` and `upsertMany` independently atomic even if an + enclosing caller catches a failure and continues. Preserve duplicate + last-entry behavior, stable references, aliases, constraints, and untouched + rows. Graph publication never accepts a new Markdown grounding baseline. +12. Reuse only a fixed, owned set of synchronous storage statements on their + original connection. Keep dynamic queries and caller-owned iterators + independent; a generic global SQL cache can reuse an active iterator or + retain closed databases. Fingerprint point reads use weak connection + ownership; GraphStore hot statements belong to the store instance. ## Gotchas @@ -131,6 +142,15 @@ only to explicit maintenance workflows. - Wall-clock status timings vary by machine and process-start overhead. Keep the benchmark non-gating, record its environment, and protect correctness with deterministic race, non-mutation, and bounded-work tests. +- An active outer transaction does not establish rollback ownership. Omitting + the nested fingerprint savepoint is safe only for the explicit full-publisher + path. A failed multi-value bucket statement may have written a partial prefix; + standalone writes must restore the entire batch before returning an error. +- Construction in a child process does not transfer the parent's maintenance + lease or publication authority. Wait for child `close` before candidate or + workspace cleanup and recheck directory identity, including full-width + device/inode values. A parent-lifetime pipe can stop a busy child after parent + death; it cannot run cleanup in a parent killed by `SIGKILL`. ## Verify @@ -145,6 +165,11 @@ only to explicit maintenance workflows. - [ ] Source/config symlink escape, retarget, atomic replacement, and ABA tests preserve the prior snapshot. - [ ] Failed parse/stage/publication tests preserve prior facts and metadata. +- [ ] Fingerprint foreign-key and partial bucket failures restore complete + batches when caught inside an outer transaction; publisher failures roll + back all graph facts. Duplicate/ref/alias output remains identical. +- [ ] Reused statements remain correct after rollback and while independent + iterators are active; closing one connection never affects another. - [ ] Candidate replacement, candidate WAL, rollback, maintenance-lock, and first-publication failure tests leave either the prior graph or no graph. - [ ] Ordinary check, doctor, dashboard, and status paths do not change graph @@ -166,6 +191,12 @@ fix that boundary instead of adding timing delays. ## Update Scaffold +The 2026-09-09 update records the owned-transaction and statement-lifetime +contracts from the branch implementation. Existing `grounds_to` fingerprints and +`bodyHash` values are retained unchanged; this upkeep does not authorize baseline +renewal. See `docs/design/code-graph-performance-implementation.md` for evidence +and remaining process/memory limits. + - [ ] Update `.mex/ROUTER.md` when freshness, refresh, or recovery capabilities move from "Not Built" to "Working". - [ ] Add new graph failure modes to this pattern after they are reproduced and diff --git a/docs/design/0.8.1-release-plan.md b/docs/design/0.8.1-release-plan.md index 4a39aa34..728844bc 100644 --- a/docs/design/0.8.1-release-plan.md +++ b/docs/design/0.8.1-release-plan.md @@ -5,7 +5,8 @@ Phase 2b is committed locally as `7f4575f`. Phase 3 (Practical Relays) is committed locally as `02eafd4`. Phase 4 is committed as `d554258`. Phase 5 is implemented; platform verification is tracked by the draft PR checks. Phase 6 has not started. -Settings heap calibration remains a release gate on the pinned Linux runner. +The three Settings heap leaves are calibrated from retained pinned Linux +evidence; enforcing CI after that correction remains a release gate. Branch: `codex/0.8.1`, starting from `4942489`. Draft PR: [#176](https://github.com/mex-memory/mex/pull/176), targeting `main`. @@ -28,7 +29,7 @@ Release publication and telemetry delivery remain outside this checkpoint. | 2. Context-focused Hub | Show all knowledge entities in a graph-first Context view, organized by type, with direct code groundings expanding on selection and details in a sidebar. Retain search/filters and a list alternative. Simplify navigation and retire Specs/Workstreams from the primary experience. | The usual 25–35 entities are visible together, including unlinked sections; recorded relationships and code groundings are useful to explore; old artifacts and links remain readable; route and resource budgets hold. | Locally verified | | 2b. Inbox knowledge contributions | Restore Inbox in navigation; let agents or people propose one addition/correction to existing project knowledge, reuse review/publication, retain evidence, and keep legacy Specs compatible. | A discussion can become a local draft, a Git-shareable Markdown proposal, and an explicitly approved change to existing Wiki knowledge; ordinary GROW upkeep remains available. | Locally verified | | 3. Practical Relays | Open-to-team audience including future active Members; drafts without recipients; shorter agent/CLI saving; persistent sharing and claim-eligibility information; inactive-member recovery. | An engineer can save context now and an eligible teammate can take it later; local publication is never presented as verified delivery. | Locally verified | -| 4. Focused corrections and knowledge reuse | Unknown-file migration classification, contribution/evidence provenance, confirmed fresh-install bugs, configurable agent logging and relevant log retrieval, docs/translations. | Identified defects are fixed; logging policy is understandable; prior notes can inform work without treating every note as accepted knowledge. | Locally verified; Settings heap calibration pending | +| 4. Focused corrections and knowledge reuse | Unknown-file migration classification, contribution/evidence provenance, confirmed fresh-install bugs, configurable agent logging and relevant log retrieval, docs/translations. | Identified defects are fixed; logging policy is understandable; prior notes can inform work without treating every note as accepted knowledge. | Locally verified; Settings calibrated; enforcing CI pending | | 5. Windows hardening | Two independently reviewable changes: narrow CRLF normalization, then preserve full-width inode identity in affected paths. | Content/revision, preview/recovery, replacement, and lock checks pass, including real Windows coverage. | Implemented; platform results in draft PR checks | | 6. Telemetry | Separate final change: namespaced CLI commands and outcomes; server-side allowlisted Hub events excluding IDs, paths, content, search text, and personal data; honor opt-outs. | Payloads, outcomes, opt-outs, and documentation have dedicated verification. | Not started | @@ -551,6 +552,63 @@ production limits, and pinned performance budgets remain unchanged. The four affected local cases and all typechecks pass. Final Windows runner evidence is attached to the draft PR checks. +## Graph performance PR #180: initial CI and correction + +The separate graph performance branch targets `codex/0.8.1` through +[PR #180](https://github.com/mex-memory/mex/pull/180). Its +[implementation report](code-graph-performance-implementation.md) records the +scope and local measurements without changing the Phase 6 telemetry boundary. + +Initial CI run +[`34286120355`](https://github.com/mex-memory/mex/actions/runs/34286120355) +passed both Node 22/24 jobs and the Windows/macOS storage-portability jobs, +including the added graph lifecycle/storage coverage. The run was not green: +the browser job failed two outdated graph progress assertions, and final +performance enforcement failed because the three Settings heap budgets left +open in Phase 4 were still missing. The subsequent browser correction and +corrected-head CI verification are recorded below. + +The retained performance report completed schema-valid measurement on pinned +Ubuntu 24.04 x64 / Node 22.22.0. Its exact raw hash was validated before deriving +only the small/medium/large Settings heap limits: 6,302,493 / 6,304,420 / +6,308,933 bytes, each `ceil(p95 * 1.15)`. The +[calibration record](settings-heap-calibration.json) preserves runner, artifact, +commit, raw-sample, and hash evidence. Only those three previously absent leaves +and calibration-status metadata change; all existing Graph and other budgets +remain frozen. + +The deterministic missing-budget failure suppressed runtime confirmation. +Graph maintenance crossings in that first report remain unconfirmed and must +go through the ordinary enforcing/confirmation jobs after calibration. Final +correction tests and the next exact-head CI result are pending. The phase-local +verification and earlier calibration-pending notes above remain historical +checkpoint records; this update does not claim all CI is green or authorize a +merge or release. + +### Graph timing confirmation and accepted isolation cost + +Corrected run +[`34288560611`](https://github.com/mex-memory/mex/actions/runs/34288560611) +passed Node 22/24, browser, and Windows/macOS portability at `4d6683e`. +Independent pinned allocations confirmed exactly five material Graph timing +failures. The small fixtures expose fresh-process startup and compiler warmup; +the jobs finish successfully. No memory or other metric produced a final +material failure under the existing confirmation rules. + +The product decision accepts that latency for Hub responsiveness and release +of the graph child's memory after construction. Only small/medium Graph refresh +and small/medium/large Graph rebuild limits are recalibrated with the existing +formula from the first healthy corrected report. All other limits, fixtures, +sample counts, and enforcement rules stay unchanged. The +[timing calibration](graph-maintenance-timing-calibration.json) retains both +runner allocations, raw samples, old values, and unowned-budget proof. The +[local isolation diagnostic](graph-isolation-diagnostic.json) records the +separate memory tradeoff and does not supply calibration numbers. + +A clean enforcing run on the calibrated head remains a release gate. This work +continues only on PR #180 into `codex/0.8.1`; nothing is merged and telemetry +remains separate. + ## Scope limits - A small audited Relay follow-up is optional after core Relay fixes; broad diff --git a/docs/design/code-graph-performance-implementation.md b/docs/design/code-graph-performance-implementation.md new file mode 100644 index 00000000..b0a42c63 --- /dev/null +++ b/docs/design/code-graph-performance-implementation.md @@ -0,0 +1,341 @@ +# Code graph performance implementation + +Status: implemented on `codex/0.8.1-graph-performance`, targeting `codex/0.8.1`. +This is branch work, not a published release. Telemetry remains separate. +The [resource investigation](code-graph-resource-investigation.md) records the +historical baseline and temporary experiments; its timings are not a matched +comparison with this final implementation. +The durable [performance results](code-graph-performance-results.json) retain +the final engine comparison, actual Hub report, and repeated-operation fixture +measurements summarized below. + +## Scope + +- **Fingerprint publication:** the engine calls the internal + `upsertFingerprintsInOwnedTransaction(db, entries)` only inside its complete + publication transaction. Omitting the nested corpus savepoint avoids the + measured SQLite memory-journal amplification. Ordinary `upsert` and + `upsertMany` retain independent batch rollback, including when an enclosing + caller catches a failure and continues. Duplicate entries still use the last + value; stable references, constraints, aliases, and unaffected rows survive. +- **Statement ownership:** GraphStore reuses a fixed set of synchronous hot + statements owned by that store and connection. Fingerprint point reads use + one fixed statement per connection through weak ownership. Dynamic queries + and caller iterators remain independent; there is no generic global SQL cache. +- **Construction working set:** continuity planning reads old IDs first. When + every ID survives, it avoids loading old node bodies and fingerprints. Moves + retain only planned aliases and the minimal fingerprints needed for fallback; + the old node/signature collections become collectible before publication. + Fresh nodes come from staging instead of a second database read. Resolved + compiler references become edges directly; unresolved/fallback references and + import bindings remain available for resolution. +- **Hub execution:** refresh/rebuild candidate construction runs in a disposable + Node process. The Hub retains the maintenance lease, validation, source and + database revalidation, and final atomic publication. Numeric parse counts and + phase updates reach Jobs without exposing source contents. Redundant nested + candidate hashes were removed while preserving the complete before/after byte + identity around validation and the final identity check before publication. + +The CLI keeps its in-process default. Changed-source refresh still stages and +rebuilds the full eligible corpus; this does not introduce incremental compiler +indexing, a language rewrite, a new graph schema, or a new public package API. + +## Safety and limits + +The owned fingerprint function requires **full outer rollback on every failure**. +An active transaction alone is insufficient authorization to use it. Code that +catches a write failure and continues must use the ordinary store methods. +Graph refresh does not renew tracked Markdown grounding fingerprints or +`bodyHash` baselines; existing explicit grounding review rules remain intact. + +The parent creates and identity-binds the child's temporary workspace. Child +results cannot authorize publication: they are bounded, validated messages, and +the parent checks the actual candidate after the child closes. Cancellation, +crash, malformed messages, early IPC disconnect, startup failure, and the build +hang guard stop construction without replacing the last trustworthy graph. +Cleanup waits for child `close`, then verifies workspace identity; it refuses to +remove a replaced directory. Full-width device/inode identities remain intact. + +A separate child watchdog reads an inherited parent-lifetime pipe while the +compiler thread is busy. Parent death closes that pipe and stops the child; +watchdog failure also stops construction. A fatal parent `SIGKILL` can still +leave its owned temporary workspace or candidate artifacts because parent +cleanup cannot run. Those artifacts are not published as a successful graph. + +The private message ceiling is 1 MiB. Ordinary progress updates are throttled +to four per second and respect IPC backpressure, with bounded phase/final-count +exceptions. Startup has a 15-second guard, construction a 30-minute hang guard, +and cancellation escalates termination after 500 ms. These are lifecycle bounds, +**not a CPU or peak-memory quota**. Compiler dependency loading can still create +a large transient working set. Process exit releases that child's address space; +it does not prove that every native, slow, or repository-specific leak is absent. + +Initial checks and parent validation/publication still perform synchronous work. +The Hub can pause at those boundaries, and cancellation there waits for the Hub +to handle the request. Cross-platform cleanup and lifecycle cases are included +in the existing macOS/Windows storage-portability matrix without changing its +pinned runners or Node version. The accepted five-leaf timing calibration is +recorded below; memory limits remain unchanged. Local macOS results do not +establish Windows behavior; platform CI remains required evidence. + +## Matched engine comparison + +Baseline and final production engines processed the same frozen MEX source +corpus and seed graph, in separate processes, baseline first. Both indexed all +723 files successfully and produced 33,479 nodes, 88,068 edges, and 23,680 +fingerprints. These instrumented local macOS arm64 runs used Node 22.17.1 and +SQLite 3.50.0. They measure the engine refresh, excluding the outer maintenance +service's candidate-copy/validation/publication and Hub costs. + +| Measurement | Baseline | Final production | +|---|---:|---:| +| Engine-reported refresh duration | 305,712 ms | 71,046 ms | +| Native statement preparations through sync return | 426,415 | 117 | +| Independently sampled peak RSS | 2,126.469 MiB | 1,681.328 MiB | +| JS heap after close and diagnostic GC | 47.710 MiB | 47.609 MiB | + +Refresh took about 77% less time in this pair, or about 4.3 times faster. The +sampled RSS peak was about 21% lower. This is one ordered local pair: compiler +GC, filesystem caches, instrumentation, compression, and host scheduling can +affect both results. It does not establish a portable speedup or memory budget, +and sampled RSS can miss brief peaks. The preparation count stops at sync +return; it excludes the subsequent unchanged refresh. + +Normalized output parity passed for all 12 data tables and four representative +ranked full-text queries. The comparison excludes operational timestamps and +surrogate row identifiers, retaining semantic snapshot provenance; both stores +passed SQLite integrity and foreign-key checks. This checks equivalent graph +meaning and retrieval output, not byte-identical database files. + +## Repeated-operation characterization + +A 180-file multiproject fixture performed five operations in one process, +including real source edits between unchanged checks. The results are retained +in the same [performance results](code-graph-performance-results.json). + +| Operation | Elapsed time | +|---|---:| +| Initial build | 2,234.621 ms | +| Unchanged refresh | 36.687 ms | +| Changed-source refresh | 1,995.934 ms | +| Second unchanged refresh | 31.941 ms | +| Second changed-source refresh | 2,007.576 ms | + +Every build/changed refresh indexed all 180 files; unchanged refreshes indexed +zero. Heap after engine close and three diagnostic GCs stayed between 40.09 and +41.68 MiB; the maximum sampled RSS was 509.47 MiB. This provides bounded +same-process retention evidence for this fixture, not a long soak or proof that +all leaks are absent. Forced GC is measurement-only and is not used by the +production graph path. + +## Actual Hub observation + +The local probe used the built Hub on MEX's repository with 723 parsed files, +made a source edit, completed refresh, then made another edit and cancelled the +next refresh during observed parsing. Environment: macOS arm64, Node 22.17.1, +SQLite 3.50.0. The Hub section of the durable +[performance results](code-graph-performance-results.json) was generated at +`2026-09-08T22:05:08.938Z`; CLI SHA-256 +`cad49214c1e692a5641a38a5503ba7005bfdce927afb14a2e4b39a71135e8fd8`. + +| Observation | Measured result | +|---|---:| +| Completed changed-source refresh | 92.107 s | +| Completed-run job polling p95 / maximum | 3.944 ms / 4.673 s | +| Completed-run session polling p95 / maximum | 2.796 ms / 4.673 s | +| Largest job poll across both runs | 5.238 s, during initial checks | +| Cancel HTTP acknowledgement during parsing | 8.036 ms | +| Cancel request to observed `interrupted` state | 135.598 ms | +| Peak sampled Hub + descendant RSS | 2,058,682,368 bytes (about 1,963 MiB) | +| Peak sampled RSS in observed publication phase, parent only | 335,282,176 bytes (about 320 MiB) | +| HTTP failures / requests across both runs | 0 / 1,489 | + +The completed refresh published a different graph and read back as fresh. The +cancelled run left the live graph byte-identical to the completed graph, kept +the second edit stale, stopped the child, and left no candidate files. Only the +Hub parent remained at the end of each run, and the probe stopped it afterward. + +Polling ran every 100 ms with at most one outstanding request per route, a +10-second HTTP deadline, and a 180-second job deadline. Busy-route ticks were +skipped rather than queued; p95 describes completed requests, so the maxima and +observed pauses matter. Validation/publication also produced multi-second +pauses. Phase attribution comes from client observations and can itself be +delayed by a blocked Hub. RSS sums concurrently observed processes and may count +shared pages twice; it is neither V8 live heap nor macOS physical footprint. +This single local run establishes Hub behavior, not a Hub speedup percentage or +a portable resource budget. The matched engine comparison above measures a +different scope. Pinned release results remain separate evidence. + +## Verification before PR #180 + +Focused regressions cover fingerprint output parity and full-width references, +foreign-key and partial bucket-write failures, catch-and-continue batch rollback, +outer publication rollback, statement reuse after rollback, iterator isolation, +unchanged-ID continuity without old-body/fingerprint reads, and rename/move alias +chains. Existing graph integrity and compiler tests retain ambiguity and output +checks. Candidate regressions cover process failure, cancellation, parent death, +workspace replacement, protocol failures, and bounded progress delivery. + +The root suite ran 216 files / 3,396 tests: 3,376 passed, one skipped, and 19 +initially failed. All 19 then passed on targeted reruns: eight 15-second Team +contract timeouts were rerun with one worker; ten process/listener permission +failures were rerun with the necessary sandbox permissions; one exact writer +inventory assertion was updated for identity-bound candidate workspace cleanup. +No timeout, safety detector, or release budget was relaxed. The final candidate +suite passed 12 tests in 6.98 seconds; the final focused integration rerun passed +56 tests. The full Hub web suite passed 428 tests across 18 files in 16.19 seconds. +Typecheck, build, and graph evaluation passed. Emitted package-root declarations +match the prior surface apart from comments. + +Final package smoke passed, including refresh/rebuild through the packed graph +entrypoint. The asset-only release gate passed. Rebuilt public declarations +again matched apart from comments, and the real checkout's graph SHA-256 stayed +unchanged. At this local checkpoint, Windows lifecycle and pinned runtime release +CI were still pending the PR; the local runs did not replace those gates. The +subsequent CI result and correction are recorded below. + +## Verify Checklist + +These are the seven exact items from `.mex/context/conventions.md`, with status +and evidence at the pre-PR local checkpoint. Current correction status follows. + +1. **The public `src/index.ts` surface and emitted declarations changed only if compatibility work explicitly requires it.** + **Pass:** `src/index.ts` is unchanged; emitted declarations differ only in + comments. The owned fingerprint writer stays outside the emitted class API. +2. **Ordinary reads remain non-mutating; writes have explicit authority, containment, revision, and failure-atomicity checks.** + **Pass:** immutable reads remain unchanged. Parent-held maintenance authority, + complete publication rollback, independent standalone batches, and exact + candidate/workspace identity checks retain their regression coverage. +3. **Inputs, scans, output, diagnostics, and retained local state remain deterministically bounded.** + **Qualified:** corpus, diagnostic, message, and progress bounds remain + enforced. There is no peak-RSS quota, and repeated fatal-parent exits can + leave owned temporary artifacts. Their total accumulation is not claimed to + be globally bounded. Windows lifecycle behavior still requires platform CI. +4. **Focused tests for the changed boundary pass, followed by `npm run typecheck`; run `npm test` without a concurrent build when full coverage is warranted.** + **Pass with recorded reruns:** root suite and targeted rerun results are + recorded above; typecheck passed. Final candidate and full Hub web suites + passed in sequential runs. +5. **Run `npm run build` for packaging/Hub/asset changes and `npm run eval:test` for graph evaluator or protocol changes.** + **Pass:** build, evaluation, packed refresh/rebuild smoke, and the asset-only + release gate passed. The local measurements above do not replace pinned + runtime release performance gates. +6. **`git diff --check` passes and only intended tracked paths changed; generated `.mex/*.db*`, `.mex/local/`, `dist/`, and unrelated worktree files remain unstaged.** + **Pass at review:** whitespace checks passed, generated state remained + unstaged, and the live graph SHA-256 stayed unchanged. Recheck the exact + staged file list before commit. +7. **Graph/Wiki protocol shapes, stable error codes, ordering, cursors, and non-mutation contracts remain covered when affected.** + **Pass:** existing protocol/integrity/evaluator tests, adapter/maintenance + regressions, full Hub web tests, and normalized graph/FTS parity cover these + boundaries. + +## PR #180 initial CI and correction, 2026-09-09 + +[CI run `34286120355`](https://github.com/mex-memory/mex/actions/runs/34286120355) +passed the Node 22/24 jobs and both Windows/macOS storage-portability jobs. This +supplies platform evidence for that tested head, including the new graph +lifecycle and storage suites. The workflow was not green: the browser job +failed two outdated graph progress assertions, and the final performance job +failed on three missing Settings heap budgets from Phase 4. The subsequent +browser correction and its CI verification are recorded below. + +The pinned Linux measurement completed and validated against the report schema, +but the deterministic `budget_missing` failure prevented runtime confirmation. +Its Graph maintenance crossings remain first-pass observations, not a confirmed +regression or a runtime pass. Existing Graph budgets remain unchanged. + +The Settings correction uses only the three missing heap leaves and +calibration-status metadata, with the existing `ceil(p95 * 1.15)` formula. Small, +medium, and large limits are 6,302,493, 6,304,420, and 6,308,933 bytes. The +[retained calibration record](settings-heap-calibration.json) identifies the +schema-valid Ubuntu 24.04 x64 / Node 22.22.0 report, all five raw samples, the +verified raw-report SHA-256, and the unchanged unowned-budget hash. All prior +asset/runtime limits, material thresholds, and confirmation rules remain +frozen. A clean enforcing CI result on the corrected head remains required. + +The correction also skips tree-sitter runtime initialization when there are no +uncached supported grammars to load. Explicit initialization, Python/Rust, and +compiler fallback retain their existing behavior. The benchmark now observes +the same job event stream as the Hub UI instead of making a status/database read +every 20 ms. Its report records that observation method; POST-to-terminal timing +and complete Hub/child resource sampling remain included. Stream frames, total +bytes, event count, and the original absolute deadline are bounded. + +Local correction verification passed: two affected browser cases, eight UI phase +cases, 48 grammar/extractor cases, 17 compiler/containment/Python/Rust engine cases, +70 release-gate/engine cases, and 70 event-stream/measurement/gate cases. Typecheck +and build passed. A real temporary Hub completed all four Graph/Wiki maintenance +kinds through the event-stream observer. These are integration checks, not new +calibration measurements. The earlier 723-file measurements describe `6e12e6d`; +this correction does not replace that evidence with unmeasured speedup claims. + +### Confirmed timing tradeoff and scoped calibration + +[Corrected CI run `34288560611`](https://github.com/mex-memory/mex/actions/runs/34288560611) +passed both Node versions, browser checks, and Windows/macOS portability at +`4d6683e`. Two independent pinned runners confirmed five material maintenance +timing failures: small/medium Graph refresh and small/medium/large rebuild. +No memory or other metric produced a final material failure, although some +repeated memory crossings remained advisory under the existing rules. + +The product decision accepts the small-job latency cost of a disposable worker +for Hub responsiveness and release of the compiler's address space afterward. +Only those five timing limits now use the first healthy corrected report's +`ceil(p95 * 1.15)` candidates: 1634/1689 ms for small refresh/rebuild, +1842/1820 ms for medium refresh/rebuild, and 2278 ms for large rebuild. +Large refresh and all memory, asset, read, Wiki, fixture, sample-count, and +confirmation constraints stay unchanged. The +[calibration record](graph-maintenance-timing-calibration.json) retains both +runner allocations and samples plus the prior values and unowned-budget hash. +A clean enforcing run on the new calibrated head remains required. + +Local calibration verification passed 46 release-budget tests, 14 CI +orchestration/workflow tests, 11 measurement tests, and all typechecks. Six +measurement cases initially hit sandbox process/listener restrictions and +passed when rerun with those permissions. Independent review verified the +exact five numeric changes, every retained sample/hash, and restoration of the +complete prior budget object. Both historical reports pass when replayed with +the accepted limits; that replay does not replace fresh enforcing CI. + +The [same-code local diagnostic](graph-isolation-diagnostic.json) ran two +warmups and three measured four-file rebuilds per execution mode, with the same +parent validation. Median elapsed time was 879.5 ms in a disposable process +versus 220.2 ms in a warm parent. Validation took about 19–21 ms in both modes; +process spawn to parsing took 490–494 ms in measured runs. All ten graph core +digests and counts matched. There was no fixed success-path cancellation wait. + +Isolation alone raised sampled aggregate peak RSS in that diagnostic from +320.6 to 403.7 MiB, while the surviving parent's final RSS fell from 320.5 to +164.0 MiB. The compiler process exists only during construction and exits before +parent validation/publication. Each process has its own runtime/heap; a child +watchdog thread observes parent death while compilation is synchronous. This +is a bounded Mac comparison in fixed mode order, not a universal peak-memory +reduction, pinned calibration, or proof about every possible memory leak. + +### Current Verify Checklist for the CI correction and calibration + +1. **The public `src/index.ts` surface and emitted declarations changed only if compatibility work explicitly requires it.** + **Pass:** no public exports or declaration shapes changed; the correction's + declaration comparison passed. Calibration changes only evidence and limits. +2. **Ordinary reads remain non-mutating; writes have explicit authority, containment, revision, and failure-atomicity checks.** + **Pass:** corrected-head Node/platform CI covered publication and rollback. + Calibration leaves those authorities and all production write paths intact. +3. **Inputs, scans, output, diagnostics, and retained local state remain deterministically bounded.** + **Qualified:** only the five explicitly accepted timing limits change after + independent confirmation. All memory and other limits remain guarded. No + peak-RSS quota or globally bounded fatal-parent artifact accumulation is + claimed. Corrected Windows/macOS suites pass; final enforcement is pending. +4. **Focused tests for the changed boundary pass, followed by `npm run typecheck`; run `npm test` without a concurrent build when full coverage is warranted.** + **Pass locally:** 71 focused budget, orchestration, workflow, and measurement + tests pass, followed by all typechecks. Corrected-head full Node suites pass; + final calibrated-head CI remains required. +5. **Run `npm run build` for packaging/Hub/asset changes and `npm run eval:test` for graph evaluator or protocol changes.** + **Pass for unchanged production:** corrected-head CI builds and evaluator + checks pass; this calibration changes no packaging or protocol. A clean + enforcing run with the accepted limits remains required. +6. **`git diff --check` passes and only intended tracked paths changed; generated `.mex/*.db*`, `.mex/local/`, `dist/`, and unrelated worktree files remain unstaged.** + **Pass at scoped review:** whitespace and exact five-leaf ownership checks + pass; only budgets/tests and retained documentation/evidence changed. Existing + grounding baselines and generated state remain unstaged and unchanged. +7. **Graph/Wiki protocol shapes, stable error codes, ordering, cursors, and non-mutation contracts remain covered when affected.** + **Pass:** corrected-head browser, compiler, and protocol checks pass. This + calibration changes no protocol, ranking, cursor, or read behavior. diff --git a/docs/design/code-graph-performance-results.json b/docs/design/code-graph-performance-results.json new file mode 100644 index 00000000..d320fd31 --- /dev/null +++ b/docs/design/code-graph-performance-results.json @@ -0,0 +1,1880 @@ +{ + "schemaVersion": 1, + "kind": "local-graph-performance-verification-not-release-budget", + "generatedAt": "2026-09-08T22:24:18.557Z", + "source": { + "baseBranch": "codex/0.8.1", + "baseCommit": "35bfc075888706d6b105b39b0d711ff7a588b551", + "implementationBranch": "codex/0.8.1-graph-performance", + "files": 723, + "seedGraphSha256": "0b6dd1f8e161f19e4a15111f125a83d2245f01c01e791f21e38f2c8a4a61af41" + }, + "environment": { + "os": "macOS 26.0.1", + "platform": "darwin", + "arch": "arm64", + "node": "v22.17.1", + "v8": "12.4.254.21-node.27", + "sqlite": "3.50.0", + "typescript": "5.9.3" + }, + "matchedEngineComparison": { + "method": { + "sameFrozenSourceAndInstalledDependencies": true, + "sameIndependentSeedCopy": true, + "order": [ + "baseline", + "production" + ], + "repetitions": 1, + "baseline": "Instrumented engine from the base commit, preserved by the resource investigation.", + "production": "Final engine implementation bundle; subsequent edits affected only process progress, tests, and documentation.", + "scope": "engine.sync and its internal publication transaction; excludes Hub maintenance copy, validation and atomic file replacement", + "nativePreparation": "Counts DatabaseSync.prepare calls, including diagnostic/connection startup work", + "externalRssSampleIntervalMs": 1000, + "diagnosticGc": "Three forced GCs 100ms apart after engine close; never a production GC policy", + "supervisorDeadlineSeconds": 600, + "supervisorSampledRssGuardMiB": 4608, + "telemetryDisabled": true, + "limitations": [ + "One sequential local pair, not a randomized or pinned-runner estimate. Host caching, compression, scheduling and instrumentation affect timings and RSS.", + "RSS is resident set size, not physical footprint or V8 live heap. Sampling can miss peaks; process lifetime max RSS is recorded separately.", + "Engine-only measurements exclude Hub parent overhead; use actualHub for combined resource observations." + ] + }, + "runs": { + "baseline": { + "engineBundleSha256": "ea161e06667f8c879c543f48775bbb8eaeffb19d435ef72f71f0d2048c86a18b", + "result": { + "filesIndexed": 723, + "nodesCreated": 33479, + "edgesCreated": 88068, + "health": { + "ok": 723, + "partial": 0, + "failed": 0 + }, + "durationMs": 305712 + }, + "syncAndCloseMs": 306141.91766700003, + "totalProcessWorkMs": 306945.795917, + "nativePreparesAtSyncReturn": 426415, + "nativePreparesIncludingUnchanged": 427165, + "cpuAtSyncReturnMs": 299349.417, + "sampledPeakRssBytes": 2229764096, + "rssSampleCount": 307, + "processLifetimeMaxRssBytes": 2232270848, + "postGcMemory": { + "heapUsed": 50027096, + "heapTotal": 93093888, + "rss": 1612546048, + "external": 36239690, + "arrayBuffers": 559012 + }, + "unchanged": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 283, + "elapsedMs": 283.1332499999553 + } + }, + "production": { + "engineBundleSha256": "730b719ef06bf5b782a271d4eaef6642ccf22fbcbd0d381121bbb7f7b4eae380", + "result": { + "filesIndexed": 723, + "nodesCreated": 33479, + "edgesCreated": 88068, + "health": { + "ok": 723, + "partial": 0, + "failed": 0 + }, + "durationMs": 71046 + }, + "syncAndCloseMs": 71424.51699999999, + "totalProcessWorkMs": 72307.769625, + "nativePreparesAtSyncReturn": 117, + "nativePreparesIncludingUnchanged": 144, + "cpuAtSyncReturnMs": 83937.832, + "sampledPeakRssBytes": 1763000320, + "rssSampleCount": 73, + "processLifetimeMaxRssBytes": 1782480896, + "postGcMemory": { + "heapUsed": 49921200, + "heapTotal": 92045312, + "rss": 875397120, + "external": 36239690, + "arrayBuffers": 559012 + }, + "unchanged": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 306, + "elapsedMs": 306.12812499998836 + } + } + }, + "parity": { + "allEqual": true, + "tableCount": 12, + "normalizedTables": { + "_mex_grounded_source": { + "rows": 13, + "sha256": "4799b3e1f4401c61c30b72a02cb99180821acf42d89152025109a9eabba8ed6e" + }, + "edges": { + "rows": 88068, + "sha256": "d01913ff25625d699514a1f436f8e4bf930821a2542dd9ccb0e6dc734915cd88" + }, + "files": { + "rows": 723, + "sha256": "0443de844e9b476418c6d4bcf3f62153868dd63c87bd448e7e432250b12c9c96" + }, + "import_bindings": { + "rows": 11231, + "sha256": "093845203504383cab13844e2c274a286320718c2ee98cc530237829c7575bb0" + }, + "lsh_buckets": { + "rows": 757760, + "sha256": "8e2312ef5bc83526de8aaeb874c1aa15f27c43b260faab34f3db9f2a98dd73ef" + }, + "node_aliases": { + "rows": 1707, + "sha256": "0457b6cae3c4056f9b7f49745b48fda05e9e8e469783a43d014121051d47fc6b" + }, + "node_fingerprints": { + "rows": 23680, + "sha256": "aa817aba04adf15d0f05fe0e2f0e7dd09dc5fdba493b438de2f5d13a13824f34" + }, + "nodes": { + "rows": 33479, + "sha256": "53a1e1a1e99d84d13b54ea155f23f77c8ac2638462b4122e48229b7913e0025d" + }, + "project_metadata": { + "rows": 9, + "sha256": "45f3b5c9950772a8b6e39688bef164bd76edae182427216c2cf54a7d7c405644" + }, + "schema_versions": { + "rows": 4, + "sha256": "9ce76e9408211f3a56c2221a40c8a6624513dd0bd7214a1cbcb08375393c83f5" + }, + "source_chunks": { + "rows": 4627, + "sha256": "c08acb6cf46ec3bf8f09eaa44112adacc718e15b7070b131069b1860eeca35d9" + }, + "unresolved_refs": { + "rows": 88413, + "sha256": "13530185dbdedd99c28d1e974b9d9c6ffee5ff7292350bd421212eca7af6d647" + } + }, + "excludedColumns": { + "nodes": [ + "updated_at" + ], + "edges": [ + "id" + ], + "files": [ + "indexed_at" + ], + "unresolved_refs": [ + "id" + ], + "node_aliases": [ + "created_at" + ], + "source_chunks": [ + "id" + ], + "schema_versions": [ + "applied_at" + ], + "project_metadata": [ + "updated_at" + ], + "node_fingerprints": [ + "ref" + ] + }, + "snapshotExcluded": [ + "indexedAt", + "lastSuccessfulIndexAt" + ], + "ftsQueries": [ + { + "query": "graph", + "nodeResults": 30, + "sourceResults": 30, + "normalizedResultSha256": "3e3adc420981adb786d15ddaac5f6dc27dbe4a217628ed1af011248f7ee55063" + }, + { + "query": "compiler", + "nodeResults": 30, + "sourceResults": 30, + "normalizedResultSha256": "5d9c548e91281e9f77365dbccb4fbf0a27407c2bba22bb13092b13756b5bf6da" + }, + { + "query": "relay", + "nodeResults": 30, + "sourceResults": 30, + "normalizedResultSha256": "b26f68daf1dfdc46325b2e4ba04c5c5394ed43acaa4f68db61fec5fdd2edb4ab" + }, + { + "query": "fingerprint", + "nodeResults": 30, + "sourceResults": 30, + "normalizedResultSha256": "64562514151f05b4041b10e92ceba19735188e8baa0beafa1fdc14261585976b" + } + ], + "checks": { + "final-baseline": { + "quickCheck": [ + { + "quick_check": "ok" + } + ], + "foreignKeyViolations": [] + }, + "final-production": { + "quickCheck": [ + { + "quick_check": "ok" + } + ], + "foreignKeyViolations": [] + } + } + } + }, + "actualHub": { + "schemaVersion": 1, + "probe": "mex-actual-hub-responsiveness", + "generatedAt": "2026-09-08T22:05:08.938Z", + "environment": { + "node": "v22.17.1", + "sqlite": "3.50.0", + "platform": "darwin", + "arch": "arm64" + }, + "measurement": { + "pollIntervalMs": 100, + "maxOutstandingPerRoute": 1, + "httpTimeoutMs": 10000, + "jobDeadlineMs": 180000, + "totalGuardMs": 300000, + "scope": "Hub and observed descendants", + "rss": "sum of concurrent resident sets; shared pages may be counted twice", + "cpu": "observed process lifetime deltas; exited final CPU and short-lived children can be missed", + "phaseAttribution": "Client-observed job phases. Outstanding request intervals survive phase changes. A blocked Hub can delay phase observation; start/end phase and overlapping observed phases do not establish exact internal phase timing." + }, + "checks": { + "beforeRefreshStale": true, + "successPublishedDifferentGraph": true, + "completedRefreshFresh": true, + "secondEditStale": true, + "cancelledChildCleanup": true, + "cancelPreservedLiveGraph": true, + "cancelledSourceRemainsStale": true, + "cancelLeftNoCandidateFiles": true + }, + "runs": [ + { + "kind": "changed_refresh_complete", + "elapsedMs": 92106.517, + "terminalState": "succeeded", + "cancelledDuringObservedParse": false, + "phases": [ + { + "phase": "queued", + "atMs": 0, + "pendingRequests": 0, + "oldestPendingMs": 0 + }, + { + "phase": "running", + "atMs": 3655.682, + "pendingRequests": 1, + "oldestPendingMs": 6.501 + }, + { + "phase": "stage", + "atMs": 8424.315, + "pendingRequests": 1, + "oldestPendingMs": 4673.206 + }, + { + "phase": "parse", + "atMs": 9696.769, + "pendingRequests": 1, + "oldestPendingMs": 2.637 + }, + { + "phase": "resolve", + "atMs": 57382.893, + "pendingRequests": 1, + "oldestPendingMs": 2.052 + }, + { + "phase": "validate", + "atMs": 80937.995, + "pendingRequests": 1, + "oldestPendingMs": 99.001 + }, + { + "phase": "publish", + "atMs": 88190.874, + "pendingRequests": 1, + "oldestPendingMs": 2818.593 + }, + { + "phase": "complete", + "atMs": 92088.212, + "pendingRequests": 1, + "oldestPendingMs": 73.151 + } + ], + "resources": { + "peakRssBytes": 2058682368, + "cpuMs": 110340, + "maxProcesses": 2, + "finalProcesses": 1 + }, + "http": { + "requests": 1455, + "failures": 0, + "pendingAfterDrain": 0, + "skippedTicks": { + "session": 152, + "job": 152, + "create": 0, + "cancel": 0 + }, + "httpStatuses": { + "200": 1454, + "202": 1 + }, + "failureCodes": {}, + "overall": { + "session": { + "count": 727, + "p95Ms": 2.796, + "maxMs": 4673.005 + }, + "job": { + "count": 727, + "p95Ms": 3.944, + "maxMs": 4673.211 + }, + "create": { + "count": 1, + "p95Ms": 3642.652, + "maxMs": 3642.652 + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "byStartPhase": { + "queued": { + "session": { + "count": 1, + "p95Ms": 4.122, + "maxMs": 4.122 + }, + "job": { + "count": 1, + "p95Ms": 6.54, + "maxMs": 6.54 + }, + "create": { + "count": 1, + "p95Ms": 3642.652, + "maxMs": 3642.652 + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "running": { + "session": { + "count": 1, + "p95Ms": 4673.005, + "maxMs": 4673.005 + }, + "job": { + "count": 1, + "p95Ms": 4673.211, + "maxMs": 4673.211 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "stage": { + "session": { + "count": 13, + "p95Ms": 3.872, + "maxMs": 3.872 + }, + "job": { + "count": 13, + "p95Ms": 3.675, + "maxMs": 3.675 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "parse": { + "session": { + "count": 474, + "p95Ms": 2.826, + "maxMs": 6.984 + }, + "job": { + "count": 474, + "p95Ms": 3.935, + "maxMs": 19.934 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "resolve": { + "session": { + "count": 233, + "p95Ms": 1.6, + "maxMs": 98.375 + }, + "job": { + "count": 233, + "p95Ms": 3.755, + "maxMs": 99.128 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "validate": { + "session": { + "count": 3, + "p95Ms": 4270.511, + "maxMs": 4270.511 + }, + "job": { + "count": 3, + "p95Ms": 4271.106, + "maxMs": 4271.106 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "publish": { + "session": { + "count": 2, + "p95Ms": 3636.542, + "maxMs": 3636.542 + }, + "job": { + "count": 2, + "p95Ms": 3637.615, + "maxMs": 3637.615 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "complete": { + "session": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "job": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + } + }, + "byOverlappedPhase": { + "queued": { + "session": { + "count": 1, + "p95Ms": 4.122, + "maxMs": 4.122 + }, + "job": { + "count": 1, + "p95Ms": 6.54, + "maxMs": 6.54 + }, + "create": { + "count": 1, + "p95Ms": 3642.652, + "maxMs": 3642.652 + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "running": { + "session": { + "count": 1, + "p95Ms": 4673.005, + "maxMs": 4673.005 + }, + "job": { + "count": 2, + "p95Ms": 4673.211, + "maxMs": 4673.211 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "stage": { + "session": { + "count": 13, + "p95Ms": 3.872, + "maxMs": 3.872 + }, + "job": { + "count": 14, + "p95Ms": 4673.211, + "maxMs": 4673.211 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "parse": { + "session": { + "count": 474, + "p95Ms": 2.826, + "maxMs": 6.984 + }, + "job": { + "count": 475, + "p95Ms": 3.935, + "maxMs": 19.934 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "resolve": { + "session": { + "count": 233, + "p95Ms": 1.6, + "maxMs": 98.375 + }, + "job": { + "count": 234, + "p95Ms": 3.755, + "maxMs": 99.128 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "validate": { + "session": { + "count": 3, + "p95Ms": 4270.511, + "maxMs": 4270.511 + }, + "job": { + "count": 4, + "p95Ms": 4271.106, + "maxMs": 4271.106 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "publish": { + "session": { + "count": 2, + "p95Ms": 3636.542, + "maxMs": 3636.542 + }, + "job": { + "count": 3, + "p95Ms": 3637.615, + "maxMs": 3637.615 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "complete": { + "session": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "job": { + "count": 1, + "p95Ms": 73.189, + "maxMs": 73.189 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + } + }, + "slowestRequests": [ + { + "kind": "job", + "startMs": 3751.109, + "phaseAtStart": "running", + "httpStatus": 200, + "ok": true, + "finishMs": 8424.32, + "phaseAtEnd": "stage", + "latencyMs": 4673.211, + "observedPhasesOverlapped": [ + "running", + "stage" + ] + }, + { + "kind": "session", + "startMs": 3750.591, + "phaseAtStart": "running", + "httpStatus": 200, + "ok": true, + "finishMs": 8423.596, + "phaseAtEnd": "running", + "latencyMs": 4673.005, + "observedPhasesOverlapped": [ + "running" + ] + }, + { + "kind": "job", + "startMs": 81040.688, + "phaseAtStart": "validate", + "httpStatus": 200, + "ok": true, + "finishMs": 85311.794, + "phaseAtEnd": "validate", + "latencyMs": 4271.106, + "observedPhasesOverlapped": [ + "validate" + ] + }, + { + "kind": "session", + "startMs": 81040.484, + "phaseAtStart": "validate", + "httpStatus": 200, + "ok": true, + "finishMs": 85310.995, + "phaseAtEnd": "validate", + "latencyMs": 4270.511, + "observedPhasesOverlapped": [ + "validate" + ] + }, + { + "kind": "create", + "startMs": 5.979, + "phaseAtStart": "queued", + "httpStatus": 202, + "ok": true, + "finishMs": 3648.631, + "phaseAtEnd": "queued", + "latencyMs": 3642.652, + "observedPhasesOverlapped": [ + "queued" + ] + }, + { + "kind": "job", + "startMs": 88287.587, + "phaseAtStart": "publish", + "httpStatus": 200, + "ok": true, + "finishMs": 91925.202, + "phaseAtEnd": "publish", + "latencyMs": 3637.615, + "observedPhasesOverlapped": [ + "publish" + ] + }, + { + "kind": "session", + "startMs": 88287.399, + "phaseAtStart": "publish", + "httpStatus": 200, + "ok": true, + "finishMs": 91923.941, + "phaseAtEnd": "publish", + "latencyMs": 3636.542, + "observedPhasesOverlapped": [ + "publish" + ] + }, + { + "kind": "job", + "startMs": 85372.281, + "phaseAtStart": "validate", + "httpStatus": 200, + "ok": true, + "finishMs": 88190.94, + "phaseAtEnd": "publish", + "latencyMs": 2818.659, + "observedPhasesOverlapped": [ + "validate", + "publish" + ] + }, + { + "kind": "session", + "startMs": 85371.967, + "phaseAtStart": "validate", + "httpStatus": 200, + "ok": true, + "finishMs": 88190.049, + "phaseAtEnd": "validate", + "latencyMs": 2818.082, + "observedPhasesOverlapped": [ + "validate" + ] + }, + { + "kind": "job", + "startMs": 80838.994, + "phaseAtStart": "resolve", + "httpStatus": 200, + "ok": true, + "finishMs": 80938.122, + "phaseAtEnd": "validate", + "latencyMs": 99.128, + "observedPhasesOverlapped": [ + "resolve", + "validate" + ] + }, + { + "kind": "session", + "startMs": 80838.761, + "phaseAtStart": "resolve", + "httpStatus": 200, + "ok": true, + "finishMs": 80937.136, + "phaseAtEnd": "resolve", + "latencyMs": 98.375, + "observedPhasesOverlapped": [ + "resolve" + ] + }, + { + "kind": "job", + "startMs": 92015.061, + "phaseAtStart": "publish", + "httpStatus": 200, + "ok": true, + "finishMs": 92088.25, + "phaseAtEnd": "complete", + "latencyMs": 73.189, + "observedPhasesOverlapped": [ + "publish", + "complete" + ] + }, + { + "kind": "session", + "startMs": 92014.616, + "phaseAtStart": "publish", + "httpStatus": 200, + "ok": true, + "finishMs": 92087.637, + "phaseAtEnd": "publish", + "latencyMs": 73.021, + "observedPhasesOverlapped": [ + "publish" + ] + }, + { + "kind": "job", + "startMs": 28304.58, + "phaseAtStart": "parse", + "httpStatus": 200, + "ok": true, + "finishMs": 28324.514, + "phaseAtEnd": "parse", + "latencyMs": 19.934, + "observedPhasesOverlapped": [ + "parse" + ] + }, + { + "kind": "job", + "startMs": 28406.113, + "phaseAtStart": "parse", + "httpStatus": 200, + "ok": true, + "finishMs": 28420.848, + "phaseAtEnd": "parse", + "latencyMs": 14.735, + "observedPhasesOverlapped": [ + "parse" + ] + }, + { + "kind": "job", + "startMs": 80134.382, + "phaseAtStart": "resolve", + "httpStatus": 200, + "ok": true, + "finishMs": 80145.929, + "phaseAtEnd": "resolve", + "latencyMs": 11.547, + "observedPhasesOverlapped": [ + "resolve" + ] + }, + { + "kind": "job", + "startMs": 62629.201, + "phaseAtStart": "resolve", + "httpStatus": 200, + "ok": true, + "finishMs": 62638.383, + "phaseAtEnd": "resolve", + "latencyMs": 9.182, + "observedPhasesOverlapped": [ + "resolve" + ] + }, + { + "kind": "job", + "startMs": 64135.616, + "phaseAtStart": "resolve", + "httpStatus": 200, + "ok": true, + "finishMs": 64142.928, + "phaseAtEnd": "resolve", + "latencyMs": 7.312, + "observedPhasesOverlapped": [ + "resolve" + ] + }, + { + "kind": "session", + "startMs": 28405.688, + "phaseAtStart": "parse", + "httpStatus": 200, + "ok": true, + "finishMs": 28412.672, + "phaseAtEnd": "parse", + "latencyMs": 6.984, + "observedPhasesOverlapped": [ + "parse" + ] + }, + { + "kind": "job", + "startMs": 3649.181, + "phaseAtStart": "queued", + "httpStatus": 200, + "ok": true, + "finishMs": 3655.721, + "phaseAtEnd": "running", + "latencyMs": 6.54, + "observedPhasesOverlapped": [ + "queued", + "running" + ] + } + ] + }, + "resourcePhases": { + "running": { + "samples": 48, + "peakRssBytes": 404652032, + "maxProcesses": 2, + "finalProcesses": 1 + }, + "stage": { + "samples": 12, + "peakRssBytes": 541917184, + "maxProcesses": 2, + "finalProcesses": 2 + }, + "parse": { + "samples": 474, + "peakRssBytes": 1976205312, + "maxProcesses": 2, + "finalProcesses": 2 + }, + "resolve": { + "samples": 234, + "peakRssBytes": 2058682368, + "maxProcesses": 2, + "finalProcesses": 1 + }, + "validate": { + "samples": 73, + "peakRssBytes": 267010048, + "maxProcesses": 1, + "finalProcesses": 1 + }, + "publish": { + "samples": 38, + "peakRssBytes": 335282176, + "maxProcesses": 1, + "finalProcesses": 1 + } + } + }, + { + "kind": "changed_refresh_cancel", + "elapsedMs": 11232.437, + "terminalState": "interrupted", + "interruptedReason": "user_cancelled", + "cancelledDuringObservedParse": true, + "cancelRequestedAtMs": 10715.349, + "stateAt500MsGrace": "interrupted", + "phases": [ + { + "phase": "queued", + "atMs": 0, + "pendingRequests": 0, + "oldestPendingMs": 0 + }, + { + "phase": "running", + "atMs": 4478.008, + "pendingRequests": 1, + "oldestPendingMs": 4.963 + }, + { + "phase": "stage", + "atMs": 9812.965, + "pendingRequests": 1, + "oldestPendingMs": 5238.366 + }, + { + "phase": "parse", + "atMs": 10715.343, + "pendingRequests": 1, + "oldestPendingMs": 4.084 + }, + { + "phase": "interrupted", + "atMs": 10850.947, + "pendingRequests": 1, + "oldestPendingMs": 38.799 + } + ], + "resources": { + "peakRssBytes": 608321536, + "cpuMs": 10350, + "maxProcesses": 2, + "finalProcesses": 1 + }, + "http": { + "requests": 34, + "failures": 0, + "pendingAfterDrain": 0, + "skippedTicks": { + "session": 52, + "job": 52, + "create": 0, + "cancel": 0 + }, + "httpStatuses": { + "200": 33, + "202": 1 + }, + "failureCodes": {}, + "overall": { + "session": { + "count": 16, + "p95Ms": 5236.74, + "maxMs": 5236.74 + }, + "job": { + "count": 16, + "p95Ms": 5238.374, + "maxMs": 5238.374 + }, + "create": { + "count": 1, + "p95Ms": 4457.835, + "maxMs": 4457.835 + }, + "cancel": { + "count": 1, + "p95Ms": 8.036, + "maxMs": 8.036 + } + }, + "byStartPhase": { + "queued": { + "session": { + "count": 1, + "p95Ms": 3.566, + "maxMs": 3.566 + }, + "job": { + "count": 1, + "p95Ms": 4.969, + "maxMs": 4.969 + }, + "create": { + "count": 1, + "p95Ms": 4457.835, + "maxMs": 4457.835 + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "running": { + "session": { + "count": 1, + "p95Ms": 5236.74, + "maxMs": 5236.74 + }, + "job": { + "count": 1, + "p95Ms": 5238.374, + "maxMs": 5238.374 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "stage": { + "session": { + "count": 9, + "p95Ms": 4.108, + "maxMs": 4.108 + }, + "job": { + "count": 9, + "p95Ms": 4.333, + "maxMs": 4.333 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "parse": { + "session": { + "count": 1, + "p95Ms": 38.79, + "maxMs": 38.79 + }, + "job": { + "count": 1, + "p95Ms": 38.803, + "maxMs": 38.803 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 1, + "p95Ms": 8.036, + "maxMs": 8.036 + } + }, + "interrupted": { + "session": { + "count": 4, + "p95Ms": 4.293, + "maxMs": 4.293 + }, + "job": { + "count": 4, + "p95Ms": 4.276, + "maxMs": 4.276 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + } + }, + "byOverlappedPhase": { + "queued": { + "session": { + "count": 1, + "p95Ms": 3.566, + "maxMs": 3.566 + }, + "job": { + "count": 1, + "p95Ms": 4.969, + "maxMs": 4.969 + }, + "create": { + "count": 1, + "p95Ms": 4457.835, + "maxMs": 4457.835 + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "running": { + "session": { + "count": 1, + "p95Ms": 5236.74, + "maxMs": 5236.74 + }, + "job": { + "count": 2, + "p95Ms": 5238.374, + "maxMs": 5238.374 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "stage": { + "session": { + "count": 9, + "p95Ms": 4.108, + "maxMs": 4.108 + }, + "job": { + "count": 10, + "p95Ms": 5238.374, + "maxMs": 5238.374 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + }, + "parse": { + "session": { + "count": 1, + "p95Ms": 38.79, + "maxMs": 38.79 + }, + "job": { + "count": 2, + "p95Ms": 38.803, + "maxMs": 38.803 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 1, + "p95Ms": 8.036, + "maxMs": 8.036 + } + }, + "interrupted": { + "session": { + "count": 4, + "p95Ms": 4.293, + "maxMs": 4.293 + }, + "job": { + "count": 5, + "p95Ms": 38.803, + "maxMs": 38.803 + }, + "create": { + "count": 0, + "p95Ms": null, + "maxMs": null + }, + "cancel": { + "count": 0, + "p95Ms": null, + "maxMs": null + } + } + }, + "slowestRequests": [ + { + "kind": "job", + "startMs": 4574.599, + "phaseAtStart": "running", + "httpStatus": 200, + "ok": true, + "finishMs": 9812.973, + "phaseAtEnd": "stage", + "latencyMs": 5238.374, + "observedPhasesOverlapped": [ + "running", + "stage" + ] + }, + { + "kind": "session", + "startMs": 4574.324, + "phaseAtStart": "running", + "httpStatus": 200, + "ok": true, + "finishMs": 9811.064, + "phaseAtEnd": "running", + "latencyMs": 5236.74, + "observedPhasesOverlapped": [ + "running" + ] + }, + { + "kind": "create", + "startMs": 15.015, + "phaseAtStart": "queued", + "httpStatus": 202, + "ok": true, + "finishMs": 4472.85, + "phaseAtEnd": "queued", + "latencyMs": 4457.835, + "observedPhasesOverlapped": [ + "queued" + ] + }, + { + "kind": "job", + "startMs": 10812.148, + "phaseAtStart": "parse", + "httpStatus": 200, + "ok": true, + "finishMs": 10850.951, + "phaseAtEnd": "interrupted", + "latencyMs": 38.803, + "observedPhasesOverlapped": [ + "parse", + "interrupted" + ] + }, + { + "kind": "session", + "startMs": 10811.585, + "phaseAtStart": "parse", + "httpStatus": 200, + "ok": true, + "finishMs": 10850.375, + "phaseAtEnd": "parse", + "latencyMs": 38.79, + "observedPhasesOverlapped": [ + "parse" + ] + }, + { + "kind": "cancel", + "startMs": 10715.355, + "phaseAtStart": "parse", + "httpStatus": 200, + "ok": true, + "finishMs": 10723.391, + "phaseAtEnd": "parse", + "latencyMs": 8.036, + "observedPhasesOverlapped": [ + "parse" + ] + }, + { + "kind": "job", + "startMs": 4473.045, + "phaseAtStart": "queued", + "httpStatus": 200, + "ok": true, + "finishMs": 4478.014, + "phaseAtEnd": "running", + "latencyMs": 4.969, + "observedPhasesOverlapped": [ + "queued", + "running" + ] + }, + { + "kind": "job", + "startMs": 10711.259, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10715.592, + "phaseAtEnd": "parse", + "latencyMs": 4.333, + "observedPhasesOverlapped": [ + "stage", + "parse" + ] + }, + { + "kind": "session", + "startMs": 11213.377, + "phaseAtStart": "interrupted", + "httpStatus": 200, + "ok": true, + "finishMs": 11217.67, + "phaseAtEnd": "interrupted", + "latencyMs": 4.293, + "observedPhasesOverlapped": [ + "interrupted" + ] + }, + { + "kind": "job", + "startMs": 11213.749, + "phaseAtStart": "interrupted", + "httpStatus": 200, + "ok": true, + "finishMs": 11218.025, + "phaseAtEnd": "interrupted", + "latencyMs": 4.276, + "observedPhasesOverlapped": [ + "interrupted" + ] + }, + { + "kind": "session", + "startMs": 10711.059, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10715.167, + "phaseAtEnd": "stage", + "latencyMs": 4.108, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "job", + "startMs": 10309.264, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10313.213, + "phaseAtEnd": "stage", + "latencyMs": 3.949, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "session", + "startMs": 10308.813, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10312.744, + "phaseAtEnd": "stage", + "latencyMs": 3.931, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "session", + "startMs": 4472.858, + "phaseAtStart": "queued", + "httpStatus": 200, + "ok": true, + "finishMs": 4476.424, + "phaseAtEnd": "queued", + "latencyMs": 3.566, + "observedPhasesOverlapped": [ + "queued" + ] + }, + { + "kind": "session", + "startMs": 10510.483, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10513.782, + "phaseAtEnd": "stage", + "latencyMs": 3.299, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "job", + "startMs": 10510.714, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10513.981, + "phaseAtEnd": "stage", + "latencyMs": 3.267, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "session", + "startMs": 10108.567, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10111.251, + "phaseAtEnd": "stage", + "latencyMs": 2.684, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "job", + "startMs": 10108.802, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10111.379, + "phaseAtEnd": "stage", + "latencyMs": 2.577, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "session", + "startMs": 10207.996, + "phaseAtStart": "stage", + "httpStatus": 200, + "ok": true, + "finishMs": 10210.528, + "phaseAtEnd": "stage", + "latencyMs": 2.532, + "observedPhasesOverlapped": [ + "stage" + ] + }, + { + "kind": "session", + "startMs": 11113.506, + "phaseAtStart": "interrupted", + "httpStatus": 200, + "ok": true, + "finishMs": 11116.023, + "phaseAtEnd": "interrupted", + "latencyMs": 2.517, + "observedPhasesOverlapped": [ + "interrupted" + ] + } + ] + }, + "resourcePhases": { + "running": { + "samples": 53, + "peakRssBytes": 400998400, + "maxProcesses": 2, + "finalProcesses": 1 + }, + "stage": { + "samples": 9, + "peakRssBytes": 588414976, + "maxProcesses": 2, + "finalProcesses": 2 + }, + "parse": { + "samples": 2, + "peakRssBytes": 608321536, + "maxProcesses": 2, + "finalProcesses": 1 + }, + "interrupted": { + "samples": 4, + "peakRssBytes": 394608640, + "maxProcesses": 1, + "finalProcesses": 1 + } + } + } + ], + "outcome": "passed", + "cliSha256": "cad49214c1e692a5641a38a5503ba7005bfdce927afb14a2e4b39a71135e8fd8", + "graphBefore": "0b6dd1f8e161f19e4a15111f125a83d2245f01c01e791f21e38f2c8a4a61af41", + "graphAfterCompleted": "3ba85d808f01bbffce5bf568b7735c49cc162380ba0f154d284cb92c6b06eb9b", + "cancelCleanupMs": 244.785, + "graphAfterCancelled": "3ba85d808f01bbffce5bf568b7735c49cc162380ba0f154d284cb92c6b06eb9b", + "cancelledGraphStatus": { + "indexStatus": "stale", + "changes": 1, + "parseTotal": 723, + "parseFailed": 0 + }, + "aggregateResources": { + "rssBytes": 394297344, + "peakRssBytes": 2058682368, + "cpuMs": 135770, + "maxProcesses": 2, + "sampleCount": 1319 + }, + "hubStopped": true, + "elapsedMs": 119951.455 + }, + "repeatedEngineCharacterization": { + "schemaVersion": 1, + "benchmark": "mex-graph-characterization", + "generatedAt": "2026-09-08T22:22:11.897Z", + "environment": { + "node": "v22.17.1", + "sqlite": "3.50.0", + "v8": "12.4.254.21-node.27", + "platform": "darwin", + "arch": "arm64" + }, + "workerBundleSha256": "1bff351b210a98339d416ff6abaa3e0da72610fa358d2969784871e1491442f3", + "fixture": { + "mutableSource": "packages/p0/module-0.ts", + "digest": "51b2557891202bc2b10d9e61cf93fa051ad0287a08d1be6888096bc00f875c4a", + "inputBytes": 465529, + "sourceFiles": 180, + "projects": 4, + "configFiles": 5, + "functionsPerFile": 12, + "inferredFiles": 20, + "dependencyDeclarations": 128, + "largeBodyLines": 4096 + }, + "measurement": { + "scope": "engine-worker-and-descendants", + "rss": "sum-of-resident-sets-in-one-sample", + "cpu": "observed-process-lifetime-deltas", + "intervalMs": 100, + "limitation": "Shared pages may be counted twice; short-lived children and final CPU between samples may be missed.", + "engineOnly": true, + "forcedGcAfterClose": 3, + "note": "Characterization only. Five operations share one process. CPU/RSS sampling is separate from post-GC retained memory; no laptop-derived release budgets." + }, + "samples": [ + { + "mode": "build", + "elapsedMs": 2234.6207080000004, + "cpuMs": 4422.221, + "result": { + "filesIndexed": 180, + "nodesCreated": 2120, + "edgesCreated": 4047, + "health": { + "ok": 180, + "partial": 0, + "failed": 0 + }, + "durationMs": 2227 + }, + "returnMemory": { + "rss": 502366208, + "heapTotal": 189759488, + "heapUsed": 65053568, + "external": 36811249, + "arrayBuffers": 1103922 + }, + "postGcMemory": { + "rss": 502153216, + "heapTotal": 80740352, + "heapUsed": 42033784, + "external": 35790381, + "arrayBuffers": 83014 + }, + "aggregate": { + "rssBytes": 502153216, + "peakRssBytes": 515850240, + "cpuMs": 4510, + "maxProcesses": 1, + "sampleCount": 30 + } + }, + { + "mode": "unchanged", + "elapsedMs": 36.687167000000045, + "cpuMs": 57.063, + "result": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 36 + }, + "returnMemory": { + "rss": 502185984, + "heapTotal": 80740352, + "heapUsed": 44131016, + "external": 35793474, + "arrayBuffers": 83035 + }, + "postGcMemory": { + "rss": 502185984, + "heapTotal": 80740352, + "heapUsed": 42080280, + "external": 35790381, + "arrayBuffers": 83014 + }, + "aggregate": { + "rssBytes": 502185984, + "peakRssBytes": 502185984, + "cpuMs": 120, + "maxProcesses": 1, + "sampleCount": 3 + } + }, + { + "mode": "changed", + "elapsedMs": 1995.9336249999997, + "cpuMs": 3894.464, + "result": { + "filesIndexed": 180, + "nodesCreated": 2120, + "edgesCreated": 4047, + "health": { + "ok": 180, + "partial": 0, + "failed": 0 + }, + "durationMs": 1990 + }, + "returnMemory": { + "rss": 509165568, + "heapTotal": 352452608, + "heapUsed": 318783736, + "external": 36411852, + "arrayBuffers": 704485 + }, + "postGcMemory": { + "rss": 489357312, + "heapTotal": 81641472, + "heapUsed": 43705904, + "external": 35790381, + "arrayBuffers": 83014 + }, + "aggregate": { + "rssBytes": 489357312, + "peakRssBytes": 534216704, + "cpuMs": 3960, + "maxProcesses": 1, + "sampleCount": 26 + } + }, + { + "mode": "unchanged", + "elapsedMs": 31.94133299999976, + "cpuMs": 51.422, + "result": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 32 + }, + "returnMemory": { + "rss": 489422848, + "heapTotal": 81641472, + "heapUsed": 45694072, + "external": 35793474, + "arrayBuffers": 83035 + }, + "postGcMemory": { + "rss": 489078784, + "heapTotal": 81575936, + "heapUsed": 43407048, + "external": 35790381, + "arrayBuffers": 83014 + }, + "aggregate": { + "rssBytes": 489078784, + "peakRssBytes": 489357312, + "cpuMs": 120, + "maxProcesses": 1, + "sampleCount": 3 + } + }, + { + "mode": "changed", + "elapsedMs": 2007.5755, + "cpuMs": 3928.895, + "result": { + "filesIndexed": 180, + "nodesCreated": 2120, + "edgesCreated": 4047, + "health": { + "ok": 180, + "partial": 0, + "failed": 0 + }, + "durationMs": 2000 + }, + "returnMemory": { + "rss": 521142272, + "heapTotal": 418152448, + "heapUsed": 378976544, + "external": 36186882, + "arrayBuffers": 483141 + }, + "postGcMemory": { + "rss": 493256704, + "heapTotal": 81690624, + "heapUsed": 42033976, + "external": 35786755, + "arrayBuffers": 83014 + }, + "aggregate": { + "rssBytes": 493256704, + "peakRssBytes": 521109504, + "cpuMs": 4000, + "maxProcesses": 1, + "sampleCount": 26 + } + } + ] + } +} diff --git a/docs/design/code-graph-resource-investigation.json b/docs/design/code-graph-resource-investigation.json new file mode 100644 index 00000000..33d71ce0 --- /dev/null +++ b/docs/design/code-graph-resource-investigation.json @@ -0,0 +1,2775 @@ +{ + "schemaVersion": 1, + "kind": "diagnostic-investigation-not-release-budget", + "date": "2026-09-09", + "source": { + "branch": "codex/0.8.1", + "head": "35bfc075888706d6b105b39b0d711ff7a588b551", + "sourceFiles": 708, + "compilerSourceBytes": 10591819, + "sourceCorpusDigest": "c2e8bdbbfd66963b9fdf549235105732d3424021af43de7a4f55cfca9094fd50", + "manifestHash": "a4f5457f66e5c393a9eda8711c3ff5a7fc198e3382a46f4eda82c323005e78e8" + }, + "environment": { + "os": "macOS 26.0.1", + "architecture": "arm64", + "node": "22.17.1", + "v8": "12.4.254.21-node.27", + "sqlite": "3.50.0", + "typescript": "5.9.3", + "webTreeSitter": "0.25.10" + }, + "method": { + "cpuProfilerIntervalMicroseconds": 2000, + "externalRssSampleIntervalMilliseconds": 1000, + "diagnosticGC": "three forced GCs, 100ms apart, after close", + "supervisorDurationSeconds": 600, + "supervisorSampledRssLimitMiB": 4608, + "engineOnly": true, + "liveHubRunning": false, + "telemetryDisabled": true + }, + "runs": { + "real-baseline": { + "variant": "baseline", + "repeatedFullWork": false, + "v8OldSpaceMiB": null, + "cycles": [ + { + "durationMs": 312942, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 312942 + }, + "nativePreparesCumulative": 399775, + "nativePreparesSincePreviousSync": 399775, + "returnMemory": { + "heapUsed": 2420390688, + "heapTotal": 2511323136, + "rss": 1092485120, + "external": 39085747, + "arrayBuffers": 3402528, + "maxRSSKiB": 2057904 + }, + "postGcMemory": { + "heapUsed": 54255424, + "heapTotal": 98074624, + "rss": 1221197824, + "external": 36242231, + "arrayBuffers": 559012, + "maxRSSKiB": 2057904 + } + } + ], + "noop": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 311 + }, + "processLifetime": { + "label": "real-baseline", + "dbPath": "/real-baseline.sqlite", + "elapsedMs": 314516.87429199996, + "memory": { + "rss": 1225916416, + "heapTotal": 97812480, + "heapUsed": 54370056, + "external": 36242231, + "arrayBuffers": 559012 + }, + "resourceUsage": { + "userCPUTime": 292068175, + "systemCPUTime": 16656055, + "maxRSS": 2057904, + "sharedMemorySize": 0, + "unsharedDataSize": 0, + "unsharedStackSize": 0, + "minorPageFault": 607416, + "majorPageFault": 666, + "swappedOut": 0, + "fsRead": 0, + "fsWrite": 1, + "ipcSent": 0, + "ipcReceived": 3, + "signalsCount": 124884, + "voluntaryContextSwitches": 125256, + "involuntaryContextSwitches": 922716 + }, + "prepares": 400510 + }, + "stages": [ + { + "stage": "process-start", + "elapsedMs": 0, + "heapUsedBytes": 4895408, + "rssBytes": 41795584, + "lifetimeMaxRssKiB": 45920, + "cpuMicroseconds": { + "user": 33432, + "system": 167716 + }, + "nativePrepares": 0, + "metadata": {} + }, + { + "stage": "module-loaded", + "elapsedMs": 203, + "heapUsedBytes": 37256136, + "rssBytes": 149471232, + "lifetimeMaxRssKiB": 145968, + "cpuMicroseconds": { + "user": 258423, + "system": 188897 + }, + "nativePrepares": 0, + "metadata": {} + }, + { + "stage": "discover-start", + "elapsedMs": 500, + "heapUsedBytes": 43250088, + "rssBytes": 176553984, + "lifetimeMaxRssKiB": 172416, + "cpuMicroseconds": { + "user": 407312, + "system": 241724 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "discover-end", + "elapsedMs": 624, + "heapUsedBytes": 57263680, + "rssBytes": 196411392, + "lifetimeMaxRssKiB": 191808, + "cpuMicroseconds": { + "user": 483121, + "system": 308929 + }, + "nativePrepares": 708, + "metadata": { + "files": 708 + } + }, + { + "stage": "compiler-start", + "elapsedMs": 659, + "heapUsedBytes": 56554400, + "rssBytes": 197410816, + "lifetimeMaxRssKiB": 192784, + "cpuMicroseconds": { + "user": 509868, + "system": 323183 + }, + "nativePrepares": 708, + "metadata": { + "sourceBytes": 10591819, + "inputFiles": 709 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 690, + "heapUsedBytes": 62094760, + "rssBytes": 213221376, + "lifetimeMaxRssKiB": 208224, + "cpuMicroseconds": { + "user": 549704, + "system": 325757 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-contracts/tsconfig.json", + "roots": 7 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 1233, + "heapUsedBytes": 188120728, + "rssBytes": 337248256, + "lifetimeMaxRssKiB": 331216, + "cpuMicroseconds": { + "user": 1606681, + "system": 401461 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-contracts/tsconfig.json", + "sourceFiles": 292 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 3826, + "heapUsedBytes": 561624088, + "rssBytes": 718209024, + "lifetimeMaxRssKiB": 701376, + "cpuMicroseconds": { + "user": 6489666, + "system": 638657 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-contracts/tsconfig.json", + "owned": 7 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 3827, + "heapUsedBytes": 561658672, + "rssBytes": 718225408, + "lifetimeMaxRssKiB": 701392, + "cpuMicroseconds": { + "user": 6490049, + "system": 638788 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-web/tsconfig.json", + "roots": 95 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 4687, + "heapUsedBytes": 299624376, + "rssBytes": 828981248, + "lifetimeMaxRssKiB": 813360, + "cpuMicroseconds": { + "user": 7946261, + "system": 777243 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-web/tsconfig.json", + "sourceFiles": 929 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 12663, + "heapUsedBytes": 707320320, + "rssBytes": 954056704, + "lifetimeMaxRssKiB": 937008, + "cpuMicroseconds": { + "user": 19884996, + "system": 1233250 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-web/tsconfig.json", + "owned": 96 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 12663, + "heapUsedBytes": 707357416, + "rssBytes": 954056704, + "lifetimeMaxRssKiB": 937008, + "cpuMicroseconds": { + "user": 19885128, + "system": 1233394 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/mex-mcp/tsconfig.json", + "roots": 6 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 13013, + "heapUsedBytes": 826958528, + "rssBytes": 1035976704, + "lifetimeMaxRssKiB": 1011696, + "cpuMicroseconds": { + "user": 20415260, + "system": 1289658 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/mex-mcp/tsconfig.json", + "sourceFiles": 289 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 13332, + "heapUsedBytes": 898200680, + "rssBytes": 1100578816, + "lifetimeMaxRssKiB": 1074784, + "cpuMicroseconds": { + "user": 21181835, + "system": 1336952 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/mex-mcp/tsconfig.json", + "owned": 6 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 13332, + "heapUsedBytes": 898205928, + "rssBytes": 1100578816, + "lifetimeMaxRssKiB": 1074784, + "cpuMicroseconds": { + "user": 21182169, + "system": 1337092 + }, + "nativePrepares": 708, + "metadata": { + "config": "/tsconfig.json", + "roots": 442 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 14449, + "heapUsedBytes": 1223080000, + "rssBytes": 1425457152, + "lifetimeMaxRssKiB": 1392256, + "cpuMicroseconds": { + "user": 22564545, + "system": 1548575 + }, + "nativePrepares": 708, + "metadata": { + "config": "/tsconfig.json", + "sourceFiles": 1216 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 34250, + "heapUsedBytes": 1987876680, + "rssBytes": 1710768128, + "lifetimeMaxRssKiB": 1964592, + "cpuMicroseconds": { + "user": 46168548, + "system": 2782959 + }, + "nativePrepares": 708, + "metadata": { + "config": "/tsconfig.json", + "owned": 442 + } + }, + { + "stage": "compiler-end", + "elapsedMs": 46805, + "heapUsedBytes": 1970172176, + "rssBytes": 1769897984, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 61857190, + "system": 4121250 + }, + "nativePrepares": 708, + "metadata": { + "files": 701, + "projects": 5 + } + }, + { + "stage": "grammar-start", + "elapsedMs": 46806, + "heapUsedBytes": 1970864392, + "rssBytes": 1770913792, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 61858359, + "system": 4121624 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "grammar-end", + "elapsedMs": 46830, + "heapUsedBytes": 1961558592, + "rssBytes": 1775304704, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 61878934, + "system": 4129430 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "materialize-end", + "elapsedMs": 47206, + "heapUsedBytes": 2026791072, + "rssBytes": 1595162624, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 62254586, + "system": 4200857 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fallback-resolution-start", + "elapsedMs": 47206, + "heapUsedBytes": 2026795000, + "rssBytes": 1595162624, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 62254660, + "system": 4200936 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fallback-resolution-end", + "elapsedMs": 47310, + "heapUsedBytes": 2064475120, + "rssBytes": 1583710208, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 62365969, + "system": 4222332 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fingerprints-start", + "elapsedMs": 47344, + "heapUsedBytes": 2064352904, + "rssBytes": 1572552704, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 62405835, + "system": 4230245 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fingerprints-end", + "elapsedMs": 48744, + "heapUsedBytes": 2107445696, + "rssBytes": 1391083520, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 64021814, + "system": 4317565 + }, + "nativePrepares": 708, + "metadata": { + "fingerprints": 23376 + } + }, + { + "stage": "old-graph-start", + "elapsedMs": 48752, + "heapUsedBytes": 2109588712, + "rssBytes": 1389543424, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 64029008, + "system": 4319128 + }, + "nativePrepares": 1390, + "metadata": {} + }, + { + "stage": "old-graph-end", + "elapsedMs": 50731, + "heapUsedBytes": 2202368048, + "rssBytes": 1302118400, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 65253075, + "system": 4509759 + }, + "nativePrepares": 33535, + "metadata": { + "nodes": 32143, + "fingerprints": 22597 + } + }, + { + "stage": "clear-start", + "elapsedMs": 51018, + "heapUsedBytes": 2211790576, + "rssBytes": 1302200320, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 65346835, + "system": 4553306 + }, + "nativePrepares": 33535, + "metadata": {} + }, + { + "stage": "clear-end", + "elapsedMs": 54864, + "heapUsedBytes": 2211795536, + "rssBytes": 1169342464, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 66865127, + "system": 5720947 + }, + "nativePrepares": 33535, + "metadata": {} + }, + { + "stage": "source-chunks-end", + "elapsedMs": 56453, + "heapUsedBytes": 2213735816, + "rssBytes": 1157726208, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 68392404, + "system": 5819827 + }, + "nativePrepares": 44833, + "metadata": {} + }, + { + "stage": "insert-nodes-end", + "elapsedMs": 59605, + "heapUsedBytes": 2222031288, + "rssBytes": 1179418624, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 70932386, + "system": 6152929 + }, + "nativePrepares": 77954, + "metadata": {} + }, + { + "stage": "insert-edges-end", + "elapsedMs": 68483, + "heapUsedBytes": 2229366176, + "rssBytes": 1261764608, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 77199148, + "system": 7561833 + }, + "nativePrepares": 350266, + "metadata": { + "edges": 87048 + } + }, + { + "stage": "insert-fingerprints-end", + "elapsedMs": 306868, + "heapUsedBytes": 2227539120, + "rssBytes": 938196992, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 288646831, + "system": 15510549 + }, + "nativePrepares": 350316, + "metadata": {} + }, + { + "stage": "aliases-end", + "elapsedMs": 310671, + "heapUsedBytes": 2419725568, + "rssBytes": 1283719168, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291493233, + "system": 15890074 + }, + "nativePrepares": 399764, + "metadata": {} + }, + { + "stage": "fts-end", + "elapsedMs": 310882, + "heapUsedBytes": 2419729944, + "rssBytes": 1253113856, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291568295, + "system": 15917629 + }, + "nativePrepares": 399764, + "metadata": {} + }, + { + "stage": "invariants-end", + "elapsedMs": 311445, + "heapUsedBytes": 2419737776, + "rssBytes": 1205698560, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291631590, + "system": 15966388 + }, + "nativePrepares": 399767, + "metadata": {} + }, + { + "stage": "publication-end", + "elapsedMs": 313144, + "heapUsedBytes": 2419749936, + "rssBytes": 1083965440, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291694162, + "system": 16398730 + }, + "nativePrepares": 399775, + "metadata": {} + }, + { + "stage": "sync-returned", + "elapsedMs": 313202, + "heapUsedBytes": 2420390688, + "rssBytes": 1092485120, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291697939, + "system": 16436343 + }, + "nativePrepares": 399775, + "metadata": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 312942 + } + }, + { + "stage": "engine-closed", + "elapsedMs": 313474, + "heapUsedBytes": 2420395464, + "rssBytes": 1092272128, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291699534, + "system": 16487541 + }, + "nativePrepares": 399775, + "metadata": {} + }, + { + "stage": "after-forced-gc", + "elapsedMs": 313878, + "heapUsedBytes": 54255424, + "rssBytes": 1221197824, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291829537, + "system": 16589557 + }, + "nativePrepares": 399775, + "metadata": {} + }, + { + "stage": "noop-returned", + "elapsedMs": 314190, + "heapUsedBytes": 66648368, + "rssBytes": 1226457088, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 291984142, + "system": 16645931 + }, + "nativePrepares": 400510, + "metadata": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 311 + } + }, + { + "stage": "after-noop-gc", + "elapsedMs": 314516, + "heapUsedBytes": 54354248, + "rssBytes": 1225916416, + "lifetimeMaxRssKiB": 2057904, + "cpuMicroseconds": { + "user": 292067874, + "system": 16655872 + }, + "nativePrepares": 400510, + "metadata": {} + } + ] + }, + "real-outer": { + "variant": "outer-owned fingerprint transaction", + "repeatedFullWork": false, + "v8OldSpaceMiB": null, + "cycles": [ + { + "durationMs": 99449, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 99449 + }, + "nativePreparesCumulative": 399775, + "nativePreparesSincePreviousSync": 399775, + "returnMemory": { + "heapUsed": 1517693248, + "heapTotal": 1956265984, + "rss": 606912512, + "external": 39365811, + "arrayBuffers": 3682592, + "maxRSSKiB": 1828608 + }, + "postGcMemory": { + "heapUsed": 52125632, + "heapTotal": 95502336, + "rss": 707641344, + "external": 36238605, + "arrayBuffers": 559012, + "maxRSSKiB": 1828608 + } + } + ], + "noop": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 394 + }, + "processLifetime": { + "label": "real-outer", + "dbPath": "/real-outer.sqlite", + "elapsedMs": 101308.909166, + "memory": { + "rss": 712998912, + "heapTotal": 95502336, + "heapUsed": 52243816, + "external": 36238605, + "arrayBuffers": 559012 + }, + "resourceUsage": { + "userCPUTime": 86888927, + "systemCPUTime": 13056038, + "maxRSS": 1828608, + "sharedMemorySize": 0, + "unsharedDataSize": 0, + "unsharedStackSize": 0, + "minorPageFault": 462936, + "majorPageFault": 274, + "swappedOut": 0, + "fsRead": 0, + "fsWrite": 0, + "ipcSent": 0, + "ipcReceived": 3, + "signalsCount": 41292, + "voluntaryContextSwitches": 86614, + "involuntaryContextSwitches": 580921 + }, + "prepares": 400510 + }, + "stages": [ + { + "stage": "process-start", + "elapsedMs": 0, + "heapUsedBytes": 4895472, + "rssBytes": 47267840, + "lifetimeMaxRssKiB": 46160, + "cpuMicroseconds": { + "user": 38394, + "system": 988076 + }, + "nativePrepares": 0, + "metadata": {} + }, + { + "stage": "module-loaded", + "elapsedMs": 201, + "heapUsedBytes": 37200432, + "rssBytes": 151863296, + "lifetimeMaxRssKiB": 148304, + "cpuMicroseconds": { + "user": 262748, + "system": 1006207 + }, + "nativePrepares": 0, + "metadata": {} + }, + { + "stage": "discover-start", + "elapsedMs": 547, + "heapUsedBytes": 41888264, + "rssBytes": 177979392, + "lifetimeMaxRssKiB": 173808, + "cpuMicroseconds": { + "user": 419447, + "system": 1063358 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "discover-end", + "elapsedMs": 760, + "heapUsedBytes": 56165112, + "rssBytes": 196116480, + "lifetimeMaxRssKiB": 191520, + "cpuMicroseconds": { + "user": 508119, + "system": 1144260 + }, + "nativePrepares": 708, + "metadata": { + "files": 708 + } + }, + { + "stage": "compiler-start", + "elapsedMs": 804, + "heapUsedBytes": 56802600, + "rssBytes": 197771264, + "lifetimeMaxRssKiB": 193136, + "cpuMicroseconds": { + "user": 536169, + "system": 1166719 + }, + "nativePrepares": 708, + "metadata": { + "sourceBytes": 10591819, + "inputFiles": 709 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 867, + "heapUsedBytes": 60381192, + "rssBytes": 213417984, + "lifetimeMaxRssKiB": 208416, + "cpuMicroseconds": { + "user": 578362, + "system": 1171409 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-contracts/tsconfig.json", + "roots": 7 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 1576, + "heapUsedBytes": 185618936, + "rssBytes": 348045312, + "lifetimeMaxRssKiB": 339888, + "cpuMicroseconds": { + "user": 1811123, + "system": 1298051 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-contracts/tsconfig.json", + "sourceFiles": 292 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 4783, + "heapUsedBytes": 554925760, + "rssBytes": 709722112, + "lifetimeMaxRssKiB": 693088, + "cpuMicroseconds": { + "user": 7761933, + "system": 1717155 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-contracts/tsconfig.json", + "owned": 7 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 4784, + "heapUsedBytes": 554991312, + "rssBytes": 709787648, + "lifetimeMaxRssKiB": 693152, + "cpuMicroseconds": { + "user": 7762275, + "system": 1717291 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-web/tsconfig.json", + "roots": 95 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 5762, + "heapUsedBytes": 769707168, + "rssBytes": 930414592, + "lifetimeMaxRssKiB": 908608, + "cpuMicroseconds": { + "user": 9286086, + "system": 1914296 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-web/tsconfig.json", + "sourceFiles": 929 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 16310, + "heapUsedBytes": 1287632112, + "rssBytes": 1342783488, + "lifetimeMaxRssKiB": 1373824, + "cpuMicroseconds": { + "user": 24380730, + "system": 2844307 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/hub-web/tsconfig.json", + "owned": 96 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 16310, + "heapUsedBytes": 1287637584, + "rssBytes": 1342783488, + "lifetimeMaxRssKiB": 1373824, + "cpuMicroseconds": { + "user": 24380841, + "system": 2844421 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/mex-mcp/tsconfig.json", + "roots": 6 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 16759, + "heapUsedBytes": 1407701904, + "rssBytes": 1354547200, + "lifetimeMaxRssKiB": 1373824, + "cpuMicroseconds": { + "user": 24898050, + "system": 2943506 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/mex-mcp/tsconfig.json", + "sourceFiles": 289 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 17087, + "heapUsedBytes": 1457494632, + "rssBytes": 1370292224, + "lifetimeMaxRssKiB": 1373824, + "cpuMicroseconds": { + "user": 25364709, + "system": 3008931 + }, + "nativePrepares": 708, + "metadata": { + "config": "/packages/mex-mcp/tsconfig.json", + "owned": 6 + } + }, + { + "stage": "ts-project-start", + "elapsedMs": 17088, + "heapUsedBytes": 1457498968, + "rssBytes": 1370292224, + "lifetimeMaxRssKiB": 1373824, + "cpuMicroseconds": { + "user": 25364796, + "system": 3009079 + }, + "nativePrepares": 708, + "metadata": { + "config": "/tsconfig.json", + "roots": 442 + } + }, + { + "stage": "ts-program-created", + "elapsedMs": 18252, + "heapUsedBytes": 440671704, + "rssBytes": 1440350208, + "lifetimeMaxRssKiB": 1406592, + "cpuMicroseconds": { + "user": 26969552, + "system": 3179551 + }, + "nativePrepares": 708, + "metadata": { + "config": "/tsconfig.json", + "sourceFiles": 1216 + } + }, + { + "stage": "ts-project-end", + "elapsedMs": 37452, + "heapUsedBytes": 1210058912, + "rssBytes": 1257455616, + "lifetimeMaxRssKiB": 1539600, + "cpuMicroseconds": { + "user": 50878058, + "system": 4147203 + }, + "nativePrepares": 708, + "metadata": { + "config": "/tsconfig.json", + "owned": 442 + } + }, + { + "stage": "compiler-end", + "elapsedMs": 48921, + "heapUsedBytes": 1068317480, + "rssBytes": 1806123008, + "lifetimeMaxRssKiB": 1778704, + "cpuMicroseconds": { + "user": 65351144, + "system": 5304210 + }, + "nativePrepares": 708, + "metadata": { + "files": 701, + "projects": 5 + } + }, + { + "stage": "grammar-start", + "elapsedMs": 48922, + "heapUsedBytes": 1069021176, + "rssBytes": 1806221312, + "lifetimeMaxRssKiB": 1778704, + "cpuMicroseconds": { + "user": 65352302, + "system": 5304366 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "grammar-end", + "elapsedMs": 48939, + "heapUsedBytes": 1062625496, + "rssBytes": 1809448960, + "lifetimeMaxRssKiB": 1778704, + "cpuMicroseconds": { + "user": 65368169, + "system": 5307426 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "materialize-end", + "elapsedMs": 49268, + "heapUsedBytes": 1128552944, + "rssBytes": 1824210944, + "lifetimeMaxRssKiB": 1782288, + "cpuMicroseconds": { + "user": 65743952, + "system": 5351962 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fallback-resolution-start", + "elapsedMs": 49268, + "heapUsedBytes": 1128556864, + "rssBytes": 1824210944, + "lifetimeMaxRssKiB": 1782288, + "cpuMicroseconds": { + "user": 65744026, + "system": 5352023 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fallback-resolution-end", + "elapsedMs": 49368, + "heapUsedBytes": 1164540256, + "rssBytes": 1836253184, + "lifetimeMaxRssKiB": 1804352, + "cpuMicroseconds": { + "user": 65862257, + "system": 5368141 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fingerprints-start", + "elapsedMs": 49398, + "heapUsedBytes": 1178104848, + "rssBytes": 1841774592, + "lifetimeMaxRssKiB": 1804352, + "cpuMicroseconds": { + "user": 65897094, + "system": 5370552 + }, + "nativePrepares": 708, + "metadata": {} + }, + { + "stage": "fingerprints-end", + "elapsedMs": 50783, + "heapUsedBytes": 1206845184, + "rssBytes": 1866874880, + "lifetimeMaxRssKiB": 1823120, + "cpuMicroseconds": { + "user": 67509844, + "system": 5435396 + }, + "nativePrepares": 708, + "metadata": { + "fingerprints": 23376 + } + }, + { + "stage": "old-graph-start", + "elapsedMs": 50791, + "heapUsedBytes": 1208991472, + "rssBytes": 1871626240, + "lifetimeMaxRssKiB": 1827760, + "cpuMicroseconds": { + "user": 67516956, + "system": 5436666 + }, + "nativePrepares": 1390, + "metadata": {} + }, + { + "stage": "old-graph-end", + "elapsedMs": 54004, + "heapUsedBytes": 1300395424, + "rssBytes": 1804451840, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 68797618, + "system": 5613765 + }, + "nativePrepares": 33535, + "metadata": { + "nodes": 32143, + "fingerprints": 22597 + } + }, + { + "stage": "clear-start", + "elapsedMs": 54302, + "heapUsedBytes": 1309906272, + "rssBytes": 1756643328, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 68893055, + "system": 5659608 + }, + "nativePrepares": 33535, + "metadata": {} + }, + { + "stage": "clear-end", + "elapsedMs": 65855, + "heapUsedBytes": 1309911392, + "rssBytes": 1005617152, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 70627231, + "system": 8119231 + }, + "nativePrepares": 33535, + "metadata": {} + }, + { + "stage": "source-chunks-end", + "elapsedMs": 67646, + "heapUsedBytes": 1313267752, + "rssBytes": 968933376, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 72142657, + "system": 8253877 + }, + "nativePrepares": 44833, + "metadata": {} + }, + { + "stage": "insert-nodes-end", + "elapsedMs": 71751, + "heapUsedBytes": 1321599880, + "rssBytes": 1073446912, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 74707253, + "system": 8708563 + }, + "nativePrepares": 77954, + "metadata": {} + }, + { + "stage": "insert-edges-end", + "elapsedMs": 87503, + "heapUsedBytes": 1327931528, + "rssBytes": 598540288, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 81391924, + "system": 10583306 + }, + "nativePrepares": 350266, + "metadata": { + "edges": 87048 + } + }, + { + "stage": "insert-fingerprints-end", + "elapsedMs": 91853, + "heapUsedBytes": 1326138216, + "rssBytes": 643776512, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 83550904, + "system": 11832649 + }, + "nativePrepares": 350316, + "metadata": {} + }, + { + "stage": "aliases-end", + "elapsedMs": 95727, + "heapUsedBytes": 1517018216, + "rssBytes": 855343104, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86296689, + "system": 12143422 + }, + "nativePrepares": 399764, + "metadata": {} + }, + { + "stage": "fts-end", + "elapsedMs": 96186, + "heapUsedBytes": 1517026392, + "rssBytes": 841351168, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86367884, + "system": 12174171 + }, + "nativePrepares": 399764, + "metadata": {} + }, + { + "stage": "invariants-end", + "elapsedMs": 96898, + "heapUsedBytes": 1517034208, + "rssBytes": 827834368, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86432019, + "system": 12221468 + }, + "nativePrepares": 399767, + "metadata": {} + }, + { + "stage": "publication-end", + "elapsedMs": 99648, + "heapUsedBytes": 1517046344, + "rssBytes": 596983808, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86498158, + "system": 12724562 + }, + "nativePrepares": 399775, + "metadata": {} + }, + { + "stage": "sync-returned", + "elapsedMs": 99705, + "heapUsedBytes": 1517693248, + "rssBytes": 606912512, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86501184, + "system": 12759014 + }, + "nativePrepares": 399775, + "metadata": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 99449 + } + }, + { + "stage": "engine-closed", + "elapsedMs": 100208, + "heapUsedBytes": 1517698304, + "rssBytes": 612597760, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86503063, + "system": 12909110 + }, + "nativePrepares": 399775, + "metadata": {} + }, + { + "stage": "after-forced-gc", + "elapsedMs": 100589, + "heapUsedBytes": 52125632, + "rssBytes": 707641344, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86632693, + "system": 12985786 + }, + "nativePrepares": 399775, + "metadata": {} + }, + { + "stage": "noop-returned", + "elapsedMs": 100984, + "heapUsedBytes": 64573400, + "rssBytes": 713375744, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86811938, + "system": 13046215 + }, + "nativePrepares": 400510, + "metadata": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 394 + } + }, + { + "stage": "after-noop-gc", + "elapsedMs": 101308, + "heapUsedBytes": 52219672, + "rssBytes": 712998912, + "lifetimeMaxRssKiB": 1828608, + "cpuMicroseconds": { + "user": 86888515, + "system": 13055857 + }, + "nativePrepares": 400510, + "metadata": {} + } + ] + }, + "real-outer-repeat": { + "variant": "outer-owned fingerprint transaction", + "repeatedFullWork": true, + "v8OldSpaceMiB": null, + "cycles": [ + { + "durationMs": 90237, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 90237 + }, + "nativePreparesCumulative": 399092, + "nativePreparesSincePreviousSync": 399092, + "returnMemory": { + "heapUsed": 1575282424, + "heapTotal": 1665564672, + "rss": 1246035968, + "external": 39554259, + "arrayBuffers": 3871040, + "maxRSSKiB": 1942112 + }, + "postGcMemory": { + "heapUsed": 54262416, + "heapTotal": 96239616, + "rss": 1298399232, + "external": 36242231, + "arrayBuffers": 559012, + "maxRSSKiB": 1942112 + } + }, + { + "durationMs": 79186, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 79186 + }, + "nativePreparesCumulative": 751207, + "nativePreparesSincePreviousSync": 352115, + "returnMemory": { + "heapUsed": 1565474288, + "heapTotal": 1635024896, + "rss": 998522880, + "external": 39008905, + "arrayBuffers": 3329312, + "maxRSSKiB": 1942112 + }, + "postGcMemory": { + "heapUsed": 54125696, + "heapTotal": 96862208, + "rss": 1060143104, + "external": 36238605, + "arrayBuffers": 559012, + "maxRSSKiB": 1942112 + } + }, + { + "durationMs": 82681, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 82681 + }, + "nativePreparesCumulative": 1103322, + "nativePreparesSincePreviousSync": 352115, + "returnMemory": { + "heapUsed": 442125376, + "heapTotal": 1652080640, + "rss": 1566867456, + "external": 36314761, + "arrayBuffers": 635168, + "maxRSSKiB": 2009120 + }, + "postGcMemory": { + "heapUsed": 55052568, + "heapTotal": 97910784, + "rss": 1544208384, + "external": 36238605, + "arrayBuffers": 559012, + "maxRSSKiB": 2009120 + } + } + ], + "noop": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 308 + }, + "processLifetime": { + "label": "real-outer-repeat", + "dbPath": "/real-outer-repeat.sqlite", + "elapsedMs": 255477.405042, + "memory": { + "rss": 1548910592, + "heapTotal": 97910784, + "heapUsed": 54985240, + "external": 36238605, + "arrayBuffers": 559012 + }, + "resourceUsage": { + "userCPUTime": 254841692, + "systemCPUTime": 29758297, + "maxRSS": 2009120, + "sharedMemorySize": 0, + "unsharedDataSize": 0, + "unsharedStackSize": 0, + "minorPageFault": 938710, + "majorPageFault": 281, + "swappedOut": 0, + "fsRead": 0, + "fsWrite": 0, + "ipcSent": 0, + "ipcReceived": 7, + "signalsCount": 103350, + "voluntaryContextSwitches": 160786, + "involuntaryContextSwitches": 1196418 + }, + "prepares": 1104057 + }, + "stages": [] + }, + "real-outer-stmtcache-repeat": { + "variant": "outer-owned fingerprint transaction; bounded statement reuse", + "repeatedFullWork": true, + "v8OldSpaceMiB": null, + "cycles": [ + { + "durationMs": 75620, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 75620 + }, + "nativePreparesCumulative": 42, + "nativePreparesSincePreviousSync": 42, + "returnMemory": { + "heapUsed": 2460181016, + "heapTotal": 2536325120, + "rss": 1989935104, + "external": 40030219, + "arrayBuffers": 4347000, + "maxRSSKiB": 2286192 + }, + "postGcMemory": { + "heapUsed": 54260880, + "heapTotal": 94928896, + "rss": 1952088064, + "external": 36242231, + "arrayBuffers": 559012, + "maxRSSKiB": 2286192 + } + }, + { + "durationMs": 67490, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 67490 + }, + "nativePreparesCumulative": 83, + "nativePreparesSincePreviousSync": 41, + "returnMemory": { + "heapUsed": 1469165896, + "heapTotal": 2019557376, + "rss": 1855848448, + "external": 38681225, + "arrayBuffers": 3001632, + "maxRSSKiB": 2344768 + }, + "postGcMemory": { + "heapUsed": 54118408, + "heapTotal": 100007936, + "rss": 1792851968, + "external": 36238605, + "arrayBuffers": 559012, + "maxRSSKiB": 2344768 + } + }, + { + "durationMs": 80050, + "result": { + "filesIndexed": 708, + "nodesCreated": 33121, + "edgesCreated": 87048, + "health": { + "ok": 708, + "partial": 0, + "failed": 0 + }, + "durationMs": 80050 + }, + "nativePreparesCumulative": 124, + "nativePreparesSincePreviousSync": 41, + "returnMemory": { + "heapUsed": 2417504904, + "heapTotal": 2506244096, + "rss": 1220722688, + "external": 39000713, + "arrayBuffers": 3321120, + "maxRSSKiB": 2582560 + }, + "postGcMemory": { + "heapUsed": 54943000, + "heapTotal": 97386496, + "rss": 1270890496, + "external": 36238605, + "arrayBuffers": 559012, + "maxRSSKiB": 2582560 + } + } + ], + "noop": { + "filesIndexed": 0, + "nodesCreated": 0, + "edgesCreated": 0, + "durationMs": 447 + }, + "processLifetime": { + "label": "real-outer-stmtcache-repeat", + "dbPath": "/real-outer-stmtcache-repeat.sqlite", + "elapsedMs": 227436.207041, + "memory": { + "rss": 1272430592, + "heapTotal": 97386496, + "heapUsed": 54875048, + "external": 36238605, + "arrayBuffers": 559012 + }, + "resourceUsage": { + "userCPUTime": 222677045, + "systemCPUTime": 26588721, + "maxRSS": 2582560, + "sharedMemorySize": 0, + "unsharedDataSize": 0, + "unsharedStackSize": 0, + "minorPageFault": 548096, + "majorPageFault": 271, + "swappedOut": 0, + "fsRead": 0, + "fsWrite": 0, + "ipcSent": 0, + "ipcReceived": 7, + "signalsCount": 86498, + "voluntaryContextSwitches": 130722, + "involuntaryContextSwitches": 897660 + }, + "prepares": 141 + }, + "stages": [] + }, + "real-outer-stmtcache-heap1024": { + "variant": "outer-owned transaction and bounded statement reuse", + "v8OldSpaceMiB": 1024, + "failure": { + "status": "failed", + "signal": "SIGABRT", + "pid": 66966, + "cause": "V8 heap exhaustion", + "proof": [ + "node::OOMErrorHandler", + "v8::internal::V8::FatalProcessOutOfMemory", + "v8::internal::Heap::FatalProcessOutOfMemory" + ], + "lastInstrumentedStage": "ts-program-created:root tsconfig", + "observedDurationMs": 48046, + "maxSampledRssKiB": 1299952, + "seedAndFailedDatabaseSha256": "0b6dd1f8e161f19e4a15111f125a83d2245f01c01e791f21e38f2c8a4a61af41", + "sourceSpoolAfterFatalExitBytes": 10594835, + "sourceSpoolCleanup": "removed exact newly created experiment spool; older unrelated spools left alone" + }, + "lastStage": { + "stage": "ts-program-created", + "elapsedMs": 17059, + "rss": 859701248, + "heapTotal": 474742784, + "heapUsed": 431751336, + "external": 2259388, + "arrayBuffers": 141707, + "maxRSSKiB": 1171936, + "cpu": { + "user": 28993085, + "system": 2321754 + }, + "prepares": 17, + "metadata": { + "config": "/tsconfig.json", + "sourceFiles": 1216 + } + } + } + }, + "limitations": [ + "Single workstation and real source corpus; profiler and host overhead; not calibrated release timings.", + "RSS, physical footprint, live heap and external memory are different overlapping metrics.", + "First repeated-series startup briefly overlapped the end of parity validation; use it for retention, not precise timing.", + "Repeated full work uses a nonempty source hint without changing actual source bytes.", + "Three GC observations per series cannot exclude native leaks or a slow retained-memory slope.", + "Fingerprint experiment omits nested savepoint only in a temporary bundle; production standalone API must retain atomicity.", + "Statement reuse experiment caches at most256native statements per database, clears on close; production iterators/reentrancy remain to be designed.", + "Initial supervisor depended on successful ps samples for deadline checks; later heap experiment independently checks deadline and escalates termination." + ], + "sqliteMicrobenchmarks": [ + { + "n": 1000, + "mode": "nested", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 56.41541600000001, + "cpuMs": 67.638, + "processPeakRSSBeforeValidation": 97040, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "1c9ea9d097db0b965be99f5b899b6a6beefbdc5642f4f724f2e364eef1824a9d" + }, + { + "n": 1000, + "mode": "outer", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 33.072708000000006, + "cpuMs": 43.442, + "processPeakRSSBeforeValidation": 83280, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "1c9ea9d097db0b965be99f5b899b6a6beefbdc5642f4f724f2e364eef1824a9d" + }, + { + "n": 1000, + "mode": "chunked", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 39.47562500000001, + "cpuMs": 50.065, + "processPeakRSSBeforeValidation": 88272, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "1c9ea9d097db0b965be99f5b899b6a6beefbdc5642f4f724f2e364eef1824a9d" + }, + { + "n": 1000, + "mode": "nested", + "tempStore": "FILE", + "fail": false, + "failure": null, + "writeMs": 173.860084, + "cpuMs": 149.595, + "processPeakRSSBeforeValidation": 82592, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "1c9ea9d097db0b965be99f5b899b6a6beefbdc5642f4f724f2e364eef1824a9d" + }, + { + "n": 2000, + "mode": "nested", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 268.033542, + "cpuMs": 280.795, + "processPeakRSSBeforeValidation": 146448, + "counts": { + "parent": 2000, + "buckets": 64000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "5ad06faa071e9c5c931a0f2ccc3598635ca2b574ed901e3ebbd5003e25511271" + }, + { + "n": 2000, + "mode": "outer", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 63.546583999999996, + "cpuMs": 84.881, + "processPeakRSSBeforeValidation": 111872, + "counts": { + "parent": 2000, + "buckets": 64000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "5ad06faa071e9c5c931a0f2ccc3598635ca2b574ed901e3ebbd5003e25511271" + }, + { + "n": 2000, + "mode": "chunked", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 85.14824999999999, + "cpuMs": 109.148, + "processPeakRSSBeforeValidation": 119424, + "counts": { + "parent": 2000, + "buckets": 64000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "5ad06faa071e9c5c931a0f2ccc3598635ca2b574ed901e3ebbd5003e25511271" + }, + { + "n": 2000, + "mode": "nested", + "tempStore": "FILE", + "fail": false, + "failure": null, + "writeMs": 270.42029199999996, + "cpuMs": 277.097, + "processPeakRSSBeforeValidation": 111392, + "counts": { + "parent": 2000, + "buckets": 64000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "5ad06faa071e9c5c931a0f2ccc3598635ca2b574ed901e3ebbd5003e25511271" + }, + { + "n": 4000, + "mode": "nested", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 1679.4848339999999, + "cpuMs": 1721.625, + "processPeakRSSBeforeValidation": 227728, + "counts": { + "parent": 4000, + "buckets": 128000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "e4e0df844494989c2361eaf5171c22a6cfacb3a07067af80cfc0ee085a47d47f" + }, + { + "n": 4000, + "mode": "outer", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 141.15587499999998, + "cpuMs": 184.957, + "processPeakRSSBeforeValidation": 161616, + "counts": { + "parent": 4000, + "buckets": 128000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "e4e0df844494989c2361eaf5171c22a6cfacb3a07067af80cfc0ee085a47d47f" + }, + { + "n": 4000, + "mode": "chunked", + "tempStore": "MEMORY", + "fail": false, + "failure": null, + "writeMs": 209.59279199999997, + "cpuMs": 253.153, + "processPeakRSSBeforeValidation": 170512, + "counts": { + "parent": 4000, + "buckets": 128000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "e4e0df844494989c2361eaf5171c22a6cfacb3a07067af80cfc0ee085a47d47f" + }, + { + "n": 4000, + "mode": "nested", + "tempStore": "FILE", + "fail": false, + "failure": null, + "writeMs": 564.145292, + "cpuMs": 612.788, + "processPeakRSSBeforeValidation": 161136, + "counts": { + "parent": 4000, + "buckets": 128000 + }, + "fkViolations": 0, + "rollbackExact": null, + "digest": "e4e0df844494989c2361eaf5171c22a6cfacb3a07067af80cfc0ee085a47d47f" + }, + { + "n": 1000, + "mode": "nested", + "tempStore": "MEMORY", + "fail": true, + "failure": "FOREIGN KEY constraint failed", + "writeMs": 16.521791000000007, + "cpuMs": 27.439, + "processPeakRSSBeforeValidation": 87776, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": true, + "digest": "1eb0fa708e16ec0d06388a444d10adc8d624f20a8527627095bb4bef3bf8a37b" + }, + { + "n": 1000, + "mode": "outer", + "tempStore": "MEMORY", + "fail": true, + "failure": "FOREIGN KEY constraint failed", + "writeMs": 12.76279199999999, + "cpuMs": 23.636, + "processPeakRSSBeforeValidation": 80768, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": true, + "digest": "1eb0fa708e16ec0d06388a444d10adc8d624f20a8527627095bb4bef3bf8a37b" + }, + { + "n": 1000, + "mode": "chunked", + "tempStore": "MEMORY", + "fail": true, + "failure": "FOREIGN KEY constraint failed", + "writeMs": 15.415667, + "cpuMs": 26.35, + "processPeakRSSBeforeValidation": 85760, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": true, + "digest": "1eb0fa708e16ec0d06388a444d10adc8d624f20a8527627095bb4bef3bf8a37b" + }, + { + "n": 1000, + "mode": "nested", + "tempStore": "FILE", + "fail": true, + "failure": "FOREIGN KEY constraint failed", + "writeMs": 50.757875, + "cpuMs": 59.243, + "processPeakRSSBeforeValidation": 80880, + "counts": { + "parent": 1000, + "buckets": 32000 + }, + "fkViolations": 0, + "rollbackExact": true, + "digest": "1eb0fa708e16ec0d06388a444d10adc8d624f20a8527627095bb4bef3bf8a37b" + } + ], + "parityChecks": [ + { + "labels": [ + "real-baseline", + "real-outer-repeat", + "real-outer-stmtcache-repeat" + ], + "allEqual": true, + "excludedColumns": { + "nodes": [ + "updated_at" + ], + "edges": [ + "id" + ], + "files": [ + "indexed_at" + ], + "unresolved_refs": [ + "id" + ], + "node_aliases": [ + "created_at" + ], + "source_chunks": [ + "id" + ], + "schema_versions": [ + "applied_at" + ], + "project_metadata": [ + "updated_at" + ], + "node_fingerprints": [ + "ref" + ] + }, + "snapshotExcluded": [ + "indexedAt", + "lastSuccessfulIndexAt" + ], + "results": { + "real-baseline": { + "tables": { + "_mex_grounded_source": { + "rows": 13, + "sha256": "4799b3e1f4401c61c30b72a02cb99180821acf42d89152025109a9eabba8ed6e" + }, + "edges": { + "rows": 87048, + "sha256": "f4d6dc24d3ba892aae8368cfa3a646c21df8e258dab5761779c850d55b879a61" + }, + "files": { + "rows": 708, + "sha256": "59653fd28a849882736b4a1f59f088d1d25e42bf898da3cbc3a7668daed2cd21" + }, + "import_bindings": { + "rows": 11062, + "sha256": "92de1ec993b07fdd838b4253255a848edce82d3263c2b804c27a3c9d7b68ed54" + }, + "lsh_buckets": { + "rows": 748032, + "sha256": "f87bd5290d4282281805e952f1c6ee49099a95f0f030885970a4537a95f4ade8" + }, + "node_aliases": { + "rows": 1465, + "sha256": "495f16e9b73f4fb8aa2f8b757e9d43da446b6df340b8ee9cff1de669b80308af" + }, + "node_fingerprints": { + "rows": 23376, + "sha256": "4014ca2ecc981b15f9800b32a8250dda83b6e90cfd056fa419c7ac5bfab03273" + }, + "nodes": { + "rows": 33121, + "sha256": "abb2cd9fe0a142766d249d3bc84aee12b3be52d68c52c6b63a16205622481501" + }, + "project_metadata": { + "rows": 9, + "sha256": "75c72d2d96d0284c399b1a7cba61be4665a10faee651612acc6ba31a1cc7a698" + }, + "schema_versions": { + "rows": 4, + "sha256": "9ce76e9408211f3a56c2221a40c8a6624513dd0bd7214a1cbcb08375393c83f5" + }, + "source_chunks": { + "rows": 4587, + "sha256": "ead9cc9cd850c4d20b6e2dd169927d27570eeb87f379da0954ca289b3a96587e" + }, + "unresolved_refs": { + "rows": 87154, + "sha256": "295ad1cfb16b0fd0a22aaf9542e77270d2156b827ef65d954c00e11dd848ea5d" + } + }, + "foreignKeyViolations": [], + "quickCheck": [ + { + "quick_check": "ok" + } + ], + "fts": { + "graph": { + "nodes": 30, + "source": 30, + "sha256": "f5e96f006be23247cc7004e17b44b9093cd3255210075d0c1043bc5aadb470ba" + }, + "compiler": { + "nodes": 30, + "source": 30, + "sha256": "bcabe6b0ce020c5a8fa892430a89b09f3105c58e7bbcc4e892fa0d6900293082" + }, + "relay": { + "nodes": 30, + "source": 30, + "sha256": "2c58bea0e096e68446c2032249a55593505eea7b5e395d895d2f8b9d2727e287" + }, + "fingerprint": { + "nodes": 30, + "source": 30, + "sha256": "ee4552ac447f2dc476d0fc46e8e143a75c272e4bf32fdb5aacae9dc50b50e6f8" + } + } + }, + "real-outer-repeat": { + "tables": { + "_mex_grounded_source": { + "rows": 13, + "sha256": "4799b3e1f4401c61c30b72a02cb99180821acf42d89152025109a9eabba8ed6e" + }, + "edges": { + "rows": 87048, + "sha256": "f4d6dc24d3ba892aae8368cfa3a646c21df8e258dab5761779c850d55b879a61" + }, + "files": { + "rows": 708, + "sha256": "59653fd28a849882736b4a1f59f088d1d25e42bf898da3cbc3a7668daed2cd21" + }, + "import_bindings": { + "rows": 11062, + "sha256": "92de1ec993b07fdd838b4253255a848edce82d3263c2b804c27a3c9d7b68ed54" + }, + "lsh_buckets": { + "rows": 748032, + "sha256": "f87bd5290d4282281805e952f1c6ee49099a95f0f030885970a4537a95f4ade8" + }, + "node_aliases": { + "rows": 1465, + "sha256": "495f16e9b73f4fb8aa2f8b757e9d43da446b6df340b8ee9cff1de669b80308af" + }, + "node_fingerprints": { + "rows": 23376, + "sha256": "4014ca2ecc981b15f9800b32a8250dda83b6e90cfd056fa419c7ac5bfab03273" + }, + "nodes": { + "rows": 33121, + "sha256": "abb2cd9fe0a142766d249d3bc84aee12b3be52d68c52c6b63a16205622481501" + }, + "project_metadata": { + "rows": 9, + "sha256": "75c72d2d96d0284c399b1a7cba61be4665a10faee651612acc6ba31a1cc7a698" + }, + "schema_versions": { + "rows": 4, + "sha256": "9ce76e9408211f3a56c2221a40c8a6624513dd0bd7214a1cbcb08375393c83f5" + }, + "source_chunks": { + "rows": 4587, + "sha256": "ead9cc9cd850c4d20b6e2dd169927d27570eeb87f379da0954ca289b3a96587e" + }, + "unresolved_refs": { + "rows": 87154, + "sha256": "295ad1cfb16b0fd0a22aaf9542e77270d2156b827ef65d954c00e11dd848ea5d" + } + }, + "foreignKeyViolations": [], + "quickCheck": [ + { + "quick_check": "ok" + } + ], + "fts": { + "graph": { + "nodes": 30, + "source": 30, + "sha256": "f5e96f006be23247cc7004e17b44b9093cd3255210075d0c1043bc5aadb470ba" + }, + "compiler": { + "nodes": 30, + "source": 30, + "sha256": "bcabe6b0ce020c5a8fa892430a89b09f3105c58e7bbcc4e892fa0d6900293082" + }, + "relay": { + "nodes": 30, + "source": 30, + "sha256": "2c58bea0e096e68446c2032249a55593505eea7b5e395d895d2f8b9d2727e287" + }, + "fingerprint": { + "nodes": 30, + "source": 30, + "sha256": "ee4552ac447f2dc476d0fc46e8e143a75c272e4bf32fdb5aacae9dc50b50e6f8" + } + } + }, + "real-outer-stmtcache-repeat": { + "tables": { + "_mex_grounded_source": { + "rows": 13, + "sha256": "4799b3e1f4401c61c30b72a02cb99180821acf42d89152025109a9eabba8ed6e" + }, + "edges": { + "rows": 87048, + "sha256": "f4d6dc24d3ba892aae8368cfa3a646c21df8e258dab5761779c850d55b879a61" + }, + "files": { + "rows": 708, + "sha256": "59653fd28a849882736b4a1f59f088d1d25e42bf898da3cbc3a7668daed2cd21" + }, + "import_bindings": { + "rows": 11062, + "sha256": "92de1ec993b07fdd838b4253255a848edce82d3263c2b804c27a3c9d7b68ed54" + }, + "lsh_buckets": { + "rows": 748032, + "sha256": "f87bd5290d4282281805e952f1c6ee49099a95f0f030885970a4537a95f4ade8" + }, + "node_aliases": { + "rows": 1465, + "sha256": "495f16e9b73f4fb8aa2f8b757e9d43da446b6df340b8ee9cff1de669b80308af" + }, + "node_fingerprints": { + "rows": 23376, + "sha256": "4014ca2ecc981b15f9800b32a8250dda83b6e90cfd056fa419c7ac5bfab03273" + }, + "nodes": { + "rows": 33121, + "sha256": "abb2cd9fe0a142766d249d3bc84aee12b3be52d68c52c6b63a16205622481501" + }, + "project_metadata": { + "rows": 9, + "sha256": "75c72d2d96d0284c399b1a7cba61be4665a10faee651612acc6ba31a1cc7a698" + }, + "schema_versions": { + "rows": 4, + "sha256": "9ce76e9408211f3a56c2221a40c8a6624513dd0bd7214a1cbcb08375393c83f5" + }, + "source_chunks": { + "rows": 4587, + "sha256": "ead9cc9cd850c4d20b6e2dd169927d27570eeb87f379da0954ca289b3a96587e" + }, + "unresolved_refs": { + "rows": 87154, + "sha256": "295ad1cfb16b0fd0a22aaf9542e77270d2156b827ef65d954c00e11dd848ea5d" + } + }, + "foreignKeyViolations": [], + "quickCheck": [ + { + "quick_check": "ok" + } + ], + "fts": { + "graph": { + "nodes": 30, + "source": 30, + "sha256": "f5e96f006be23247cc7004e17b44b9093cd3255210075d0c1043bc5aadb470ba" + }, + "compiler": { + "nodes": 30, + "source": 30, + "sha256": "bcabe6b0ce020c5a8fa892430a89b09f3105c58e7bbcc4e892fa0d6900293082" + }, + "relay": { + "nodes": 30, + "source": 30, + "sha256": "2c58bea0e096e68446c2032249a55593505eea7b5e395d895d2f8b9d2727e287" + }, + "fingerprint": { + "nodes": 30, + "source": 30, + "sha256": "ee4552ac447f2dc476d0fc46e8e143a75c272e4bf32fdb5aacae9dc50b50e6f8" + } + } + } + } + }, + { + "labels": [ + "real-baseline", + "real-outer" + ], + "allEqual": true, + "excludedColumns": { + "nodes": [ + "updated_at" + ], + "edges": [ + "id" + ], + "files": [ + "indexed_at" + ], + "unresolved_refs": [ + "id" + ], + "node_aliases": [ + "created_at" + ], + "source_chunks": [ + "id" + ], + "schema_versions": [ + "applied_at" + ], + "project_metadata": [ + "updated_at" + ], + "node_fingerprints": [ + "ref" + ] + }, + "snapshotExcluded": [ + "indexedAt", + "lastSuccessfulIndexAt" + ], + "results": { + "real-baseline": { + "tables": { + "_mex_grounded_source": { + "rows": 13, + "sha256": "4799b3e1f4401c61c30b72a02cb99180821acf42d89152025109a9eabba8ed6e" + }, + "edges": { + "rows": 87048, + "sha256": "f4d6dc24d3ba892aae8368cfa3a646c21df8e258dab5761779c850d55b879a61" + }, + "files": { + "rows": 708, + "sha256": "59653fd28a849882736b4a1f59f088d1d25e42bf898da3cbc3a7668daed2cd21" + }, + "import_bindings": { + "rows": 11062, + "sha256": "92de1ec993b07fdd838b4253255a848edce82d3263c2b804c27a3c9d7b68ed54" + }, + "lsh_buckets": { + "rows": 748032, + "sha256": "f87bd5290d4282281805e952f1c6ee49099a95f0f030885970a4537a95f4ade8" + }, + "node_aliases": { + "rows": 1465, + "sha256": "495f16e9b73f4fb8aa2f8b757e9d43da446b6df340b8ee9cff1de669b80308af" + }, + "node_fingerprints": { + "rows": 23376, + "sha256": "4014ca2ecc981b15f9800b32a8250dda83b6e90cfd056fa419c7ac5bfab03273" + }, + "nodes": { + "rows": 33121, + "sha256": "abb2cd9fe0a142766d249d3bc84aee12b3be52d68c52c6b63a16205622481501" + }, + "project_metadata": { + "rows": 9, + "sha256": "75c72d2d96d0284c399b1a7cba61be4665a10faee651612acc6ba31a1cc7a698" + }, + "schema_versions": { + "rows": 4, + "sha256": "9ce76e9408211f3a56c2221a40c8a6624513dd0bd7214a1cbcb08375393c83f5" + }, + "source_chunks": { + "rows": 4587, + "sha256": "ead9cc9cd850c4d20b6e2dd169927d27570eeb87f379da0954ca289b3a96587e" + }, + "unresolved_refs": { + "rows": 87154, + "sha256": "295ad1cfb16b0fd0a22aaf9542e77270d2156b827ef65d954c00e11dd848ea5d" + } + }, + "foreignKeyViolations": [], + "quickCheck": [ + { + "quick_check": "ok" + } + ], + "fts": { + "graph": { + "nodes": 30, + "source": 30, + "sha256": "f5e96f006be23247cc7004e17b44b9093cd3255210075d0c1043bc5aadb470ba" + }, + "compiler": { + "nodes": 30, + "source": 30, + "sha256": "bcabe6b0ce020c5a8fa892430a89b09f3105c58e7bbcc4e892fa0d6900293082" + }, + "relay": { + "nodes": 30, + "source": 30, + "sha256": "2c58bea0e096e68446c2032249a55593505eea7b5e395d895d2f8b9d2727e287" + }, + "fingerprint": { + "nodes": 30, + "source": 30, + "sha256": "ee4552ac447f2dc476d0fc46e8e143a75c272e4bf32fdb5aacae9dc50b50e6f8" + } + } + }, + "real-outer": { + "tables": { + "_mex_grounded_source": { + "rows": 13, + "sha256": "4799b3e1f4401c61c30b72a02cb99180821acf42d89152025109a9eabba8ed6e" + }, + "edges": { + "rows": 87048, + "sha256": "f4d6dc24d3ba892aae8368cfa3a646c21df8e258dab5761779c850d55b879a61" + }, + "files": { + "rows": 708, + "sha256": "59653fd28a849882736b4a1f59f088d1d25e42bf898da3cbc3a7668daed2cd21" + }, + "import_bindings": { + "rows": 11062, + "sha256": "92de1ec993b07fdd838b4253255a848edce82d3263c2b804c27a3c9d7b68ed54" + }, + "lsh_buckets": { + "rows": 748032, + "sha256": "f87bd5290d4282281805e952f1c6ee49099a95f0f030885970a4537a95f4ade8" + }, + "node_aliases": { + "rows": 1465, + "sha256": "495f16e9b73f4fb8aa2f8b757e9d43da446b6df340b8ee9cff1de669b80308af" + }, + "node_fingerprints": { + "rows": 23376, + "sha256": "4014ca2ecc981b15f9800b32a8250dda83b6e90cfd056fa419c7ac5bfab03273" + }, + "nodes": { + "rows": 33121, + "sha256": "abb2cd9fe0a142766d249d3bc84aee12b3be52d68c52c6b63a16205622481501" + }, + "project_metadata": { + "rows": 9, + "sha256": "75c72d2d96d0284c399b1a7cba61be4665a10faee651612acc6ba31a1cc7a698" + }, + "schema_versions": { + "rows": 4, + "sha256": "9ce76e9408211f3a56c2221a40c8a6624513dd0bd7214a1cbcb08375393c83f5" + }, + "source_chunks": { + "rows": 4587, + "sha256": "ead9cc9cd850c4d20b6e2dd169927d27570eeb87f379da0954ca289b3a96587e" + }, + "unresolved_refs": { + "rows": 87154, + "sha256": "295ad1cfb16b0fd0a22aaf9542e77270d2156b827ef65d954c00e11dd848ea5d" + } + }, + "foreignKeyViolations": [], + "quickCheck": [ + { + "quick_check": "ok" + } + ], + "fts": { + "graph": { + "nodes": 30, + "source": 30, + "sha256": "f5e96f006be23247cc7004e17b44b9093cd3255210075d0c1043bc5aadb470ba" + }, + "compiler": { + "nodes": 30, + "source": 30, + "sha256": "bcabe6b0ce020c5a8fa892430a89b09f3105c58e7bbcc4e892fa0d6900293082" + }, + "relay": { + "nodes": 30, + "source": 30, + "sha256": "2c58bea0e096e68446c2032249a55593505eea7b5e395d895d2f8b9d2727e287" + }, + "fingerprint": { + "nodes": 30, + "source": 30, + "sha256": "ee4552ac447f2dc476d0fc46e8e143a75c272e4bf32fdb5aacae9dc50b50e6f8" + } + } + } + } + } + ], + "nativeSample": { + "file": "baseline-insert.sample.txt", + "mainThreadSamples": 1513, + "memjrnlTruncateSamples": 1233, + "physicalFootprintAsReported": "3.3G", + "peakPhysicalFootprintAsReported": "3.4G", + "sha256": "ccd680c0df5a1f603e4db13cab3ed7fe58f895b89975acba9d794aea0288cf0a" + }, + "baselineCpuProfile": { + "file": "CPU.20260909.020015.49429.0.001.cpuprofile", + "sha256": "a7c3ddfa920cf20574f8a062d3e1a1824462fbfde99b06593ead02559541beff", + "sampledInclusiveSeconds": { + "captureCallReference": 23.46, + "callSignatureRendering": 14.986, + "callSignatureResolution": 6.46, + "parameterIsInvoked": 0.586 + }, + "summary": { + "totalMs": 315235.25, + "top": [ + { + "name": "run", + "url": "file:///engine-profile.mjs", + "line": 701, + "ms": 242393.20499995296, + "samples": 95804 + }, + { + "name": "(garbage collector)", + "url": "", + "line": 0, + "ms": 14117.451999999908, + "samples": 5744 + }, + { + "name": "exec", + "url": "file:///engine-profile.mjs", + "line": 710, + "ms": 4146.125, + "samples": 1647 + }, + { + "name": "DatabaseSync.prepare", + "url": "file:///run-profile.mjs", + "line": 13, + "ms": 4052.062, + "samples": 1627 + }, + { + "name": "", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54851, + "ms": 3332.497999999998, + "samples": 1371 + }, + { + "name": "transaction", + "url": "file:///engine-profile.mjs", + "line": 718, + "ms": 1711.2489999999996, + "samples": 659 + }, + { + "name": "getAccessibleSymbolChainFromSymbolTable", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54824, + "ms": 1533.9939999999979, + "samples": 634 + }, + { + "name": "forEachEntry", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 16325, + "ms": 1509.1239999999987, + "samples": 617 + }, + { + "name": "get", + "url": "file:///engine-profile.mjs", + "line": 705, + "ms": 1409.1299999999999, + "samples": 563 + }, + { + "name": "all", + "url": "file:///engine-profile.mjs", + "line": 706, + "ms": 1313.7079999999996, + "samples": 530 + }, + { + "name": "digest", + "url": "node:internal/crypto/hash", + "line": 125, + "ms": 1120.0000000000007, + "samples": 449 + }, + { + "name": "Hash", + "url": "node:internal/crypto/hash", + "line": 66, + "ms": 946.8290000000005, + "samples": 374 + }, + { + "name": "canonicalJson", + "url": "file:///engine-profile.mjs", + "line": 1612, + "ms": 723.122, + "samples": 291 + }, + { + "name": "copyFile", + "url": "", + "line": 0, + "ms": 692.5419999999999, + "samples": 141 + }, + { + "name": "getSymbolIfSameReference", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54631, + "ms": 663.4189999999999, + "samples": 282 + }, + { + "name": "readFileUtf8", + "url": "", + "line": 0, + "ms": 613.124, + "samples": 245 + }, + { + "name": "(idle)", + "url": "", + "line": 0, + "ms": 611.8329999999999, + "samples": 247 + }, + { + "name": "getAliasForSymbolInContainer", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54612, + "ms": 600.7570000000002, + "samples": 256 + }, + { + "name": "trySymbolTable", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54842, + "ms": 549.2949999999997, + "samples": 229 + }, + { + "name": "normalizedAbsolute", + "url": "file:///engine-profile.mjs", + "line": 5095, + "ms": 519.332, + "samples": 213 + }, + { + "name": "createFingerprintInternal", + "url": "file:///engine-profile.mjs", + "line": 408, + "ms": 434.82899999999967, + "samples": 178 + }, + { + "name": "scan", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 12794, + "ms": 423.335, + "samples": 205 + }, + { + "name": "read", + "url": "", + "line": 0, + "ms": 417.2509999999997, + "samples": 167 + }, + { + "name": "", + "url": "file:///engine-profile.mjs", + "line": 447, + "ms": 406.1150000000001, + "samples": 163 + }, + { + "name": "update", + "url": "node:internal/crypto/hash", + "line": 107, + "ms": 335.21000000000004, + "samples": 138 + }, + { + "name": "getAccessibleSymbolChain", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54805, + "ms": 334.13100000000003, + "samples": 142 + }, + { + "name": "resolveNameHelper", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 23574, + "ms": 331.8369999999999, + "samples": 159 + }, + { + "name": "forEachSymbolTableInScope", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54738, + "ms": 328.4509999999999, + "samples": 145 + }, + { + "name": "identifierFragmentComponents", + "url": "file:///engine-profile.mjs", + "line": 1458, + "ms": 313.455, + "samples": 127 + }, + { + "name": "skipTrivia", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 11781, + "ms": 309.67199999999997, + "samples": 133 + }, + { + "name": "getCandidateListForSymbol", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54872, + "ms": 291.95899999999995, + "samples": 124 + }, + { + "name": "resolve", + "url": "node:path", + "line": 1210, + "ms": 282.7919999999999, + "samples": 117 + }, + { + "name": "close", + "url": "file:///engine-profile.mjs", + "line": 729, + "ms": 274.792, + "samples": 3 + }, + { + "name": "intersectsRanges", + "url": "file:///engine-profile.mjs", + "line": 4871, + "ms": 267.329, + "samples": 109 + }, + { + "name": "resolveExternalModuleNameWorker", + "url": "file:///node_modules/typescript/lib/typescript.js", + "line": 54013, + "ms": 251.66899999999998, + "samples": 103 + } + ] + } + } +} diff --git a/docs/design/code-graph-resource-investigation.md b/docs/design/code-graph-resource-investigation.md new file mode 100644 index 00000000..f73bce06 --- /dev/null +++ b/docs/design/code-graph-resource-investigation.md @@ -0,0 +1,152 @@ +# Code graph resource investigation + +Investigation date: 2026-09-09 (Asia/Kolkata). Source: `codex/0.8.1` at `35bfc075888706d6b105b39b0d711ff7a588b551`. This is the historical investigation before implementation; the experiments below did not change production code. Implementation follows on `codex/0.8.1-graph-performance`, targeting the release branch. Telemetry remains separate. + +## Finding and recommendation + +A full refresh of MEX's own repository spent **238.4 of 312.9 seconds saving fingerprints**. Computing those fingerprints took only **1.4 seconds**. The dominant delay was SQLite's native savepoint/journal bookkeeping, not parsing or hashing. + +An isolated experiment that reused the graph publisher's existing outer transaction reduced fingerprint persistence to **4.35 seconds** and the full engine refresh to **99.45 seconds**. All 12 normalized data tables and four representative ranked full-text queries matched. Independent smaller experiments reproduced poor scaling and verified rollback on injected failures. These are local diagnostic measurements, not promised timings for every repository. + +Prioritize that small storage correction, then bounded statement ownership and the remaining compiler working set. Keep heavy graph maintenance off the Hub HTTP thread through a separately reviewed candidate-builder process. True incremental indexing and a language rewrite are larger projects; the evidence does not require them to fix the measured bottleneck. + +## What was measured + +The Hub was stopped before investigation. Its existing index remained untouched. A closed, quiescent copy of `.mex/graph.db` seeded each independent experiment under the OS temporary directory. The engine read the real repository and installed dependencies, staging its output into those temporary databases. No live Hub or network telemetry was used in the profiling runs. + +Environment: macOS 26.0.1 arm64; Node 22.17.1; V8 12.4.254.21-node.27; bundled SQLite 3.50.0; TypeScript 5.9.3; web-tree-sitter 0.25.10. The input was **708 source files**, with 10,591,819 bytes in the compiler input batch. Extraction produced **33,121 nodes, 87,048 edges, 23,376 fingerprints and 748,032 LSH buckets**; all 708 files parsed successfully. The seed index held 681 files and 32,143 nodes. + +The profiler bundled the current internal engine with stage markers, counted native statement preparations, captured V8 CPU samples every 2 ms, and independently sampled RSS/CPU once per second. An external supervisor watched for 600 seconds or 4,608 MiB sampled RSS. This is a diagnostic guard, not a strict OS memory quota. The original supervisor checked its deadline after successful `ps` calls; monitoring failure could therefore weaken that guard. Completed runs had successful samples and stayed below it. The final heap-budget experiment used an improved supervisor that enforced the deadline independently and escalated termination after two seconds. + +These measurements cover `engine.sync`, including its database transaction, but exclude the outer maintenance service's candidate-copy/validation/atomic-file-publication overhead and Hub/browser costs. Instrumentation has overhead. Runs were sequential; a parity check briefly overlapped the start of the first repeated-cycle series, so that series is retained-memory evidence rather than a calibrated timing comparison. macOS caching, compression, scheduling and I/O introduce variance. Release performance claims need unprofiled repetitions on the pinned runner. + +### Original and transaction experiment + +| Engine stage | Current code | Outer transaction experiment | +|---|---:|---:| +| Compiler extraction | 46.15 s | 48.12 s | +| Fingerprint computation | 1.40 s | 1.39 s | +| Fingerprint persistence | 238.39 s | 4.35 s | +| Full engine refresh | 312.94 s | 99.45 s | +| Subsequent unchanged refresh | 0.311 s | 0.394 s | +| Lifetime peak RSS | 2,010 MiB | 1,786 MiB | +| Heap after close and diagnostic GC | 51.7 MiB | 49.7 MiB | + +The fingerprint write was about **55 times faster**, and full refresh time fell about **68%**. Compiler work did not improve. Both runs made **400,510 native statement preparations**, including the subsequent no-op check. Do not attribute the entire modest RSS difference to the transaction change: compiler GC and host behavior varied. + +The baseline native sample also recorded **3.4 GB peak macOS physical footprint**. The earlier Hub observation recorded 3.5 GB peak physical footprint. Physical footprint, RSS and live V8 heap are different metrics; those numbers must not be mixed into a claimed memory reduction. Node documents the separate memory categories and lifetime peak semantics in its [process API](https://nodejs.org/download/release/v22.17.1/docs/api/process.html#processmemoryusage). + +## Why SQLite becomes slow + +The graph publication transaction already owns rollback for all derived rows. Inside it, [`FingerprintStore.upsertMany`](../../src/graph/fingerprint-store.ts) creates another savepoint covering the entire fingerprint corpus. [`configureConnection`](../../src/graph/db/database.ts) forces `temp_store=MEMORY`. + +The native sample during fingerprint insertion spent 1,233 of 1,513 main-thread samples in `memjrnlTruncate`, beneath `sqlite3PagerSavepoint`, statement completion and `sqlite3_step`. This is approximately 81.5% of that sample window, not of the entire refresh. The whole-run V8 profile attributed approximately 242 seconds to the SQLite `run` wrapper. + +The exact [SQLite 3.50.0 memory-journal implementation](https://github.com/sqlite/sqlite/blob/version-3.50.0/src/memjournal.c#L260) stores journal data in linked chunks. Truncating to a nonzero retained position walks from the head to that position. The [pager implementation](https://github.com/sqlite/sqlite/blob/version-3.50.0/src/pager.c) retains pages required by the outer user savepoint while releasing internal statement savepoints. MEX's temporary-store setting forces this subjournal to stay in RAM, disabling its usual spill threshold. Repeated writes can therefore repeatedly traverse a growing retained prefix. This source-backed mechanism matches both the sample and the scaling experiment. SQLite explains why [statement journals](https://www.sqlite.org/tempfiles.html#statement_journal_files) are needed for partial-statement rollback. + +The temporary full-repository variant removed only the four inner savepoint/rollback statements. Every insertion, constraint, graph transaction and graph output rule remained the same. That is an experiment, not the proposed public API: **generic fingerprint writes must keep independent batch atomicity**, including when a caller catches an error and continues its transaction. A production bulk-write entry point should explicitly belong to the graph publisher's owning transaction, with failures escaping to its rollback. Do not infer that ownership just from “a transaction exists.” + +### Independent SQLite scaling and failure checks + +Disk-backed WAL databases using MEX's fingerprint schema were seeded with prior data, then replaced using four transaction strategies. Entries had 32 buckets each. Values below are single-run fingerprint write times. + +| Fingerprints | Current nested savepoint, RAM journal | Outer transaction only | 250-entry savepoints | Nested savepoint, disk journal | +|---|---:|---:|---:|---:| +| 1,000 | 56 ms | 33 ms | 39 ms | 174 ms | +| 2,000 | 268 ms | 64 ms | 85 ms | 270 ms | +| 4,000 | 1,679 ms | 141 ms | 210 ms | 564 ms | + +Four times the input produced roughly 30 times the current write time, versus 4.3 times with outer ownership. At 4,000 entries, measured process peak before final validation was 222 MiB versus 158 MiB; those peaks include identical seeding and checksum warmup, not just the journal. + +All variants produced identical ordered fingerprint/bucket hashes and zero foreign-key violations. All four injected mid-write foreign-key failures restored the exact pre-write hash through the owning outer rollback. Smaller savepoints still rely on outer rollback for whole-batch atomicity. Disk temporary storage preserves the nested transaction structure and helps larger cases, but has an I/O cost and was slower than explicit outer ownership. It was not tested on the full repository. + +An exact-schema `EXPLAIN` also ruled out a proposed missing-index explanation for the parent fingerprint UPSERT: it does not scan `lsh_buckets`, and adding a `ref` index did not change that statement's plan. Bulk deletion of fingerprint/LSH rows before graph node deletion is already implemented. Neither should be advertised as a new fix. + +The relevant memory-journal and pager functions remain unchanged in the inspected upstream source. A newer SQLite version is worth compatibility testing, but upgrading alone is not an evidenced cure for this operation pattern. + +## Memory: what is established, and what is still uncertain + +A large transient working set is established. A persistent multi-gigabyte JavaScript leak is not established by these runs. + +After the baseline refresh, live JS heap fell from about 2,308 MiB to 52 MiB after engine close and three diagnostic GCs separated by event-loop turns. A repeated experiment ran three equivalent full-corpus refreshes in the **same process**, reopening and closing the engine each time. It used the transaction improvement, with a nonempty source hint to force full staging while leaving source bytes unchanged. This is repeated-work retention evidence; it is not an actual-edit/invalidation test. + +| Cycle | Full engine refresh | Post-GC heap | Post-GC RSS | +|---|---:|---:|---:| +| 1 | 90.24 s | 51.7 MiB | 1,238 MiB | +| 2 | 79.19 s | 51.6 MiB | 1,011 MiB | +| 3 | 82.68 s | 52.5 MiB | 1,473 MiB | + +The live heap returns to essentially the same level, while RSS remains high and variable. Three cycles cannot exclude a slow leak, native leak or repository-specific problem. They support collectible compiler/staging allocations rather than a retained multi-gigabyte JS graph. No native allocator dominator analysis or long soak was performed. Forced GC is solely a diagnostic tool, not the recommended production solution. + +A separate bounded statement-reuse experiment cached at most 256 native statements per database and cleared the cache on close. The first forced refresh made **42 preparations instead of 399,092**; three refreshes used 124 in total instead of 1,103,322. Their durations were 75.62, 67.49 and 80.05 seconds. Normalized graph data and the four FTS query sets matched the baseline after all three cycles. This supports reusable bulk statements as a compute improvement, although these are single series with host/GC variance. + +It did **not establish a memory improvement**: post-GC heap was 51.7, 51.6 and 52.4 MiB, post-GC RSS was 1,862, 1,710 and 1,212 MiB, and lifetime peak RSS reached 2,522 MiB. SQL allocation churn is real, but it does not explain away the compiler working set. The experimental generic cache is not a ready implementation: production reuse must account for concurrent iterators, statement settings, transaction boundaries and database lifetime. + +Finally, the optimized temporary engine was run with `--max-old-space-size=1024`. It **aborted from V8 heap exhaustion after about 48 seconds**, during root-project compiler extraction, before fingerprint persistence or graph publication. The macOS crash record for the exact child PID contained `node::OOMErrorHandler` and `V8::FatalProcessOutOfMemory`. Last sampled peak RSS was about 1,269 MiB; this was a failed experiment, not a successful 1 GiB operating target. + +The failed experiment database remained byte-identical to its seed (SHA-256 `0b6dd1f8e161f19e4a15111f125a83d2245f01c01e791f21e38f2c8a4a61af41`) with an empty WAL. A 10.6 MB source spool survived the fatal exit because `finally` cannot run after process abort; that exact experiment spool was removed. This adds a concrete process-isolation requirement: the supervisor must own or know the temporary workspace and clean it safely on crash/OOM. Merely setting a low heap cap or wrapping the current call in `try/catch` is not a solution. + +The source audit found concrete amplification that remains relevant: + +- Programs are processed sequentially, but each can load many dependency files. Declared project roots/loaded SourceFiles/owned graph files were 7/292/7, 95/929/96, 6/289/6 and 442/1,216/442. An inferred program processed the remaining compiler candidates. Dependencies outside the source corpus do not count against the compiler source-byte ledger. The 128 MiB compiler-source cap is not a RAM cap. +- Compiler programs and tree-sitter trees already have release boundaries. Captured compiler data is plain records; per-file WASM trees are deleted in `finally`. No obvious unbounded global AST owner was found. Recommending those existing releases again would not address the current cost. +- Publication eagerly loads all old nodes and fingerprints. Alias generation then reloads all fresh nodes and creates lookup maps while staged and old data are still live. Continuity logic usually accepts a surviving ID before it needs fingerprint matching; demand-driven old fingerprint reads and reuse of staged nodes are promising follow-ups that must preserve ambiguity and rename rules. +- Resolved compiler references are omitted from SQLite since PR #174, but an extra corresponding record is still retained in the staged heap. It may be removable after necessary resolution/validation, with fallback import hydration preserved. +- Rich strings have measurable size: signatures totaled 3.59 million characters, with one 272,441-character signature; edge evidence totaled 27.13 million characters, plus 7.22 million metadata characters. These are potential allocation and serialization costs. Truncating canonical signatures without a compatibility design can change identities or evidence. + +Compiler extraction remains the next major compute cost. Inclusive CPU-profile ancestry attributed approximately 23.5 seconds to call-reference capture, including 15.0 seconds rendering resolved call signatures and 6.5 seconds resolving signatures. These sampled inclusive values overlap and exclude GC attribution; do not sum them with wall-clock stage totals. A suspected repeated callback-body scan used only about 0.59 seconds. It is redundant work, but not a priority for this repository. + +## Why the Hub can look stuck + +[`src/hub/jobs/index.ts`](../../src/hub/jobs/index.ts) schedules the executor with a Promise microtask; [`graph.ts`](../../src/hub/jobs/graph.ts) directly invokes the repository graph adapter. The compiler and [Node `DatabaseSync`](https://nodejs.org/download/release/v22.17.1/docs/api/sqlite.html#class-databasesync) execute synchronously on that same thread. Declaring these methods async does not let the HTTP server run during the work. + +Current maintenance progress mostly reports discovery, staging, validation and publication. Cancellation checks occur at coarse engine hooks; a queued HTTP or IPC cancellation message cannot itself interrupt synchronous compiler/SQLite work. Existing tests cover mocked pending work and already-aborted signals, not responsive cancellation during a real compiler run. No new end-to-end HTTP latency measurement was taken in this investigation because the Hub remained stopped. + +The first mitigation improves time but leaves tens of seconds of same-thread work. A single disposable child process for candidate construction is justified for responsiveness, cancellation and releasing native/JS process state at exit. That boundary does not itself reduce total compute or peak aggregate memory. Avoid a worker pool or serializing complete graph objects; exchange a candidate path and bounded, coalesced stage/count messages. + +Prefer parent ownership of the existing repository lease, final source/identity validation and atomic publication. Keep expensive staging and candidate inspection off the HTTP thread where practical. A failed/killed child must leave the previous graph usable, with no orphan writer and no late publication after cancellation. Parent death, shutdown, OOM and Windows open-file/sidecar cleanup need real tests. [Worker resource limits](https://nodejs.org/download/release/v22.17.1/docs/api/worker_threads.html#new-workerfilename-options) constrain the JS engine, exclude external allocations and do not prevent whole-process OOM; a worker is not a hard native memory boundary. + +## How this relates to issue #140 + +The [original report](https://github.com/mex-memory/mex/issues/140) described MEX 0.7.2 on macOS arm64 with Python, JavaScript and Svelte: widely varying build times and a `check` reaching roughly 12 GB before being killed. In the [reporter's later 0.7.3 follow-up](https://github.com/mex-memory/mex/issues/140#issuecomment-5445828682), `check` completed in 3.3 seconds without memory ballooning and repair recovered the graph, but rebuilds still took 11m34s to more than 30 minutes. Those are the reporter's historical observations, not current reproduction timings. + +[PR #147](https://github.com/mex-memory/mex/pull/147) already fixed important read-path, compiler/WASM lifetime and fingerprint storage costs. [PR #174](https://github.com/mex-memory/mex/pull/174) already removed persisted resolved-reference duplication and added corpus exclusions. Both are ancestors of this branch. JavaScript without a tsconfig can still enter MEX's inferred TypeScript program, as verified in current source. + +The reporter's repository was not available locally. We cannot assign its remaining rebuild variance to the newly demonstrated savepoint path without a matching profile. We can say that current code still contains a severe, independently reproduced graph-build bottleneck and substantial memory amplification. + +## Proposed release work, in order + +1. **Storage correction — small and focused.** Add an explicit bulk path owned by the graph publisher's existing transaction; preserve generic fingerprint atomicity, duplicate handling, stable integer refs, constraints and every row value. Include exact-output parity and failures after both a fingerprint write and a bucket write, plus existing graph transaction/publication recovery tests. No schema or public protocol change is needed. +2. **Bound statement lifetime and reduce proven overlap — small to medium.** Reuse fixed prepared statements within a bulk operation/connection. Avoid a global cache; handle active iterators and close/reopen correctly. Then measure demand-driven continuity reads and staged-node reuse individually. Keep speculative compiler caches, identity changes and broad storage redesign outside this checkpoint. +3. **Isolate Hub graph construction — medium, separately reviewed.** One internal candidate-builder process, small progress protocol, supervised cancellation and safe failure. Preserve the graph adapter and publication contract. Plan this for 0.8.1 because the reported Hub stall remains after the storage fix; if it expands into a recovery redesign or cannot pass Windows/package tests, ship the storage correction independently and explicitly schedule isolation immediately afterward. Do not call the Hub issue fully fixed while heavy work still blocks it. +4. **Representative regression coverage alongside those changes.** Keep current budgets intact. Add a realistic multi-project/dependency fixture, repeated changed work, real HTTP/cancel responsiveness, and total Hub-plus-child CPU/RSS if isolation is introduced. Calibrate new gates on the pinned runner, not from this laptop. + +Do not put true dependency-aware incremental indexing, a persistent compiler daemon or a native engine rewrite into this small release. A nonempty supported-source change currently triggers whole-corpus staging to preserve cross-file semantic convergence; incremental updates must account for imports, exported types, moves/deletes, configuration, aliases and provenance. TypeScript's [builder API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#writing-an-incremental-program-watcher) does not maintain MEX's graph semantics automatically. The inspected [native TypeScript project](https://github.com/microsoft/typescript-go) lists its compiler API as not ready, so it is not a demonstrated drop-in replacement. The dominant work found here already runs in SQLite's native C. + +## Why the existing performance gate missed this + +The release fixtures contain **4, 16 and 48 tiny source modules**, one tsconfig, and no installed dependency graph. Their “large” designation means the largest synthetic fixture. The current 1,812 ms refresh budget and 604,061,696-byte refresh RSS ceiling are regression bounds for that fixture, not a resource promise for a 708-file dependency-heavy repository. + +Maintenance timing runs in one warmed Hub, but RSS is retained only for the first five of ten repetitions. The benchmark does not currently enforce stage CPU, post-cleanup retained-memory trends, concurrent HTTP latency or actual cancellation latency. Its sampler watches only the Hub PID: moving work to a child without changing that sampler would hide the cost. The polling loop also needs request timeouts so a blocked fetch cannot bypass its overall deadline. + +Add representative coverage without widening existing calibrated budgets. For storage, test clean build, unchanged refresh, meaningful changed refresh and failure rollback. For process isolation, verify real extraction-time HTTP responses, cancellation acknowledgment plus actual stop, child crash/OOM, parent shutdown and no late publish. Keep Windows/macOS/Linux packaging and ownership tests because process and temporary-file behavior differ. + +## Evidence retained and validation boundary + +[Measurement summary](code-graph-resource-investigation.json) retains runtime versions, source provenance, stage/memory observations, normalized table hashes, microbenchmark results and experiment limitations. Local raw CPU profiles, RSS JSONL, native samples, bundles and comparison scripts remain under `/tmp/mex-graph-investigation-20260909`; they were not added to Git. These contain repository paths/source-related profiler information and were not uploaded. + +To reproduce: check out the recorded commit, install matching dependencies, stop maintenance, verify the seed DB has no authoritative WAL, copy it to temporary storage, and bundle the internal engine outside the indexed corpus. Compare the same `sync` input and seed with only the targeted transform enabled. Record wall/CPU, memory categories, source/config/dependency identity and output digests. For retention, reopen/close the engine within one process and force equivalent full staging several times; use diagnostic GC only in the profiler. For release numbers, repeat without CPU profiling and synchronous stage logging. + +Successful data comparison included nodes, edges, files, unresolved references, import bindings, aliases, source chunks, fingerprints, LSH joins by semantic node identity, grounding baselines, schema versions and metadata. Exclusions were generated row IDs and operational write/index timestamps; snapshot source/config/compiler/grammar digests and Git coordinates remained compared. Both databases passed `quick_check` and foreign-key checks. Four ranked node/source FTS query result sets matched. This does not claim byte-identical database files, exhaustive FTS/API equivalence, or production failure-path coverage. + +Verification for this investigation: + +- Public API and emitted declarations: unchanged. +- Ordinary reads, authority and snapshot publication: production code unchanged; experiments wrote only temporary copies. +- Bounds: no production limits changed; sequential temporary runs used the diagnostic supervisor described above. +- Focused validation: full-data parity, SQLite scaling/failure checks and repeated-process observations completed. Typecheck/full tests were not run because no production TypeScript changed. +- Build/evaluator: no package or graph protocol change; no production rebuild or evaluator regeneration performed. +- Working tree: report and focused MEX context notes only; generated databases, profiles, local state and builds remain untracked/ignored outside the change. +- Protocol and recovery: current behavior preserved; proposed implementation still requires the targeted tests listed above. + +MEX context used: architecture, `safe-graph-snapshot-evolution`, `release-performance-gate`, and `fresh-graph-hub-integration`. Historical Timeline search returned no matching notes. The investigation does not re-baseline grounding or promote speculative optimization claims as implemented behavior. diff --git a/docs/design/graph-isolation-diagnostic.json b/docs/design/graph-isolation-diagnostic.json new file mode 100644 index 00000000..dd31166a --- /dev/null +++ b/docs/design/graph-isolation-diagnostic.json @@ -0,0 +1,773 @@ +{ + "kind": "local-causal-diagnostic-not-pinned-calibration", + "purpose": "Bounded same-code causal diagnostic of disposable graph candidate processes versus a reused in-process runtime. This local result is not pinned release calibration and must not supply release budget values.", + "generatedAt": "2026-09-08T23:17:48.191Z", + "sourceHead": "4d6683eec1a0bdcafe99d7b431d84cde7f02864d", + "worktreeCleanAfterMeasurement": true, + "rawReportSha256": "7926b080d863bc40ac00dddc1c733e14d65d117f143424d4b3ff8629dd9fcdfb", + "environment": { + "node": "v22.17.1", + "sqlite": "3.50.0", + "v8": "12.4.254.21-node.27", + "platform": "darwin", + "arch": "arm64" + }, + "design": { + "fixture": "release-small-four-TypeScript-files", + "warmupsPerMode": 2, + "measuredPerMode": 3, + "sameOptimizedEngineAndParentValidation": true, + "separatePersistentParentPerMode": true, + "forcedGc": false, + "resourceScope": "maintenance parent and observed descendants", + "sampler": { + "scope": "hub-and-descendants", + "rss": "sum-of-resident-sets-in-one-sample", + "cpu": "observed-process-lifetime-deltas", + "intervalMs": 100, + "limitation": "Shared pages may be counted twice; short-lived children and final CPU between samples may be missed." + }, + "note": "Direct rebuildGraph diagnostic, not a Hub HTTP benchmark. Both modes include the same parent maintenance and publication validation. Process mode launches a disposable current-source candidate each time; in-process mode reuses loaded runtime/JIT state.", + "modeOrder": [ + "process", + "in-process" + ], + "limitations": "One local macOS allocation and fixed mode order; three measured runs per mode establish a bounded causal diagnostic, not pinned release calibration or an exact import/JIT/watchdog cost decomposition.", + "harness": "Temporary source bundles built with esbuild (Node 22 target, ESM, external dependencies) against the checkout dependency installation. Schema and grammar assets copied alongside. Separate copies of the initialized frozen small release fixture; each mode uses one persistent parent for five rebuilds. No measured Node flags or forced GC.", + "bounds": { + "externalDeadlineMs": 120000, + "workerReplyDeadlineMs": 30000, + "aggregateSampledRssGuardBytes": 2147483648 + } + }, + "bundleHashes": { + "worker.mjs": "5179040af2e643c9a967b687e6151acb4c81f4de8475d2bad92bb9ab88168ee8", + "graph-candidate.js": "55e89707ff0baa2516b049e56090c4629cb6e69091caee715ae3413f2952a3e3" + }, + "measurement": { + "elapsedMs": "Time around rebuildGraph only; excludes the following logical graph digest, IPC delivery and sampler shutdown.", + "parentCpuMs": "process.cpuUsage delta around rebuildGraph in its persistent parent; excludes candidate child CPU.", + "marks": "Milliseconds from the rebuildGraph start to observed progress or internal hook callbacks; callbacks available in each mode differ.", + "returnMemory": "Parent process.memoryUsage after rebuildGraph and the logical graph digest; all fields are bytes. This is a surviving-process snapshot, not a forced-GC retained-heap measurement.", + "resources": "Aggregate sampled maintenance parent and observed descendants, from before the invocation through result delivery, including the digest and IPC interval. RSS fields are bytes, cpuMs is observed process-lifetime CPU deltas; maxProcesses is the largest sampled process count.", + "summary": "Per-mode elapsed medians and aggregate peak RSS use only the three measured iterations. Final parent RSS is from the last iteration. All ten original iterations remain below.", + "samplerScopeNote": "The reused release sampler metadata names its root hub-and-descendants; this diagnostic roots it at the direct maintenance worker, with no Hub HTTP server." + }, + "parity": { + "allTenSucceededAndFresh": true, + "commonResult": { + "state": "succeeded", + "filesIndexed": 4, + "nodesCreated": 32, + "edgesCreated": 38, + "status": "fresh", + "parseHealth": { + "total": 4, + "ok": 4, + "partial": 0, + "failed": 0, + "failedPaths": [], + "failedPathsTruncated": false + } + }, + "allCoreDigestsEqual": true, + "allCountsEqual": true, + "digestMethod": { + "algorithm": "SHA-256 of JSON.stringify({nodes,edges,files,fingerprints,refs}).", + "nodes": "All fields except updated_at; rows sorted by JSON.stringify(row).localeCompare.", + "edges": "All fields except autoincrement id; same row sorting.", + "files": "All fields except modified_at and indexed_at; same row sorting.", + "fingerprints": "node_id, hex(minhash) AS minhash, neighbors, token_count, ordered by node_id.", + "refs": "All unresolved_refs fields except autoincrement id; same row sorting.", + "scope": "Logical core graph parity only; does not compare every table, timestamps, metadata or physical SQLite bytes." + } + }, + "samples": [ + { + "mode": "process", + "iteration": 1, + "warmup": true, + "elapsedMs": 723.099625, + "parentCpuMs": 84.225, + "marks": [ + { + "phase": "discover", + "atMs": 15.368707999999998 + }, + { + "phase": "stage", + "atMs": 54.215457999999984 + }, + { + "phase": "candidate_spawn", + "atMs": 55.55016599999999 + }, + { + "phase": "parse", + "atMs": 374.1006249999999 + }, + { + "phase": "resolve", + "atMs": 619.751166 + }, + { + "phase": "candidate_built", + "atMs": 660.518375 + }, + { + "phase": "validate", + "atMs": 660.5649999999999 + }, + { + "phase": "candidate_validated", + "atMs": 682.991958 + }, + { + "phase": "publish", + "atMs": 691.125541 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 162807808, + "heapTotal": 54312960, + "heapUsed": 42425752, + "external": 15931914, + "arrayBuffers": 13751505 + }, + "resources": { + "rssBytes": 162824192, + "peakRssBytes": 395182080, + "cpuMs": 1040, + "maxProcesses": 2, + "sampleCount": 9 + } + }, + { + "mode": "process", + "iteration": 2, + "warmup": true, + "elapsedMs": 904.0846670000001, + "parentCpuMs": 54.588, + "marks": [ + { + "phase": "discover", + "atMs": 10.706292000000076 + }, + { + "phase": "stage", + "atMs": 35.59466700000007 + }, + { + "phase": "candidate_spawn", + "atMs": 36.57787500000006 + }, + { + "phase": "parse", + "atMs": 557.8742500000001 + }, + { + "phase": "resolve", + "atMs": 808.972334 + }, + { + "phase": "candidate_built", + "atMs": 847.72775 + }, + { + "phase": "validate", + "atMs": 847.7717500000001 + }, + { + "phase": "candidate_validated", + "atMs": 868.7050839999999 + }, + { + "phase": "publish", + "atMs": 876.963375 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 168689664, + "heapTotal": 54575104, + "heapUsed": 39737784, + "external": 2273319, + "arrayBuffers": 92870 + }, + "resources": { + "rssBytes": 168689664, + "peakRssBytes": 405307392, + "cpuMs": 1060, + "maxProcesses": 2, + "sampleCount": 10 + } + }, + { + "mode": "process", + "iteration": 3, + "warmup": false, + "elapsedMs": 876.0462910000001, + "parentCpuMs": 44.764, + "marks": [ + { + "phase": "discover", + "atMs": 11.62154099999998 + }, + { + "phase": "stage", + "atMs": 35.61345800000004 + }, + { + "phase": "candidate_spawn", + "atMs": 36.47279100000014 + }, + { + "phase": "parse", + "atMs": 527.4614999999999 + }, + { + "phase": "resolve", + "atMs": 778.0508330000002 + }, + { + "phase": "candidate_built", + "atMs": 818.0947500000002 + }, + { + "phase": "validate", + "atMs": 818.1847910000001 + }, + { + "phase": "candidate_validated", + "atMs": 838.8774579999999 + }, + { + "phase": "publish", + "atMs": 847.9189160000001 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 169541632, + "heapTotal": 54837248, + "heapUsed": 43514160, + "external": 15932052, + "arrayBuffers": 13751603 + }, + "resources": { + "rssBytes": 169656320, + "peakRssBytes": 396771328, + "cpuMs": 920, + "maxProcesses": 3, + "sampleCount": 10 + } + }, + { + "mode": "process", + "iteration": 4, + "warmup": false, + "elapsedMs": 879.5032920000003, + "parentCpuMs": 64.856, + "marks": [ + { + "phase": "discover", + "atMs": 14.678417000000081 + }, + { + "phase": "stage", + "atMs": 39.59062500000027 + }, + { + "phase": "candidate_spawn", + "atMs": 40.370792000000165 + }, + { + "phase": "parse", + "atMs": 534.1774170000003 + }, + { + "phase": "resolve", + "atMs": 786.0798750000004 + }, + { + "phase": "candidate_built", + "atMs": 822.985709 + }, + { + "phase": "validate", + "atMs": 823.0244170000001 + }, + { + "phase": "candidate_validated", + "atMs": 842.3342500000003 + }, + { + "phase": "publish", + "atMs": 851.2854590000002 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 171524096, + "heapTotal": 54837248, + "heapUsed": 37172464, + "external": 2249601, + "arrayBuffers": 69152 + }, + "resources": { + "rssBytes": 171524096, + "peakRssBytes": 417382400, + "cpuMs": 1080, + "maxProcesses": 2, + "sampleCount": 10 + } + }, + { + "mode": "process", + "iteration": 5, + "warmup": false, + "elapsedMs": 882.3670000000002, + "parentCpuMs": 69.309, + "marks": [ + { + "phase": "discover", + "atMs": 18.476292000000285 + }, + { + "phase": "stage", + "atMs": 40.62395900000001 + }, + { + "phase": "candidate_spawn", + "atMs": 41.56916700000011 + }, + { + "phase": "parse", + "atMs": 531.2083750000002 + }, + { + "phase": "resolve", + "atMs": 788.0578750000004 + }, + { + "phase": "candidate_built", + "atMs": 826.7629590000006 + }, + { + "phase": "validate", + "atMs": 826.803167 + }, + { + "phase": "candidate_validated", + "atMs": 846.7960840000005 + }, + { + "phase": "publish", + "atMs": 854.2553749999997 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 171917312, + "heapTotal": 55099392, + "heapUsed": 37237992, + "external": 2249594, + "arrayBuffers": 69145 + }, + "resources": { + "rssBytes": 171917312, + "peakRssBytes": 423313408, + "cpuMs": 1090, + "maxProcesses": 2, + "sampleCount": 10 + } + }, + { + "mode": "in-process", + "iteration": 1, + "warmup": true, + "elapsedMs": 409.47450000000003, + "parentCpuMs": 803.312, + "marks": [ + { + "phase": "discover", + "atMs": 15.52600000000001 + }, + { + "phase": "stage", + "atMs": 55.139667 + }, + { + "phase": "candidate_built", + "atMs": 351.91925000000003 + }, + { + "phase": "validate", + "atMs": 351.98066700000004 + }, + { + "phase": "candidate_validated", + "atMs": 373.81508400000007 + }, + { + "phase": "publish", + "atMs": 380.37912500000004 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 244318208, + "heapTotal": 124092416, + "heapUsed": 94566096, + "external": 14909094, + "arrayBuffers": 12728685 + }, + "resources": { + "rssBytes": 244285440, + "peakRssBytes": 244285440, + "cpuMs": 810, + "maxProcesses": 1, + "sampleCount": 5 + } + }, + { + "mode": "in-process", + "iteration": 2, + "warmup": true, + "elapsedMs": 260.835333, + "parentCpuMs": 451.76, + "marks": [ + { + "phase": "discover", + "atMs": 14.208292000000029 + }, + { + "phase": "stage", + "atMs": 39.153625000000034 + }, + { + "phase": "candidate_built", + "atMs": 200.53437500000007 + }, + { + "phase": "validate", + "atMs": 200.604875 + }, + { + "phase": "candidate_validated", + "atMs": 223.77587500000004 + }, + { + "phase": "publish", + "atMs": 232.78370800000005 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 286752768, + "heapTotal": 109363200, + "heapUsed": 46445176, + "external": 14875744, + "arrayBuffers": 12695295 + }, + "resources": { + "rssBytes": 286752768, + "peakRssBytes": 294404096, + "cpuMs": 450, + "maxProcesses": 2, + "sampleCount": 4 + } + }, + { + "mode": "in-process", + "iteration": 3, + "warmup": false, + "elapsedMs": 228.46966699999996, + "parentCpuMs": 306.749, + "marks": [ + { + "phase": "discover", + "atMs": 14.26237500000002 + }, + { + "phase": "stage", + "atMs": 38.24895900000001 + }, + { + "phase": "candidate_built", + "atMs": 173.80383399999994 + }, + { + "phase": "validate", + "atMs": 173.85683400000005 + }, + { + "phase": "candidate_validated", + "atMs": 193.550167 + }, + { + "phase": "publish", + "atMs": 201.24904200000003 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 294895616, + "heapTotal": 125927424, + "heapUsed": 95962496, + "external": 27523170, + "arrayBuffers": 25342721 + }, + "resources": { + "rssBytes": 294895616, + "peakRssBytes": 295157760, + "cpuMs": 310, + "maxProcesses": 2, + "sampleCount": 4 + } + }, + { + "mode": "in-process", + "iteration": 4, + "warmup": false, + "elapsedMs": 220.15945899999997, + "parentCpuMs": 243.71, + "marks": [ + { + "phase": "discover", + "atMs": 13.114542000000029 + }, + { + "phase": "stage", + "atMs": 37.133959000000004 + }, + { + "phase": "candidate_built", + "atMs": 166.501667 + }, + { + "phase": "validate", + "atMs": 166.54874999999993 + }, + { + "phase": "candidate_validated", + "atMs": 185.73087499999997 + }, + { + "phase": "publish", + "atMs": 193.30279199999995 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 302596096, + "heapTotal": 172113920, + "heapUsed": 142000784, + "external": 28605640, + "arrayBuffers": 26425191 + }, + "resources": { + "rssBytes": 302596096, + "peakRssBytes": 302596096, + "cpuMs": 250, + "maxProcesses": 1, + "sampleCount": 4 + } + }, + { + "mode": "in-process", + "iteration": 5, + "warmup": false, + "elapsedMs": 219.6812910000001, + "parentCpuMs": 244.137, + "marks": [ + { + "phase": "discover", + "atMs": 13.49958300000003 + }, + { + "phase": "stage", + "atMs": 36.46279100000015 + }, + { + "phase": "candidate_built", + "atMs": 163.14637500000003 + }, + { + "phase": "validate", + "atMs": 163.19095800000014 + }, + { + "phase": "candidate_validated", + "atMs": 184.39962500000001 + }, + { + "phase": "publish", + "atMs": 192.67262500000015 + } + ], + "graph": { + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "sha256": "3aab6bb4fc5db62e8dd60c90428f49dd08629937307b01897cfe4d8b08a7ba04" + }, + "returnMemory": { + "rss": 336035840, + "heapTotal": 130334720, + "heapUsed": 50367624, + "external": 14899399, + "arrayBuffers": 12718950 + }, + "resources": { + "rssBytes": 336035840, + "peakRssBytes": 336216064, + "cpuMs": 240, + "maxProcesses": 2, + "sampleCount": 4 + } + } + ], + "summary": { + "process": { + "samples": [ + 876.0462910000001, + 879.5032920000003, + 882.3670000000002 + ], + "medianMs": 879.5032920000003, + "maxRssBytes": 423313408, + "observedCpuMs": [ + 920, + 1080, + 1090 + ], + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "finalParentRssBytes": 171917312, + "sampleOrder": "Elapsed samples are sorted ascending; observedCpuMs follows measured iteration order. Per-iteration pairs are retained in samples." + }, + "in-process": { + "samples": [ + 219.6812910000001, + 220.15945899999997, + 228.46966699999996 + ], + "medianMs": 220.15945899999997, + "maxRssBytes": 336216064, + "observedCpuMs": [ + 310, + 250, + 240 + ], + "counts": { + "nodes": 32, + "edges": 38, + "files": 4, + "fingerprints": 16, + "refs": 8 + }, + "finalParentRssBytes": 336035840, + "sampleOrder": "Elapsed samples are sorted ascending; observedCpuMs follows measured iteration order. Per-iteration pairs are retained in samples." + } + }, + "interpretation": { + "medianDifferenceMs": 659.3438330000004, + "medianRatio": 3.994846716987983, + "finding": "On this small fixture, disposable candidate processes take longer and have a higher sampled aggregate peak RSS, while leaving a lower-RSS surviving parent. Equal logical core outputs and matching parent validation support attribution to candidate lifecycle/runtime reuse rather than different graph work.", + "limits": "One local macOS allocation, fixed mode order and three measured iterations per mode. No precise import/JIT/watchdog cost decomposition, general peak-memory improvement, leak conclusion, large-project forecast or pinned budget calibration is established." + }, + "outcome": "passed", + "totalDiagnosticElapsedMs": 8658.5395 +} diff --git a/docs/design/graph-maintenance-timing-calibration.json b/docs/design/graph-maintenance-timing-calibration.json new file mode 100644 index 00000000..020591d0 --- /dev/null +++ b/docs/design/graph-maintenance-timing-calibration.json @@ -0,0 +1,871 @@ +{ + "schemaVersion": 1, + "kind": "graph-maintenance-timing-calibration", + "source": { + "runUrl": "https://github.com/mex-memory/mex/actions/runs/34288560611", + "pullRequest": 180, + "pullRequestHead": "4d6683eec1a0bdcafe99d7b431d84cde7f02864d", + "repositoryHead": "6d92bb04d757c8a00693ef679d1f4281669a9b57", + "primaryAttempt": "first", + "primarySelection": "The first corrected pinned attempt is the deterministic calibration source; the second independently confirms the old-budget tradeoff and is not pooled or selected per metric.", + "finalReportSha256": "66bb54c302ea6cb41084c668007711eacf65abd83931f2fe81d325277ef746a5" + }, + "environment": { + "os": "ubuntu-24.04", + "platform": "linux", + "architecture": "x64", + "node": "v22.22.0", + "pinnedBudgetEnvironment": true + }, + "configuration": { + "fixtureProfiles": { + "small": { + "sourceFiles": 4, + "wikiEntities": 4, + "workstreams": 1, + "inboxDrafts": 1, + "inboxProposals": 1, + "members": 2, + "relayDrafts": 1, + "relays": 1, + "activityEvents": 4 + }, + "medium": { + "sourceFiles": 16, + "wikiEntities": 16, + "workstreams": 1, + "inboxDrafts": 1, + "inboxProposals": 1, + "members": 2, + "relayDrafts": 1, + "relays": 1, + "activityEvents": 16 + }, + "large": { + "sourceFiles": 48, + "wikiEntities": 48, + "workstreams": 1, + "inboxDrafts": 1, + "inboxProposals": 1, + "members": 2, + "relayDrafts": 1, + "relays": 1, + "activityEvents": 48 + } + }, + "processMeasurement": { + "scope": "hub-and-descendants", + "rss": "sum-of-resident-sets-in-one-sample", + "cpu": "observed-process-lifetime-deltas", + "intervalMs": 10, + "limitation": "Shared pages may be counted twice; short-lived children and final CPU between samples may be missed." + }, + "maintenanceObservation": "job-event-stream", + "samples": { + "timing": 10, + "idleMemory": 5 + }, + "runtimeBudgetsEnforced": true, + "assetBudgetsEnforced": true, + "provisionalBudgets": false + }, + "formula": "ceil(measured p95 * 1.15)", + "sampleCount": 10, + "previousCalibrationStatus": "pinned-checkpoints-A-G-and-Settings-34286120355", + "unownedBudgetSha256": "d4209e5549ed49c37dcd4e4814eab0ceb7640124d75bdf433347ea9de1514c80", + "calibratedLeaves": [ + { + "metric": "runtime.maintenanceMs.small.graph_refresh", + "previousBudgetMs": 984, + "primaryP95Ms": 1420.61, + "budgetMs": 1634 + }, + { + "metric": "runtime.maintenanceMs.small.graph_rebuild", + "previousBudgetMs": 496, + "primaryP95Ms": 1468.48, + "budgetMs": 1689 + }, + { + "metric": "runtime.maintenanceMs.medium.graph_refresh", + "previousBudgetMs": 1237, + "primaryP95Ms": 1600.897, + "budgetMs": 1842 + }, + { + "metric": "runtime.maintenanceMs.medium.graph_rebuild", + "previousBudgetMs": 743, + "primaryP95Ms": 1581.908, + "budgetMs": 1820 + }, + { + "metric": "runtime.maintenanceMs.large.graph_rebuild", + "previousBudgetMs": 1229, + "primaryP95Ms": 1980.154, + "budgetMs": 2278 + } + ], + "unchangedLargeGraphRefreshMs": 1812, + "attempts": [ + { + "role": "primary", + "manifest": { + "schemaVersion": 1, + "kind": "mex-release-benchmark-attempt", + "repositoryHead": "6d92bb04d757c8a00693ef679d1f4281669a9b57", + "github": { + "runId": "34288560611", + "runAttempt": "1", + "sha": "6d92bb04d757c8a00693ef679d1f4281669a9b57" + }, + "runnerAllocation": { + "job": "release_performance_attempt_1", + "runnerName": "GitHub Actions 1000000935", + "runnerOs": "Linux", + "runnerArch": "X64" + }, + "rawReportSha256": "fec98eae2728a017fcaf3480d275d47180d88740b248468d69a6fa858768c9b3", + "retryRequired": true, + "disposition": "confirmation_required" + }, + "graph": { + "small": { + "graph_refresh": { + "elapsedMs": { + "samples": [ + 1341.863, + 1345.533, + 1368.565, + 1373.907, + 1378.077, + 1383.913, + 1387.332, + 1390.599, + 1416.879, + 1420.61 + ], + "min": 1341.863, + "median": 1380.995, + "p95": 1420.61, + "max": 1420.61 + }, + "peakRssBytes": { + "samples": [ + 517021696, + 521039872, + 522711040, + 523661312, + 526897152 + ], + "min": 517021696, + "median": 522711040, + "p95": 526897152, + "max": 526897152 + }, + "cpuMs": { + "samples": [ + 2390, + 2410, + 2430, + 2450, + 2460, + 2460, + 2490, + 2500, + 2520, + 2520 + ], + "min": 2390, + "median": 2460, + "p95": 2520, + "max": 2520 + } + }, + "graph_rebuild": { + "elapsedMs": { + "samples": [ + 1391.8, + 1391.816, + 1398.561, + 1413.862, + 1417.463, + 1423.925, + 1429.694, + 1430.895, + 1431.209, + 1468.48 + ], + "min": 1391.8, + "median": 1420.694, + "p95": 1468.48, + "max": 1468.48 + }, + "peakRssBytes": { + "samples": [ + 422674432, + 423641088, + 429113344, + 430256128, + 430649344 + ], + "min": 422674432, + "median": 429113344, + "p95": 430649344, + "max": 430649344 + }, + "cpuMs": { + "samples": [ + 2520, + 2530, + 2570, + 2590, + 2610, + 2630, + 2640, + 2640, + 2660, + 2800 + ], + "min": 2520, + "median": 2620, + "p95": 2800, + "max": 2800 + } + } + }, + "medium": { + "graph_refresh": { + "elapsedMs": { + "samples": [ + 1520.31, + 1531.869, + 1532.251, + 1534.805, + 1542.041, + 1551.169, + 1560.108, + 1577.819, + 1581.672, + 1600.897 + ], + "min": 1520.31, + "median": 1546.605, + "p95": 1600.897, + "max": 1600.897 + }, + "peakRssBytes": { + "samples": [ + 598917120, + 600551424, + 600866816, + 601272320, + 635428864 + ], + "min": 598917120, + "median": 600866816, + "p95": 635428864, + "max": 635428864 + }, + "cpuMs": { + "samples": [ + 2740, + 2760, + 2780, + 2800, + 2800, + 2800, + 2850, + 2850, + 2900, + 2960 + ], + "min": 2740, + "median": 2800, + "p95": 2960, + "max": 2960 + } + }, + "graph_rebuild": { + "elapsedMs": { + "samples": [ + 1546.797, + 1555.926, + 1556.308, + 1557.454, + 1566.463, + 1567.502, + 1576.954, + 1578.601, + 1581.423, + 1581.908 + ], + "min": 1546.797, + "median": 1566.983, + "p95": 1581.908, + "max": 1581.908 + }, + "peakRssBytes": { + "samples": [ + 602345472, + 603676672, + 638533632, + 639234048, + 642658304 + ], + "min": 602345472, + "median": 638533632, + "p95": 642658304, + "max": 642658304 + }, + "cpuMs": { + "samples": [ + 2760, + 2770, + 2770, + 2770, + 2780, + 2800, + 2810, + 2830, + 2840, + 2840 + ], + "min": 2760, + "median": 2790, + "p95": 2840, + "max": 2840 + } + } + }, + "large": { + "graph_refresh": { + "elapsedMs": { + "samples": [ + 1892.69, + 1898.16, + 1904.118, + 1917.955, + 1923.385, + 1923.55, + 1939.172, + 1949.167, + 1953.842, + 1955.065 + ], + "min": 1892.69, + "median": 1923.468, + "p95": 1955.065, + "max": 1955.065 + }, + "peakRssBytes": { + "samples": [ + 610271232, + 614817792, + 614899712, + 615059456, + 615129088 + ], + "min": 610271232, + "median": 614899712, + "p95": 615129088, + "max": 615129088 + }, + "cpuMs": { + "samples": [ + 3320, + 3330, + 3340, + 3380, + 3380, + 3380, + 3380, + 3390, + 3410, + 3440 + ], + "min": 3320, + "median": 3380, + "p95": 3440, + "max": 3440 + } + }, + "graph_rebuild": { + "elapsedMs": { + "samples": [ + 1910.85, + 1918.468, + 1921.088, + 1929.351, + 1930.704, + 1947.294, + 1954.774, + 1964.977, + 1977.912, + 1980.154 + ], + "min": 1910.85, + "median": 1938.999, + "p95": 1980.154, + "max": 1980.154 + }, + "peakRssBytes": { + "samples": [ + 615219200, + 615550976, + 615976960, + 617656320, + 618491904 + ], + "min": 615219200, + "median": 615976960, + "p95": 618491904, + "max": 618491904 + }, + "cpuMs": { + "samples": [ + 3330, + 3330, + 3360, + 3370, + 3370, + 3380, + 3390, + 3430, + 3440, + 3450 + ], + "min": 3330, + "median": 3375, + "p95": 3450, + "max": 3450 + } + } + } + } + }, + { + "role": "confirmation", + "manifest": { + "schemaVersion": 1, + "kind": "mex-release-benchmark-attempt", + "repositoryHead": "6d92bb04d757c8a00693ef679d1f4281669a9b57", + "github": { + "runId": "34288560611", + "runAttempt": "1", + "sha": "6d92bb04d757c8a00693ef679d1f4281669a9b57" + }, + "runnerAllocation": { + "job": "release_performance_attempt_2", + "runnerName": "GitHub Actions 1000000940", + "runnerOs": "Linux", + "runnerArch": "X64" + }, + "rawReportSha256": "732d2b897b4592ba7e89d9fedb508c0c86027ae7cb239580ed51aa5a9de9efb9", + "retryRequired": true, + "disposition": "confirmation_required" + }, + "graph": { + "small": { + "graph_refresh": { + "elapsedMs": { + "samples": [ + 1123.844, + 1181.976, + 1251.239, + 1263.552, + 1356.591, + 1405.457, + 1441.052, + 1594.281, + 1613.055, + 1853.568 + ], + "min": 1123.844, + "median": 1381.024, + "p95": 1853.568, + "max": 1853.568 + }, + "peakRssBytes": { + "samples": [ + 513835008, + 516476928, + 524251136, + 527007744, + 559038464 + ], + "min": 513835008, + "median": 524251136, + "p95": 559038464, + "max": 559038464 + }, + "cpuMs": { + "samples": [ + 2050, + 2070, + 2070, + 2100, + 2110, + 2120, + 2130, + 2130, + 2150, + 2160 + ], + "min": 2050, + "median": 2115, + "p95": 2160, + "max": 2160 + } + }, + "graph_rebuild": { + "elapsedMs": { + "samples": [ + 1304.684, + 1315.851, + 1348.025, + 1389.671, + 1391.572, + 1409.607, + 1414.26, + 1460.416, + 1475.798, + 1608.501 + ], + "min": 1304.684, + "median": 1400.59, + "p95": 1608.501, + "max": 1608.501 + }, + "peakRssBytes": { + "samples": [ + 421404672, + 429764608, + 525230080, + 532393984, + 532467712 + ], + "min": 421404672, + "median": 525230080, + "p95": 532467712, + "max": 532467712 + }, + "cpuMs": { + "samples": [ + 2080, + 2100, + 2180, + 2280, + 2280, + 2290, + 2300, + 2310, + 2330, + 2360 + ], + "min": 2080, + "median": 2285, + "p95": 2360, + "max": 2360 + } + } + }, + "medium": { + "graph_refresh": { + "elapsedMs": { + "samples": [ + 1271.705, + 1285.42, + 1314.929, + 1329.755, + 1447.731, + 1553.504, + 1571.778, + 1606.949, + 1640.042, + 1714.77 + ], + "min": 1271.705, + "median": 1500.617, + "p95": 1714.77, + "max": 1714.77 + }, + "peakRssBytes": { + "samples": [ + 605356032, + 609439744, + 609964032, + 612069376, + 615657472 + ], + "min": 605356032, + "median": 609964032, + "p95": 615657472, + "max": 615657472 + }, + "cpuMs": { + "samples": [ + 2370, + 2380, + 2390, + 2400, + 2400, + 2420, + 2430, + 2450, + 2460, + 2470 + ], + "min": 2370, + "median": 2410, + "p95": 2470, + "max": 2470 + } + }, + "graph_rebuild": { + "elapsedMs": { + "samples": [ + 1323.223, + 1323.635, + 1327.093, + 1363.659, + 1419.472, + 1424.562, + 1438.274, + 1450.918, + 1506.022, + 1550.249 + ], + "min": 1323.223, + "median": 1422.017, + "p95": 1550.249, + "max": 1550.249 + }, + "peakRssBytes": { + "samples": [ + 610123776, + 610553856, + 612372480, + 612442112, + 612491264 + ], + "min": 610123776, + "median": 612372480, + "p95": 612491264, + "max": 612491264 + }, + "cpuMs": { + "samples": [ + 2420, + 2450, + 2450, + 2470, + 2470, + 2480, + 2480, + 2490, + 2500, + 2510 + ], + "min": 2420, + "median": 2475, + "p95": 2510, + "max": 2510 + } + } + }, + "large": { + "graph_refresh": { + "elapsedMs": { + "samples": [ + 1625.325, + 1629.425, + 1647.157, + 1671.584, + 1711.165, + 1819.815, + 1859.421, + 1904.886, + 1908.309, + 1925.563 + ], + "min": 1625.325, + "median": 1765.49, + "p95": 1925.563, + "max": 1925.563 + }, + "peakRssBytes": { + "samples": [ + 620830720, + 627740672, + 629227520, + 629358592, + 632696832 + ], + "min": 620830720, + "median": 629227520, + "p95": 632696832, + "max": 632696832 + }, + "cpuMs": { + "samples": [ + 2850, + 2870, + 2870, + 2880, + 2890, + 2920, + 2920, + 2920, + 2990, + 3020 + ], + "min": 2850, + "median": 2905, + "p95": 3020, + "max": 3020 + } + }, + "graph_rebuild": { + "elapsedMs": { + "samples": [ + 1585.808, + 1599.499, + 1600.839, + 1611.116, + 1612.166, + 1617.531, + 1929.309, + 1944.566, + 2009.876, + 2074.786 + ], + "min": 1585.808, + "median": 1614.849, + "p95": 2074.786, + "max": 2074.786 + }, + "peakRssBytes": { + "samples": [ + 626761728, + 632528896, + 635752448, + 642117632, + 643031040 + ], + "min": 626761728, + "median": 635752448, + "p95": 643031040, + "max": 643031040 + }, + "cpuMs": { + "samples": [ + 2790, + 2810, + 2820, + 2870, + 2890, + 2890, + 2900, + 2920, + 2930, + 2990 + ], + "min": 2790, + "median": 2890, + "p95": 2990, + "max": 2990 + } + } + } + } + } + ], + "confirmedMaterialAssessments": [ + { + "metric": "runtime.maintenanceMs.small.graph_refresh", + "category": "maintenance_ms", + "classification": "material", + "reason": "repeated_material_threshold", + "budget": 984, + "relativeExcessRatio": 0.15, + "minimumExcess": 50, + "materialThreshold": 1131.6, + "firstMeasured": 1420.61, + "secondMeasured": 1853.568, + "requiredSupportingSamples": 2, + "firstSampleCount": 10, + "firstSupportingSamples": 10, + "secondSampleCount": 10, + "secondSupportingSamples": 9 + }, + { + "metric": "runtime.maintenanceMs.small.graph_rebuild", + "category": "maintenance_ms", + "classification": "material", + "reason": "repeated_material_threshold", + "budget": 496, + "relativeExcessRatio": 0.15, + "minimumExcess": 50, + "materialThreshold": 570.4, + "firstMeasured": 1468.48, + "secondMeasured": 1608.501, + "requiredSupportingSamples": 2, + "firstSampleCount": 10, + "firstSupportingSamples": 10, + "secondSampleCount": 10, + "secondSupportingSamples": 10 + }, + { + "metric": "runtime.maintenanceMs.medium.graph_refresh", + "category": "maintenance_ms", + "classification": "material", + "reason": "repeated_material_threshold", + "budget": 1237, + "relativeExcessRatio": 0.15, + "minimumExcess": 50, + "materialThreshold": 1422.55, + "firstMeasured": 1600.897, + "secondMeasured": 1714.77, + "requiredSupportingSamples": 2, + "firstSampleCount": 10, + "firstSupportingSamples": 10, + "secondSampleCount": 10, + "secondSupportingSamples": 6 + }, + { + "metric": "runtime.maintenanceMs.medium.graph_rebuild", + "category": "maintenance_ms", + "classification": "material", + "reason": "repeated_material_threshold", + "budget": 743, + "relativeExcessRatio": 0.15, + "minimumExcess": 50, + "materialThreshold": 854.45, + "firstMeasured": 1581.908, + "secondMeasured": 1550.249, + "requiredSupportingSamples": 2, + "firstSampleCount": 10, + "firstSupportingSamples": 10, + "secondSampleCount": 10, + "secondSupportingSamples": 10 + }, + { + "metric": "runtime.maintenanceMs.large.graph_rebuild", + "category": "maintenance_ms", + "classification": "material", + "reason": "repeated_material_threshold", + "budget": 1229, + "relativeExcessRatio": 0.15, + "minimumExcess": 50, + "materialThreshold": 1413.35, + "firstMeasured": 1980.154, + "secondMeasured": 2074.786, + "requiredSupportingSamples": 2, + "firstSampleCount": 10, + "firstSupportingSamples": 10, + "secondSampleCount": 10, + "secondSupportingSamples": 10 + } + ], + "note": "The user accepted higher measured small-job wall-clock cost for responsive, cancellable Hub construction. This calibrates only these five Graph maintenance timing leaves with the existing formula; it is a product tradeoff, not a speedup claim. Large Graph refresh, all memory/asset/read/other limits, fixtures, materiality policy and confirmation rules remain unchanged. A clean enforcing run on the calibrated head is still required." +} diff --git a/docs/design/release-performance-baseline.md b/docs/design/release-performance-baseline.md index 0dda5efd..9a76815f 100644 --- a/docs/design/release-performance-baseline.md +++ b/docs/design/release-performance-baseline.md @@ -117,14 +117,72 @@ unavailable page cannot satisfy measurement. Settings must remain outside the initial shell and Home closures. Only its additive asset limits may be copied from the final deterministic build using `ceil(bytes * 1.05)`. -Settings heap calibration is pending: the three Settings heap budget leaves -remain absent until a retained Ubuntu 24.04/Node 22 report supplies the usual -`ceil(p95 * 1.15)` candidates. Measurement therefore emits `budget_missing` -and blocks release; missing calibration is not a zero-cost or passing route. -The schemas accept Settings as an additive optional field so historical -reports stay valid. Every previously frozen budget and formula remains -unchanged. A clean enforcing pinned run on the final exact head is still -required after calibration. +At the Phase 4 checkpoint, the three Settings heap budget leaves remained +absent, so enforcement emitted `budget_missing` and blocked release. Initial +PR #180 CI run +[`34286120355`](https://github.com/mex-memory/mex/actions/runs/34286120355) +then supplied the retained, schema-valid measurement report on Ubuntu 24.04, +Linux x64, Node 22.22.0. Artifact `10079816681` measured PR head +`6e12e6dd34a3bb11735ce3580aece8d37e2e8043` through synthetic merge commit +`8c046500e2969463014e12dc5844abf76ae403ce`; the raw report SHA-256 was verified as +`98007786b450eb7a6142d0ce43cf2bb6fc85c287d6e0a8837850318d5924fd04`. + +Only the three missing Settings heap leaves and calibration-status metadata +are now added using the frozen `ceil(p95 * 1.15)` formula: + +| Profile | Measured Settings heap p95 | New limit | +|---|---:|---:| +| Small | 5,480,428 bytes | 6,302,493 bytes | +| Medium | 5,482,104 bytes | 6,304,420 bytes | +| Large | 5,486,028 bytes | 6,308,933 bytes | + +The [retained calibration evidence](settings-heap-calibration.json) records all +five raw samples per profile, exact report identity, formula, and a hash of +every unowned budget. Existing Graph and other runtime/asset limits, sample +counts, material thresholds, and confirmation rules remain unchanged. Settings +stays an additive optional schema field so historical reports remain valid. + +That first CI run still failed enforcement: the missing leaves produced an +immediate hard failure and suppressed runtime confirmation. Its first-pass +Graph maintenance crossings are unconfirmed, not a runtime pass or an +established regression. A clean enforcing run on the corrected final head must +apply the ordinary fresh-runner confirmation rule; calibration alone does not +satisfy the release gate. + +### Accepted graph isolation timing tradeoff + +Corrected PR #180 run +[`34288560611`](https://github.com/mex-memory/mex/actions/runs/34288560611) +passed browser, Node 22/24, and Windows/macOS portability checks. Its two +independently allocated pinned runners confirmed exactly five material Graph +maintenance timing failures on PR head +`4d6683eec1a0bdcafe99d7b431d84cde7f02864d`, synthetic merge +`6d92bb04d757c8a00693ef679d1f4281669a9b57`. Repeated memory crossings remained +advisory under the existing materiality/sample-support rules; no memory or +other metric produced a final material failure. + +The product decision explicitly accepts disposable-worker startup latency for +Hub responsiveness and compiler-memory release after each job. This is a real +small-job regression. Only the five confirmed timing leaves are recalibrated +from the first healthy corrected report using the existing `ceil(p95 * 1.15)` +formula; the second allocation supplies independent confirmation. + +| Graph operation | Prior limit (ms) | First p95 (ms) | Confirmation p95 (ms) | New limit (ms) | +|---|---:|---:|---:|---:| +| Small refresh | 984 | 1420.610 | 1853.568 | 1634 | +| Small rebuild | 496 | 1468.480 | 1608.501 | 1689 | +| Medium refresh | 1237 | 1600.897 | 1714.770 | 1842 | +| Medium rebuild | 743 | 1581.908 | 1550.249 | 1820 | +| Large rebuild | 1229 | 1980.154 | 2074.786 | 2278 | + +The [calibration record](graph-maintenance-timing-calibration.json) retains +runner identities, both raw-report hashes and samples, prior limits, and a hash +guard for every unowned budget. Large refresh, all memory/asset/read/Wiki +limits, fixtures, formulas, sample counts, and confirmation rules remain +unchanged. The [local diagnostic](graph-isolation-diagnostic.json) attributes +the fixed startup cost using identical optimized code and parent validation; +its Mac timings are not calibration inputs. A clean enforcing CI run on the +new calibrated head remains required before release. ## Runner contract diff --git a/docs/design/settings-heap-calibration.json b/docs/design/settings-heap-calibration.json new file mode 100644 index 00000000..0809b523 --- /dev/null +++ b/docs/design/settings-heap-calibration.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": 1, + "kind": "settings-browser-heap-calibration", + "source": { + "runUrl": "https://github.com/mex-memory/mex/actions/runs/34286120355", + "artifactUrl": "https://github.com/mex-memory/mex/actions/runs/34286120355/artifacts/10079816681", + "pullRequest": 180, + "pullRequestHead": "6e12e6dd34a3bb11735ce3580aece8d37e2e8043", + "schemaVersion": 1, + "kind": "mex-release-benchmark-attempt", + "repositoryHead": "8c046500e2969463014e12dc5844abf76ae403ce", + "github": { + "runId": "34286120355", + "runAttempt": "1", + "sha": "8c046500e2969463014e12dc5844abf76ae403ce" + }, + "runnerAllocation": { + "job": "release_performance_attempt_1", + "runnerName": "GitHub Actions 1000000930", + "runnerOs": "Linux", + "runnerArch": "X64" + }, + "rawReportSha256": "98007786b450eb7a6142d0ce43cf2bb6fc85c287d6e0a8837850318d5924fd04", + "retryRequired": false, + "disposition": "hard_failure" + }, + "environment": { + "os": "ubuntu-24.04", + "platform": "linux", + "architecture": "x64", + "node": "v22.22.0", + "pinnedBudgetEnvironment": true + }, + "formula": "ceil(measured p95 * 1.15)", + "sampleCount": 5, + "previousCalibrationStatus": "calibrated-from-pinned-runs-33005876613-33083122092-33117048710-E33169865368-F33249296778-Goverview", + "unownedBudgetSha256": "7d4c45e5399361ddf3b4292aa33b3ba3e179e0c833547ee4ecc4891bb03c469c", + "profiles": { + "small": { + "samples": [ + 5480288, + 5480372, + 5480384, + 5480384, + 5480428 + ], + "min": 5480288, + "median": 5480384, + "p95": 5480428, + "max": 5480428, + "budgetBytes": 6302493 + }, + "medium": { + "samples": [ + 5474652, + 5474656, + 5480372, + 5480384, + 5482104 + ], + "min": 5474652, + "median": 5480372, + "p95": 5482104, + "max": 5482104, + "budgetBytes": 6304420 + }, + "large": { + "samples": [ + 5480384, + 5480388, + 5481520, + 5482008, + 5486028 + ], + "min": 5480384, + "median": 5481520, + "p95": 5486028, + "max": 5486028, + "budgetBytes": 6308933 + } + }, + "note": "The report completed measurement but failed enforcement because these three Settings budgets were missing. This calibrates only Settings browser heap; all existing limits, confirmation rules and formulas remain frozen. Graph maintenance crossings still require ordinary enforcing CI." +} diff --git a/packages/hub-web/src/app/page-states.test.tsx b/packages/hub-web/src/app/page-states.test.tsx index 51b46720..5554e355 100644 --- a/packages/hub-web/src/app/page-states.test.tsx +++ b/packages/hub-web/src/app/page-states.test.tsx @@ -264,6 +264,7 @@ describe("Home states", () => { const determinateProgress = within(await screen.findByRole("region", { name: "Active operation" })).getByRole("progressbar"); expect(determinateProgress).toHaveAccessibleName("Graph refresh · Parse"); expect(determinateProgress).toHaveAttribute("aria-valuenow", "68"); + expect(screen.getByText("124 / 183 files parsed")).toBeVisible(); determinate.unmount(); renderRoute("/", createFixtureApi({ overviewFixture: "job-indeterminate" })); @@ -273,13 +274,32 @@ describe("Home states", () => { expect(screen.getByText("37 completed")).toBeVisible(); }); + it.each(["resolve", "validate", "publish"] as const)("keeps Overview indeterminate after parsing during %s", async (phase) => { + const fixture = createFixtureApi({ overviewFixture: "job-determinate" }); + const overview = await fixture.getOverview(); + if (overview.operation.availability !== "available" || !overview.operation.active) { + throw new Error("The determinate fixture must supply an active graph operation."); + } + overview.operation.active = { + ...overview.operation.active, + phase, + progress: { completed: 183, total: 183 }, + }; + renderRoute("/", apiWith({ getOverview: async () => overview })); + const operation = within(await screen.findByRole("region", { name: "Active operation" })); + expect(operation.getByText("183 / 183 files parsed")).toBeVisible(); + const progress = operation.getByRole("progressbar"); + expect(progress).toHaveAccessibleName(`Graph refresh · ${phase[0].toUpperCase()}${phase.slice(1)}`); + expect(progress).not.toHaveAttribute("aria-valuenow"); + }); + it("shows only the bounded relevant failed operation when no newer success supersedes it", async () => { renderRoute("/", createFixtureApi({ overviewFixture: "failure" })); expect(await screen.findByRole("heading", { name: "Operation needs attention" })).toBeVisible(); const progress = within(screen.getByRole("region", { name: "Operation needs attention" })).getByRole("progressbar"); expect(progress).toHaveAccessibleName("Graph refresh · Failed"); - expect(progress).toHaveAttribute("aria-valuenow", "96"); + expect(progress).not.toHaveAttribute("aria-valuenow"); expect(screen.getByText("The previous trustworthy Graph index was preserved.")).toBeVisible(); expect(within(screen.getByRole("region", { name: "Operation needs attention" })) .getByRole("button", { name: "View operation" })).toHaveAttribute( @@ -960,6 +980,19 @@ describe("Health states", () => { }); describe("Jobs states", () => { + it.each(["parse", "resolve", "validate", "publish"] as const)("shows parsed files without claiming overall completion during %s", async (phase) => { + const fixture = createFixtureApi(); + const original = (await fixture.getJobs()).items[0]!; + const job: JobSummary = { ...original, kind: "graph_refresh", state: "running", phase, progress: { completed: 10, total: 10 } }; + renderRoute("/jobs", apiWith({ + getJobs: async () => ({ items: [job], nextCursor: null }), + subscribeToJob: () => ({ close: () => undefined }), + })); + expect(await screen.findByText("10 / 10 files parsed")).toBeVisible(); + if (phase === "parse") expect(screen.getByRole("progressbar", { name: "100% of files parsed" })).toBeVisible(); + else expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + it("renders its loading state while persisted summaries are pending", async () => { renderRoute("/jobs", apiWith({ getJobs: () => pending() })); diff --git a/packages/hub-web/src/pages/HomeOverview.tsx b/packages/hub-web/src/pages/HomeOverview.tsx index fb929d55..88099be6 100644 --- a/packages/hub-web/src/pages/HomeOverview.tsx +++ b/packages/hub-web/src/pages/HomeOverview.tsx @@ -878,7 +878,8 @@ function OperationCard({ operation }: { operation: OverviewResponse["operation"] const job = operation.active ?? operation.latestRelevantFailure; if (job === null) return null; const isActive = operation.active !== null; - const percent = job.progress?.total === undefined + const graph = job.kind === "graph_refresh" || job.kind === "graph_rebuild"; + const percent = job.progress?.total === undefined || (graph && job.phase !== "parse") ? null : Math.round((job.progress.completed / job.progress.total) * 100); return ( @@ -906,7 +907,9 @@ function OperationCard({ operation }: { operation: OverviewResponse["operation"] {sentenceCase(job.kind)} · {sentenceCase(job.phase)} - {() => percent !== null + {() => graph && job.progress + ? `${job.progress.completed}${job.progress.total === undefined ? "" : ` / ${job.progress.total}`} files parsed` + : percent !== null ? `${job.progress!.completed} / ${job.progress!.total}` : job.progress ? `${job.progress.completed} completed` : "In progress"} diff --git a/packages/hub-web/src/pages/JobsPage.tsx b/packages/hub-web/src/pages/JobsPage.tsx index afee1581..1a69a274 100644 --- a/packages/hub-web/src/pages/JobsPage.tsx +++ b/packages/hub-web/src/pages/JobsPage.tsx @@ -49,7 +49,7 @@ import { import styles from "../styles/jobs.module.css"; const operations: Array<{ kind: JobKind; label: string; detail: string }> = [ - { kind: "graph_refresh", label: "Refresh graph", detail: "Index the bounded repository delta" }, + { kind: "graph_refresh", label: "Refresh graph", detail: "Check for changes and update the code graph" }, { kind: "graph_rebuild", label: "Rebuild graph", detail: "Replace the derived graph safely" }, { kind: "wiki_refresh", label: "Refresh Wiki", detail: "Update structured project memory" }, { kind: "wiki_rebuild", label: "Rebuild Wiki", detail: "Recreate the derived Wiki index" }, @@ -118,16 +118,20 @@ function JobStateIcon({ state }: { state: JobState }) { } function JobProgressView({ job }: { job: JobSummary }) { - const value = percentage(job.progress); + // Graph's durable counts describe parsed files, not overall job completion. + const graph = isGraphJob(job.kind); + const value = graph && job.phase !== "parse" ? null : percentage(job.progress); const phase = graphPhaseIndex(job.phase) >= 0 ? sentenceCase(job.phase) : job.phase; return (
{phase} - {value === null ? (job.progress ? `${job.progress.completed} complete · total unknown` : "Total unknown") : `${value}%`} + {graph && job.progress + ? `${job.progress.completed}${job.progress.total === undefined ? "" : ` / ${job.progress.total}`} files parsed` + : value === null ? (job.progress ? `${job.progress.completed} complete · total unknown` : "Total unknown") : `${value}%`}
{value === null ? null : ( - + )}
); diff --git a/scripts/release-benchmark/README.md b/scripts/release-benchmark/README.md new file mode 100644 index 00000000..fd066fb6 --- /dev/null +++ b/scripts/release-benchmark/README.md @@ -0,0 +1,55 @@ +# Release resource measurements + +`run.mjs` retains the frozen small/medium/large fixture profiles and budgets. +Hub idle and maintenance RSS now sum the Hub and its observed descendants in +each sample. Maintenance also records observed process CPU deltas. The report's +`configuration.processMeasurement` describes scope and sampling limitations; +older reports remain schema-valid but measured only the Hub PID. + +Linux uses `/proc` with a 10 ms interval. macOS samples `ps`; Windows keeps one +local PowerShell/CIM sampler running, avoiding a shell startup per sample. Those +platforms use a 100 ms interval. CPU already observed from an exited child stays +in the total, and process start identities distinguish reused PIDs. Sampling +can miss short-lived children and CPU between the last sample and exit. Summed +RSS can double-count shared pages; it is not unique physical memory. Sampling +errors fail the run instead of becoming zero usage. Windows/macOS measurement +support does not establish a second calibration platform. + +HTTP deadlines cover headers and bounded streamed bodies, including job +creation. Ordinary requests and stream headers are limited to five seconds or +the remaining job deadline, whichever is smaller. Maintenance then waits for a +terminal snapshot on the same SSE route as the production UI, retaining the +absolute job deadline through stream consumption. Events, event count, and +total stream bytes are bounded. This avoids adding repeated SQLite status reads +while the candidate works; no retry hides stream failures. Elapsed time still +covers job creation through terminal delivery, and child resource sampling +continues throughout. New reports identify this method with +`configuration.maintenanceObservation: "job-event-stream"`; earlier reports +without that field used repeated job-status polling. + +## Separate graph characterization + +Run `node scripts/release-benchmark/graph-characterization.mjs --smoke` to verify +the harness on 10 files. Omit `--smoke` for the 180-file characterization corpus; +use `--output ` to retain the JSON report. These measurements create no +release budgets and do not replace the pinned release gate. + +The corpus contains four TypeScript projects, an overlapping root configuration, +inferred JavaScript, a synthetic installed declaration dependency, and a large +function. It is generated locally without network/package installation. Five +operations run in one disposable engine process: fresh build, unchanged sync, +changed sync, unchanged sync, changed sync. Changed syncs toggle an executable +numeric addition in the large function. The tool validates expected source +coverage and no-op behavior, and records input digest, runtime versions, CPU, +sampled RSS, and post-close memory after three diagnostic GCs. This distinguishes +working set from retained memory without introducing forced GC in production. +The aggregate CPU sample includes cleanup/diagnostic GC; the operation's own +`cpuMs` excludes that work. This engine-only test complements the Hub process +tree measurements; it does not claim to test browser responsiveness. + +The worker has a 2 GiB V8 old-space hang guard, a 2.5 GiB sampled RSS stop, +30-second smoke/180-second normal per-operation deadlines, and bounded output. +These are experiment guardrails, not product budgets or exact OS memory caps. +Temporary corpora and source bundles are parent-owned and removed after success +or failure. Run representative characterization serially with other resource +measurements, and never calibrate release limits from laptop results. diff --git a/scripts/release-benchmark/budgets.json b/scripts/release-benchmark/budgets.json index 471daf49..cae43937 100644 --- a/scripts/release-benchmark/budgets.json +++ b/scripts/release-benchmark/budgets.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "provisional": false, "calibration": { - "status": "calibrated-from-pinned-runs-33005876613-33083122092-33117048710-E33169865368-F33249296778-Goverview", + "status": "pinned-A-G-Settings-34286120355-Graph-timing-34288560611", "runtimeFormula": "ceil(measured p95 * 1.15)", "assetFormula": "ceil(built bytes * 1.05)" }, @@ -55,9 +55,9 @@ "large": { "search": 342, "code": 142, "knowledge": 215, "activity": 58, "inboxDrafts": 7, "inboxProposals": 6, "relayDrafts": 4, "relays": 13 } }, "maintenanceMs": { - "small": { "graph_refresh": 984, "graph_rebuild": 496, "wiki_refresh": 186, "wiki_rebuild": 181 }, - "medium": { "graph_refresh": 1237, "graph_rebuild": 743, "wiki_refresh": 262, "wiki_rebuild": 301 }, - "large": { "graph_refresh": 1812, "graph_rebuild": 1229, "wiki_refresh": 418, "wiki_rebuild": 617 } + "small": { "graph_refresh": 1634, "graph_rebuild": 1689, "wiki_refresh": 186, "wiki_rebuild": 181 }, + "medium": { "graph_refresh": 1842, "graph_rebuild": 1820, "wiki_refresh": 262, "wiki_rebuild": 301 }, + "large": { "graph_refresh": 1812, "graph_rebuild": 2278, "wiki_refresh": 418, "wiki_rebuild": 617 } }, "maintenancePeakRssBytes": { "small": { "graph_refresh": 470639616, "graph_rebuild": 518977741, "wiki_refresh": 512307815, "wiki_rebuild": 512604570 }, @@ -69,19 +69,19 @@ "home": 4932332, "search": 5426602, "knowledge": 4769934, "knowledgeDetail": 4855696, "code": 5388496, "codeSymbol": 4845185, "workstreams": 6091035, "specs": 6180560, "specsDetail": 6166273, "playbooks": 4203807, "catchUp": 4203807, "inbox": 6492790, "relays": 7753875, "members": 4902625, "activity": 4874206, - "jobs": 4567014, "health": 4510981, "notFound": 4185582 + "jobs": 4567014, "health": 4510981, "settings": 6302493, "notFound": 4185582 }, "medium": { "home": 4931467, "search": 5762301, "knowledge": 5013554, "knowledgeDetail": 4856552, "code": 5720349, "codeSymbol": 4849523, "workstreams": 6070059, "specs": 6182460, "specsDetail": 6166337, "playbooks": 4199281, "catchUp": 4199281, "inbox": 6497358, "relays": 7754561, "members": 4886295, "activity": 5130992, - "jobs": 4564691, "health": 4511036, "notFound": 4185554 + "jobs": 4564691, "health": 4511036, "settings": 6304420, "notFound": 4185554 }, "large": { "home": 4933528, "search": 6027330, "knowledge": 5135808, "knowledgeDetail": 4858258, "code": 5989987, "codeSymbol": 4847729, "workstreams": 6091086, "specs": 6175215, "specsDetail": 6170891, "playbooks": 4203858, "catchUp": 4203858, "inbox": 6504906, "relays": 7748627, "members": 4895624, "activity": 5275901, - "jobs": 4563113, "health": 4509017, "notFound": 4181773 + "jobs": 4563113, "health": 4509017, "settings": 6308933, "notFound": 4181773 } }, "databaseToInputRatio": { diff --git a/scripts/release-benchmark/graph-characterization-worker.ts b/scripts/release-benchmark/graph-characterization-worker.ts new file mode 100644 index 00000000..998e4831 --- /dev/null +++ b/scripts/release-benchmark/graph-characterization-worker.ts @@ -0,0 +1,52 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { createGraphEngine } from "../../src/graph/engine-impl.js"; + +const root = process.argv[2]!; +let running = false; +process.on("disconnect", () => process.exit()); +process.on("message", async (message: { mode?: string }) => { + if (running || !["build", "unchanged", "changed"].includes(message.mode ?? "")) { + process.exitCode = 1; + process.disconnect?.(); + return; + } + running = true; + let graph: ReturnType | undefined; + try { + if (message.mode === "changed") { + const path = join(root, "packages/p0/module-0.ts"); + const before = readFileSync(path, "utf8"); + const states = ["value += 101; // characterization-state:A", "value += 207; // characterization-state:B"]; + const current = before.includes(states[0]!) ? 0 : 1; + const after = before.replace(states[current]!, states[1 - current]!); + if (after === before) throw new Error("Missing characterization change marker."); + writeFileSync(path, after); + } + const beforeCpu = process.cpuUsage(); + const start = performance.now(); + graph = createGraphEngine({ rootDir: root, dbPath: join(root, ".mex", "graph.db") }); + const result = message.mode === "build" + ? await graph.build() + : await graph.sync(message.mode === "changed" ? ["packages/p0/module-0.ts"] : []); + const elapsedMs = performance.now() - start; + const cpu = process.cpuUsage(beforeCpu); + const returnMemory = process.memoryUsage(); + graph.close(); + graph = undefined; + // This is diagnostic retained-memory characterization, never a production GC policy. + for (let attempt = 0; attempt < 3; attempt += 1) { + global.gc?.(); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + process.send?.({ mode: message.mode, elapsedMs, cpuMs: (cpu.user + cpu.system) / 1_000, + result, returnMemory, postGcMemory: process.memoryUsage() }); + } catch (error) { + process.send?.({ error: error instanceof Error ? error.message.slice(0, 2_000) : "Graph characterization failed." }); + } finally { + graph?.close(); + running = false; + } +}); +process.send?.({ ready: true }); diff --git a/scripts/release-benchmark/graph-characterization.mjs b/scripts/release-benchmark/graph-characterization.mjs new file mode 100644 index 00000000..4dbf3b9c --- /dev/null +++ b/scripts/release-benchmark/graph-characterization.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { createBenchmarkEnvironment } from "./environment.mjs"; +import { createGraphCharacterizationFixture } from "./graph-fixture.mjs"; +import { PROCESS_MEASUREMENT, startProcessTreeSampler } from "./process-tree.mjs"; + +const args = process.argv.slice(2); +if (args.includes("--help")) { + process.stdout.write("Usage: node scripts/release-benchmark/graph-characterization.mjs [--smoke] [--output path]\nCharacterization only; no release budgets are created or calibrated.\n"); + process.exit(0); +} +let output; +let smoke = false; +for (let index = 0; index < args.length; index += 1) { + if (args[index] === "--smoke") smoke = true; + else if (args[index] === "--output" && args[index + 1]) output = resolve(args[++index]); + else throw new Error(`Unknown graph characterization argument: ${args[index]}`); +} +const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const work = mkdtempSync(join(tmpdir(), "mex-graph-characterization-")); +// External imports resolve through this repository's node_modules, without changing dist. +const toolParent = join(repository, "test-results", "graph-characterization"); +mkdirSync(toolParent, { recursive: true }); +const tool = mkdtempSync(join(toolParent, "worker-")); +let worker; +let sampler; +try { + const environment = createBenchmarkEnvironment(work); + const fixture = createGraphCharacterizationFixture(join(work, "fixture"), { smoke }); + await build({ entryPoints: [join(repository, "scripts/release-benchmark/graph-characterization-worker.ts")], + outfile: join(tool, "worker.mjs"), bundle: true, platform: "node", target: "node22", format: "esm", packages: "external" }); + cpSync(join(repository, "src/graph/schema.sql"), join(tool, "schema.sql")); + cpSync(join(repository, "src/graph/wasm"), join(tool, "wasm"), { recursive: true }); + worker = spawn(process.execPath, ["--expose-gc", "--max-old-space-size=2048", join(tool, "worker.mjs"), fixture.root], { + cwd: fixture.root, env: environment, stdio: ["ignore", "ignore", "pipe", "ipc"], windowsHide: true, + }); + let stderr = ""; + worker.stderr.on("data", (chunk) => { stderr = (stderr + chunk.toString("utf8")).slice(-2_000); }); + await receive(worker, 10_000); + const samples = []; + for (const mode of ["build", "unchanged", "changed", "unchanged", "changed"]) { + sampler = await startProcessTreeSampler(worker.pid); + let oversized = false; + const guard = setInterval(() => { + void sampler?.sample().then((usage) => { + if (usage.rssBytes > 2.5 * 1024 ** 3) { oversized = true; worker.kill("SIGKILL"); } + }).catch(() => undefined); + }, 250); + try { + const reply = receive(worker, smoke ? 30_000 : 180_000); + worker.send({ mode }); + const measured = await reply; + if (measured.error) throw new Error(measured.error); + if (measured.mode !== mode || !measured.result) throw new Error("Invalid graph characterization reply."); + if (mode === "unchanged" && measured.result.filesIndexed !== 0) throw new Error("Unchanged graph sync unexpectedly reindexed source."); + if (mode !== "unchanged" && measured.result.filesIndexed !== fixture.sourceFiles) throw new Error("Graph characterization source coverage changed."); + const aggregate = await sampler.stop(); + sampler = undefined; + samples.push({ ...measured, aggregate }); + } catch (error) { + if (oversized) throw new Error("Graph characterization exceeded its 2.5 GiB sampled RSS hang guard."); + throw new Error(`${error.message}${stderr ? `\n${stderr}` : ""}`); + } finally { clearInterval(guard); } + } + const { root: _fixtureRoot, ...fixtureDescription } = fixture; + const report = { + schemaVersion: 1, benchmark: "mex-graph-characterization", generatedAt: new Date().toISOString(), + environment: { node: process.version, sqlite: process.versions.sqlite, v8: process.versions.v8, platform: process.platform, arch: process.arch }, + workerBundleSha256: createHash("sha256").update(readFileSync(join(tool, "worker.mjs"))).digest("hex"), + fixture: fixtureDescription, + measurement: { ...PROCESS_MEASUREMENT, scope: "engine-worker-and-descendants", engineOnly: true, forcedGcAfterClose: 3, + note: "Characterization only. Five operations share one process. CPU/RSS sampling is separate from post-GC retained memory; no laptop-derived release budgets." }, + samples, + }; + const json = `${JSON.stringify(report, null, 2)}\n`; + if (Buffer.byteLength(json) > 128 * 1024) throw new Error("Graph characterization report exceeded 128 KiB."); + if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, json); } + process.stdout.write(json); +} finally { + await sampler?.stop().catch(() => undefined); + if (worker && worker.exitCode === null && worker.signalCode === null) { + const stopped = once(worker, "exit"); + worker.kill("SIGKILL"); + await stopped; + } + rmSync(tool, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); +} + +function receive(child, timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { cleanup(); child.kill("SIGKILL"); reject(new Error("Graph characterization deadline expired.")); }, timeoutMs); + const message = (value) => { cleanup(); resolve(value); }; + const exited = () => { cleanup(); reject(new Error("Graph characterization worker exited.")); }; + const failed = (error) => { cleanup(); reject(error); }; + const cleanup = () => { + clearTimeout(timer); child.off("message", message); child.off("exit", exited); child.off("error", failed); + }; + child.once("message", message); child.once("exit", exited); child.once("error", failed); + }); +} diff --git a/scripts/release-benchmark/graph-fixture.mjs b/scripts/release-benchmark/graph-fixture.mjs new file mode 100644 index 00000000..28856d5a --- /dev/null +++ b/scripts/release-benchmark/graph-fixture.mjs @@ -0,0 +1,71 @@ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** An independent characterization corpus; frozen release fixture sizes stay unchanged. */ +export function createGraphCharacterizationFixture(root, { smoke = false } = {}) { + if (existsSync(root)) throw new Error("Graph characterization requires a new fixture directory."); + const projects = 4; + const filesPerProject = smoke ? 2 : 40; + const inferredFiles = smoke ? 2 : 20; + const functionsPerFile = smoke ? 2 : 12; + const largeBodyLines = smoke ? 128 : 4_096; + const digest = createHash("sha256"); + let inputBytes = 0; + const write = (path, text) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + inputBytes += Buffer.byteLength(text); + digest.update(path).update("\0").update(text).update("\0"); + }; + const json = (path, value) => write(path, `${JSON.stringify(value, null, 2)}\n`); + json("package.json", { name: "mex-graph-characterization", private: true, type: "module" }); + const compilerOptions = { target: "ES2022", module: "ESNext", moduleResolution: "Bundler", strict: true, noEmit: true }; + // Broad root intentionally overlaps the four specific ownership projects. + json("tsconfig.json", { compilerOptions, include: ["packages/**/*.ts"] }); + write(".mex/ROUTER.md", "# Deterministic graph characterization fixture\n"); + write(".gitignore", ".mex/graph.db*\n.mex/local/\nnode_modules/\n"); + json("node_modules/@benchmark/model/package.json", { name: "@benchmark/model", version: "1.0.0", types: "index.d.ts" }); + write("node_modules/@benchmark/model/index.d.ts", [ + "export interface Model { value: number; key: string }", + ...Array.from({ length: smoke ? 4 : 128 }, (_, index) => `export interface Model${index} extends Model { field${index}?: Model }`), + "export declare function adapt(value: T): T;", + "", + ].join("\n")); + for (let project = 0; project < projects; project += 1) { + json(`packages/p${project}/tsconfig.json`, { compilerOptions, include: ["*.ts"] }); + for (let file = 0; file < filesPerProject; file += 1) { + const lines = ["import { adapt, type Model } from '@benchmark/model';"]; + if (file) lines.push(`import { work0 as previous } from './module-${file - 1}';`); + else if (project) lines.push(`import { work0 as previous } from '../p${project - 1}/module-0';`); + for (let fn = 0; fn < functionsPerFile; fn += 1) { + lines.push(`export function work${fn}(input: Model): number {`); + lines.push(" const model = adapt(input);"); + lines.push(` let value = model.value + ${project + file + fn};`); + if (fn === 0 && project === 0 && file === 0) { + lines.push(" value += 101; // characterization-state:A"); + for (let line = 0; line < largeBodyLines; line += 1) lines.push(` value = (value + ${line}) % 100003;`); + } + if (file || project) lines.push(" value += previous(model);"); + lines.push(" return value;", "}"); + } + write(`packages/p${project}/module-${file}.ts`, `${lines.join("\n")}\n`); + } + } + for (let file = 0; file < inferredFiles; file += 1) { + write(`scripts/entry-${file}.js`, `import { work0 } from '../packages/p0/module-0';\nexport function run${file}(value) { return work0({ value, key: 'fixture' }); }\n`); + } + return { + root, + mutableSource: "packages/p0/module-0.ts", + digest: digest.digest("hex"), + inputBytes, + sourceFiles: projects * filesPerProject + inferredFiles, + projects, + configFiles: projects + 1, + functionsPerFile, + inferredFiles, + dependencyDeclarations: smoke ? 4 : 128, + largeBodyLines, + }; +} diff --git a/scripts/release-benchmark/http.mjs b/scripts/release-benchmark/http.mjs new file mode 100644 index 00000000..c1946fb0 --- /dev/null +++ b/scripts/release-benchmark/http.mjs @@ -0,0 +1,45 @@ +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const DEFAULT_TIMEOUT_MS = 5_000; + +/** The deadline covers connection, headers, and body consumption. */ +export async function requestJson(url, init = {}, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 180_000) { + throw new Error("Benchmark HTTP timeout must be within 1–180000 milliseconds."); + } + const controller = new AbortController(); + const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; + let timer; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(`Benchmark HTTP request exceeded ${Math.ceil(timeoutMs)} ms.`); + controller.abort(error); + reject(error); + }, timeoutMs); + }); + try { + return await Promise.race([expired, (async () => { + const response = await fetch(url, { ...init, signal, redirect: "error" }); + if (!response.body) throw new Error("Benchmark HTTP response omitted its JSON body."); + const reader = response.body.getReader(); + const chunks = []; + let bytes = 0; + try { + for (;;) { + const next = await reader.read(); + if (next.done) break; + bytes += next.value.byteLength; + if (bytes > MAX_RESPONSE_BYTES) { + controller.abort(); + throw new Error(`Benchmark HTTP response exceeded ${MAX_RESPONSE_BYTES} bytes.`); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + return { response, body: JSON.parse(Buffer.concat(chunks, bytes).toString("utf8")) }; + })()]); + } finally { + clearTimeout(timer); + } +} diff --git a/scripts/release-benchmark/hub.mjs b/scripts/release-benchmark/hub.mjs index 8ff85ef7..085052ca 100644 --- a/scripts/release-benchmark/hub.mjs +++ b/scripts/release-benchmark/hub.mjs @@ -1,12 +1,12 @@ -import { spawn, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import { performance } from "node:perf_hooks"; +import { requestJson } from "./http.mjs"; +import { startProcessTreeSampler } from "./process-tree.mjs"; +import { validateBenchmarkJob, waitForHubJobTerminal } from "./job-events.mjs"; const MAX_CHILD_OUTPUT_BYTES = 128 * 1024; -const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; const HUB_START_TIMEOUT_MS = 30_000; const JOB_TIMEOUT_MS = 180_000; -const PROCESS_SAMPLE_INTERVAL_MS = 10; const IDLE_WINDOW_MS = 2_000; const TERMINAL_JOB_STATES = new Set(["succeeded", "failed", "interrupted"]); const REVISION_PATTERN = /^[a-f0-9]{64}$/u; @@ -97,13 +97,12 @@ export async function authenticateHub(server) { const url = new URL(server.bootstrapUrl); const token = new URLSearchParams(url.hash.slice(1)).get("token"); if (!token) throw new Error("Hub readiness output omitted its bootstrap token."); - const response = await fetch(`${server.origin}/api/v1/session/bootstrap`, { + const { response, body } = await requestJson(`${server.origin}/api/v1/session/bootstrap`, { method: "POST", redirect: "error", headers: { "content-type": "application/json", origin: server.origin }, body: JSON.stringify({ token }), }); - const body = await boundedJson(response, "Hub bootstrap"); if (response.status !== 201 || typeof body.expiresAt !== "string") { throw new Error(`Hub bootstrap failed with HTTP ${response.status}.`); } @@ -115,22 +114,16 @@ export async function authenticateHub(server) { } export async function measureIdleProcess(server) { - const initialRss = readProcessRssBytes(server.child.pid); - const initialCpu = readProcessCpuMs(server.child.pid); - let peakRss = initialRss; - const timer = setInterval(() => { - peakRss = Math.max(peakRss, readProcessRssBytes(server.child.pid)); - }, PROCESS_SAMPLE_INTERVAL_MS); + const sampler = await startProcessTreeSampler(server.child.pid); + let measured; try { await delay(IDLE_WINDOW_MS); } finally { - clearInterval(timer); + measured = await sampler.stop(); } - const finalRss = readProcessRssBytes(server.child.pid); - const finalCpu = readProcessCpuMs(server.child.pid); return { - rssBytes: Math.max(initialRss, finalRss, peakRss), - cpuMs: Math.max(0, finalCpu - initialCpu), + rssBytes: measured.peakRssBytes, + cpuMs: measured.cpuMs, windowMs: IDLE_WINDOW_MS, }; } @@ -286,70 +279,51 @@ export async function measureMaintenance({ for (const kind of ["graph_refresh", "graph_rebuild", "wiki_refresh", "wiki_rebuild"]) { const elapsedMs = []; const peakRssBytes = []; + const cpuMs = []; for (let sample = 0; sample < timingSamples; sample += 1) { if (kind === "graph_refresh") beforeGraphRefresh(); if (kind === "wiki_refresh") beforeWikiRefresh(); const measured = await runMaintenanceJob(server, auth, kind); elapsedMs.push(measured.elapsedMs); + cpuMs.push(measured.cpuMs); if (sample < memorySamples) peakRssBytes.push(measured.peakRssBytes); } - output[kind] = { elapsedMs, peakRssBytes }; + output[kind] = { elapsedMs, peakRssBytes, cpuMs }; } return output; } -export async function hubJson(server, path, auth, init = {}) { +export async function hubJson(server, path, auth, init = {}, requestOptions) { const headers = new Headers(init.headers); headers.set("accept", "application/json, application/problem+json"); if (auth?.cookie) headers.set("cookie", auth.cookie); - const response = await fetch(`${server.origin}${path}`, { + const { response, body } = await requestJson(`${server.origin}${path}`, { ...init, headers, redirect: "error", - }); - const body = await boundedJson(response, path); + }, requestOptions); if (!response.ok) { throw new Error(`${path} failed with HTTP ${response.status}: ${bounded(JSON.stringify(body))}`); } return body; } -export function readProcessRssBytes(pid) { - if (!Number.isInteger(pid) || pid <= 0) throw new Error("Cannot sample a process without a PID."); - if (process.platform === "linux") { - const status = readFileSync(`/proc/${pid}/status`, "utf8"); - const match = status.match(/^VmRSS:\s+(\d+)\s+kB$/mu); - if (!match) throw new Error(`Could not read VmRSS for process ${pid}.`); - return Number(match[1]) * 1024; +export async function runMaintenanceJob(server, auth, kind, { timeoutMs = JOB_TIMEOUT_MS } = {}) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > JOB_TIMEOUT_MS) { + throw new Error("Invalid maintenance deadline."); } - const result = spawnSync("ps", ["-o", "rss=", "-p", String(pid)], { encoding: "utf8" }); - const kib = Number(result.stdout.trim()); - if (result.status !== 0 || !Number.isFinite(kib)) throw new Error(`Could not sample RSS for process ${pid}.`); - return kib * 1024; -} - -export function readProcessCpuMs(pid) { - if (process.platform === "linux") { - const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); - const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/u); - const ticks = Number(fields[11]) + Number(fields[12]); - if (!Number.isFinite(ticks)) throw new Error(`Could not read CPU ticks for process ${pid}.`); - return ticks * 1_000 / clockTicksPerSecond(); - } - const result = spawnSync("ps", ["-o", "time=", "-p", String(pid)], { encoding: "utf8" }); - if (result.status !== 0) throw new Error(`Could not sample CPU time for process ${pid}.`); - return parsePsCpuTime(result.stdout.trim()); -} - -async function runMaintenanceJob(server, auth, kind) { - const baselineRss = readProcessRssBytes(server.child.pid); - let peakRss = baselineRss; - const timer = setInterval(() => { - peakRss = Math.max(peakRss, readProcessRssBytes(server.child.pid)); - }, PROCESS_SAMPLE_INTERVAL_MS); + const sampler = await startProcessTreeSampler(server.child.pid); const startedAt = performance.now(); + const deadline = startedAt + timeoutMs; + const requestOptions = () => { + const remaining = deadline - performance.now(); + if (remaining <= 0) throw new Error(`${kind} did not settle within ${timeoutMs} ms.`); + return { timeoutMs: Math.min(5_000, remaining) }; + }; + let measured; + let completedAt; try { - const job = await hubJson(server, "/api/v1/jobs", auth, { + const job = validateBenchmarkJob(await hubJson(server, "/api/v1/jobs", auth, { method: "POST", headers: { "content-type": "application/json", @@ -357,37 +331,18 @@ async function runMaintenanceJob(server, auth, kind) { "x-mex-csrf": auth.csrfToken, }, body: JSON.stringify({ kind }), - }); - if (typeof job.id !== "string") throw new Error(`${kind} did not return a job ID.`); - let terminal = job; - const deadline = performance.now() + JOB_TIMEOUT_MS; - while (!TERMINAL_JOB_STATES.has(terminal.state)) { - if (performance.now() >= deadline) throw new Error(`${kind} did not settle within ${JOB_TIMEOUT_MS} ms.`); - await delay(20); - terminal = await hubJson(server, `/api/v1/jobs/${encodeURIComponent(job.id)}`, auth); - } + }, requestOptions()), { kind }); + const terminal = TERMINAL_JOB_STATES.has(job.state) + ? job + : await waitForHubJobTerminal(server, auth, { id: job.id, kind, deadline }); if (terminal.state !== "succeeded") { throw new Error(`${kind} settled as ${String(terminal.state)} (${String(terminal.problem?.code ?? "unknown")}).`); } - return { - elapsedMs: performance.now() - startedAt, - peakRssBytes: Math.max(peakRss, readProcessRssBytes(server.child.pid)), - }; + completedAt = performance.now(); } finally { - clearInterval(timer); - } -} - -async function boundedJson(response, label) { - const bytes = Buffer.from(await response.arrayBuffer()); - if (bytes.byteLength > MAX_RESPONSE_BYTES) { - throw new Error(`${label} exceeded the ${MAX_RESPONSE_BYTES}-byte benchmark response bound.`); - } - try { - return JSON.parse(bytes.toString("utf8")); - } catch (error) { - throw new Error(`${label} did not return JSON: ${error instanceof Error ? error.message : String(error)}`); + measured = await sampler.stop(); } + return { elapsedMs: completedAt - startedAt, peakRssBytes: measured.peakRssBytes, cpuMs: measured.cpuMs }; } async function stopHub(child) { @@ -405,25 +360,6 @@ async function stopHub(child) { }); } -let cachedClockTicks; -function clockTicksPerSecond() { - if (cachedClockTicks !== undefined) return cachedClockTicks; - const result = spawnSync("getconf", ["CLK_TCK"], { encoding: "utf8" }); - const value = Number(result.stdout.trim()); - cachedClockTicks = result.status === 0 && Number.isFinite(value) && value > 0 ? value : 100; - return cachedClockTicks; -} - -function parsePsCpuTime(value) { - const match = value.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/u); - if (!match) throw new Error(`Could not parse process CPU time: ${value}`); - const days = Number(match[1] ?? 0); - const hours = Number(match[2] ?? 0); - const minutes = Number(match[3]); - const seconds = Number(match[4]); - return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1_000; -} - function bounded(value) { const text = String(value ?? "").trim(); return text.length <= 2_000 ? text : `${text.slice(0, 2_000)}…`; diff --git a/scripts/release-benchmark/job-events.mjs b/scripts/release-benchmark/job-events.mjs new file mode 100644 index 00000000..82bb522e --- /dev/null +++ b/scripts/release-benchmark/job-events.mjs @@ -0,0 +1,110 @@ +import { HubJobSnapshotSchema } from "@mex/hub-contracts"; +import { performance } from "node:perf_hooks"; + +const MAX_EVENT_BYTES = 32 * 1024; +const MAX_STREAM_BYTES = 2 * 1024 * 1024; +const MAX_EVENTS = 1_024; +const TERMINAL_STATES = new Set(["succeeded", "failed", "interrupted"]); + +export function validateBenchmarkJob(value, { id, kind }) { + const parsed = HubJobSnapshotSchema.safeParse(value); + if (!parsed.success || parsed.data.kind !== kind || (id !== undefined && parsed.data.id !== id)) { + throw new Error("Benchmark received an invalid or unexpected job snapshot."); + } + return parsed.data; +} + +/** One bounded subscription, matching the Hub's snapshot/progress/terminal stream. */ +export async function waitForHubJobTerminal(server, auth, { id, kind, deadline }) { + const remaining = deadline - performance.now(); + if (!Number.isFinite(remaining) || remaining <= 0 || remaining > 180_000) { + throw new Error("Benchmark job stream exceeded its maintenance deadline."); + } + const controller = new AbortController(); + let reader; + let deadlineTimer; + let headersTimer; + const expired = new Promise((_, reject) => { + const expire = (message) => { + const error = new Error(message); + controller.abort(error); + reject(error); + }; + deadlineTimer = setTimeout(() => expire("Benchmark job stream exceeded its maintenance deadline."), remaining); + headersTimer = setTimeout(() => expire("Benchmark job stream headers exceeded 5000 ms."), Math.min(5_000, remaining)); + }); + try { + return await Promise.race([expired, (async () => { + const response = await fetch(`${server.origin}/api/v1/jobs/${encodeURIComponent(id)}/events`, { + headers: { accept: "text/event-stream", cookie: auth.cookie }, + redirect: "error", + signal: controller.signal, + }); + clearTimeout(headersTimer); + if (!response.ok) throw new Error(`Benchmark job stream failed with HTTP ${response.status}.`); + if (response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "text/event-stream" || !response.body) { + throw new Error("Benchmark job stream did not return an event stream."); + } + reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let totalBytes = 0; + let events = 0; + for (;;) { + const next = await reader.read(); + if (next.done) throw new Error("Benchmark job stream ended before a terminal snapshot."); + totalBytes += next.value.byteLength; + if (totalBytes > MAX_STREAM_BYTES) throw new Error("Benchmark job stream exceeded its total byte bound."); + buffer += decoder.decode(next.value, { stream: true }); + for (let separator; (separator = /\r?\n\r?\n/u.exec(buffer)) !== null;) { + const frame = buffer.slice(0, separator.index); + buffer = buffer.slice(separator.index + separator[0].length); + if (Buffer.byteLength(frame) > MAX_EVENT_BYTES) throw new Error("Benchmark job stream exceeded its event byte bound."); + if (++events > MAX_EVENTS) throw new Error("Benchmark job stream exceeded its event count bound."); + const job = parseFrame(frame, { id, kind }); + if (job && TERMINAL_STATES.has(job.state)) return job; + } + if (Buffer.byteLength(buffer) > MAX_EVENT_BYTES) throw new Error("Benchmark job stream exceeded its event byte bound."); + } + })()]); + } finally { + clearTimeout(deadlineTimer); + clearTimeout(headersTimer); + controller.abort(); + // Cancellation is deliberately not awaited after the absolute deadline. + // Aborting fetch releases the Hub subscription even with an outstanding read. + if (reader) { + void reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + } +} + +function parseFrame(frame, expected) { + let type; + let revision; + const data = []; + for (const line of frame.split(/\r?\n/u)) { + if (!line || line.startsWith(":")) continue; + const colon = line.indexOf(":"); + if (colon < 0) throw new Error("Benchmark received a malformed job event."); + const field = line.slice(0, colon); + const value = line.slice(colon + 1).replace(/^ /u, ""); + if (field === "data") data.push(value); + else if (field === "event" && type === undefined) type = value; + else if (field === "id" && revision === undefined) revision = value; + else throw new Error("Benchmark received an unsupported job event field."); + } + if (type === undefined && revision === undefined && data.length === 0) return null; // Heartbeat. + if (!["snapshot", "progress", "terminal"].includes(type) || data.length === 0) { + throw new Error("Benchmark received an invalid job event type or body."); + } + let value; + try { value = JSON.parse(data.join("\n")); } + catch { throw new Error("Benchmark received malformed job event JSON."); } + const job = validateBenchmarkJob(value, expected); + if (revision !== job.revision || (type === "terminal" && !TERMINAL_STATES.has(job.state))) { + throw new Error("Benchmark received an inconsistent job event."); + } + return job; +} diff --git a/scripts/release-benchmark/job-events.test.js b/scripts/release-benchmark/job-events.test.js new file mode 100644 index 00000000..d21cc99d --- /dev/null +++ b/scripts/release-benchmark/job-events.test.js @@ -0,0 +1,132 @@ +import { once } from "node:events"; +import { createServer } from "node:http"; +import { performance } from "node:perf_hooks"; +import { describe, expect, it } from "vitest"; +import { waitForHubJobTerminal } from "./job-events.mjs"; + +const JOB_ID = "job_01ARZ3NDEKTSV4RRFFQ69G5FAV"; +const KIND = "graph_rebuild"; +const revision = "a".repeat(64); +function snapshot(state = "running", extra = {}) { + return { + id: JOB_ID, scaffoldId: "benchmark", kind: KIND, generation: 1, + phase: state === "succeeded" ? "complete" : state === "interrupted" ? "interrupted" : "parse", + progress: null, state, cancelRequested: state === "interrupted", + createdAt: "2026-08-23T00:00:00.000Z", revision, ...extra, + }; +} +function event(job, type = "snapshot", newline = "\n") { + return [`event: ${type}`, `id: ${job.revision}`, `data: ${JSON.stringify(job)}`, "", ""].join(newline); +} +async function withServer(handler, run) { + const server = createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { await run({ origin: `http://127.0.0.1:${server.address().port}` }); } + finally { server.closeAllConnections(); server.close(); await once(server, "close"); } +} +const wait = (server, timeoutMs = 2_000) => waitForHubJobTerminal(server, { cookie: "test=session" }, { + id: JOB_ID, kind: KIND, deadline: performance.now() + timeoutMs, +}); + +describe("bounded maintenance event observation", () => { + it("handles split UTF-8 and CRLF frames, coalesced events and heartbeats", async () => { + const running = snapshot("running", { summary: "caf\u00e9" }); + const bytes = Buffer.from(event(running, "snapshot", "\r\n")); + const split = bytes.indexOf(Buffer.from("\u00e9")) + 1; + const parts = [bytes.subarray(0, split), bytes.subarray(split, bytes.length - 1), bytes.subarray(bytes.length - 1)]; + await withServer((request, response) => { + expect(request.url).toBe(`/api/v1/jobs/${JOB_ID}/events`); + expect(request.headers.cookie).toBe("test=session"); + response.writeHead(200, { "content-type": "text/event-stream; charset=UTF-8" }); + response.write(parts[0]); + setTimeout(() => response.write(parts[1]), 5); + setTimeout(() => { + response.write(parts[2]); + response.end(`: heartbeat\n\n${event(snapshot(), "progress")}${event(snapshot("succeeded"), "terminal")}`); + }, 10); + }, async (server) => { + expect((await wait(server)).state).toBe("succeeded"); + }); + }); + + it.each(["snapshot", "terminal"])("accepts an already-terminal initial %s and closes the subscription", async (type) => { + let requests = 0; + let closed; + await withServer((_, response) => { + requests += 1; + closed = once(response, "close"); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(event(snapshot("succeeded"), type)); + // Deliberately keep the server open: the terminal waiter must release it. + }, async (server) => { + expect((await wait(server)).state).toBe("succeeded"); + await closed; + expect(requests).toBe(1); + }); + }); + + it("returns interrupted terminal state so callers cannot count cancellation as success", async () => { + await withServer((_, response) => { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(event(snapshot("interrupted", { interruptedReason: "user_cancelled" }), "terminal")); + }, async (server) => { + expect(await wait(server)).toMatchObject({ state: "interrupted", interruptedReason: "user_cancelled" }); + }); + }); + + it("keeps the absolute deadline through a stalled body and cancels the reader", async () => { + let closed; + await withServer((_, response) => { + closed = once(response, "close"); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(event(snapshot())); + }, async (server) => { + await expect(wait(server, 75)).rejects.toThrow(/deadline/u); + await closed; + }); + }); + + it("does not reconnect after an HTTP failure or a premature stream close", async () => { + let requests = 0; + await withServer((_, response) => { requests += 1; response.writeHead(503); response.end("Unavailable"); }, async (server) => { + await expect(wait(server)).rejects.toThrow(/HTTP 503/u); + expect(requests).toBe(1); + }); + await withServer((_, response) => { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(event(snapshot())); + }, async (server) => { + await expect(wait(server)).rejects.toThrow(/ended before/u); + }); + }); + + it.each([ + ["malformed JSON", `event: snapshot\nid: ${revision}\ndata: {broken}\n\n`, /malformed.*JSON/u], + ["wrong job", event(snapshot("succeeded", { id: "job_01ARZ3NDEKTSV4RRFFQ69G5FAW" }), "terminal"), /unexpected job/u], + ["wrong kind", event(snapshot("succeeded", { kind: "wiki_rebuild" }), "terminal"), /unexpected job/u], + ["wrong state", event(snapshot("invented"), "terminal"), /invalid.*job snapshot/u], + ["inconsistent terminal", event(snapshot(), "terminal"), /inconsistent/u], + ["wrong revision", event(snapshot()).replace(`id: ${revision}`, `id: ${"b".repeat(64)}`), /inconsistent/u], + ])("rejects %s rather than waiting for a later valid frame", async (_, frame, error) => { + await withServer((_, response) => { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(frame + event(snapshot("succeeded"), "terminal")); + }, async (server) => { + await expect(wait(server)).rejects.toThrow(error); + }); + }); + + it.each([ + ["event bytes", "data: " + "x".repeat(32 * 1024 + 1), /event byte bound/u], + ["event count", ": heartbeat\n\n".repeat(1_025), /event count bound/u], + ["total bytes", (":" + "x".repeat(16_000) + "\n\n").repeat(132), /total byte bound/u], + ])("bounds %s while consuming the stream", async (_, body, error) => { + await withServer((_, response) => { + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(body); + }, async (server) => { + await expect(wait(server)).rejects.toThrow(error); + }); + }); +}); diff --git a/scripts/release-benchmark/measurement.test.js b/scripts/release-benchmark/measurement.test.js new file mode 100644 index 00000000..f0c73706 --- /dev/null +++ b/scripts/release-benchmark/measurement.test.js @@ -0,0 +1,179 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { requestJson } from "./http.mjs"; +import { runMaintenanceJob } from "./hub.mjs"; +import { createProcessTreeAccumulator, parseLinuxStat, parsePsSnapshot, startProcessTreeSampler } from "./process-tree.mjs"; +import { createGraphCharacterizationFixture } from "./graph-fixture.mjs"; + +const row = (pid, ppid, cpuMs, rssBytes, started = "start") => ({ pid, ppid, cpuMs, rssBytes, started }); + +describe("process tree measurements", () => { + it("counts descendants, preserves exited CPU, excludes unrelated processes and handles PID reuse", () => { + const accumulator = createProcessTreeAccumulator(10); + expect(accumulator.add([row(10, 1, 100, 20), row(99, 1, 900, 999)]).cpuMs).toBe(0); + expect(accumulator.add([row(10, 1, 110, 22), row(11, 10, 30, 40), row(12, 11, 5, 8), row(99, 1, 990, 999)])) + .toEqual({ rssBytes: 70, peakRssBytes: 70, cpuMs: 45, maxProcesses: 3, sampleCount: 2 }); + expect(accumulator.add([row(10, 1, 120, 20), row(12, 1, 8, 8)]).cpuMs).toBe(58); + expect(accumulator.add([row(10, 1, 120, 20), row(11, 10, 4, 10, "new")]).cpuMs).toBe(62); + }); + + it("rejects missing roots and malformed/unbounded samples instead of reporting zero", () => { + const accumulator = createProcessTreeAccumulator(10); + expect(() => accumulator.add([row(11, 1, 0, 0)])).toThrow(/disappeared/u); + expect(() => accumulator.add([row(10, 1, NaN, 0)])).toThrow(/Invalid/u); + expect(() => accumulator.add(Array.from({ length: 257 }, (_, index) => row(10 + index, index ? 10 : 1, 0, 0)))) + .toThrow(/256/u); + const reused = createProcessTreeAccumulator(10); + reused.add([row(10, 1, 0, 0)]); + expect(() => reused.add([row(10, 1, 0, 0, "reused")])).toThrow(/root PID was reused/u); + }); + + it("decodes Linux stat without splitting command names and macOS elapsed CPU formats", () => { + const fields = Array(22).fill("0"); + fields[0] = "S"; fields[1] = "10"; fields[11] = "123"; fields[12] = "7"; + fields[19] = "456"; fields[21] = "100"; + expect(parseLinuxStat(`11 (node (helper)) ${fields.join(" ")}`, { ticks: 100, pageBytes: 4096 })) + .toEqual(row(11, 10, 1300, 409600, "456")); + expect(parsePsSnapshot(" 11 10 Wed Sep 9 02:00:00 2026 1-02:03:04.50 400\n")) + .toEqual([row(11, 10, 93_784_500, 409600, "Wed Sep 9 02:00:00 2026")]); + }); + + it("surfaces timer sampling failures and closes the source", async () => { + let reads = 0; + let closed = false; + const sampler = await startProcessTreeSampler(10, { intervalMs: 1, source: { + read() { if (reads++) throw new Error("sampling failed"); return [row(10, 1, 0, 20)]; }, + close() { closed = true; }, + } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + await expect(sampler.stop()).rejects.toThrow("sampling failed"); + expect(closed).toBe(true); + }); + + it("includes a live descendant working set on the host platform", async () => { + const child = spawn(process.execPath, ["-e", ` + const {spawn}=require('node:child_process'); + process.on('message', message => { + if(message==='spawn') { + const nested=spawn(process.execPath,['-e', "global.kept=Buffer.alloc(8*1024*1024,1);process.send('ready');setInterval(()=>{},1000)"],{stdio:['ignore','ignore','ignore','ipc']}); + nested.once('message',()=>process.send('ready')); + process.on('disconnect',()=>{nested.kill();process.exit()}); + } + }); + process.send('started'); + `], { stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true }); + let sampler; + try { + await once(child, "message"); + sampler = await startProcessTreeSampler(child.pid); + const initial = await sampler.sample(); + const ready = once(child, "message"); + child.send("spawn"); + await ready; + // The Windows stream can have one snapshot queued before the spawn. + let final; + for (let attempt = 0; attempt < 10; attempt += 1) { + final = await sampler.sample(); + if (final.maxProcesses >= 2) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(final.maxProcesses).toBe(2); + expect(final.peakRssBytes).toBeGreaterThan(initial.peakRssBytes + 4 * 1024 * 1024); + } finally { + try { await sampler?.stop(); } + finally { + const exited = once(child, "exit"); + child.disconnect(); + await exited; + } + } + }, 15_000); +}); + +describe("graph characterization fixture", () => { + it("reproduces mixed compiler ownership, dependency types and the large-body input deterministically", () => { + const work = mkdtempSync(join(tmpdir(), "mex-graph-fixture-test-")); + try { + const first = createGraphCharacterizationFixture(join(work, "a")); + const second = createGraphCharacterizationFixture(join(work, "b")); + expect(first.digest).toBe(second.digest); + expect(first.sourceFiles).toBe(180); + expect(first.configFiles).toBe(5); + expect(first.inferredFiles).toBe(20); + const changedSource = readFileSync(join(first.root, first.mutableSource), "utf8"); + expect(changedSource.length).toBeGreaterThan(100_000); + expect(changedSource).toContain("value += 101; // characterization-state:A"); + expect(readFileSync(join(first.root, "node_modules/@benchmark/model/index.d.ts"), "utf8")).toContain("Model127"); + const smoke = createGraphCharacterizationFixture(join(work, "smoke"), { smoke: true }); + expect(smoke.sourceFiles).toBe(10); + expect(smoke.projects).toBe(first.projects); + expect(() => createGraphCharacterizationFixture(first.root)).toThrow(/new fixture/u); + } finally { rmSync(work, { recursive: true, force: true }); } + }); +}); + +describe("bounded benchmark HTTP", () => { + async function withServer(handler, run) { + const server = createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { await run(`http://127.0.0.1:${server.address().port}`); } + finally { server.closeAllConnections(); server.close(); await once(server, "close"); } + } + + it("bounds a server that never sends headers", async () => { + await withServer(() => {}, async (url) => { + await expect(requestJson(url, {}, { timeoutMs: 50 })).rejects.toThrow(/exceeded/u); + }); + }); + + it("keeps the deadline active after headers arrive", async () => { + await withServer((_, response) => { response.writeHead(200, { "content-type": "application/json" }); response.write('{"pending":'); }, async (url) => { + await expect(requestJson(url, {}, { timeoutMs: 50 })).rejects.toThrow(/exceeded/u); + }); + }); + + it("shares one job deadline across creation and a stalled event stream", async () => { + let requests = 0; + let startedAt; + await withServer((request, response) => { + requests += 1; + if (request.method === "POST") { + startedAt = performance.now(); + setTimeout(() => response.end(JSON.stringify({ + id: "job_01ARZ3NDEKTSV4RRFFQ69G5FAV", scaffoldId: "test", kind: "graph_rebuild", + generation: 1, phase: "queued", progress: null, state: "queued", cancelRequested: false, + createdAt: "2026-08-23T00:00:00.000Z", revision: "a".repeat(64), + })), 40); + } else { + expect(request.url).toBe("/api/v1/jobs/job_01ARZ3NDEKTSV4RRFFQ69G5FAV/events"); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write(": heartbeat\n\n"); + } + }, async (origin) => { + await expect(runMaintenanceJob({ origin, child: { pid: process.pid } }, { csrfToken: "test" }, "graph_rebuild", { timeoutMs: 150 })) + .rejects.toThrow(/exceeded|did not settle/u); + expect(requests).toBe(2); + expect(performance.now() - startedAt).toBeLessThan(1_500); + }); + }, 15_000); + + it("stops an oversized streaming body before buffering the entire response", async () => { + await withServer((_, response) => { response.writeHead(200); response.end(" ".repeat(2 * 1024 * 1024 + 1)); }, async (url) => { + await expect(requestJson(url)).rejects.toThrow(/exceeded 2097152 bytes/u); + }); + }); + + it("returns parsed JSON and the response metadata", async () => { + await withServer((_, response) => { response.writeHead(201); response.end('{"ok":true}'); }, async (url) => { + const value = await requestJson(url); + expect(value.response.status).toBe(201); + expect(value.body).toEqual({ ok: true }); + }); + }); +}); diff --git a/scripts/release-benchmark/process-tree.mjs b/scripts/release-benchmark/process-tree.mjs new file mode 100644 index 00000000..3a394981 --- /dev/null +++ b/scripts/release-benchmark/process-tree.mjs @@ -0,0 +1,262 @@ +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, readdirSync } from "node:fs"; + +const MAX_PROCESSES = 16_384; +const MAX_TREE_PROCESSES = 256; +const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const SAMPLE_TIMEOUT_MS = 5_000; + +export const PROCESS_MEASUREMENT = Object.freeze({ + scope: "hub-and-descendants", + rss: "sum-of-resident-sets-in-one-sample", + cpu: "observed-process-lifetime-deltas", + intervalMs: process.platform === "linux" ? 10 : 100, + limitation: "Shared pages may be counted twice; short-lived children and final CPU between samples may be missed.", +}); + +/** Retain CPU already observed in exited children; PID reuse starts a new identity. */ +export function createProcessTreeAccumulator(rootPid) { + if (!Number.isInteger(rootPid) || rootPid <= 0) throw new Error("Process tree root must be a positive PID."); + const observed = new Map(); + let rootStarted; + let initialized = false; + let peakRssBytes = 0; + let cpuMs = 0; + let maxProcesses = 0; + let sampleCount = 0; + return { + add(rows) { + if (!Array.isArray(rows) || rows.length > MAX_PROCESSES) throw new Error("Process snapshot exceeded its row bound."); + const byPid = new Map(); + const children = new Map(); + for (const row of rows) { + if (!Number.isInteger(row.pid) || row.pid <= 0 || !Number.isInteger(row.ppid) || row.ppid < 0 + || typeof row.started !== "string" || !row.started || row.started.length > 100 + || !Number.isFinite(row.rssBytes) || row.rssBytes < 0 + || !Number.isFinite(row.cpuMs) || row.cpuMs < 0 || byPid.has(row.pid)) { + throw new Error("Invalid process snapshot row."); + } + byPid.set(row.pid, row); + const list = children.get(row.ppid) ?? []; + list.push(row.pid); + children.set(row.ppid, list); + } + if (!byPid.has(rootPid)) throw new Error("Benchmark root process disappeared while sampling."); + const currentRootStarted = byPid.get(rootPid).started; + if (rootStarted !== undefined && rootStarted !== currentRootStarted) throw new Error("Benchmark root PID was reused while sampling."); + rootStarted = currentRootStarted; + const selected = new Set(); + const pending = [rootPid]; + // A previously identified child can remain alive briefly after reparenting. + for (const row of rows) if (observed.has(`${row.pid}:${row.started}`)) pending.push(row.pid); + while (pending.length) { + const pid = pending.pop(); + if (selected.has(pid)) continue; + selected.add(pid); + if (selected.size > MAX_TREE_PROCESSES) throw new Error("Benchmark process tree exceeded 256 processes."); + pending.push(...(children.get(pid) ?? [])); + } + let rssBytes = 0; + for (const pid of selected) { + const row = byPid.get(pid); + const identity = `${pid}:${row.started}`; + const previous = observed.get(identity); + cpuMs += previous === undefined ? (initialized ? row.cpuMs : 0) : Math.max(0, row.cpuMs - previous); + observed.set(identity, Math.max(previous ?? 0, row.cpuMs)); + rssBytes += row.rssBytes; + } + if (observed.size > MAX_PROCESSES) throw new Error("Benchmark lifetime process count exceeded its bound."); + initialized = true; + sampleCount += 1; + peakRssBytes = Math.max(peakRssBytes, rssBytes); + maxProcesses = Math.max(maxProcesses, selected.size); + return { rssBytes, peakRssBytes, cpuMs, maxProcesses, sampleCount }; + }, + }; +} + +export async function startProcessTreeSampler(rootPid, { source, intervalMs = PROCESS_MEASUREMENT.intervalMs } = {}) { + if (!Number.isFinite(intervalMs) || intervalMs < 1 || intervalMs > 1_000) throw new Error("Invalid process sample interval."); + const backend = source ?? createProcessSource(rootPid); + const accumulator = createProcessTreeAccumulator(rootPid); + let latest; + let failure; + let pending; + let stopped = false; + const sample = () => { + if (failure) return Promise.reject(failure); + if (pending) return pending; + pending = Promise.resolve().then(() => backend.read()).then((rows) => { + latest = accumulator.add(rows); + return latest; + }).catch((error) => { failure = error; throw error; }).finally(() => { pending = undefined; }); + return pending; + }; + try { await sample(); } catch (error) { await backend.close?.(); throw error; } + const timer = setInterval(() => { void sample().catch(() => undefined); }, intervalMs); + return { + sample, + async stop() { + if (stopped) { if (failure) throw failure; return latest; } + stopped = true; + clearInterval(timer); + try { return await sample(); } finally { await backend.close?.(); } + }, + }; +} + +function createProcessSource(rootPid) { + if (process.platform === "linux") return { read: () => readLinuxTree(rootPid) }; + if (process.platform === "darwin") return { + read: async () => parsePsSnapshot(await commandOutput("ps", ["-axo", "pid=,ppid=,lstart=,time=,rss="])), + }; + if (process.platform === "win32") return windowsSource(); + throw new Error(`Process-tree measurement is unsupported on ${process.platform}.`); +} + +let linuxUnits; +function readLinuxTree(rootPid) { + linuxUnits ??= { + ticks: getconf("CLK_TCK"), + pageBytes: getconf("PAGESIZE"), + }; + const pending = [rootPid]; + const visited = new Set(); + const rows = []; + while (pending.length) { + const pid = pending.pop(); + if (visited.has(pid)) continue; + visited.add(pid); + if (visited.size > MAX_TREE_PROCESSES) throw new Error("Benchmark process tree exceeded 256 processes."); + try { + rows.push(parseLinuxStat(readFileSync(`/proc/${pid}/stat`, "utf8"), linuxUnits)); + const threads = readdirSync(`/proc/${pid}/task`); + if (threads.length > 512) throw new Error("Benchmark process exceeded 512 threads."); + for (const tid of threads) { + try { + const children = readFileSync(`/proc/${pid}/task/${tid}/children`, "utf8").trim(); + if (children) pending.push(...children.split(/\s+/u).map(Number)); + } catch (error) { if (error.code !== "ENOENT" && error.code !== "ESRCH") throw error; } + } + } catch (error) { + if (pid === rootPid || (error.code !== "ENOENT" && error.code !== "ESRCH")) throw error; + } + } + return rows; +} + +export function parseLinuxStat(text, { ticks, pageBytes }) { + const pid = Number(text.slice(0, text.indexOf(" "))); + const fields = text.slice(text.lastIndexOf(")") + 2).trim().split(/\s+/u); + return { + pid, ppid: Number(fields[1]), started: fields[19], + cpuMs: (Number(fields[11]) + Number(fields[12])) * 1_000 / ticks, + rssBytes: Number(fields[21]) * pageBytes, + }; +} + +export function parsePsSnapshot(text) { + return text.trim().split(/\r?\n/u).filter(Boolean).map((line) => { + const fields = line.trim().split(/\s+/u); + if (fields.length !== 9) throw new Error("Unexpected ps process sample format."); + return { + pid: Number(fields[0]), ppid: Number(fields[1]), started: fields.slice(2, 7).join(" "), + cpuMs: parsePsCpuTime(fields[7]), rssBytes: Number(fields[8]) * 1024, + }; + }); +} + +function parsePsCpuTime(value) { + const match = value.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/u); + if (!match) throw new Error("Could not parse process CPU time."); + return (((Number(match[1] ?? 0) * 24 + Number(match[2] ?? 0)) * 60 + Number(match[3])) * 60 + Number(match[4])) * 1_000; +} + +function getconf(name) { + const result = spawnSync("getconf", [name], { encoding: "utf8", timeout: SAMPLE_TIMEOUT_MS, maxBuffer: 1024 }); + const value = Number(result.stdout?.trim()); + if (result.status !== 0 || !Number.isFinite(value) || value <= 0) throw new Error(`Cannot determine ${name} for process measurement.`); + return value; +} + +function commandOutput(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, LC_ALL: "C" }, windowsHide: true }); + let output = ""; + let settled = false; + const fail = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + output = ""; + child.kill(); + reject(error); + }; + const timer = setTimeout(() => fail(new Error("Process sampling command timed out.")), SAMPLE_TIMEOUT_MS); + child.stdout.on("data", (chunk) => { + if (settled) return; + output += chunk.toString("utf8"); + if (Buffer.byteLength(output) > MAX_OUTPUT_BYTES) fail(new Error("Process snapshot exceeded its byte bound.")); + }); + child.once("error", fail); + child.once("close", (code) => { + if (settled) return; + clearTimeout(timer); + if (code !== 0) fail(new Error("Process sampling command failed.")); + else { settled = true; resolve(output); } + }); + }); +} + +/** Keep PowerShell alive: starting it for every sample would dominate the measurement. */ +function windowsSource() { + const script = [ + "$ErrorActionPreference='Stop'", + "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)", + "while ($true) {", + "$rows = @(Get-CimInstance Win32_Process | ForEach-Object { if ($_.ProcessId -gt 0 -and $null -ne $_.CreationDate) {", + "[PSCustomObject]@{pid=[int]$_.ProcessId;ppid=[int]$_.ParentProcessId;started=$_.CreationDate.ToUniversalTime().ToString('o');rssBytes=[double]$_.WorkingSetSize;cpuMs=([double]$_.KernelModeTime+[double]$_.UserModeTime)/10000}", + "}})", + "[Console]::WriteLine((ConvertTo-Json -InputObject $rows -Compress))", + "Start-Sleep -Milliseconds 100", + "}", + ].join("\n"); + const child = spawn("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { + stdio: ["ignore", "pipe", "ignore"], windowsHide: true, + }); + let buffer = ""; + let frame; + let failure; + let waiter; + const fail = (error) => { failure ??= error; buffer = ""; frame = undefined; waiter?.reject(failure); waiter = undefined; }; + child.on("error", fail); + child.on("exit", () => fail(new Error("Windows process sampler exited."))); + child.stdout.on("data", (chunk) => { + if (failure) return; + buffer += chunk.toString("utf8"); + if (Buffer.byteLength(buffer) > MAX_OUTPUT_BYTES) { fail(new Error("Windows process snapshot exceeded its byte bound.")); child.kill(); return; } + for (let newline; (newline = buffer.indexOf("\n")) >= 0;) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + try { + frame = JSON.parse(line); + if (!Array.isArray(frame) || frame.length > MAX_PROCESSES) throw new Error("Invalid Windows process snapshot."); + if (waiter) { waiter.resolve(frame); waiter = undefined; frame = undefined; } + } catch (error) { fail(error); child.kill(); return; } + } + }); + return { + async read() { + if (failure) throw failure; + if (frame) { const value = frame; frame = undefined; return value; } + let timer; + try { + return await new Promise((resolve, reject) => { + waiter = { resolve, reject }; + timer = setTimeout(() => { fail(new Error("Windows process sampler timed out.")); child.kill(); }, SAMPLE_TIMEOUT_MS); + }); + } finally { clearTimeout(timer); } + }, + close() { child.kill(); }, + }; +} diff --git a/scripts/release-benchmark/release-benchmark.test.js b/scripts/release-benchmark/release-benchmark.test.js index 0ed86419..3704719c 100644 --- a/scripts/release-benchmark/release-benchmark.test.js +++ b/scripts/release-benchmark/release-benchmark.test.js @@ -49,9 +49,24 @@ const budgetsSchema = JSON.parse(readFileSync(new URL("./budgets.schema.json", i const packageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")); const reportSchema = JSON.parse(readFileSync(new URL("./report.schema.json", import.meta.url), "utf8")); const FROZEN_NON_OVERVIEW_UX_BUDGETS_SHA256 = "e970dea48bdaffd3ca258ce62dd56327a7692f89fd0e740c72bca61092ea10d8"; +const FROZEN_PRE_GRAPH_TIMING_BUDGETS_SHA256 = "d4209e5549ed49c37dcd4e4814eab0ceb7640124d75bdf433347ea9de1514c80"; +const PRE_GRAPH_TIMING_LIMITS = { + small: { graph_refresh: 984, graph_rebuild: 496 }, + medium: { graph_refresh: 1237, graph_rebuild: 743 }, + large: { graph_rebuild: 1229 }, +}; + +function beforeGraphTimingCalibration(value) { + const projected = structuredClone(value); + projected.calibration.status = "pinned-checkpoints-A-G-and-Settings-34286120355"; + for (const [profile, operations] of Object.entries(PRE_GRAPH_TIMING_LIMITS)) { + Object.assign(projected.runtime.maintenanceMs[profile], operations); + } + return projected; +} function frozenAllowedCalibrationProjection(value) { - const projected = structuredClone(value); + const projected = beforeGraphTimingCalibration(value); projected.calibration.status = "__RELAY_CALIBRATION_STATUS__"; projected.assets.maxJsChunkBytes = "__OVERVIEW_INITIAL_JS_CALIBRATION__"; projected.assets.initial.jsBytes = "__OVERVIEW_INITIAL_JS_CALIBRATION__"; @@ -76,7 +91,7 @@ function frozenAllowedCalibrationProjection(value) { } describe("release benchmark contract", () => { - it("permits only owned calibration leaves, including the additive Settings route", () => { + it("composes owned Graph timing calibration with the original frozen budget guard", () => { const digest = (value) => createHash("sha256") .update(JSON.stringify(frozenAllowedCalibrationProjection(value))) .digest("hex"); @@ -116,8 +131,8 @@ describe("release benchmark contract", () => { medium: { sourceFiles: 16, wikiEntities: 16, workstreams: 1, inboxDrafts: 1, inboxProposals: 1, members: 2, relayDrafts: 1, relays: 1, activityEvents: 16 }, large: { sourceFiles: 48, wikiEntities: 48, workstreams: 1, inboxDrafts: 1, inboxProposals: 1, members: 2, relayDrafts: 1, relays: 1, activityEvents: 48 }, }); - // Settings assets use the deterministic build; its heap limits remain - // absent until pinned calibration rather than inventing a local limit. + // Settings assets use the deterministic build; heap limits come from the + // retained pinned Linux report, with every unrelated limit still frozen. expect(Object.keys(budgets.assets.routes)).toEqual(RELEASE_ROUTE_KEYS); expect(budgets.assets.routes.settings).toEqual({ jsBytes: 8035, cssBytes: 2910, fontBytes: 0 }); expect(Object.keys(releaseWorkbenchPaths({ @@ -134,7 +149,7 @@ describe("release benchmark contract", () => { expect(registeredPatterns).toEqual(Object.values(RELEASE_ROUTE_PATTERNS)); for (const profile of ["small", "medium", "large"]) { expect(Object.keys(budgets.runtime.browserHeapBytes[profile])) - .toEqual(RELEASE_ROUTE_KEYS.filter((route) => route !== "settings")); + .toEqual(RELEASE_ROUTE_KEYS); } expect({ small: { @@ -186,7 +201,7 @@ describe("release benchmark contract", () => { } expect(budgets.provisional).toBe(false); expect(budgets.calibration).toEqual({ - status: "calibrated-from-pinned-runs-33005876613-33083122092-33117048710-E33169865368-F33249296778-Goverview", + status: "pinned-A-G-Settings-34286120355-Graph-timing-34288560611", runtimeFormula: "ceil(measured p95 * 1.15)", assetFormula: "ceil(built bytes * 1.05)", }); @@ -293,12 +308,122 @@ describe("release benchmark contract", () => { }, ); - it("records Settings without pretending its missing heap calibration is a passing budget", () => { + it("pins Settings heap to retained runner samples without changing existing budgets", () => { + const evidence = JSON.parse(readFileSync(new URL("../../docs/design/settings-heap-calibration.json", import.meta.url), "utf8")); + expect(evidence.environment).toEqual({ ...budgets.environment, pinnedBudgetEnvironment: true }); + expect(evidence.formula).toBe(budgets.calibration.runtimeFormula); + expect(evidence.sampleCount).toBe(budgets.samples.idleMemory); + const beforeSettings = beforeGraphTimingCalibration(budgets); + beforeSettings.calibration.status = evidence.previousCalibrationStatus; + for (const profile of ["small", "medium", "large"]) { + const measured = evidence.profiles[profile]; + expect(summarize(measured.samples, evidence.sampleCount).p95).toBe(measured.p95); + expect(runtimeBudgetCandidate(measured.p95)).toBe(measured.budgetBytes); + expect(budgets.runtime.browserHeapBytes[profile].settings).toBe(measured.budgetBytes); + delete beforeSettings.runtime.browserHeapBytes[profile].settings; + } + expect(createHash("sha256").update(JSON.stringify(beforeSettings)).digest("hex")) + .toBe(evidence.unownedBudgetSha256); + }); + + it("permits exactly five Graph timing leaves while freezing every other current budget", () => { + const digest = (value) => createHash("sha256") + .update(JSON.stringify(beforeGraphTimingCalibration(value))) + .digest("hex"); + expect(digest(budgets)).toBe(FROZEN_PRE_GRAPH_TIMING_BUDGETS_SHA256); + const allowed = structuredClone(budgets); + allowed.calibration.status = "another-calibration-reference"; + for (const [profile, operations] of Object.entries(PRE_GRAPH_TIMING_LIMITS)) { + for (const operation of Object.keys(operations)) allowed.runtime.maintenanceMs[profile][operation] += 1; + } + expect(digest(allowed)).toBe(FROZEN_PRE_GRAPH_TIMING_BUDGETS_SHA256); + for (const path of [ + ["runtime", "maintenanceMs", "large", "graph_refresh"], + ["runtime", "maintenanceMs", "small", "wiki_rebuild"], + ["runtime", "maintenancePeakRssBytes", "small", "graph_refresh"], + ["runtime", "browserHeapBytes", "small", "settings"], + ["runtime", "apiLatencyMs", "small", "search"], + ["assets", "initial", "jsBytes"], + ]) { + const forbidden = structuredClone(allowed); + const parent = path.slice(0, -1).reduce((value, key) => value[key], forbidden); + parent[path.at(-1)] += 1; + expect(digest(forbidden), path.join(".")).not.toBe(FROZEN_PRE_GRAPH_TIMING_BUDGETS_SHA256); + } + }); + + it("pins Graph timing to the first corrected runner and retains independent confirmation", () => { + const evidence = JSON.parse(readFileSync(new URL("../../docs/design/graph-maintenance-timing-calibration.json", import.meta.url), "utf8")); + expect(evidence.environment).toEqual({ ...budgets.environment, pinnedBudgetEnvironment: true }); + expect(evidence.configuration.fixtureProfiles).toEqual(RELEASE_FIXTURE_PROFILES); + expect(evidence.configuration.samples).toEqual(budgets.samples); + expect(evidence.configuration).toMatchObject({ maintenanceObservation: "job-event-stream", runtimeBudgetsEnforced: true, assetBudgetsEnforced: true, provisionalBudgets: false }); + expect(evidence.formula).toBe(budgets.calibration.runtimeFormula); + expect(evidence.sampleCount).toBe(budgets.samples.timing); + expect(evidence.previousCalibrationStatus).toBe(beforeGraphTimingCalibration(budgets).calibration.status); + expect(evidence.unownedBudgetSha256).toBe(FROZEN_PRE_GRAPH_TIMING_BUDGETS_SHA256); + expect(evidence.source.primaryAttempt).toBe("first"); + expect(evidence.source.pullRequestHead).toBe("4d6683eec1a0bdcafe99d7b431d84cde7f02864d"); + expect(evidence.source.repositoryHead).toBe("6d92bb04d757c8a00693ef679d1f4281669a9b57"); + expect(evidence.attempts.map(({ role }) => role)).toEqual(["primary", "confirmation"]); + expect(evidence.attempts.map(({ manifest }) => manifest.rawReportSha256)).toEqual([ + "fec98eae2728a017fcaf3480d275d47180d88740b248468d69a6fa858768c9b3", + "732d2b897b4592ba7e89d9fedb508c0c86027ae7cb239580ed51aa5a9de9efb9", + ]); + const [primary, confirmation] = evidence.attempts; + expect(primary.manifest.runnerAllocation.job).not.toBe(confirmation.manifest.runnerAllocation.job); + expect(primary.manifest.runnerAllocation.runnerName).not.toBe(confirmation.manifest.runnerAllocation.runnerName); + for (const attempt of evidence.attempts) { + expect(attempt.manifest).toMatchObject({ + repositoryHead: evidence.source.repositoryHead, + github: { runId: "34288560611", runAttempt: "1", sha: evidence.source.repositoryHead }, + runnerAllocation: { runnerOs: "Linux", runnerArch: "X64" }, + }); + for (const profile of ["small", "medium", "large"]) { + for (const operation of ["graph_refresh", "graph_rebuild"]) { + for (const [metric, summary] of Object.entries(attempt.graph[profile][operation])) { + expect(summary).toEqual(summarize(summary.samples, metric === "peakRssBytes" ? budgets.samples.idleMemory : budgets.samples.timing)); + } + } + } + } + const expectedMetrics = Object.entries(PRE_GRAPH_TIMING_LIMITS).flatMap(([profile, operations]) => + Object.keys(operations).map((operation) => `runtime.maintenanceMs.${profile}.${operation}`)); + expect(evidence.calibratedLeaves.map(({ metric }) => metric)).toEqual(expectedMetrics); + expect(evidence.confirmedMaterialAssessments.map(({ metric }) => metric)).toEqual(expectedMetrics); + for (const [index, leaf] of evidence.calibratedLeaves.entries()) { + const [, , profile, operation] = leaf.metric.split("."); + const first = primary.graph[profile][operation].elapsedMs; + const second = confirmation.graph[profile][operation].elapsedMs; + expect(leaf.previousBudgetMs).toBe(PRE_GRAPH_TIMING_LIMITS[profile][operation]); + expect(leaf.primaryP95Ms).toBe(first.p95); + expect(leaf.budgetMs).toBe(runtimeBudgetCandidate(first.p95)); + expect(budgets.runtime.maintenanceMs[profile][operation]).toBe(leaf.budgetMs); + // Reconstruct the historical assessment using the old budget, not the newly calibrated policy. + const threshold = Math.round((leaf.previousBudgetMs + Math.max(leaf.previousBudgetMs * 0.15, 50)) * 1000) / 1000; + const firstSupport = first.samples.filter((sample) => sample > threshold).length; + const secondSupport = second.samples.filter((sample) => sample > threshold).length; + expect(firstSupport).toBeGreaterThanOrEqual(2); + expect(secondSupport).toBeGreaterThanOrEqual(2); + expect(evidence.confirmedMaterialAssessments[index]).toEqual({ + metric: leaf.metric, category: "maintenance_ms", classification: "material", reason: "repeated_material_threshold", + budget: leaf.previousBudgetMs, relativeExcessRatio: 0.15, minimumExcess: 50, materialThreshold: threshold, + firstMeasured: first.p95, secondMeasured: second.p95, requiredSupportingSamples: 2, + firstSampleCount: 10, firstSupportingSamples: firstSupport, secondSampleCount: 10, secondSupportingSamples: secondSupport, + }); + } + expect(evidence.unchangedLargeGraphRefreshMs).toBe(1812); + expect(budgets.runtime.maintenanceMs.large.graph_refresh).toBe(evidence.unchangedLargeGraphRefreshMs); + }); + + it("still fails closed if a Settings heap budget is missing", () => { const profiles = Object.fromEntries(["small", "medium", "large"].map((profile) => [ profile, { ...runtimeProfile(100), browserHeap: { outboundRequestCount: 0, routes: { settings: { p95: 123 } } } }, ])); - const violations = evaluateRuntimeBudgets(profiles, budgets.runtime) + const missing = structuredClone(budgets.runtime); + for (const profile of ["small", "medium", "large"]) delete missing.browserHeapBytes[profile].settings; + const violations = evaluateRuntimeBudgets(profiles, missing) .filter(({ metric }) => metric.endsWith(".settings")); expect(violations).toEqual(["small", "medium", "large"].map((profile) => ({ metric: `runtime.browserHeapBytes.${profile}.settings`, measured: 123, budget: null, reason: "budget_missing", @@ -334,10 +459,11 @@ describe("release benchmark contract", () => { it("accepts legacy raw precision when its rounded p95 matches the violation", () => { const metric = "runtime.maintenanceMs.small.graph_rebuild"; - const violation = runtimeViolation(metric, 580.769); + const measured = Math.ceil(runtimeMaterialityPolicy(metric).materialThreshold) + 10.769; + const violation = runtimeViolation(metric, measured); const report = benchmarkPass({ runtimeViolations: [violation] }); const summary = report.profiles.small.maintenance.graph_rebuild.elapsedMs; - summary.samples[summary.samples.length - 1] = 580.7687; + summary.samples[summary.samples.length - 1] = measured - 0.0003; expect(runtimeSampleSupport(report, [violation]).get(metric)).toEqual({ sampleCount: 10, supportingSamples: 2, @@ -972,7 +1098,7 @@ describe("release benchmark contract", () => { } const exactMetrics = committedConfirmableRuntimeMetrics(); - expect(exactMetrics).toHaveLength(111); + expect(exactMetrics).toHaveLength(114); for (const metric of exactMetrics) { expect(runtimeMaterialityPolicy(metric)).not.toBeNull(); const violation = runtimeViolation(metric); diff --git a/scripts/release-benchmark/report.schema.json b/scripts/release-benchmark/report.schema.json index e99a7a9e..15270a88 100644 --- a/scripts/release-benchmark/report.schema.json +++ b/scripts/release-benchmark/report.schema.json @@ -10,7 +10,7 @@ "benchmark": { "const": "mex-release-performance" }, "generatedAt": { "type": "string", "format": "date-time", "maxLength": 40 }, "environment": { "type": "object", "maxProperties": 5 }, - "configuration": { "type": "object", "maxProperties": 6 }, + "configuration": { "type": "object", "maxProperties": 7 }, "assets": { "$ref": "#/$defs/assets" }, "profiles": { "type": "object", @@ -231,7 +231,8 @@ "required": ["elapsedMs", "peakRssBytes"], "properties": { "elapsedMs": { "$ref": "#/$defs/summary10" }, - "peakRssBytes": { "$ref": "#/$defs/summary5" } + "peakRssBytes": { "$ref": "#/$defs/summary5" }, + "cpuMs": { "$ref": "#/$defs/summary10" } } }, "maintenanceSummaries": { diff --git a/scripts/release-benchmark/run.mjs b/scripts/release-benchmark/run.mjs index 1be1c791..8de8d30c 100644 --- a/scripts/release-benchmark/run.mjs +++ b/scripts/release-benchmark/run.mjs @@ -35,6 +35,7 @@ import { } from "./hub.mjs"; import { candidateRuntimeBudgets, evaluateRuntimeBudgets } from "./runtime-budgets.mjs"; import { round, summarize } from "./statistics.mjs"; +import { PROCESS_MEASUREMENT } from "./process-tree.mjs"; const scriptRoot = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = resolve(scriptRoot, "../.."); @@ -75,6 +76,8 @@ const report = { environment: environmentRecord(budgets.environment, enforceRuntime), configuration: { fixtureProfiles: RELEASE_FIXTURE_PROFILES, + processMeasurement: PROCESS_MEASUREMENT, + maintenanceObservation: "job-event-stream", samples: budgets.samples, runtimeBudgetsEnforced: enforceRuntime, assetBudgetsEnforced: true, @@ -226,6 +229,7 @@ function summarizeProfile(input) { const maintenance = Object.fromEntries(Object.entries(input.maintenance).map(([kind, samples]) => [kind, { elapsedMs: summarize(samples.elapsedMs, input.timingSamples), peakRssBytes: summarize(samples.peakRssBytes, input.memorySamples), + cpuMs: summarize(samples.cpuMs, input.timingSamples), }])); const browserHeap = input.browser === null ? null : { routes: Object.fromEntries(Object.entries(input.browser.measurements).map(([route, values]) => [ diff --git a/src/graph/__tests__/application-adapter.test.ts b/src/graph/__tests__/application-adapter.test.ts index b64fb58d..47bcad1a 100644 --- a/src/graph/__tests__/application-adapter.test.ts +++ b/src/graph/__tests__/application-adapter.test.ts @@ -7,7 +7,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MexPortError } from "../../team/contracts/shared.js"; import { createRepositoryGraphPort, @@ -91,6 +91,24 @@ function expectPortCode(error: unknown, code: string): void { } describe("RepositoryGraphPort", () => { + it.each([undefined, "process"] as const)("forwards maintenance authority with execution mode %s", async (candidateExecution) => { + const root = temporaryRoot(); + const refresh = vi.fn(async () => { throw new Error("injected stop"); }); + const rebuild = vi.fn(async () => { throw new Error("injected stop"); }); + const port = createRepositoryGraphPort(root, { + candidateExecution, + __internal: { refresh, rebuild }, + }); + const controller = new AbortController(); + const onProgress = vi.fn(); + const options = { signal: controller.signal, onProgress }; + await expect(port.refresh(options)).rejects.toBeInstanceOf(MexPortError); + await expect(port.rebuild(options)).rejects.toBeInstanceOf(MexPortError); + const forwarded = candidateExecution ? { ...options, candidateExecution } : options; + expect(refresh).toHaveBeenCalledExactlyOnceWith(root, forwarded); + expect(rebuild).toHaveBeenCalledExactlyOnceWith(root, forwarded); + }); + it("maps a non-lossless repair state to sanitized rebuild guidance", async () => { const root = temporaryRoot(); source(root, "src/service.ts", "export const service = true;\n"); diff --git a/src/graph/__tests__/candidate-process.test.ts b/src/graph/__tests__/candidate-process.test.ts new file mode 100644 index 00000000..73257c8e --- /dev/null +++ b/src/graph/__tests__/candidate-process.test.ts @@ -0,0 +1,347 @@ +import { createHash } from "node:crypto"; +import { spawn, type ChildProcess } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { boundedCandidateMessage } from "../candidate-protocol.js"; +import { createGraphCandidateProgressSender } from "../candidate-progress.js"; +import { runGraphCandidateProcess } from "../candidate-process.js"; +import { createGraphEngine } from "../engine-impl.js"; +import { rebuildGraph, refreshGraph, type GraphMaintenanceExecutionOptions } from "../maintenance.js"; +import { openSqlite } from "../db/sqlite.js"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const roots: string[] = []; +const children = new Set(); +let bundleDir: string; +let realEntry: string; +let busyEntry: string; +let crashEntry: string; +let lateEntry: string; +let disconnectedEntry: string; +let parentEntry: string; + +function root(): string { + const path = mkdtempSync(join(tmpdir(), "mex-candidate-process-test-")); + roots.push(path); + mkdirSync(join(path, ".mex")); + writeFileSync(join(path, "service.py"), "def service():\n return 1\n"); + return path; +} + +async function bundle(name: string, source: string): Promise { + const outfile = join(bundleDir, `${name}.mjs`); + await build({ + stdin: { contents: source, resolveDir: repository, sourcefile: `${name}.ts`, loader: "ts" }, + outfile, bundle: true, platform: "node", format: "esm", target: "node22", packages: "external", logLevel: "silent", + }); + return outfile; +} + +beforeAll(async () => { + // The temporary bundle lives under node_modules so external runtime imports + // resolve exactly as in an installation, without a TS loader in the child. + const cache = join(repository, "node_modules", ".cache"); + mkdirSync(cache, { recursive: true }); + bundleDir = mkdtempSync(join(cache, "mex-candidate-tests-")); + cpSync(join(repository, "src/graph/schema.sql"), join(bundleDir, "schema.sql")); + cpSync(join(repository, "src/graph/wasm"), join(bundleDir, "wasm"), { recursive: true }); + realEntry = await bundle("real", 'import "./src/graph/candidate-entry.ts";'); + const prefix = ` + import { startGraphCandidateWatchdog } from "./src/graph/candidate-watchdog.ts"; + import { mkdirSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { DatabaseSync } from "node:sqlite"; + await startGraphCandidateWatchdog(); + process.once("message", (request) => { + const spool = join(request.workspace, "spool"); + mkdirSync(spool); + writeFileSync(join(spool, "source"), "private temporary source"); + `; + const suffix = '}); process.send({ type: "ready" });'; + busyEntry = await bundle("busy", prefix + ` + process.on("SIGTERM", () => {}); + process.send({ type: "progress", progress: { phase: "parse", completed: 1, total: 2 } }); + for (;;) Math.sqrt(Math.random()); + ` + suffix); + crashEntry = await bundle("crash", prefix + ` + const db = new DatabaseSync(request.candidatePath); + db.exec("PRAGMA journal_mode=WAL; BEGIN IMMEDIATE; CREATE TABLE candidate_crash (value TEXT); INSERT INTO candidate_crash VALUES ('unpublished');"); + process.kill(process.pid, "SIGKILL"); + ` + suffix); + lateEntry = await bundle("late", prefix + ` + process.on("SIGTERM", () => {}); + process.send({ type: "progress", progress: { phase: "parse", completed: 1, total: 1 } }); + process.send({ type: "complete", result: { filesIndexed: 1, nodesCreated: 1, edgesCreated: 0, durationMs: 1 } }); + for (;;) Math.sqrt(Math.random()); + ` + suffix); + disconnectedEntry = await bundle("disconnected", prefix + ` + process.on("SIGTERM", () => {}); + process.send({ type: "progress", progress: { phase: "parse", completed: 1, total: 2 } }); + process.disconnect(); + setImmediate(() => { for (;;) Math.sqrt(Math.random()); }); + ` + suffix); + parentEntry = await bundle("parent", ` + import { runGraphCandidateProcess } from "./src/graph/candidate-process.ts"; + await runGraphCandidateProcess({ + projectRoot: process.argv[2], candidatePath: process.argv[3], operation: "rebuild", + onProgress: () => process.send({ type: "busy" }), + __internal: { entrypoint: process.argv[4], onSpawn: (pid, workspace) => process.send({ type: "spawned", pid, workspace }) }, + }); + `); +}, 30_000); + +afterEach(() => { + for (const child of children) child.kill("SIGKILL"); + children.clear(); + for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }); +}); +afterAll(() => { if (bundleDir) rmSync(bundleDir, { recursive: true, force: true }); }); + +function hash(path: string): string { return createHash("sha256").update(readFileSync(path)).digest("hex"); } +function artifacts(path: string): string[] { + return readdirSync(join(path, ".mex")).filter((name) => name.startsWith("graph.db.") || name === "graph.db.lock"); +} +function processAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} +async function baseline(path: string): Promise { + const engine = createGraphEngine({ rootDir: path }); + try { await engine.build(); } finally { engine.close(); } + return join(path, ".mex/graph.db"); +} +function isolated(entrypoint: string, extra: Record = {}): GraphMaintenanceExecutionOptions { + return { candidateExecution: "process", __internal: { candidateProcess: { entrypoint, ...extra } } } as GraphMaintenanceExecutionOptions; +} + +describe("isolated graph candidate construction", () => { + it("builds and refreshes a real candidate with numeric progress and a closed, valid published database", async () => { + const path = root(); + const progress: string[] = []; + const built = await rebuildGraph(path, { ...isolated(realEntry), onProgress: (value) => progress.push(value.phase) }); + expect(built.state).toBe("succeeded"); + expect(built.status.status).toBe("fresh"); + expect(progress).toContain("parse"); + expect(progress).toContain("resolve"); + writeFileSync(join(path, "service.py"), "def service():\n return 2\n\ndef next_service():\n return service()\n"); + const refreshed = await refreshGraph(path, isolated(realEntry)); + expect(refreshed.filesIndexed).toBe(1); + expect(refreshed.status.status).toBe("fresh"); + const db = openSqlite(join(path, ".mex/graph.db"), { readOnly: true }); + try { + expect(db.prepare("PRAGMA quick_check").get()).toMatchObject({ quick_check: "ok" }); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + expect(db.prepare("SELECT name FROM nodes WHERE name = 'next_service'").all()).toHaveLength(1); + } finally { db.close(); } + expect(artifacts(path)).toEqual([]); + }); + + it("cancels real synchronous busy work, waits for process death, and preserves the prior index", async () => { + const path = root(); + const database = await baseline(path); + const before = hash(database); + const controller = new AbortController(); + let pid = 0; + let workspace = ""; + let ticks = 0; + const interval = setInterval(() => ticks++, 10); + try { + await expect(refreshGraph(path, { + ...isolated(busyEntry, { onSpawn: (childPid: number, directory: string) => { pid = childPid; workspace = directory; } }), + signal: controller.signal, + onProgress: (value) => { if (value.phase === "parse") setTimeout(() => controller.abort(), 50); }, + })).rejects.toMatchObject({ code: "GRAPH_MAINTENANCE_CANCELLED" }); + } finally { clearInterval(interval); } + expect(ticks).toBeGreaterThan(2); + expect(processAlive(pid)).toBe(false); + expect(existsSync(workspace)).toBe(false); + expect(hash(database)).toBe(before); + expect(artifacts(path)).toEqual([]); + }); + + it("cleans the spool and SQLite sidecars after an abrupt child death during an open write transaction", async () => { + const path = root(); + const database = await baseline(path); + const before = hash(database); + let workspace = ""; + await expect(refreshGraph(path, isolated(crashEntry, { + onSpawn: (_pid: number, directory: string) => { workspace = directory; }, + }))).rejects.toMatchObject({ category: "failed" }); + expect(existsSync(workspace)).toBe(false); + expect(hash(database)).toBe(before); + expect(artifacts(path)).toEqual([]); + }); + + it("preserves a replacement workspace instead of recursively deleting a directory it no longer owns", async () => { + const path = root(); + const database = await baseline(path); + const before = hash(database); + const original = join(path, "original-candidate-workspace"); + let workspace = ""; + let replacementCreated = false; + let pid = 0; + try { + await expect(refreshGraph(path, isolated(realEntry, { + onSpawn(childPid: number, directory: string) { + pid = childPid; + workspace = directory; + renameSync(directory, original); + mkdirSync(directory); + replacementCreated = true; + writeFileSync(join(directory, "sentinel"), "replacement directory must survive"); + }, + }))).rejects.toMatchObject({ code: "GRAPH_MAINTENANCE_PATH_UNSAFE" }); + expect(processAlive(pid)).toBe(false); + expect(readFileSync(join(workspace, "sentinel"), "utf8")).toBe("replacement directory must survive"); + expect(existsSync(original)).toBe(true); + expect(hash(database)).toBe(before); + expect(artifacts(path)).toEqual([]); + } finally { + // These are the two exact directories created/moved by this fixture; + // production cleanup must have left the replacement entirely alone. + if (replacementCreated) rmSync(workspace, { recursive: true, force: true }); + rmSync(original, { recursive: true, force: true }); + } + }); + + it("does not accept a completion message until the child exits, and cancellation forbids late success", async () => { + const path = root(); + const controller = new AbortController(); + await expect(runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"a".repeat(48)}`), operation: "rebuild", + signal: controller.signal, + onProgress: () => setTimeout(() => controller.abort(), 50), + __internal: { entrypoint: lateEntry }, + })).rejects.toMatchObject({ category: "cancelled" }); + }); + + it("stops a child that loses IPC and keeps computing while its parent remains alive", async () => { + const path = root(); + const database = await baseline(path); + const before = hash(database); + const controller = new AbortController(); + let pid = 0; + let workspace = ""; + let timeout: ReturnType | undefined; + try { + await expect(refreshGraph(path, { + ...isolated(disconnectedEntry, { onSpawn: (value: number, directory: string) => { pid = value; workspace = directory; } }), + signal: controller.signal, + // The watchdog only detects parent death. Without explicit disconnect + // handling this guard cancels the still-running child instead of the + // supervisor reporting the expected protocol failure. + onProgress: (value) => { if (value.phase === "parse") timeout = setTimeout(() => controller.abort(), 2000); }, + })).rejects.toMatchObject({ category: "failed" }); + } finally { if (timeout) clearTimeout(timeout); } + expect(processAlive(pid)).toBe(false); + expect(existsSync(workspace)).toBe(false); + expect(hash(database)).toBe(before); + expect(artifacts(path)).toEqual([]); + }); + + it("enforces its hang deadline even when the compiler thread ignores SIGTERM", async () => { + const path = root(); + let pid = 0; + await expect(runGraphCandidateProcess({ + projectRoot: path, candidatePath: join(path, ".mex", `graph.db.candidate-${"b".repeat(48)}`), operation: "rebuild", + __internal: { entrypoint: busyEntry, buildTimeoutMs: 500, onSpawn: (value) => { pid = value; } }, + })).rejects.toMatchObject({ category: "failed" }); + expect(processAlive(pid)).toBe(false); + }); + + it("stops an orphaned busy compiler when its parent is killed without running cleanup", async () => { + const path = root(); + const parent = spawn(process.execPath, [parentEntry, path, join(path, ".mex", `graph.db.candidate-${"c".repeat(48)}`), busyEntry], { + stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true, + }); + children.add(parent); + let pid = 0; + let workspace = ""; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Candidate did not start.")), 5000); + parent.on("message", (raw) => { + const message = raw as { type: string; pid?: number; workspace?: string }; + if (message.type === "spawned") { pid = message.pid!; workspace = message.workspace!; } + if (message.type === "busy") { clearTimeout(timeout); resolve(); } + }); + parent.once("error", reject); + }); + const closed = new Promise((resolve) => parent.once("close", () => resolve())); + parent.kill("SIGKILL"); + await closed; + children.delete(parent); + await expect.poll(() => processAlive(pid), { timeout: 5000, interval: 25 }).toBe(false); + // A fatally killed parent cannot run finally. Its private workspace is + // retained instead of deleting files while a Windows writer might be alive. + expect(existsSync(workspace)).toBe(true); + rmSync(workspace, { recursive: true, force: true }); + }); + + it("rejects unbounded and non-allowlisted protocol values", () => { + expect(boundedCandidateMessage({ type: "progress", progress: { phase: "parse", completed: 0, total: 0 } })).toBeNull(); + expect(boundedCandidateMessage({ type: "progress", progress: { phase: "parse", total: 10 } })).toBeNull(); + expect(boundedCandidateMessage({ type: "progress", progress: { phase: "parse", completed: 3, total: 2 } })).toBeNull(); + expect(boundedCandidateMessage({ type: "progress", progress: { phase: "parse", source: "private" } })).toBeNull(); + expect(boundedCandidateMessage({ type: "failed", category: "failed", stack: "private" })).toBeNull(); + expect(boundedCandidateMessage({ type: "complete", result: { filesIndexed: -1, nodesCreated: 0, edgesCreated: 0, durationMs: 0 } })).toBeNull(); + expect(boundedCandidateMessage({ type: "failed", category: "x".repeat(1024 * 1024) })).toBeNull(); + }); + + it("sends throttled counts during synchronous work without requiring completion callbacks to run", () => { + let now = 0; + let blocked = false; + const sent: number[] = []; + const callbacks: Array<() => void> = []; + const progress = createGraphCandidateProgressSender((value, done) => { + sent.push(value.completed!); + callbacks.push(done); + return !blocked; + }, () => now); + progress({ phase: "parse", completed: 0, total: 10 }); + now = 100; + progress({ phase: "parse", completed: 1, total: 10 }); + now = 300; + progress({ phase: "parse", completed: 2, total: 10 }); + expect(sent).toEqual([0, 2]); + blocked = true; + now = 600; + progress({ phase: "parse", completed: 3, total: 10 }); + now = 900; + progress({ phase: "parse", completed: 4, total: 10 }); + expect(sent).toEqual([0, 2, 3]); + callbacks.at(-1)!(); + blocked = false; + now = 1200; + progress({ phase: "parse", completed: 5, total: 10 }); + expect(sent).toEqual([0, 2, 3, 5]); + }); + + it.each([true, false])("sends a fast final parse count exactly once with channel writable=%s", (writable) => { + let now = 0; + const sent: Array<{ phase: string; completed?: number; total?: number }> = []; + const progress = createGraphCandidateProgressSender((value) => { + sent.push(value); + return writable; + }, () => now); + progress({ phase: "parse", completed: 0, total: 3 }); + now = 10; + progress({ phase: "parse", completed: 1, total: 3 }); + now = 20; + progress({ phase: "parse", completed: 3, total: 3 }); + progress({ phase: "parse", completed: 3, total: 3 }); + progress({ phase: "resolve" }); + now = 1000; + progress({ phase: "parse", completed: 3, total: 3 }); + expect(sent).toEqual([ + { phase: "parse", completed: 0, total: 3 }, + { phase: "parse", completed: 3, total: 3 }, + { phase: "resolve" }, + ]); + }); +}); diff --git a/src/graph/__tests__/engine-continuity-resources.test.ts b/src/graph/__tests__/engine-continuity-resources.test.ts new file mode 100644 index 00000000..d0ac9cb9 --- /dev/null +++ b/src/graph/__tests__/engine-continuity-resources.test.ts @@ -0,0 +1,154 @@ +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createGraphEngine } from "../engine-impl.js"; +import { GraphStore } from "../db/store.js"; +import { openSqlite } from "../db/sqlite.js"; +import { FingerprintStore } from "../fingerprint-store.js"; +import type { GraphEngine } from "../engine.js"; + +const roots: string[] = []; +const engines: GraphEngine[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const engine of engines.splice(0)) engine.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "mex-continuity-resources-")); + roots.push(root); + mkdirSync(join(root, "src")); + const dbPath = join(root, "graph.db"); + const engine = createGraphEngine({ rootDir: root, dbPath }); + engines.push(engine); + return { root, dbPath, engine }; +} + +function graphFacts(dbPath: string) { + const db = openSqlite(dbPath, { readOnly: true }); + try { + return { + nodes: db.prepare("SELECT * FROM nodes ORDER BY id").all() + .map((row) => { + const { updated_at: _updatedAt, ...node } = row as Record; + return node; + }), + edges: db.prepare("SELECT * FROM edges ORDER BY source, target, kind, IFNULL(line, -1), IFNULL(col, -1)").all() + .map((row) => { + const { id: _id, ...edge } = row as Record; + return edge; + }), + fingerprints: db.prepare( + "SELECT node_id, hex(minhash) AS minhash, neighbors, token_count FROM node_fingerprints ORDER BY node_id", + ).all(), + }; + } finally { + db.close(); + } +} + +describe("bounded publication continuity reads", () => { + it("publishes a real body edit without materializing old nodes or reading their fingerprints", async () => { + const { root, engine } = fixture(); + const source = join(root, "src", "stable.ts"); + writeFileSync(source, "export function stable(value: number): number { return value + 1; }\n"); + await engine.build(); + const before = engine.searchNodes("stable").find((node) => node.name === "stable")!; + expect(before).toBeDefined(); + + const oldNodes = vi.spyOn(GraphStore.prototype, "getAllNodes").mockImplementation(() => { + throw new Error("Unnecessary old node materialization"); + }); + const oldFingerprints = vi.spyOn(FingerprintStore.prototype, "get").mockImplementation(() => { + throw new Error("Unnecessary old fingerprint read"); + }); + writeFileSync(source, "export function stable(value: number): number { return value + 2; }\n"); + const result = await engine.sync(["src/stable.ts"]); + const after = engine.getNode(before.id); + expect(result.filesIndexed).toBe(1); + expect(after).toMatchObject({ id: before.id, name: "stable" }); + expect(after?.bodyHash).not.toBe(before.bodyHash); + expect(oldNodes).not.toHaveBeenCalled(); + expect(oldFingerprints).not.toHaveBeenCalled(); + }); + + it("preserves fingerprint-rename and signature-move alias chains without reloading fresh nodes", async () => { + const { root, dbPath, engine } = fixture(); + const source = join(root, "src", "handler.ts"); + const initial = `export function legacyHandler(value: number): number { + const first = value + 1; + const second = first * 2; + const third = second - 3; + const fourth = Math.max(third, 4); + const fifth = Math.min(fourth, 5); + return first + second + third + fourth + fifth; +}\n`; + const renamed = `export function modernHandler(input: number): number { + const alpha = input + 10; + const beta = alpha * 20; + const gamma = beta - 30; + const delta = Math.max(gamma, 40); + const epsilon = Math.min(delta, 50); + return alpha + beta + gamma + delta + epsilon; +}\n`; + writeFileSync(source, initial); + await engine.build(); + const original = engine.searchNodes("legacyHandler").find((node) => node.name === "legacyHandler")!; + const nodeReads = vi.spyOn(GraphStore.prototype, "getAllNodes"); + writeFileSync(source, renamed); + await engine.sync(["src/handler.ts"]); + const middle = engine.getNode(original.id)!; + expect(middle).toMatchObject({ name: "modernHandler", filePath: "src/handler.ts" }); + expect(middle.id).not.toBe(original.id); + // One old snapshot is needed to disambiguate removed IDs. The staged fresh + // nodes already exist and must not be loaded from SQLite a second time. + expect(nodeReads).toHaveBeenCalledTimes(1); + + nodeReads.mockClear(); + unlinkSync(source); + writeFileSync(join(root, "src", "moved.ts"), renamed.replace("input + 10", "input + 11")); + await engine.sync(["src/handler.ts", "src/moved.ts"]); + const final = engine.getNode(original.id)!; + expect(final).toMatchObject({ name: "modernHandler", filePath: "src/moved.ts" }); + expect(final.id).not.toBe(middle.id); + expect(engine.getNode(middle.id)).toEqual(final); + expect(nodeReads).toHaveBeenCalledTimes(1); + + nodeReads.mockClear(); + const fingerprintReads = vi.spyOn(FingerprintStore.prototype, "get").mockImplementation(() => { + throw new Error("Surviving alias targets do not need old fingerprints"); + }); + writeFileSync(join(root, "src", "moved.ts"), renamed.replace("input + 10", "input + 12")); + await engine.sync(["src/moved.ts"]); + expect(engine.getNode(original.id)?.id).toBe(final.id); + expect(engine.getNode(middle.id)?.id).toBe(final.id); + expect(nodeReads).not.toHaveBeenCalled(); + expect(fingerprintReads).not.toHaveBeenCalled(); + fingerprintReads.mockRestore(); + + const db = openSqlite(dbPath, { readOnly: true }); + try { + const aliases = db.prepare("SELECT alias_id, canonical_node_id, match_method, confidence FROM node_aliases").all(); + expect(aliases).toContainEqual({ + alias_id: original.id, canonical_node_id: final.id, match_method: "fingerprint", confidence: 1, + }); + expect(aliases).toContainEqual({ + alias_id: middle.id, canonical_node_id: final.id, match_method: "signature", confidence: 0.98, + }); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + } finally { + db.close(); + } + + // A clean build of the final corpus must have the same graph facts; only + // continuity aliases should depend on the sequence of previous snapshots. + nodeReads.mockRestore(); + const cleanPath = join(root, "clean.db"); + const clean = createGraphEngine({ rootDir: root, dbPath: cleanPath }); + engines.push(clean); + await clean.build(); + expect(graphFacts(dbPath)).toEqual(graphFacts(cleanPath)); + }, 20_000); +}); diff --git a/src/graph/__tests__/fingerprint-store-transactions.test.ts b/src/graph/__tests__/fingerprint-store-transactions.test.ts new file mode 100644 index 00000000..4e56c5ff --- /dev/null +++ b/src/graph/__tests__/fingerprint-store-transactions.test.ts @@ -0,0 +1,165 @@ +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openSqlite, type SqliteDatabase } from "../db/sqlite.js"; +import { GraphStore } from "../db/store.js"; +import { FingerprintStore, upsertFingerprintsInOwnedTransaction } from "../fingerprint-store.js"; +import { encodeMinhash } from "../fingerprint.js"; +import type { Fingerprint } from "../reconcile.js"; + +const databases: SqliteDatabase[] = []; +afterEach(() => { + for (const db of databases.splice(0)) db.close(); + vi.restoreAllMocks(); +}); + +function value(offset: number): Fingerprint { + return { + minhash: Array.from({ length: 64 }, (_, index) => offset + index), + neighbors: [`neighbor:${offset}`], + tokenCount: offset, + }; +} + +function fixture(ids = ["a", "b", "c"]): { db: SqliteDatabase; graph: GraphStore; store: FingerprintStore } { + const db = openSqlite(":memory:"); + databases.push(db); + db.pragma("foreign_keys = ON"); + db.exec(readFileSync(new URL("../schema.sql", import.meta.url), "utf8")); + const graph = new GraphStore(db); + for (const id of ids) { + graph.insertNode({ + id, kind: "function", name: id, qualifiedName: id, filePath: "src/example.ts", + language: "typescript", startLine: 1, endLine: 1, startColumn: 0, endColumn: 1, updatedAt: 1, + }); + } + const store = new FingerprintStore(db); + store.upsertMany(ids.map((nodeId, index) => ({ nodeId, fingerprint: value(index + 1) }))); + return { db, graph, store }; +} + +function rows(db: SqliteDatabase) { + return { + fingerprints: db.prepare( + "SELECT CAST(ref AS TEXT) AS ref, node_id, hex(minhash) AS minhash, neighbors, token_count FROM node_fingerprints ORDER BY node_id", + ).all(), + buckets: db.prepare( + "SELECT band, CAST(band_hash AS TEXT) AS band_hash, CAST(ref AS TEXT) AS ref FROM lsh_buckets ORDER BY band, band_hash, ref", + ).all(), + }; +} + +function failDuringBuckets(db: SqliteDatabase): void { + // FAIL preserves earlier rows from this multi-row statement, so successful + // rollback must restore more than just the preceding fingerprint UPDATE. + db.exec(`CREATE TRIGGER fail_fingerprint_bucket BEFORE INSERT ON lsh_buckets + WHEN NEW.band = 12 AND NEW.ref = (SELECT ref FROM node_fingerprints WHERE node_id = 'b') + BEGIN SELECT RAISE(FAIL, 'injected partial bucket failure'); END`); +} + +describe("fingerprint transaction ownership", () => { + it("avoids the growing nested savepoint only when the publisher explicitly owns rollback", () => { + const { db, graph, store } = fixture(); + const exec = vi.spyOn(db, "exec"); + graph.transaction(() => upsertFingerprintsInOwnedTransaction(db, [{ nodeId: "a", fingerprint: value(100) }])); + expect(exec.mock.calls.some(([sql]) => /SAVEPOINT|RELEASE|ROLLBACK TO/.test(sql))).toBe(false); + + exec.mockClear(); + graph.transaction(() => store.upsertMany([{ nodeId: "a", fingerprint: value(200) }])); + expect(exec.mock.calls.some(([sql]) => /^SAVEPOINT /.test(sql))).toBe(true); + expect(exec.mock.calls.some(([sql]) => /^RELEASE /.test(sql))).toBe(true); + }); + + it("keeps owned and standalone replacement identical across chunks, duplicates, aliases and wide refs", () => { + const ids = Array.from({ length: 503 }, (_, index) => `node:${String(index).padStart(3, "0")}`); + const standalone = fixture(ids); + const owned = fixture(ids); + const wideRef = 9_007_199_254_740_999n; + for (const { db, graph, store } of [standalone, owned]) { + db.exec("DELETE FROM lsh_buckets; DELETE FROM node_fingerprints"); + db.prepare("INSERT INTO node_fingerprints(ref, node_id, minhash, neighbors, token_count) VALUES (?, ?, ?, '[]', 1)") + .run(wideRef, ids[0], encodeMinhash(value(1).minhash)); + store.upsertMany(ids.map((nodeId) => ({ nodeId, fingerprint: value(1) }))); + graph.insertAlias("old:first", ids[0]!, "identity", 1); + } + const previous = rows(standalone.db); + const entries = ids.slice(0, -1).reverse().map((nodeId, index) => ({ nodeId, fingerprint: value(10_000 + index) })); + entries.push({ nodeId: ids[0]!, fingerprint: value(50_000) }); + standalone.store.upsertMany(entries); + owned.graph.transaction(() => upsertFingerprintsInOwnedTransaction(owned.db, entries)); + + expect(rows(owned.db)).toEqual(rows(standalone.db)); + expect(rows(owned.db).fingerprints.map((row) => (row as { ref: string }).ref)) + .toEqual(previous.fingerprints.map((row) => (row as { ref: string }).ref)); + expect(owned.store.get(ids.at(-1)!)).toEqual(value(1)); + expect(owned.store.get("old:first")).toEqual(value(50_000)); + expect(owned.store.lookup(value(50_000)).map((entry) => entry.nodeId)).toEqual([ids[0]]); + expect(owned.db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + }); + + it.each([false, true])("restores a failed batch before a caller continues (enclosing transaction: %s)", (enclosing) => { + for (const failure of ["foreign-key", "partial-buckets"] as const) { + const { db, store } = fixture(); + const before = rows(db); + if (failure === "partial-buckets") failDuringBuckets(db); + if (enclosing) db.exec("BEGIN"); + const entries = [ + { nodeId: "a", fingerprint: value(100) }, + { nodeId: "b", fingerprint: value(200) }, + ...(failure === "foreign-key" ? [{ nodeId: "z-missing", fingerprint: value(300) }] : []), + ]; + expect(() => store.upsertMany(entries)).toThrow(failure === "foreign-key" ? /FOREIGN KEY/ : /partial bucket/); + expect(rows(db)).toEqual(before); + // This write and COMMIT prove the batch did not abort or poison the + // enclosing transaction when its caller deliberately catches the error. + store.upsert("c", value(400)); + if (enclosing) db.exec("COMMIT"); + expect(store.get("a")).toEqual(value(1)); + expect(store.get("b")).toEqual(value(2)); + expect(store.get("c")).toEqual(value(400)); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + } + }); + + it("keeps a single upsert atomic after a partial bucket statement", () => { + const { db, store } = fixture(); + const before = rows(db); + failDuringBuckets(db); + expect(() => store.upsert("b", value(100))).toThrow(/partial bucket/); + expect(rows(db)).toEqual(before); + }); + + it.each(["foreign-key", "partial-buckets"] as const)("rolls back the whole owning publication on %s failure", (failure) => { + const { db, graph, store } = fixture(); + graph.setMetadata("snapshot", "old snapshot"); + const before = rows(db); + const oldNodes = graph.getAllNodes(); + if (failure === "partial-buckets") failDuringBuckets(db); + expect(() => graph.transaction(() => { + graph.clearDerivedGraph(); + for (const node of oldNodes.slice(0, 2)) graph.insertNode({ ...node, name: "changed" }); + graph.setMetadata("snapshot", "unpublished snapshot"); + upsertFingerprintsInOwnedTransaction(db, [ + { nodeId: "a", fingerprint: value(100) }, + { nodeId: "b", fingerprint: value(200) }, + ...(failure === "foreign-key" ? [{ nodeId: "z-missing", fingerprint: value(300) }] : []), + ]); + })).toThrow(failure === "foreign-key" ? /FOREIGN KEY/ : /partial bucket/); + expect(rows(db)).toEqual(before); + expect(graph.getAllNodes()).toEqual(oldNodes); + expect(graph.getMetadata("snapshot")).toBe("old snapshot"); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + }); + + it("rolls back malformed later entries and leaves empty batches unchanged", () => { + const { db, store } = fixture(); + const before = rows(db); + expect(() => store.upsertMany([ + { nodeId: "a", fingerprint: value(100) }, + { nodeId: "b", fingerprint: { ...value(200), minhash: [1] } }, + ])).toThrow(); + expect(rows(db)).toEqual(before); + store.upsertMany([]); + upsertFingerprintsInOwnedTransaction(db, []); + expect(rows(db)).toEqual(before); + }); +}); diff --git a/src/graph/__tests__/grammar-loading.test.ts b/src/graph/__tests__/grammar-loading.test.ts new file mode 100644 index 00000000..08071f83 --- /dev/null +++ b/src/graph/__tests__/grammar-loading.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const wasm = vi.hoisted(() => ({ + initialize: vi.fn(async () => undefined), + load: vi.fn(async (path: string) => ({ path })), +})); + +vi.mock("web-tree-sitter", () => ({ + Parser: { init: wasm.initialize }, + Language: { load: wasm.load }, +})); +vi.mock("../assets.js", () => ({ + grammarWasmPath: (file: string) => `/grammar-assets/${file}`, +})); + +beforeEach(() => { + vi.resetModules(); + wasm.initialize.mockClear(); + wasm.load.mockClear(); +}); + +describe("demand-driven grammar runtime initialization", () => { + it("does not initialize WASM when compiler extraction leaves no grammar work", async () => { + const { loadGrammars, initRuntime } = await import("../extraction/grammars.js"); + await loadGrammars([]); + expect(wasm.initialize).not.toHaveBeenCalled(); + expect(wasm.load).not.toHaveBeenCalled(); + + // Explicit callers of runtime initialization retain that contract. + await initRuntime(); + expect(wasm.initialize).toHaveBeenCalledOnce(); + }); + + it("does not initialize WASM for unsupported languages", async () => { + const { loadGrammars } = await import("../extraction/grammars.js"); + await loadGrammars(["unknown"]); + expect(wasm.initialize).not.toHaveBeenCalled(); + expect(wasm.load).not.toHaveBeenCalled(); + }); + + it("initializes before supported and compiler-fallback grammars, then reuses them", async () => { + const { loadGrammars } = await import("../extraction/grammars.js"); + await loadGrammars(["python", "typescript", "typescript", "rust"]); + expect(wasm.initialize).toHaveBeenCalledOnce(); + expect(wasm.load.mock.calls).toEqual([ + ["/grammar-assets/tree-sitter-python.wasm"], + ["/grammar-assets/tree-sitter-typescript.wasm"], + ["/grammar-assets/tree-sitter-rust.wasm"], + ]); + expect(wasm.initialize.mock.invocationCallOrder[0]).toBeLessThan(wasm.load.mock.invocationCallOrder[0]!); + + await loadGrammars(["typescript", "python", "rust"]); + expect(wasm.initialize).toHaveBeenCalledOnce(); + expect(wasm.load).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/graph/__tests__/maintenance.test.ts b/src/graph/__tests__/maintenance.test.ts index 198d10a3..17928989 100644 --- a/src/graph/__tests__/maintenance.test.ts +++ b/src/graph/__tests__/maintenance.test.ts @@ -11,6 +11,7 @@ import { symlinkSync, statSync, unlinkSync, + utimesSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -61,6 +62,22 @@ function sha256(path: string): string { return createHash("sha256").update(readFileSync(path)).digest("hex"); } +function tamperSnapshotWithRestoredMtime(path: string): Buffer { + const before = statSync(path); + const bytes = readFileSync(path); + const marker = Buffer.from('"indexedAt":"'); + const offset = bytes.indexOf(marker); + expect(offset).toBeGreaterThanOrEqual(0); + // Change only one timestamp digit inside valid JSON/SQLite, preserving the + // inode and byte length. A cached mtime/size is not publication authority. + bytes[offset + marker.length] = bytes[offset + marker.length] === 50 ? 49 : 50; + writeFileSync(path, bytes); + utimesSync(path, before.atimeMs / 1000, before.mtimeMs / 1000); + expect(statSync(path).size).toBe(before.size); + expect(statSync(path).mtimeMs).toBeCloseTo(before.mtimeMs, 1); + return bytes; +} + function ownedArtifacts(root: string): string[] { const mexDir = join(root, ".mex"); return readdirSync(mexDir) @@ -533,6 +550,45 @@ describe("graph maintenance", () => { expect(ownedArtifacts(root)).toEqual([]); }); + it("rejects same-size snapshot tampering with restored mtime after candidate validation", async () => { + const root = temporaryRoot(); + source(root, "service.py", "def service():\n return 1\n"); + const database = await buildBaseline(root); + const before = sha256(database); + source(root, "service.py", "def service():\n return 2\n"); + await expect(refreshGraph(root, { + __internal: { afterCandidateValidated: tamperSnapshotWithRestoredMtime }, + } as GraphMaintenanceOptions)).rejects.toMatchObject({ code: "GRAPH_MAINTENANCE_RACE" }); + expect(sha256(database)).toBe(before); + expect(ownedArtifacts(root)).toEqual([]); + }); + + it.each(["candidate", "live", "rollback"] as const)( + "revalidates exact %s bytes after the final publish progress callback", + async (target) => { + const root = temporaryRoot(); + source(root, "service.py", "def service():\n return 1\n"); + const database = await buildBaseline(root); + const before = readFileSync(database); + source(root, "service.py", "def service():\n return 2\n"); + let candidate = ""; + let rollback = ""; + let replacement: Buffer | undefined; + await expect(refreshGraph(root, { + onProgress(progress) { + if (progress.phase !== "publish") return; + replacement = tamperSnapshotWithRestoredMtime(target === "live" ? database : target === "candidate" ? candidate : rollback); + }, + __internal: { + afterCandidateValidated(path: string) { candidate = path; }, + afterRollbackCreated(path: string) { rollback = path; }, + }, + } as GraphMaintenanceOptions)).rejects.toMatchObject({ code: "GRAPH_MAINTENANCE_RACE" }); + expect(readFileSync(database)).toEqual(target === "live" ? replacement : before); + expect(ownedArtifacts(root)).toEqual([]); + }, + ); + it("aborts on a nonempty candidate WAL instead of unlinking it into publication", async () => { const root = temporaryRoot(); source(root, "src/service.ts", "export const service = 1;\n"); diff --git a/src/graph/__tests__/store-statements.test.ts b/src/graph/__tests__/store-statements.test.ts new file mode 100644 index 00000000..b679bac3 --- /dev/null +++ b/src/graph/__tests__/store-statements.test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openSqlite, type SqliteDatabase } from "../db/sqlite.js"; +import { GraphStore } from "../db/store.js"; +import { FingerprintStore } from "../fingerprint-store.js"; +import type { GraphNode } from "../types.js"; + +const databases: SqliteDatabase[] = []; +afterEach(() => { + for (const db of databases.splice(0)) db.close(); + vi.restoreAllMocks(); +}); + +function fixture() { + const db = openSqlite(":memory:"); + databases.push(db); + db.pragma("foreign_keys = ON"); + db.exec(readFileSync(new URL("../schema.sql", import.meta.url), "utf8")); + return { db, store: new GraphStore(db) }; +} + +function node(id: string): GraphNode { + return { + id, kind: "function", name: id, qualifiedName: id, filePath: "src/example.ts", + language: "typescript", startLine: 1, endLine: 1, startColumn: 0, endColumn: 1, updatedAt: 1, + }; +} + +describe("GraphStore statement ownership", () => { + it("prepares a fixed number of statements across repeated writes, replacements and point reads", () => { + const { db, store } = fixture(); + const prepare = vi.spyOn(db, "prepare"); + const exercise = (count: number) => store.transaction(() => { + for (let index = 0; index < count; index++) { + const source = node(`source:${index}`); + const target = node(`target:${index}`); + store.insertNode(source); + store.insertNode(target); + store.insertNode({ ...target, returnType: "string" }); + store.insertEdge({ source: source.id, target: target.id, kind: "calls", confidence: 0.5 }); + store.insertEdge({ source: source.id, target: target.id, kind: "calls", confidence: 1 }); + store.upsertFile({ + path: source.filePath, contentHash: `hash:${index}`, language: "typescript", size: 10, + modifiedAt: 1, indexedAt: 1, nodeCount: count * 2, + }); + store.insertUnresolvedRef({ + fromNodeId: source.id, referenceName: "unknown", referenceKind: "calls", + filePath: source.filePath, language: "typescript", + }); + store.insertImportBinding({ + bindingKey: `binding:${index}`, filePath: source.filePath, localName: "target", + importedName: "target", moduleSpecifier: "./target", targetId: target.id, + }); + store.insertAlias(`alias:${index}`, target.id, "identity", 1); + store.replaceSourceChunks(source.filePath, "export function source() { return target(); }", `hash:${index}`); + store.replaceSourceChunks(source.filePath, "export function revised() { return target(); }", `hash:${index}`); + expect(store.getNodeById(`alias:${index}`)?.returnType).toBe("string"); + expect(store.getFileRecord(source.filePath)?.contentHash).toBe(`hash:${index}`); + store.setMetadata("counter", String(index)); + expect(store.getMetadata("counter")).toBe(String(index)); + } + }); + exercise(1); + const preparations = prepare.mock.calls.length; + expect(preparations).toBeLessThanOrEqual(20); + exercise(80); + expect(prepare).toHaveBeenCalledTimes(preparations); + expect(store.getAllNodes()).toHaveLength(160); + expect(store.getAllNodeIds()).toEqual(store.getAllNodes().map((entry) => entry.id).sort()); + expect(store.getAllEdges()).toHaveLength(80); + expect(store.getAllUnresolvedRefs()).toHaveLength(80); + expect(store.searchSourceChunks("revised")).toHaveLength(1); + expect(store.searchSourceChunks("source")).toEqual([]); + expect(store.validateInvariants(160)).toEqual({ nodes: 160, duplicateEdges: 0, danglingEdges: 0 }); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + }); + + it("keeps statements usable after rollback while restoring graph, edge and source-search rows", () => { + const { db, store } = fixture(); + store.insertNode(node("original")); + store.replaceSourceChunks("src/example.ts", "originalword", "old"); + expect(() => store.transaction(() => { + store.insertNode(node("aborted")); + store.insertEdge({ source: "original", target: "aborted", kind: "calls" }); + store.replaceSourceChunks("src/example.ts", "abortedword", "aborted"); + throw new Error("abort publication"); + })).toThrow("abort publication"); + expect(store.getNodeById("aborted")).toBeNull(); + expect(store.getAllEdges()).toEqual([]); + expect(store.searchSourceChunks("originalword")).toHaveLength(1); + expect(store.searchSourceChunks("abortedword")).toEqual([]); + + store.transaction(() => { + store.insertNode(node("committed")); + store.insertEdge({ source: "original", target: "committed", kind: "calls" }); + store.replaceSourceChunks("src/example.ts", "committedword", "committed"); + }); + expect(store.getNodeById("committed")?.name).toBe("committed"); + expect(store.getAllEdges()).toHaveLength(1); + expect(store.searchSourceChunks("committedword")).toHaveLength(1); + expect(store.searchSourceChunks("originalword")).toEqual([]); + expect(db.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + }); + + it("does not share statements with active caller-owned iterators or another connection", () => { + const first = fixture(); + for (const id of ["a", "b", "c"]) first.store.insertNode(node(id)); + const iterator = first.db.prepare("SELECT * FROM nodes").iterate(); + expect((iterator.next().value as { id: string }).id).toBe("a"); + expect(first.store.getAllNodes().map((entry) => entry.id)).toEqual(["a", "b", "c"]); + expect(first.store.getNodeById("c")?.id).toBe("c"); + expect([...iterator].map((entry) => (entry as { id: string }).id)).toEqual(["b", "c"]); + + const second = fixture(); + second.store.insertNode({ ...node("a"), name: "second database" }); + expect(first.store.getNodeById("a")?.name).toBe("a"); + expect(second.store.getNodeById("a")?.name).toBe("second database"); + first.db.close(); + expect(() => first.store.getNodeById("a")).toThrow(); + expect(second.store.getNodeById("a")?.name).toBe("second database"); + }); + + it("reuses the fingerprint point read without caching results across writes", () => { + const { db, store: graph } = fixture(); + graph.insertNode(node("a")); + graph.insertAlias("old-a", "a", "identity", 1); + const store = new FingerprintStore(db); + const prepare = vi.spyOn(db, "prepare"); + for (let index = 0; index < 20; index++) expect(store.get("old-a")).toBeNull(); + expect(prepare).toHaveBeenCalledTimes(1); + const fingerprint = { minhash: Array.from({ length: 64 }, (_, index) => index), neighbors: [], tokenCount: 64 }; + store.upsert("a", fingerprint); + const afterWrite = prepare.mock.calls.length; + expect(store.get("old-a")).toEqual(fingerprint); + expect(store.get("a")).toEqual(fingerprint); + expect(prepare).toHaveBeenCalledTimes(afterWrite); + db.close(); + expect(() => store.get("a")).toThrow(); + }); +}); diff --git a/src/graph/application-adapter.ts b/src/graph/application-adapter.ts index 52c6a2c7..b5183307 100644 --- a/src/graph/application-adapter.ts +++ b/src/graph/application-adapter.ts @@ -188,6 +188,8 @@ interface AdapterDependencies { export interface RepositoryGraphPortOptions { dbPath?: string; + /** Hub construction runs separately; ordinary readers and CLI keep their defaults. */ + candidateExecution?: "process"; /** @internal Deterministic dependency seams for adapter conformance tests. */ __internal?: Partial; } @@ -203,10 +205,12 @@ export class RepositoryGraphPort implements GraphPort { readonly #projectRoot: string; readonly #dbPath: string; readonly #deps: AdapterDependencies; + readonly #candidateExecution?: "process"; constructor(projectRoot: string, options: RepositoryGraphPortOptions = {}) { this.#projectRoot = resolve(projectRoot); this.#dbPath = options.dbPath ?? resolve(this.#projectRoot, ".mex", "graph.db"); + this.#candidateExecution = options.candidateExecution; this.#deps = { inspectStatus: options.__internal?.inspectStatus ?? inspectGraphStatus, loadFresh: options.__internal?.loadFresh ?? loadFreshGraphReadSession, @@ -486,10 +490,13 @@ export class RepositoryGraphPort implements GraphPort { operation: "refresh" | "rebuild", options: GraphMaintenanceOptions, ): Promise { + const executionOptions = this.#candidateExecution + ? { ...options, candidateExecution: this.#candidateExecution } + : options; try { return operation === "refresh" - ? await this.#deps.refresh(this.#projectRoot, options) - : await this.#deps.rebuild(this.#projectRoot, options); + ? await this.#deps.refresh(this.#projectRoot, executionOptions) + : await this.#deps.rebuild(this.#projectRoot, executionOptions); } catch (error) { throw translateMaintenanceError(error); } diff --git a/src/graph/candidate-entry.ts b/src/graph/candidate-entry.ts new file mode 100644 index 00000000..43936124 --- /dev/null +++ b/src/graph/candidate-entry.ts @@ -0,0 +1,103 @@ +import { existsSync, lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { createGraphEngine, GraphSourceStagingError } from "./engine-impl.js"; +import type { GraphEngine } from "./engine.js"; +import { + boundedCandidateMessage, + graphCandidateRequest, + type GraphCandidateMessage, + type GraphCandidateRequest, +} from "./candidate-protocol.js"; +import { startGraphCandidateWatchdog } from "./candidate-watchdog.js"; +import { createGraphCandidateProgressSender } from "./candidate-progress.js"; + +function checkDirectory(path: string, expected: GraphCandidateRequest["workspaceIdentity"]): void { + const stats = lstatSync(path, { bigint: true }); + if (!stats.isDirectory() || stats.isSymbolicLink() + || String(stats.dev) !== expected.dev || String(stats.ino) !== expected.ino + || realpathSync(path) !== expected.realPath) throw new Error("Candidate directory changed."); +} + +function checkPaths(request: GraphCandidateRequest): void { + if (!isAbsolute(request.projectRoot) || !isAbsolute(request.workspace) + || dirname(request.candidatePath) !== join(request.projectRoot, ".mex") + || !/^graph\.db\.candidate-[a-f0-9]{32,128}$/u.test(basename(request.candidatePath))) { + throw new Error("Invalid candidate path."); + } + checkDirectory(dirname(request.candidatePath), request.mexIdentity); + checkDirectory(request.workspace, request.workspaceIdentity); + const path = relative(realpathSync(request.projectRoot), request.mexIdentity.realPath); + if (isAbsolute(path) || path === ".." || path.startsWith("../") || path.startsWith("..\\")) { + throw new Error("Candidate escaped the project."); + } + if (existsSync(request.candidatePath)) { + const stats = lstatSync(request.candidatePath); + if (!stats.isFile() || stats.isSymbolicLink()) throw new Error("Unsafe candidate file."); + } +} + +function send(message: GraphCandidateMessage): Promise { + if (!boundedCandidateMessage(message) || !process.send || !process.connected) { + return Promise.reject(new Error("Invalid candidate protocol state.")); + } + return new Promise((resolve, reject) => { + process.send!(message, (error: Error | null) => error ? reject(error) : resolve()); + }); +} + +function failureCategory(error: unknown): "compatibility" | "staging" | "failed" { + if (error instanceof GraphSourceStagingError) return "staging"; + const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : ""; + const message = error instanceof Error ? error.message : ""; + if ((error instanceof Error && error.name === "GraphRebuildRequiredError") + || ["ERR_SQLITE_CORRUPT", "ERR_SQLITE_NOTADB", "ERR_SQLITE_SCHEMA", "SQLITE_CORRUPT", "SQLITE_NOTADB", "SQLITE_SCHEMA"].includes(code.toUpperCase()) + || /(?:database disk image is malformed|file is not a database|malformed database schema|no such (?:table|column)|duplicate column name|unsupported graph schema)/iu.test(message)) { + return "compatibility"; + } + return "failed"; +} + +async function main(): Promise { + if (!process.send || !process.connected) throw new Error("Candidate construction requires its private parent channel."); + const stopWatchdog = await startGraphCandidateWatchdog(); + process.once("message", async (raw: unknown) => { + let engine: GraphEngine | undefined; + let outcome: GraphCandidateMessage; + try { + const request = graphCandidateRequest.parse(raw); + checkPaths(request); + const check = () => checkPaths(request); + engine = createGraphEngine({ + rootDir: request.projectRoot, + dbPath: request.candidatePath, + __internalGraphEngineHooks: { + sourceSpoolDirectory: request.workspace, + onBuildProgress: createGraphCandidateProgressSender(), + beforeDatabaseOpen: check, + afterSemanticInputsStaged: check, + afterCompilerExtraction: check, + beforePublication: check, + }, + } as Parameters[0]); + const result = request.operation === "refresh" ? await engine.sync([]) : await engine.build(); + engine.close(); + engine = undefined; + outcome = { type: "complete", result }; + if (!boundedCandidateMessage(outcome)) throw new Error("Graph result exceeded its private message bound."); + } catch (error) { + try { engine?.close(); } catch { /* The supervisor removes the unpublished candidate after close. */ } + outcome = { type: "failed", category: failureCategory(error) }; + } + try { + await send(outcome); + await stopWatchdog(); + process.exitCode = outcome.type === "complete" ? 0 : 1; + process.disconnect(); + } catch { + process.exit(1); + } + }); + await send({ type: "ready" }); +} + +main().catch(() => process.exit(1)); diff --git a/src/graph/candidate-process.ts b/src/graph/candidate-process.ts new file mode 100644 index 00000000..a83c7a0c --- /dev/null +++ b/src/graph/candidate-process.ts @@ -0,0 +1,181 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, lstatSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BuildResult } from "./engine.js"; +import { + boundedCandidateMessage, + type GraphCandidateProgress, + type GraphCandidateRequest, +} from "./candidate-protocol.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const STARTUP_TIMEOUT_MS = 15_000; +const TERMINATE_GRACE_MS = 500; +// A hang guard, not a resource/performance promise for a particular repository. +const BUILD_TIMEOUT_MS = 30 * 60 * 1000; + +export class GraphCandidateProcessError extends Error { + override readonly name = "GraphCandidateProcessError"; + constructor(readonly category: "cancelled" | "compatibility" | "staging" | "failed" | "unsafe") { + super(category === "cancelled" + ? "Graph candidate construction was cancelled." + : "The isolated graph candidate could not be completed safely."); + } +} + +export interface GraphCandidateProcessOptions { + projectRoot: string; + candidatePath: string; + operation: "refresh" | "rebuild"; + signal?: AbortSignal; + onProgress?: (progress: GraphCandidateProgress) => void; + /** Deterministic subprocess fault seams; never exposed by the GraphPort. */ + __internal?: { + entrypoint?: string; + onSpawn?: (pid: number, workspace: string) => void; + buildTimeoutMs?: number; + }; +} + +function identity(path: string): GraphCandidateRequest["workspaceIdentity"] { + const stats = lstatSync(path, { bigint: true }); + if (!stats.isDirectory() || stats.isSymbolicLink()) throw new GraphCandidateProcessError("unsafe"); + return { realPath: realpathSync(path), dev: String(stats.dev), ino: String(stats.ino) }; +} + +function sameDirectory(path: string, expected: ReturnType): boolean { + try { + const current = identity(path); + return current.realPath === expected.realPath && current.dev === expected.dev && current.ino === expected.ino; + } catch { + return false; + } +} + +function candidateEntrypoint(): string { + // Bundled CLI and library use the sibling asset. Source-based Hub tests/dev + // use the installed build; no TypeScript loader or public CLI is spawned. + const candidates = [join(HERE, "graph-candidate.js"), join(HERE, "../../dist/graph-candidate.js")]; + const entrypoint = candidates.find((path) => existsSync(path)); + if (!entrypoint) throw new GraphCandidateProcessError("failed"); + return entrypoint; +} + +/** Construct only the parent's candidate. Lease and publication stay in maintenance. */ +export async function runGraphCandidateProcess(options: GraphCandidateProcessOptions): Promise { + if (options.signal?.aborted) throw new GraphCandidateProcessError("cancelled"); + const workspace = mkdtempSync(join(tmpdir(), "mex-graph-candidate-")); + const workspaceIdentity = identity(workspace); + try { + const request: GraphCandidateRequest = { + version: 1, + operation: options.operation, + projectRoot: options.projectRoot, + candidatePath: options.candidatePath, + workspace, + workspaceIdentity, + mexIdentity: identity(dirname(options.candidatePath)), + }; + return await superviseCandidate(options, request); + } finally { + // Never remove a replaced/symlinked directory. Wait for `close` (not just an + // IPC result or `exit`) before deleting anything: SQLite/WASM may hold files + // until the OS has closed the child's handles, particularly on Windows. + if (!sameDirectory(workspace, workspaceIdentity)) throw new GraphCandidateProcessError("unsafe"); + rmSync(workspace, { recursive: true, force: false }); + } +} + +function superviseCandidate(options: GraphCandidateProcessOptions, request: GraphCandidateRequest): Promise { + return new Promise((resolve, reject) => { + let child: ChildProcess; + try { + child = spawn(process.execPath, [options.__internal?.entrypoint ?? candidateEntrypoint()], { + cwd: options.projectRoot, + env: { ...process.env, MEX_TELEMETRY: "0", DO_NOT_TRACK: "1" }, + // FD 4 is a lifeline. A watchdog in the child observes EOF even while + // its main thread is inside synchronous TypeScript or SQLite work. + // Overlapped enables asynchronous reads on the inherited Windows + // lifeline handle; it is identical to `pipe` on Unix. + stdio: ["ignore", "ignore", "ignore", "ipc", "overlapped"], + serialization: "json", + windowsHide: true, + }); + } catch { + reject(new GraphCandidateProcessError("failed")); + return; + } + let ready = false; + let result: BuildResult | undefined; + let failure: GraphCandidateProcessError | undefined; + let terminateTimer: ReturnType | undefined; + const stop = (error: GraphCandidateProcessError) => { + if (failure) return; + failure = error; + child.kill("SIGTERM"); + terminateTimer = setTimeout(() => child.kill("SIGKILL"), TERMINATE_GRACE_MS); + terminateTimer.unref(); + }; + const cancelled = () => stop(new GraphCandidateProcessError("cancelled")); + const parentExit = () => child.kill("SIGKILL"); + const startupTimer = setTimeout(() => stop(new GraphCandidateProcessError("failed")), STARTUP_TIMEOUT_MS); + const buildTimer = setTimeout( + () => stop(new GraphCandidateProcessError("failed")), + options.__internal?.buildTimeoutMs ?? BUILD_TIMEOUT_MS, + ); + startupTimer.unref(); + buildTimer.unref(); + process.once("exit", parentExit); + options.signal?.addEventListener("abort", cancelled, { once: true }); + child.on("error", () => stop(new GraphCandidateProcessError("failed"))); + child.on("disconnect", () => { + // A live parent keeps FD4 open, so the parent-death watchdog cannot help + // when only IPC is lost. A disconnected writer without a terminal result + // must be stopped promptly instead of holding the lease until timeout. + if (!result && !failure) stop(new GraphCandidateProcessError("failed")); + }); + child.on("message", (raw: unknown) => { + if (failure) return; + const message = boundedCandidateMessage(raw); + if (!message || result) return stop(new GraphCandidateProcessError("failed")); + if (message.type === "ready") { + if (ready) return stop(new GraphCandidateProcessError("failed")); + ready = true; + clearTimeout(startupTimer); + child.send(request, (error) => { if (error) stop(new GraphCandidateProcessError("failed")); }); + } else if (!ready) { + stop(new GraphCandidateProcessError("failed")); + } else if (message.type === "progress") { + try { + options.onProgress?.(message.progress); + } catch { + stop(new GraphCandidateProcessError("failed")); + } + } else if (message.type === "complete") { + result = message.result; + } else { + stop(new GraphCandidateProcessError(message.category)); + } + }); + child.once("close", (code, signal) => { + clearTimeout(startupTimer); + clearTimeout(buildTimer); + if (terminateTimer) clearTimeout(terminateTimer); + process.removeListener("exit", parentExit); + options.signal?.removeEventListener("abort", cancelled); + child.stdio[4]?.destroy(); + if (options.signal?.aborted) reject(new GraphCandidateProcessError("cancelled")); + else if (failure) reject(failure); + else if (code !== 0 || signal || !result) reject(new GraphCandidateProcessError("failed")); + else resolve(result); + }); + try { + if (child.pid) options.__internal?.onSpawn?.(child.pid, request.workspace); + if (options.signal?.aborted) cancelled(); + } catch { + stop(new GraphCandidateProcessError("failed")); + } + }); +} diff --git a/src/graph/candidate-progress.ts b/src/graph/candidate-progress.ts new file mode 100644 index 00000000..7037fee0 --- /dev/null +++ b/src/graph/candidate-progress.ts @@ -0,0 +1,42 @@ +import { graphCandidateProgress, type GraphCandidateProgress } from "./candidate-protocol.js"; + +type ProgressSender = (progress: GraphCandidateProgress, done: () => void) => boolean; + +function sendProgress(progress: GraphCandidateProgress, done: () => void): boolean { + if (!process.send || !process.connected) throw new Error("Candidate parent disconnected."); + return process.send({ type: "progress", progress }, (error: Error | null) => { + if (error) process.exit(1); + done(); + }); +} + +/** Fixed-size counts, at most four ordinary updates/second, respecting IPC backpressure. */ +export function createGraphCandidateProgressSender( + send: ProgressSender = sendProgress, + clock: () => number = () => performance.now(), +): (progress: GraphCandidateProgress) => void { + let lastPhase: GraphCandidateProgress["phase"] | undefined; + let lastSent = 0; + let backpressured = false; + let finalParseSent = false; + return (raw) => { + const parsed = graphCandidateProgress.safeParse(raw); + if (!parsed.success) throw new Error("Invalid graph progress."); + const progress = parsed.data; + const finalParse = progress.phase === "parse" + && progress.total !== undefined && progress.completed === progress.total; + if (finalParse && finalParseSent) return; + const now = clock(); + const phaseChanged = progress.phase !== lastPhase; + if (!phaseChanged && !finalParse && (backpressured || now - lastSent < 250)) return; + lastPhase = progress.phase; + lastSent = now; + if (finalParse) finalParseSent = true; + // Completion callbacks cannot run while this thread is inside the compiler. + // Use the channel's actual backpressure signal, not one-callback-at-a-time + // gating, so counts continue to arrive during synchronous work. The two + // phase changes and one final parse count may bypass throttling so fast + // parses do not leave the durable job showing an incomplete file count. + backpressured = !send(progress, () => { backpressured = false; }); + }; +} diff --git a/src/graph/candidate-protocol.ts b/src/graph/candidate-protocol.ts new file mode 100644 index 00000000..faa9b2f2 --- /dev/null +++ b/src/graph/candidate-protocol.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; +import type { BuildResult } from "./engine.js"; + +/** Private process boundary. Never serialize graph rows, source, or raw errors. */ +export const GRAPH_CANDIDATE_MESSAGE_BYTES = 1024 * 1024; +const count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const text = z.string().max(8192); +const identity = z.object({ realPath: text, dev: z.string().regex(/^\d+$/u), ino: z.string().regex(/^\d+$/u) }).strict(); + +export const graphCandidateRequest = z.object({ + version: z.literal(1), + operation: z.enum(["refresh", "rebuild"]), + projectRoot: text, + candidatePath: text, + workspace: text, + workspaceIdentity: identity, + mexIdentity: identity, +}).strict(); +export type GraphCandidateRequest = z.infer; + +export const graphCandidateProgress = z.object({ + phase: z.enum(["parse", "resolve"]), + completed: count.optional(), + total: count.positive().optional(), +}).strict().refine((value) => value.total === undefined + || (value.completed !== undefined && value.completed <= value.total)); +export type GraphCandidateProgress = z.infer; + +const buildResult = z.object({ + filesIndexed: count, + nodesCreated: count, + edgesCreated: count, + durationMs: z.number().finite().nonnegative(), + health: z.object({ ok: count, partial: count, failed: count }).strict().optional(), + skipped: z.array(z.object({ + filePath: text, + reason: z.literal("corpus-limit"), + limit: text, + limitBytes: count, + observedBytes: count.optional(), + message: text, + }).strict()).max(20_000).optional(), + declinedInputs: z.array(z.object({ + filePath: text, + reason: z.literal("outside-project-corpus"), + message: text, + }).strict()).max(20_000).optional(), +}).strict() satisfies z.ZodType; + +export const graphCandidateMessage = z.discriminatedUnion("type", [ + z.object({ type: z.literal("ready") }).strict(), + z.object({ type: z.literal("progress"), progress: graphCandidateProgress }).strict(), + z.object({ type: z.literal("complete"), result: buildResult }).strict(), + z.object({ type: z.literal("failed"), category: z.enum(["compatibility", "staging", "failed"]) }).strict(), +]); +export type GraphCandidateMessage = z.infer; + +export function boundedCandidateMessage(value: unknown): GraphCandidateMessage | null { + try { + // JSON is also the fork serialization mode; this bounds the complete payload, + // rather than merely capping an individual diagnostics array. + if (Buffer.byteLength(JSON.stringify(value), "utf8") > GRAPH_CANDIDATE_MESSAGE_BYTES) return null; + const parsed = graphCandidateMessage.safeParse(value); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} diff --git a/src/graph/candidate-watchdog.ts b/src/graph/candidate-watchdog.ts new file mode 100644 index 00000000..8e10cf47 --- /dev/null +++ b/src/graph/candidate-watchdog.ts @@ -0,0 +1,35 @@ +import { Worker } from "node:worker_threads"; + +/** + * Only the lifeline runs in a Worker; graph construction stays in the disposable + * process. EOF is an OS-owned parent-lifetime signal, not a PID liveness guess + * that could accept a reused PID. It works during a blocked compiler/SQLite call. + */ +export async function startGraphCandidateWatchdog(): Promise<() => Promise> { + const worker = new Worker(` + const { parentPort } = require("node:worker_threads"); + const { Socket } = require("node:net"); + const lifeline = new Socket({ fd: 4, readable: true, writable: false }); + const stop = () => process.kill(process.pid, "SIGKILL"); + lifeline.on("end", stop); + lifeline.on("error", stop); + lifeline.on("close", stop); + lifeline.resume(); + parentPort.postMessage("ready"); + `, { eval: true, execArgv: [] }); + await new Promise((resolve, reject) => { + worker.once("message", () => resolve()); + worker.once("error", reject); + worker.once("exit", (code) => { if (code !== 0) reject(new Error("Graph watchdog failed.")); }); + }); + // Losing the watchdog while construction is active fails closed. Normal + // termination removes these handlers before deliberately stopping it. + const failed = () => process.kill(process.pid, "SIGKILL"); + worker.on("error", failed); + worker.on("exit", failed); + return async () => { + worker.removeListener("error", failed); + worker.removeListener("exit", failed); + await worker.terminate(); + }; +} diff --git a/src/graph/db/store.ts b/src/graph/db/store.ts index ca5136c7..72f80252 100644 --- a/src/graph/db/store.ts +++ b/src/graph/db/store.ts @@ -10,7 +10,7 @@ import { createHash } from "node:crypto"; import type { EdgeKind, GraphEdge, GraphNode, Language, NodeKind, ReferenceKind } from "../types.js"; import { identifierComponents, isLowValueGraphPath, planGraphQuery } from "../retrieval/query.js"; -import type { SqliteDatabase } from "./sqlite.js"; +import type { SqliteDatabase, SqliteStatement } from "./sqlite.js"; /** An unresolved reference row: a name a node points at, bound after indexing. */ export interface UnresolvedRefRecord { @@ -233,7 +233,32 @@ function strongerEdge(left: GraphEdge, right: GraphEdge): GraphEdge { return canonicalString(edgeSupport(right)) < canonicalString(edgeSupport(left)) ? right : left; } +type ReusableStatement = + | "insertNode" + | "findEdge" + | "updateEdge" + | "insertEdge" + | "upsertFile" + | "insertUnresolvedRef" + | "insertImportBinding" + | "insertAlias" + | "insertSourceChunk" + | "insertSourceChunkFts" + | "findSourceChunks" + | "deleteSourceChunkFts" + | "deleteSourceChunks" + | "getNodeById" + | "getFileRecord" + | "setMetadata" + | "getMetadata"; + export class GraphStore { + // Only this fixed set of synchronous run/get/all statements is retained. + // Dynamic queries and iterators prepare their own statements; an active + // cursor can never be reset by another store call. Closing the owning + // database finalizes these statements, and a new connection gets a new store. + private readonly statements: Partial> = {}; + constructor(private readonly db: SqliteDatabase) {} /** Run `fn` inside a single transaction (bulk-write speed + atomicity). */ @@ -241,11 +266,16 @@ export class GraphStore { return this.db.transaction(fn); } + private statement(name: ReusableStatement, sql: string): Pick { + return this.statements[name] ??= this.db.prepare(sql); + } + // --- Writes --------------------------------------------------------------- insertNode(node: GraphNode): void { - this.db - .prepare( + this + .statement( + "insertNode", `INSERT INTO nodes ( id, kind, name, qualified_name, container_id, identity_key, file_path, language, start_line, end_line, start_column, end_column, @@ -313,7 +343,8 @@ export class GraphStore { /** Insert an edge, skipping it unless both endpoints exist (FK safety). */ insertEdge(edge: GraphEdge): boolean { - const existingRow = this.db.prepare( + const existingRow = this.statement( + "findEdge", `SELECT * FROM edges WHERE source = ? AND target = ? AND kind = ? AND IFNULL(line, -1) = IFNULL(?, -1) @@ -324,7 +355,8 @@ export class GraphStore { if (existingRow) { const existing = rowToEdge(existingRow); const winner = strongerEdge(existing, edge); - this.db.prepare( + this.statement( + "updateEdge", `UPDATE edges SET metadata = ?, provenance = ?, confidence = ?, resolution_method = ?, evidence = ? WHERE source = ? AND target = ? AND kind = ? @@ -345,8 +377,9 @@ export class GraphStore { return false; } - const result = this.db - .prepare( + const result = this + .statement( + "insertEdge", `INSERT INTO edges ( source, target, kind, metadata, line, col, provenance, confidence, resolution_method, evidence @@ -372,8 +405,9 @@ export class GraphStore { } upsertFile(file: FileRecord): void { - this.db - .prepare( + this + .statement( + "upsertFile", `INSERT INTO files ( path, content_hash, language, size, modified_at, indexed_at, node_count, errors, parse_status, diagnostic_count, missing_count, error_coverage, extractor_version @@ -410,8 +444,9 @@ export class GraphStore { } insertUnresolvedRef(ref: UnresolvedRefRecord): void { - this.db - .prepare( + this + .statement( + "insertUnresolvedRef", `INSERT INTO unresolved_refs ( ref_key, from_node_id, reference_name, reference_kind, line, col, candidates, file_path, language, receiver, qualifier, import_source, @@ -450,7 +485,8 @@ export class GraphStore { } insertImportBinding(binding: ImportBindingRecord): void { - this.db.prepare( + this.statement( + "insertImportBinding", `INSERT INTO import_bindings ( binding_key, file_path, local_name, imported_name, module_specifier, resolved_file_path, target_id, is_type_only, metadata @@ -468,7 +504,8 @@ export class GraphStore { insertAlias(aliasId: string, canonicalNodeId: string, matchMethod: string, confidence: number): boolean { if (aliasId === canonicalNodeId) return false; - return this.db.prepare( + return this.statement( + "insertAlias", `INSERT INTO node_aliases (alias_id, canonical_node_id, match_method, confidence, created_at) SELECT ?,?,?,?,? WHERE EXISTS (SELECT 1 FROM nodes WHERE id = ?) ON CONFLICT(alias_id) DO UPDATE SET @@ -494,12 +531,14 @@ export class GraphStore { const pathTerms = splitIndexTerms(filePath).join(" "); const identifierTerms = splitIndexTerms(text).join(" "); const commentTerms = chunk.filter(isCommentLine).join(" "); - const inserted = this.db.prepare( + const inserted = this.statement( + "insertSourceChunk", `INSERT INTO source_chunks ( file_path, start_line, end_line, content_hash, path_terms, identifier_terms, comment_terms ) VALUES (?,?,?,?,?,?,?)`, ).run(filePath, startLine, endLine, contentHash, pathTerms, identifierTerms, commentTerms); - this.db.prepare( + this.statement( + "insertSourceChunkFts", `INSERT INTO source_chunks_fts ( rowid, path_terms, identifier_terms, comment_terms, source_text ) VALUES (?,?,?,?,?)`, @@ -511,9 +550,9 @@ export class GraphStore { } deleteSourceChunks(filePath: string): void { - const rows = this.db.prepare("SELECT id FROM source_chunks WHERE file_path = ?").all(filePath) as Array<{ id: number }>; - for (const row of rows) this.db.prepare("DELETE FROM source_chunks_fts WHERE rowid = ?").run(row.id); - this.db.prepare("DELETE FROM source_chunks WHERE file_path = ?").run(filePath); + const rows = this.statement("findSourceChunks", "SELECT id FROM source_chunks WHERE file_path = ?").all(filePath) as Array<{ id: number }>; + for (const row of rows) this.statement("deleteSourceChunkFts", "DELETE FROM source_chunks_fts WHERE rowid = ?").run(row.id); + this.statement("deleteSourceChunks", "DELETE FROM source_chunks WHERE file_path = ?").run(filePath); } searchSourceChunks(query: string, limit = 40): SourceChunkHit[] { @@ -614,7 +653,8 @@ export class GraphStore { // --- Reads ---------------------------------------------------------------- getNodeById(id: string): GraphNode | null { - const row = this.db.prepare( + const row = this.statement( + "getNodeById", `SELECT nodes.* FROM nodes WHERE nodes.id = ? UNION ALL SELECT nodes.* FROM node_aliases JOIN nodes ON nodes.id = node_aliases.canonical_node_id @@ -629,6 +669,12 @@ export class GraphStore { return (this.db.prepare("SELECT * FROM nodes").all() as NodeRow[]).map(rowToNode); } + /** Identity-only continuity check; avoids decoding full prior node payloads. */ + getAllNodeIds(): string[] { + return (this.db.prepare("SELECT id FROM nodes ORDER BY id").all() as Array<{ id: string }>) + .map((row) => row.id); + } + /** * Bulk edge read for corpus-wide consumers such as fingerprint generation. * Keeping this as one indexed query avoids an incoming + outgoing SQL pair @@ -701,7 +747,7 @@ export class GraphStore { } getFileRecord(path: string): FileRecord | null { - const row = this.db.prepare("SELECT * FROM files WHERE path = ?").get(path) as + const row = this.statement("getFileRecord", "SELECT * FROM files WHERE path = ?").get(path) as | { path: string; content_hash: string; @@ -758,14 +804,15 @@ export class GraphStore { } setMetadata(key: string, value: string): void { - this.db.prepare( + this.statement( + "setMetadata", `INSERT INTO project_metadata (key, value, updated_at) VALUES (?,?,?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, ).run(key, value, Date.now()); } getMetadata(key: string): string | null { - const row = this.db.prepare("SELECT value FROM project_metadata WHERE key = ?").get(key) as + const row = this.statement("getMetadata", "SELECT value FROM project_metadata WHERE key = ?").get(key) as | { value: string } | undefined; return row?.value ?? null; diff --git a/src/graph/engine-impl.ts b/src/graph/engine-impl.ts index ced0fe3d..831210d7 100644 --- a/src/graph/engine-impl.ts +++ b/src/graph/engine-impl.ts @@ -61,7 +61,7 @@ import { type CompilerSemanticInput, type CompilerStagedInput, } from "./extraction/index.js"; -import { FingerprintStore } from "./fingerprint-store.js"; +import { FingerprintStore, upsertFingerprintsInOwnedTransaction } from "./fingerprint-store.js"; import { createFingerprintBuilder } from "./fingerprint.js"; import { MIN_TOKENS } from "./config.js"; import { minhashJaccard } from "./reconcile-engine.js"; @@ -104,6 +104,14 @@ export interface GraphEngineOptions { } interface GraphEngineInternalHooks { + /** Parent-owned workspace for cleanup after a candidate process aborts. */ + sourceSpoolDirectory?: string; + /** Numeric construction progress; never carries source paths or content. */ + onBuildProgress?: (progress: { + phase: "parse" | "resolve"; + completed?: number; + total?: number; + }) => void; afterSemanticInputsStaged?: () => void; afterCompilerExtraction?: () => void; afterFinalSemanticInputRead?: (path: string) => void; @@ -154,10 +162,14 @@ export class GraphSourceStagingError extends Error { * retained as strings in the maintenance process. */ class GraphSourceSpool { - private readonly directory = mkdtempSync(join(tmpdir(), "mex-graph-stage-")); + private readonly directory: string; private readonly entries = new Map(); private disposed = false; + constructor(parentDirectory = tmpdir()) { + this.directory = mkdtempSync(join(parentDirectory, "mex-graph-stage-")); + } + stage(relPath: string, source: string): void { if (this.disposed) throw new Error("The graph source spool is closed."); if (this.entries.has(relPath)) throw new Error(`Duplicate staged source: ${relPath}`); @@ -426,14 +438,8 @@ class GraphEngineImpl implements GraphEngine { ): Omit { this.internal.beforePublication?.(); const store = this.getStore(true); - const oldNodes = store.getAllNodes(); - const oldAliases = store.getAllAliases(); - const priorFingerprintStore = new FingerprintStore(this.db!); - const oldFingerprints = new Map(); - for (const node of oldNodes) { - const fingerprint = priorFingerprintStore.get(node.id); - if (fingerprint) oldFingerprints.set(node.id, fingerprint); - } + const freshNodes = staged.files.flatMap((file) => file.nodes); + const continuity = planCompatibilityAliases(store, freshNodes, new FingerprintStore(this.db!)); // Continuity reads above may be substantial on a mature index. Re-probe // only after they finish, at the final synchronous boundary before the // snapshot is constructed and its publication transaction begins. @@ -498,9 +504,9 @@ class GraphEngineImpl implements GraphEngine { for (const binding of file.imports) store.insertImportBinding(binding); } - new FingerprintStore(this.db!).upsertMany(staged.fingerprints); + upsertFingerprintsInOwnedTransaction(this.db!, staged.fingerprints); createCompatibilityAliases( - store, oldNodes, oldAliases, oldFingerprints, new FingerprintStore(this.db!), + store, continuity, freshNodes, new FingerprintStore(this.db!), ); store.rebuildSearchIndex(); store.validateInvariants(expectedNodes); @@ -582,7 +588,7 @@ async function stageCorpus( internal: GraphEngineInternalHooks = {}, compilerExtraction?: CompilerExtractionOptions, ): Promise { - const sourceSpool = new GraphSourceSpool(); + const sourceSpool = new GraphSourceSpool(internal.sourceSpoolDirectory); try { const { files: discovered, skipped } = discoverSourceFiles(root, sourceFileAccess, sourceSpool); const configSources = discoverGraphConfigSources(root); @@ -615,6 +621,12 @@ async function stageCorpus( } compilerInputs.sort((left, right) => compareCodePoints(left.filePath, right.filePath)); internal.afterSemanticInputsStaged?.(); + const reportParsed = (completed: number): void => internal.onBuildProgress?.({ + phase: "parse", + completed, + ...(discovered.length > 0 ? { total: discovered.length } : {}), + }); + reportParsed(0); let compiler: CompilerExtractionResult; const semanticInputLedger = createGraphSemanticInputLedger(); @@ -637,7 +649,7 @@ async function stageCorpus( } return source; }, - }); + }, reportParsed); } catch (error) { if (error instanceof GraphSourceStagingError) throw error; throw new GraphSourceStagingError([sourceStagingFailure(".", "read", error)]); @@ -663,12 +675,15 @@ async function stageCorpus( .map((file) => detectLanguage(file.relPath)))]; await loadGrammars([...treeLanguages, ...fallbackLanguages]); + let parsed = compiler.files.length; const files = discovered.map((file) => { const source = sourceSpool.read(file); const compilerFile = compilerByPath.get(file.relPath); - return compilerFile + const staged = compilerFile ? stageCompilerFile(file, source, compilerFile) : stageTreeFile(file, source); + if (!compilerFile) reportParsed(++parsed); + return staged; }); compilerByPath.clear(); // Release project/file extraction arrays after their durable staged shape @@ -676,6 +691,7 @@ async function stageCorpus( compiler.files.length = 0; compiler.projects.length = 0; + internal.onBuildProgress?.({ phase: "resolve" }); stageFrameworkAndFallbackResolution(root, files, configSources, sourceSpool); validateStagedCorpus(files); const fingerprints = stageFingerprints(files, sourceSpool); @@ -995,6 +1011,23 @@ function stageCompilerFile( const edges: GraphEdge[] = []; const references: UnresolvedRefRecord[] = []; for (const ref of extraction.references) { + if (ref.status === "resolved" && ref.targetId) { + edges.push({ + source: ref.sourceId, + target: ref.targetId, + kind: ref.kind, + metadata: { targetName: ref.targetName, targetQualifiedName: ref.targetQualifiedName }, + line: ref.line + 1, + column: ref.column, + provenance: ref.provenance, + confidence: ref.confidence, + resolutionMethod: ref.resolutionMethod, + evidence: [ref.evidence], + }); + continue; + } + // Resolved compiler references are fully represented by the edge above. + // Keep fallback references separately: import hydration still needs them. const reference: UnresolvedRefRecord = { refKey: ref.id, fromNodeId: ref.sourceId, @@ -1014,20 +1047,6 @@ function stageCompilerFile( resolver: ref.resolutionMethod, }; references.push(reference); - if (ref.status === "resolved" && ref.targetId) { - edges.push({ - source: ref.sourceId, - target: ref.targetId, - kind: ref.kind, - metadata: { targetName: ref.targetName, targetQualifiedName: ref.targetQualifiedName }, - line: ref.line + 1, - column: ref.column, - provenance: ref.provenance, - confidence: ref.confidence, - resolutionMethod: ref.resolutionMethod, - evidence: [ref.evidence], - }); - } } const imports: ImportBindingRecord[] = extraction.importBindings.map((binding) => ({ bindingKey: binding.id, @@ -1178,16 +1197,32 @@ function fallbackBindings(reference: UnresolvedRefRecord): Array<{ localName: st }); } -function createCompatibilityAliases( +interface CompatibilityAliasPlan { + oldAliases: NodeAliasRecord[]; + canonicalMap: Map; + direct: NodeAliasRecord[]; + fingerprints: Array<{ id: string; kind: GraphNode["kind"]; baseline: Fingerprint }>; +} + +/** Plan continuity while old rows exist; retain only data needed after replacement. */ +function planCompatibilityAliases( store: GraphStore, - oldNodes: GraphNode[], - oldAliases: NodeAliasRecord[], - oldFingerprints: ReadonlyMap, - freshFingerprints: FingerprintStore, -): void { - const fresh = store.getAllNodes(); + fresh: readonly GraphNode[], + fingerprints: FingerprintStore, +): CompatibilityAliasPlan { const freshIds = new Set(fresh.map((node) => node.id)); - const freshById = new Map(fresh.map((node) => [node.id, node])); + const oldIds = store.getAllNodeIds(); + const plan: CompatibilityAliasPlan = { + oldAliases: store.getAllAliases(), + canonicalMap: new Map(oldIds.filter((id) => freshIds.has(id)).map((id) => [id, id])), + direct: [], + fingerprints: [], + }; + // Body edits normally keep every identity. Avoid loading old descriptions, + // signatures and fingerprints merely to discover that each ID survived. + if (plan.canonicalMap.size === oldIds.length) return plan; + + const oldNodes = store.getAllNodes(); const byQualified = groupUnique(fresh, (node) => `${node.filePath}\0${node.kind}\0${node.qualifiedName}`); const oldBySignature = groupUnique( oldNodes.filter((node) => normalizedSignature(node.signature).length > 0), @@ -1198,12 +1233,8 @@ function createCompatibilityAliases( compatibilitySignatureKey, ); const byBody = groupUnique(fresh.filter((node) => node.bodyHash), (node) => `${node.kind}\0${node.bodyHash}`); - const canonicalMap = new Map(); for (const old of oldNodes) { - if (freshIds.has(old.id)) { - canonicalMap.set(old.id, old.id); - continue; - } + if (freshIds.has(old.id)) continue; const qualified = byQualified.get(`${old.filePath}\0${old.kind}\0${old.qualifiedName}`); const signatureKey = normalizedSignature(old.signature) ? compatibilitySignatureKey(old) : undefined; const oldSignature = signatureKey ? oldBySignature.get(signatureKey) : undefined; @@ -1213,13 +1244,45 @@ function createCompatibilityAliases( const match = qualified?.length === 1 ? { node: qualified[0]!, method: "qualified-name", confidence: 1 } : signature?.length === 1 ? { node: signature[0]!, method: "signature", confidence: 0.98 } : body?.length === 1 ? { node: body[0]!, method: "body-hash", confidence: 0.95 } - : fingerprintAliasMatch(old, oldFingerprints.get(old.id), freshFingerprints, freshById); - if (!match) continue; - store.insertAlias(old.id, match.node.id, match.method, match.confidence); - canonicalMap.set(old.id, match.node.id); + : undefined; + if (match) { + plan.direct.push({ + aliasId: old.id, + canonicalNodeId: match.node.id, + matchMethod: match.method, + confidence: match.confidence, + }); + plan.canonicalMap.set(old.id, match.node.id); + } else { + const baseline = fingerprints.get(old.id); + if (baseline && baseline.tokenCount >= MIN_TOKENS) { + plan.fingerprints.push({ id: old.id, kind: old.kind, baseline }); + } + } + } + return plan; +} + +function createCompatibilityAliases( + store: GraphStore, + plan: CompatibilityAliasPlan, + fresh: readonly GraphNode[], + freshFingerprints: FingerprintStore, +): void { + for (const alias of plan.direct) { + store.insertAlias(alias.aliasId, alias.canonicalNodeId, alias.matchMethod, alias.confidence); + } + if (plan.fingerprints.length > 0) { + const freshById = new Map(fresh.map((node) => [node.id, node])); + for (const old of plan.fingerprints) { + const match = fingerprintAliasMatch(old, old.baseline, freshFingerprints, freshById); + if (!match) continue; + store.insertAlias(old.id, match.node.id, match.method, match.confidence); + plan.canonicalMap.set(old.id, match.node.id); + } } - for (const alias of oldAliases) { - const canonical = canonicalMap.get(alias.canonicalNodeId); + for (const alias of plan.oldAliases) { + const canonical = plan.canonicalMap.get(alias.canonicalNodeId); if (canonical) store.insertAlias(alias.aliasId, canonical, alias.matchMethod, alias.confidence); } } @@ -1234,7 +1297,7 @@ function compatibilitySignatureKey(node: GraphNode): string { } function fingerprintAliasMatch( - old: GraphNode, + old: Pick, baseline: Fingerprint | undefined, freshFingerprints: FingerprintStore, freshById: ReadonlyMap, @@ -1255,7 +1318,7 @@ function fingerprintAliasMatch( return { node: best.node, method: "fingerprint", confidence: best.score }; } -function groupUnique(values: T[], key: (value: T) => string): Map { +function groupUnique(values: readonly T[], key: (value: T) => string): Map { const grouped = new Map(); for (const value of values) { const bucket = grouped.get(key(value)) ?? []; diff --git a/src/graph/extraction/compiler.ts b/src/graph/extraction/compiler.ts index a6724eb8..4223f0ad 100644 --- a/src/graph/extraction/compiler.ts +++ b/src/graph/extraction/compiler.ts @@ -438,6 +438,8 @@ export function buildTypeScriptExtraction( rootDir: string, candidateFiles?: readonly string[], options: CompilerExtractionOptions = {}, + /** Internal construction observer; no source identity crosses this seam. */ + onFileCaptured?: (completed: number) => void, ): CompilerExtractionResult { const root = resolve(rootDir); const inputs = new CompilerInputLedger(root, options); @@ -524,6 +526,7 @@ export function buildTypeScriptExtraction( bindings, captured: captureReferences(context, bindings), }); + onFileCaptured?.(capturedByFile.size); } }; diff --git a/src/graph/extraction/grammars.ts b/src/graph/extraction/grammars.ts index 18ccac9f..e7b57885 100644 --- a/src/graph/extraction/grammars.ts +++ b/src/graph/extraction/grammars.ts @@ -90,10 +90,13 @@ export async function initRuntime(): Promise { * documented WASM-heap race when grammars load concurrently on Node. */ export async function loadGrammars(languages: Language[]): Promise { - await initRuntime(); const toLoad = [...new Set(languages)].filter( (lang) => lang in WASM_GRAMMAR_FILES && !languageCache.has(lang), ); + // Successful compiler-only extraction has no tree-sitter work. In a fresh + // candidate process, initializing its WASM runtime here would be wasted. + if (toLoad.length === 0) return; + await initRuntime(); for (const lang of toLoad) { const wasmFile = WASM_GRAMMAR_FILES[lang]!; const grammar = await WasmLanguage.load(grammarWasmPath(wasmFile)); diff --git a/src/graph/fingerprint-store.ts b/src/graph/fingerprint-store.ts index 2c066b84..f302f652 100644 --- a/src/graph/fingerprint-store.ts +++ b/src/graph/fingerprint-store.ts @@ -20,6 +20,28 @@ interface FingerprintRow { token_count: number; } +/** + * Full graph publication only: the caller owns the encompassing transaction + * and MUST roll it back if this function throws. This deliberately omits a + * corpus-sized nested savepoint, whose SQLite memory journal can make each + * bucket write revisit an increasingly large retained journal prefix. + * + * Do not select this path merely because a transaction is active. A caller + * that catches a write failure and continues must use FingerprintStore.upsertMany. + * Kept outside the class so it cannot leak through the public grounding types. + * @internal + */ +export function upsertFingerprintsInOwnedTransaction( + db: SqliteDatabase, + entries: Iterable<{ nodeId: string; fingerprint: Fingerprint }>, +): void { + writeFingerprints(db, entries, false); +} + +// One fixed synchronous read statement per connection; weak ownership does not +// retain closed/discarded databases, and no caller-owned iterator is reused. +const fingerprintReadStatements = new WeakMap>(); + export class FingerprintStore { constructor(private readonly db: SqliteDatabase) {} @@ -28,84 +50,15 @@ export class FingerprintStore { } /** - * Persist a corpus of fingerprints with one savepoint and one set of - * prepared statements. Each node still replaces exactly the same fingerprint - * and LSH rows as {@link upsert}; batching only removes statement preparation, - * savepoint, and per-band insert overhead from full graph builds. + * Atomically replace a batch, including when an enclosing transaction catches + * the failure and continues. The last entry for each node wins. */ upsertMany(entries: Iterable<{ nodeId: string; fingerprint: Fingerprint }>): void { - const latestByNode = new Map(); - for (const entry of entries) latestByNode.set(entry.nodeId, entry); - const ordered = [...latestByNode.values()].sort((left, right) => left.nodeId.localeCompare(right.nodeId)); - if (ordered.length === 0) return; - - const upsertFingerprint = this.db.prepare( - `INSERT INTO node_fingerprints (node_id, minhash, neighbors, token_count) - VALUES (?, ?, ?, ?) - ON CONFLICT(node_id) DO UPDATE SET minhash=excluded.minhash, - neighbors=excluded.neighbors, token_count=excluded.token_count`, - ); - // ON CONFLICT DO UPDATE keeps the existing row, so `ref` is stable across - // re-upserts of the same node — stale LSH rows are deleted by ref below. - const selectRef = this.db.prepare( - "SELECT CAST(ref AS TEXT) AS ref FROM node_fingerprints WHERE node_id = ?", - ); - const bucketCount = bandHashInts(ordered[0]!.fingerprint).length; - const insertBuckets = this.db.prepare( - `INSERT INTO lsh_buckets (band, band_hash, ref) VALUES ${ - Array.from({ length: bucketCount }, () => "(?, ?, ?)").join(", ") - }`, - ); - - this.db.exec("SAVEPOINT mex_fingerprint_upsert_many"); - try { - // Delete prior buckets before inserting any replacements. Chunked IN - // deletes over the ref subquery scan the table a bounded number of times - // and produce the identical final rows (the last duplicate entry wins). - const deleteChunkSize = 500; - for (let offset = 0; offset < ordered.length; offset += deleteChunkSize) { - const nodeIds = ordered.slice(offset, offset + deleteChunkSize).map((entry) => entry.nodeId); - this.db.prepare( - `DELETE FROM lsh_buckets WHERE ref IN ( - SELECT ref FROM node_fingerprints WHERE node_id IN (${nodeIds.map(() => "?").join(",")}) - )`, - ).run(...nodeIds); - } - for (const { nodeId, fingerprint } of ordered) { - const buckets = bandHashInts(fingerprint); - if (buckets.length !== bucketCount) { - throw new Error(`Inconsistent fingerprint band count for ${nodeId}.`); - } - upsertFingerprint.run( - nodeId, - encodeMinhash(fingerprint.minhash), - JSON.stringify(fingerprint.neighbors), - fingerprint.tokenCount, - ); - const row = selectRef.get(nodeId) as { ref: string } | undefined; - if (!row) throw new Error(`Fingerprint upsert failed for ${nodeId}.`); - const ref = BigInt(row.ref); - insertBuckets.run(...buckets.flatMap((bandHash, band) => [band, bandHash, ref])); - } - this.db.exec("RELEASE mex_fingerprint_upsert_many"); - } catch (error) { - this.db.exec("ROLLBACK TO mex_fingerprint_upsert_many"); - this.db.exec("RELEASE mex_fingerprint_upsert_many"); - throw error; - } + writeFingerprints(this.db, entries, true); } get(nodeId: string): Fingerprint | null { - const row = this.db.prepare( - `SELECT node_id, minhash, neighbors, token_count - FROM node_fingerprints WHERE node_id = ? - UNION ALL - SELECT fingerprints.node_id, fingerprints.minhash, fingerprints.neighbors, fingerprints.token_count - FROM node_aliases aliases - JOIN node_fingerprints fingerprints ON fingerprints.node_id = aliases.canonical_node_id - WHERE aliases.alias_id = ? - LIMIT 1`, - ).get(nodeId, nodeId) as FingerprintRow | undefined; + const row = fingerprintReadStatement(this.db).get(nodeId, nodeId) as FingerprintRow | undefined; return row ? decodeRow(row) : null; } @@ -210,6 +163,91 @@ export class FingerprintStore { } } +function writeFingerprints( + db: SqliteDatabase, + entries: Iterable<{ nodeId: string; fingerprint: Fingerprint }>, + independentRollback: boolean, +): void { + const latestByNode = new Map(); + for (const entry of entries) latestByNode.set(entry.nodeId, entry); + const ordered = [...latestByNode.values()].sort((left, right) => left.nodeId.localeCompare(right.nodeId)); + if (ordered.length === 0) return; + + const upsertFingerprint = db.prepare( + `INSERT INTO node_fingerprints (node_id, minhash, neighbors, token_count) + VALUES (?, ?, ?, ?) + ON CONFLICT(node_id) DO UPDATE SET minhash=excluded.minhash, + neighbors=excluded.neighbors, token_count=excluded.token_count`, + ); + // ON CONFLICT DO UPDATE keeps the existing row, so `ref` is stable across + // re-upserts of the same node — stale LSH rows are deleted by ref below. + const selectRef = db.prepare( + "SELECT CAST(ref AS TEXT) AS ref FROM node_fingerprints WHERE node_id = ?", + ); + const bucketCount = bandHashInts(ordered[0]!.fingerprint).length; + const insertBuckets = db.prepare( + `INSERT INTO lsh_buckets (band, band_hash, ref) VALUES ${ + Array.from({ length: bucketCount }, () => "(?, ?, ?)").join(", ") + }`, + ); + + if (independentRollback) db.exec("SAVEPOINT mex_fingerprint_upsert_many"); + try { + // Delete prior buckets before inserting any replacements. Chunked IN + // deletes over the ref subquery scan the table a bounded number of times + // and produce the identical final rows (the last duplicate entry wins). + const deleteChunkSize = 500; + for (let offset = 0; offset < ordered.length; offset += deleteChunkSize) { + const nodeIds = ordered.slice(offset, offset + deleteChunkSize).map((entry) => entry.nodeId); + db.prepare( + `DELETE FROM lsh_buckets WHERE ref IN ( + SELECT ref FROM node_fingerprints WHERE node_id IN (${nodeIds.map(() => "?").join(",")}) + )`, + ).run(...nodeIds); + } + for (const { nodeId, fingerprint } of ordered) { + const buckets = bandHashInts(fingerprint); + if (buckets.length !== bucketCount) { + throw new Error(`Inconsistent fingerprint band count for ${nodeId}.`); + } + upsertFingerprint.run( + nodeId, + encodeMinhash(fingerprint.minhash), + JSON.stringify(fingerprint.neighbors), + fingerprint.tokenCount, + ); + const row = selectRef.get(nodeId) as { ref: string } | undefined; + if (!row) throw new Error(`Fingerprint upsert failed for ${nodeId}.`); + const ref = BigInt(row.ref); + insertBuckets.run(...buckets.flatMap((bandHash, band) => [band, bandHash, ref])); + } + if (independentRollback) db.exec("RELEASE mex_fingerprint_upsert_many"); + } catch (error) { + if (independentRollback) { + db.exec("ROLLBACK TO mex_fingerprint_upsert_many"); + db.exec("RELEASE mex_fingerprint_upsert_many"); + } + throw error; + } +} + +function fingerprintReadStatement(db: SqliteDatabase): ReturnType { + const existing = fingerprintReadStatements.get(db); + if (existing) return existing; + const statement = db.prepare( + `SELECT node_id, minhash, neighbors, token_count + FROM node_fingerprints WHERE node_id = ? + UNION ALL + SELECT fingerprints.node_id, fingerprints.minhash, fingerprints.neighbors, fingerprints.token_count + FROM node_aliases aliases + JOIN node_fingerprints fingerprints ON fingerprints.node_id = aliases.canonical_node_id + WHERE aliases.alias_id = ? + LIMIT 1`, + ); + fingerprintReadStatements.set(db, statement); + return statement; +} + interface BaselineRow { subject_kind: string; subject_id: string; diff --git a/src/graph/maintenance.ts b/src/graph/maintenance.ts index 0c0ecb47..b42a6f0a 100644 --- a/src/graph/maintenance.ts +++ b/src/graph/maintenance.ts @@ -37,6 +37,7 @@ import { } from "./status.js"; import { GRAPH_SNAPSHOT_METADATA_KEY } from "./snapshot.js"; import { tryEnsureSetupIgnoreProtection } from "../setup/ignore.js"; +import { GraphCandidateProcessError, runGraphCandidateProcess, type GraphCandidateProcessOptions } from "./candidate-process.js"; const LOCK_FILE = "graph.db.lock"; const LOCK_GATE_FILE = "graph.db.lock.gate"; @@ -149,8 +150,6 @@ interface StatIdentity { interface ValidatedCandidate { status: GraphStatus; identity: DatabaseIdentity; - snapshotRaw: string; - snapshotHash: string; } interface LockOwner { @@ -166,6 +165,7 @@ interface MaintenanceLock { } interface GraphMaintenanceInternalHooks { + candidateProcess?: GraphCandidateProcessOptions["__internal"]; token?: () => string; now?: () => Date; inspectStatus?: typeof inspectGraphStatus; @@ -185,7 +185,12 @@ interface GraphMaintenanceInternalHooks { afterLockGateAcquired?: (gatePath: string) => void; } -type InternalMaintenanceOptions = GraphMaintenanceOptions & { +/** Internal execution policy; the CLI retains its existing in-process default. */ +export type GraphMaintenanceExecutionOptions = GraphMaintenanceOptions & { + candidateExecution?: "process"; +}; + +type InternalMaintenanceOptions = GraphMaintenanceExecutionOptions & { /** Deterministic fault/race seams. Deliberately absent from public declarations. */ __internal?: GraphMaintenanceInternalHooks; }; @@ -217,8 +222,8 @@ export type GraphMaintenanceLeaseMode = "refresh" | "rebuild" | "repair"; export interface GraphMaintenanceLease { readonly projectRoot: string; readonly databasePath: string; - refresh(options?: GraphMaintenanceOptions): Promise; - rebuild(options?: GraphMaintenanceOptions): Promise; + refresh(options?: GraphMaintenanceExecutionOptions): Promise; + rebuild(options?: GraphMaintenanceExecutionOptions): Promise; repair(options?: GraphMaintenanceOptions): Promise; release(): void; } @@ -226,7 +231,7 @@ export interface GraphMaintenanceLease { export function acquireGraphMaintenanceLease( projectRoot: string, mode: GraphMaintenanceLeaseMode = "refresh", - options: GraphMaintenanceOptions = {}, + options: GraphMaintenanceExecutionOptions = {}, ): GraphMaintenanceLease { const internalOptions = options as InternalMaintenanceOptions; const paths = resolveMaintenancePaths(projectRoot, mode === "rebuild"); @@ -258,7 +263,7 @@ export function acquireGraphMaintenanceLease( let active = false; const run = async ( operation: "refresh" | "rebuild" | "repair", - operationOptions: GraphMaintenanceOptions, + operationOptions: GraphMaintenanceExecutionOptions, ): Promise => { if (released) throw new Error("The graph maintenance lease has already been released."); if (active) { @@ -275,6 +280,7 @@ export function acquireGraphMaintenanceLease( // retaining acquisition-time deterministic hooks. const merged = { ...operationOptions, + candidateExecution: resolved.candidateExecution ?? internalOptions.candidateExecution, __internal: resolved.__internal ?? internalOptions.__internal, } as InternalMaintenanceOptions; await merged.__internal?.afterLockAcquired?.(); @@ -304,7 +310,7 @@ export function acquireGraphMaintenanceLease( /** Explicit correctness-first refresh. Reads never call this function. */ export async function refreshGraph( projectRoot: string, - options: GraphMaintenanceOptions = {}, + options: GraphMaintenanceExecutionOptions = {}, ): Promise { const lease = acquireGraphMaintenanceLease(projectRoot, "refresh", options); try { @@ -511,7 +517,7 @@ async function refreshGraphWithLease( /** Explicit isolated rebuild for missing, incompatible, or corrupt indexes. */ export async function rebuildGraph( projectRoot: string, - options: GraphMaintenanceOptions = {}, + options: GraphMaintenanceExecutionOptions = {}, ): Promise { const lease = acquireGraphMaintenanceLease(projectRoot, "rebuild", options); try { @@ -613,6 +619,7 @@ async function refreshCandidate( options: InternalMaintenanceOptions, ): Promise { progress(options, "stage", "Checking exact graph provenance and restaging the corpus when required."); + if (options.candidateExecution === "process") return runIsolatedCandidate(paths, candidatePath, "refresh", options); const engine = maintenanceEngine(paths, candidatePath, options); try { // The empty-hint sync path performs the exact corpus, semantic-input, @@ -630,6 +637,7 @@ async function rebuildCandidate( options: InternalMaintenanceOptions, ): Promise { progress(options, "stage", "Building the graph in an isolated same-directory candidate."); + if (options.candidateExecution === "process") return runIsolatedCandidate(paths, candidatePath, "rebuild", options); const engine = maintenanceEngine(paths, candidatePath, options); try { return await engine.build(paths.projectRoot); @@ -638,6 +646,41 @@ async function rebuildCandidate( } } +async function runIsolatedCandidate( + paths: MaintenancePaths, + candidatePath: string, + operation: "refresh" | "rebuild", + options: InternalMaintenanceOptions, +): Promise { + options.__internal?.beforeCandidateDatabaseOpen?.(candidatePath); + assertMaintenanceDirectoryUnchanged(paths); + assertNotAborted(options.signal); + try { + return await runGraphCandidateProcess({ + projectRoot: paths.projectRoot, + candidatePath, + operation, + signal: options.signal, + onProgress: (update) => { + assertNotAborted(options.signal); + options.onProgress?.({ + ...update, + message: update.phase === "parse" + ? "Extracting graph source files." + : "Resolving references and writing the graph candidate.", + }); + }, + __internal: options.__internal?.candidateProcess, + }); + } catch (error) { + if (error instanceof GraphCandidateProcessError) { + if (error.category === "cancelled") throw new GraphMaintenanceError("GRAPH_MAINTENANCE_CANCELLED", error.message); + if (error.category === "unsafe") throw new GraphMaintenanceError("GRAPH_MAINTENANCE_PATH_UNSAFE", error.message); + } + throw error; + } +} + function materializeRepairCandidate( paths: MaintenancePaths, candidatePath: string, @@ -1715,8 +1758,11 @@ async function validateCandidate( assertMaintenanceDirectoryUnchanged(paths); assertStatus(status); assertClearSidecars(candidatePath); - const snapshot = readExactSnapshot(candidatePath); + // This pair of full-byte identities brackets both the complete inspection + // and its snapshot read. Nested hashes around the same read add no evidence. + assertSnapshotWithinIdentityCheck(candidatePath); const identityAfter = captureDatabaseIdentity(candidatePath); + assertClearSidecars(candidatePath); if (!sameDatabaseIdentity(identityBefore, identityAfter)) { throw new GraphMaintenanceError( "GRAPH_CANDIDATE_INVALID", @@ -1727,8 +1773,6 @@ async function validateCandidate( return { status, identity: identityAfter, - snapshotRaw: snapshot.raw, - snapshotHash: snapshot.hash, }; } @@ -1738,10 +1782,11 @@ function assertCandidateUnchanged(candidatePath: string, candidate: ValidatedCan } assertClearSidecars(candidatePath); const identity = captureDatabaseIdentity(candidatePath); - const snapshot = readExactSnapshot(candidatePath); - if (!sameDatabaseIdentity(identity, candidate.identity) - || snapshot.raw !== candidate.snapshotRaw - || snapshot.hash !== candidate.snapshotHash) { + assertClearSidecars(candidatePath); + // Exact whole-database byte equality includes the validated snapshot row. + // Reopening SQLite and hashing the entire file twice again to compare that + // one row cannot strengthen this identity-bound publication check. + if (!sameDatabaseIdentity(identity, candidate.identity)) { throw new GraphMaintenanceError( "GRAPH_MAINTENANCE_RACE", "The graph candidate changed after validation and was not published.", @@ -1750,11 +1795,10 @@ function assertCandidateUnchanged(candidatePath: string, candidate: ValidatedCan } } -function readExactSnapshot(path: string): { raw: string; hash: string } { +/** Called only inside validateCandidate's complete before/after byte binding. */ +function assertSnapshotWithinIdentityCheck(path: string): void { assertClearSidecars(path); - const before = captureDatabaseIdentity(path); const db = openSqlite(path, { readOnly: true, immutable: true }); - let raw: string; try { const row = db.prepare( "SELECT value FROM project_metadata WHERE key = ?", @@ -1765,18 +1809,9 @@ function readExactSnapshot(path: string): { raw: string; hash: string } { "The graph candidate is missing exact graph_snapshot_v1 provenance.", ); } - raw = row.value; } finally { db.close(); } - const after = captureDatabaseIdentity(path); - if (!sameDatabaseIdentity(before, after)) { - throw new GraphMaintenanceError( - "GRAPH_CANDIDATE_INVALID", - "The graph candidate changed while its snapshot token was being read.", - ); - } - return { raw, hash: createHash("sha256").update(raw).digest("hex") }; } function shouldCloneForContinuity(status: GraphStatus): boolean { @@ -1789,6 +1824,7 @@ function shouldCloneForContinuity(status: GraphStatus): boolean { } function isContinuityCloneCompatibilityFailure(error: unknown): boolean { + if (error instanceof GraphCandidateProcessError) return error.category === "compatibility"; if (error instanceof GraphSourceStagingError || error instanceof GraphMaintenanceError) return false; const code = errorCode(error).toUpperCase(); const name = error instanceof Error ? error.name : ""; diff --git a/src/hub/command.ts b/src/hub/command.ts index a4a3f072..4e91d591 100644 --- a/src/hub/command.ts +++ b/src/hub/command.ts @@ -36,7 +36,7 @@ export async function runHubCommand(options: RunHubCommandOptions): Promise { unsubscribe(); }); + it("retains parsed file counts through later graph phases and suppresses duplicate phase updates", async () => { + const root = tempProject(); + let context: HubJobExecutorContext | undefined; + let finish!: () => void; + const pending = new Promise((resolve) => { finish = resolve; }); + const manager = new HubJobManager({ + localState: localState(root), + now: () => NOW, + generateId: () => JOB_A, + executors: { graph_refresh: async (received) => { context = received; await pending; } }, + }); + manager.initialize(); + const queued = manager.start({ kind: "graph_refresh" }); + await waitForState(manager, queued.id, "running"); + context!.reportProgress({ phase: "parse", completed: 2, total: 2 }); + expect(manager.get(queued.id)?.progress).toEqual({ completed: 2, total: 2 }); + context!.reportProgress({ phase: "resolve" }); + const resolving = manager.get(queued.id); + expect(resolving).toMatchObject({ phase: "resolve", progress: { completed: 2, total: 2 } }); + context!.reportProgress({ phase: "resolve" }); + expect(manager.get(queued.id)).toEqual(resolving); + context!.reportProgress({ phase: "validate" }); + expect(manager.get(queued.id)).toMatchObject({ phase: "validate", progress: { completed: 2, total: 2 } }); + finish(); + expect(await waitForState(manager, queued.id, "succeeded")).toMatchObject({ progress: { completed: 2, total: 2 } }); + }); + it("does not persist or publish an identical progress snapshot", async () => { const root = tempProject(); const store = localState(root); diff --git a/test/hub-e2e/__screenshots__/darwin/hub-jobs.png b/test/hub-e2e/__screenshots__/darwin/hub-jobs.png index 3e03b4c0..21f108ce 100644 Binary files a/test/hub-e2e/__screenshots__/darwin/hub-jobs.png and b/test/hub-e2e/__screenshots__/darwin/hub-jobs.png differ diff --git a/test/hub-e2e/hub.spec.ts b/test/hub-e2e/hub.spec.ts index cbc41a64..c920bf64 100644 --- a/test/hub-e2e/hub.spec.ts +++ b/test/hub-e2e/hub.spec.ts @@ -204,7 +204,8 @@ test.describe("populated development fixture", () => { const determinate = page.getByRole("region", { name: "Active operation" }); const determinateProgress = determinate.getByRole("progressbar"); await expect(determinateProgress).toHaveAttribute("aria-valuenow", "68"); - await expect(determinate.getByText("124 / 183", { exact: true })).toBeVisible(); + await expect(determinateProgress).toHaveAccessibleName("Graph refresh · Parse"); + await expect(determinate.getByText("124 / 183 files parsed", { exact: true })).toBeVisible(); await expect(determinate.getByRole("button", { name: "View operation" })).toHaveAttribute( "href", `/jobs?job=${runningJobId}`, @@ -214,6 +215,7 @@ test.describe("populated development fixture", () => { await expect(page.locator('[data-overview-workbench="ready"]')).toBeVisible(); const indeterminate = page.getByRole("region", { name: "Active operation" }); const indeterminateProgress = indeterminate.getByRole("progressbar"); + await expect(indeterminateProgress).toHaveAccessibleName("Wiki refresh · Discover"); await expect(indeterminateProgress).not.toHaveAttribute("aria-valuenow"); await expect(indeterminate.getByText("37 completed", { exact: true })).toBeVisible(); }); @@ -587,7 +589,9 @@ test.describe("populated development fixture", () => { await page.goto(`/jobs?fixture=populated&job=${runningJobId}`); await expect(page.getByRole("heading", { name: "Jobs", exact: true })).toBeVisible(); await expect(page.getByRole("complementary", { name: "Job detail" })).toBeVisible(); - await expect(page.getByRole("complementary", { name: "Job detail" }).getByRole("progressbar", { name: "68% complete" })).toBeVisible(); + const detail = page.getByRole("complementary", { name: "Job detail" }); + await expect(detail.getByRole("progressbar", { name: "68% of files parsed" })).toHaveAttribute("aria-valuenow", "68"); + await expect(detail.getByText("124 / 183 files parsed", { exact: true })).toBeVisible(); await expect(page.getByLabel("Graph operation phases").getByText("Parse", { exact: true })).toHaveAttribute("aria-current", "step"); await expect(page.getByRole("button", { name: /^Refresh graph/ })).toBeDisabled(); await expect(page.getByRole("button", { name: /^Rebuild graph/ })).toBeDisabled(); diff --git a/test/wiki-architecture.test.ts b/test/wiki-architecture.test.ts index f09a68c1..beb1ba52 100644 --- a/test/wiki-architecture.test.ts +++ b/test/wiki-architecture.test.ts @@ -755,6 +755,7 @@ describe("no unscoped scaffold writes", () => { // one nothing in `src/` was watching at all. const KNOWN: Readonly> = { "src/agent-skills/installer.ts": "atomically installs fixed packaged skill trees and marker-scoped root instructions", + "src/graph/candidate-process.ts": "removes only the identity-bound parent-owned temporary workspace after the candidate child closes", "src/graph/engine-impl.ts": "writes only a private, bounded temporary source spool that is removed before graph publication", "src/graph/maintenance.ts": "publishes and recovers the disposable graph index under its maintenance lease", "src/graph/runtime.ts": "edits existing .mex Markdown; bypasses the pipeline (recorded D9 exception)", diff --git a/tsup.config.ts b/tsup.config.ts index eb6da509..0db7e5e2 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,12 +2,12 @@ import { defineConfig } from "tsup"; /** * Two-config build: - * - cli → dist/cli.js (shebang banner, no .d.ts; consumed by `bin`) + * - cli + private graph candidate → executable bundles, no public declarations * - index → dist/index.js + dist/index.d.ts (library entry consumed via `exports`) */ export default defineConfig([ { - entry: { cli: "src/cli.ts" }, + entry: { cli: "src/cli.ts", "graph-candidate": "src/graph/candidate-entry.ts" }, format: ["esm"], target: "node20", outDir: "dist",