mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
Some checks failed
CI / Classify Changes (push) Has been cancelled
CI / Check AGENTS.md Links (push) Has been cancelled
CI / Container Entrypoint (push) Has been cancelled
Release Edge / Prepare edge release (push) Has been cancelled
CI / Test Workspace (push) Has been cancelled
CI / Test omnigraph-server --features aws (push) Has been cancelled
CI / RustFS S3 Integration (push) Has been cancelled
Release Edge / Build edge omnigraph-linux-x86_64 (push) Has been cancelled
Release Edge / Build edge omnigraph-macos-arm64 (push) Has been cancelled
Release Edge / Build edge omnigraph-windows-x86_64 (push) Has been cancelled
Release Edge / Smoke Windows installer (push) Has been cancelled
* docs(rfc-013): bank the #295 spec-review comments as step-5 constraints (§5.1) 3b shipped a minimal WriteTxn{branch,base} and deferred the full §4.1 opener unification (pinned-base opener, shared Session, write-local handle cache, strict-op conflict-timing move) to step 5. The greptile comments on the #295 spec were moot for #298 (none of those constructs were built) but are load-bearing for step 5: (1) the handle cache must be Send+Sync (Mutex, not RefCell); (2) the strict-op timing move needs an explicit retry contract — txn discarded after any commit, retry re-opens a fresh base — which is the SAME contract as the stale-view false-fail (§1d.2); (3) the opener-equivalence test must advance HEAD externally then assert pinned-base, not the trivial HEAD==base. * feat(engine): fold graph lineage into the __manifest publish CAS (RFC-013 Phase 7) Graph lineage no longer lives in a second write to _graph_commits.lance. Each commit's graph_commit + graph_head:<branch> rows now ride the SAME __manifest merge-insert as the table-version rows (one atomic version), and CommitGraph reads its cache from the manifest projection (read_graph_lineage). _graph_commits.lance is no longer written commit rows (it remains only as a Lance branch-ref carrier). Mechanism: a LineageIntent { graph_commit_id (ULID, minted once), branch, actor, merged_parent, created_at } threads through ManifestBatchPublisher::publish. Inside the publisher retry loop the parent is resolved per attempt from the just-loaded branch-scoped manifest (the should_replace_head winner over the visible graph_commit rows — branch-correct by Lance branch isolation; the graph_head row is written for forward-compat + the §7.1 contention point but is not the parent source, so a freshly-forked branch resolves the right fork-point parent). A CAS-conflict retry re-reads the advanced head → correct new parent; the commit_id is stable across retries. Closes two known gaps BY CONSTRUCTION (one write, no second step to fail/ race): - manifest→commit-graph atomicity (no crash window between manifest + lineage), - commit-graph parent under concurrency (no refresh→append TOCTOU; the per-write commit_graph.refresh() is gone). Recovery, branch-merge, and genesis route their lineage through the same CAS (merge: one commit_merge_with_actor; recovery: publish_recovery_commit folds the recovery commit, actor=omnigraph:recovery; genesis rides the init __manifest write). The dead _graph_commits write helpers (append_commit/_merge/_actor) are #[allow(dead_code)] (the actor sidecar table is still enumerated by optimize). Verified (sequential): build clean; the new lineage_projection gate (manifest-only — _graph_commits/_actors have 0 rows; full lineage reconstructs via the projection); branching/merge_truth_table (exhaustive, branch-aware)/composite_flow/point_in_time/ changes/consistency/recovery; failpoints (59, incl. recovery lifecycle + the now-closed atomicity gap); full --workspace. Cost tests REVERT to their pre-fold values (writes +1, write_cost ceiling 80) — the proof of true single-CAS (no extra write). invariants.md marks both gaps CLOSED. PENDING (next stages, this PR): the §7.1 concurrent graph_head one-winner gate (stage 5 — two concurrent same-branch commits, exactly one wins); the stamp bump v4 + migrate_v3_to_v4 backfill + read-only refuse for EXISTING graphs (stage 4); full doc-sync of storage.md/architecture.md/writes.md. * feat(engine): migrate existing v3 graphs to manifest lineage (RFC-013 Phase 7 stage 4) The Phase-7 fold made CommitGraph read lineage from the __manifest projection, so a pre-Phase-7 (internal-schema v3) graph — lineage in _graph_commits.lance, none in __manifest — would read an empty commit DAG. Stage 4 makes existing graphs upgrade seamlessly and not break reads. - Stamp 3 -> 4 + migrate_v3_to_v4: bumps INTERNAL_MANIFEST_SCHEMA_VERSION and adds the 3 => migrate_v3_to_v4 arm. The migration reads this branch's _graph_commits/_actors, emits one graph_commit row per commit + exactly one graph_head:<branch> for the head (should_replace_head winner, deterministic id-sort — no hash-map-order in migration output), merge-inserts into __manifest, then set_stamp(4) LAST. Idempotency guard first (read_graph_lineage non-empty -> just stamp); crash before set_stamp re-enters at v3 and the guard completes it. Does NOT touch the unenforced-PK metadata. Runs per branch: migrate_on_open backfills main; load_publish_state backfills each branch on its first write (root_uri/branch threaded through migrate_internal_schema). - v3-read fallback: CommitGraph version-gates the lineage source — stamp < 4 reads the (re-activated) _graph_commits.lance; >= 4 uses the manifest projection. So a READ-ONLY open of an un-migrated graph reads correct history with no write. Correctness catch: the legacy _graph_commit_actors.lance was never branched, so the fallback reads it FLAT (no branch checkout) while checking out the branch only on the commits dataset. - Read-only stamp-refuse: a ReadOnly open of a FUTURE-stamped graph now refuses with the same upgrade error (future-proofing the next format bump; the write path already refused via migrate_internal_schema). - Docs: storage/architecture/writes/invariants/constants updated to manifest-stored lineage; release note docs/releases/v0.8.0.md (format v4, old writers clean-break, data preserved, upgrade writers first). 6 new tests (v3 backfill, idempotent, v3 read-only fallback, future-stamp refuse in both modes, crash-before-stamp completes, legacy branch+flat-actor read). Full engine suite + failpoints (59) + cargo test --workspace --locked green; check-agents-md passes. * test(engine): graph_head concurrency gate — disjoint same-branch writers form a linear commit DAG (RFC-013 Phase 7) Two (or N) writers committing disjoint tables on one branch still share the mutable `graph_head:<branch>` manifest row, so the only row-level CAS contention is that row. The contract — exactly one writer wins each CAS round; the loser retries inside the publisher, re-resolves its parent off the freshly-advanced head, and re-commits, so every writer lands and the graph_commit DAG stays a single LINEAR chain (no fork) — had no acceptance test. This adds it. - concurrent_disjoint_writes_share_head_and_form_linear_chain: two disjoint writers + distinct LineageIntent, tokio::join!; both commit; the on-disk DAG is genesis -> c -> c' (asserted linear: exactly one genesis, no two commits share a parent, the head is the unique non-parent). - n_concurrent_disjoint_writers_converge_to_one_linear_chain: N=8 disjoint writers each with an app-level retry loop (the publisher's internal budget can be exhausted under contention); all converge to one linear chain of 8. - concurrent_disjoint_writes_form_linear_chain_on_s3: the same race on a real object store (true conditional-put CAS), bucket-gated. Cites both tests from the §7.1 contention note in invariants.md. Test-only; no production change. * perf(engine): fold the lineage parent scan into the publish path's single __manifest scan (RFC-013 P2) Each lineage publish scanned `__manifest` twice: `load_publish_state` read table state via one scan, then `resolve_lineage_rows` did a second full `read_graph_lineage` scan only to find the parent commit. Fold the `graph_commit` extraction into the existing scan. - `read_manifest_scan` gains a `collect_lineage` flag. The publish path (`read_publish_scan`) collects the `graph_commit` rows in the same pass; the table-state hot path leaves them in the forward-compat skip arm, so it never pays the O(commits) lineage JSON decode (it also skips reading the `object_id` column entirely). One shared `decode_graph_commit_row` serves both the folded path and the standalone `read_graph_lineage`, so the two cannot drift. - `resolve_lineage_rows` is now sync and takes the already-parsed rows; the per-attempt re-read is preserved because `load_publish_state` runs once per CAS attempt, so a retry still re-parents off the advanced head. - `load_publish_state` returns a named `LoadedPublishState` instead of a four-tuple; the thin `read_registered_table_locations` / `read_tombstone_versions` accessors fold away. `read_manifest_entries` becomes `#[cfg(test)]`: the fold removes its last production caller, leaving only the test-only namespace module (`db/manifest.rs`: `#[cfg(test)] mod namespace`), so gating it keeps it from becoming dead code in non-test builds. Measured at depth ~5: per-write `__manifest` reads drop 44 -> 26 (total reads 54 -> 36). write_cost.rs gains a `manifest_reads <= 34` sub-ceiling that trips if a publish-path scan is re-added, and its calibration comment is corrected. * test(engine): red — transient legacy-open failure silently completes the v3→v4 migration A pre-Phase-7 (internal schema v3) graph keeps its graph lineage in `_graph_commits.lance`; the v3→v4 internal-schema migration backfills it into `__manifest` and stamps v4. `read_legacy_commit_cache` currently maps EVERY `Dataset::open` error to "no legacy data" (`Err(_) => empty`), so a transient or corrupt open during the one-time migration backfills nothing and still stamps v4 — orphaning the real lineage permanently (the migration runs once; the v3 fallback is then disabled). Add a `migration.v3_to_v4.legacy_open` failpoint that injects a non-not-found Lance error at the legacy open, and a fault-injection regression test in the `failpoints` binary. Against the current swallow the migration completes anyway, so the test fails on its "migration must abort" assertion — the predicted symptom. The fix follows in the next commit. Test support reachable from the `failpoints` integration binary (it compiles the crate without `cfg(test)`): the v3-fixture helpers and a stamp/row-count reader are gated `cfg(any(test, feature = "failpoints"))`, still excluded from release builds. Failpoint tests stay in the integration binary because the fail registry is process-global. * fix(engine): propagate non-not-found legacy-open errors in the v3→v4 migration `read_legacy_commit_cache` mapped EVERY `Dataset::open` error to an empty cache (`Err(_) => empty`) on both the legacy commits dataset and its actor sidecar. The v3→v4 internal-schema migration reads this once before stamping internal-schema v4; a transient or corrupt open therefore backfilled nothing and stamped v4 anyway, orphaning the graph's real lineage permanently (the migration runs once, and the stamp-gated v3 fallback is disabled at v4). This is the "no silent failures" deny-list violation, and realistic on object storage. Both opens now match the not-found variants — Lance maps an object-store NotFound to `DatasetNotFound` — as the benign "no legacy data" / "no authors" signal, and propagate anything else as a loud error. The two arms share the variant contract but carry different rationale (commits-absent is the legitimate empty signal; actor-sidecar-absent is benign, but a corrupt actor open silently wiping authorship before stamping v4 is the same loss hole), commented at each site. Pinned by the `lance_surface_guards.rs::dataset_open_missing_returns_not_found_variant` guard (turns red if a Lance bump changes the absence variant) and greens the fault-injection regression test from the previous commit. * test(engine): cover the per-branch v3→v4 migration against a real Lance branch `seed_legacy_v3_lineage` writes every commit (including the "feature"-tagged one) to MAIN's `_graph_commits.lance` with `manifest_branch` as a mere field, so the production per-branch migration path — `read_legacy_commit_cache` checking out a real Lance branch, and a branch-scoped `__manifest` — was never exercised. Add `seed_legacy_v3_lineage_with_branch`, which forks a real `feature` Lance branch on BOTH `_graph_commits.lance` and `__manifest` (the branch inherits main's stripped v3 state), and a test that migrates the BRANCH and asserts the branch's lineage lands in the BRANCH's `__manifest` (genesis + A + branch commit, `graph_head:feature` → branch commit, parents + actors intact) with main's `__manifest` untouched. This empirically resolves the open question behind the merge robustness work: the fast-path `read_graph_lineage(dataset)` has no `manifest_branch` filter, but `__manifest` is Lance-branched per graph-branch, so a branch reads only its own lineage — the test confirms migrating one branch does not leak into another. No branch filter is needed. * refactor(engine): type the lineage-backfill merge conflict via the publisher classifier `state::merge_lineage_rows` (the v3→v4 lineage backfill's standalone `__manifest` merge-insert) stringified its `execute_reader` error, discarding the Lance variant. Route it through the publisher's `map_lance_publish_error` (now `pub(crate)`) so a concurrent first-open's row-level CAS loss surfaces as the SAME typed `OmniError::Manifest{ details: RowLevelCasContention }` the publisher's own retry consumes — one vocabulary, no raw-Lance matching in the migration. Deliberately NOT unified with `optimize::is_retryable_lance_conflict`: that classifier also matches `CommitConflict`/`RetryableCommitConflict` from the compaction commit path, which a row-level merge-insert never emits. Cross-linked with a comment at both sites. Behavior-preserving: the only path that changes is the error TYPE on a CAS loss (previously an opaque `Lance` string, now a typed conflict); no success/failure outcome changes. The bounded re-open retry that consumes the new type lands next. * test(engine): red — concurrent v3→v4 migrations error instead of converging `migrate_v2_to_v3` is concurrent-runner idempotent by design; v3→v4 regressed it. `merge_lineage_rows` uses `conflict_retries(0)` and `migrate_v3_to_v4` has no app-level retry, so when two processes open the same legacy graph at once the backfill's row-level CAS loser errors the whole open instead of converging. The test opens two `__manifest` handles at the same pre-migration (v3, empty-lineage) HEAD and runs both `migrate_internal_schema` calls under `tokio::join!`, forcing the `graph_head:main` CAS to fire every run. Against the current code the loser fails with `RowLevelCasContention` ("Attempted 0 retries.") — the predicted symptom — so the "both must converge" assertion panics. The bounded re-open retry that makes both converge lands next. * fix(engine): make the v3→v4 lineage backfill converge under concurrent runners `migrate_v2_to_v3` is concurrent-runner idempotent; v3→v4 was not. Two processes (or open-for-write handles) opening the same legacy graph at once both reach the backfill merge, and `merge_lineage_rows`'s `conflict_retries(0)` made the row-level CAS loser error the whole open instead of converging. Two contention points, both now handled all-or-nothing: 1. The backfill merge on `graph_head:<branch>`. Wrap (fast-path re-read → read legacy → merge) in a bounded re-open retry loop: a `RowLevelCasContention` loss re-opens the manifest past the winner's (atomic) commit and re-loops; the fast-path re-read then sees the winner's lineage and stamps. On budget exhaustion it returns a `RowLevelCasContention`-typed error so the publisher's OUTER retry loop completes it. The retry decision reuses the publisher's `is_retryable_publish_conflict` so the two stay in lockstep. 2. The terminal stamp bump. Making the merge loser converge newly lets BOTH runners reach `set_stamp(4)` — an `UpdateConfig` commit on the same key — so the loser gets `lance::Error::IncompatibleTransaction` (NOT a row-level CAS, so the merge loop doesn't catch it). This surfaced only under the concurrent full-suite run, not the isolated test. Both write the SAME value, so the conflict is benign: `commit_v4_stamp_idempotently` re-opens and, if the stamp already reached the target, succeeds; else re-applies (bounded). Greens the race test from the previous commit (3x isolated, 5x full-suite, no flake). The new `IncompatibleTransaction` match is pinned by `lance_surface_guards.rs::lance_error_incompatible_transaction_variant_exists`. * fix(engine): refuse a future internal-schema stamp on the branch read path `load_commit_cache_for_branch` dispatched on the branch's internal-schema stamp — `< CURRENT` to the v3 legacy fallback, `>= CURRENT` to the manifest projection — but never refused a `> CURRENT` branch stamp, so a newer-binary shape would be misread by the projection rather than rejected. Add `refuse_if_stamp_too_new(stamp)` (re-exported `pub(crate)` from `migrations`) right after the branch stamp is read, mirroring the main read path's `refuse_if_internal_schema_too_new`. This is defense-in-depth, not a live hole: migrations run main-first (main migrates on open; each branch on its first write), so main's stamp is always >= every branch's and the main path refuses first. The guard closes the gap if that ordering invariant is ever weakened. Tested by force-stamping a real branch past CURRENT and asserting the branch read refuses with the upgrade error (the test misreads via the projection — returns Ok — without the guard, confirmed by removing it). * docs(rfc-013): record the v3→v4 migration robustness fixes invariants.md Known Gaps: the `migrate_v3_to_v4` entry now states the migration is loud on non-not-found legacy-open errors and concurrent-runner idempotent (bounded re-open retry on the merge CAS + idempotent stamp bump), and that the branch read path refuses a `> CURRENT` stamp. lance.md: note the two new surface guards the migration depends on (`dataset_open_missing_returns_not_found_variant`, `lance_error_incompatible_transaction_variant_exists`). testing.md: note the migration fault-injection test in the failpoints row. * refactor: remove dead code and silence warnings across engine + cluster Dead-code sweep follow-up to the RFC-013 stack. No behavior change. - engine: delete the orphaned `validate_edge_cardinality` — the load path uses `validate_edge_cardinality_with_pending_loader` for every mode (including Overwrite, which it treats as the replacement table image), so the old standalone validator had no caller — and correct its sibling's now-stale doc reference. Gate `TableStore::append_batch` `#[cfg(test)]`: it is the inline- commit residual kept only for recovery test setup, with no non-test caller. - cluster: drop unused imports in `lib.rs`, delete the unused `ClusterStore::payload_display`, and raise `LiveGraphObservation` / `GraphObservationJson` / `PolicyTarget` to `pub(crate)` to match the functions that return them. Both lib crates now build warning-free. * fix(engine): match Lance's typed DatasetAlreadyExists, not the message string The internal create-or-open idempotency fallbacks in `db/commit_graph.rs` and `db/recovery_audit.rs` classified the "already exists" race by `err.to_string().contains("Dataset already exists")` — a Lance display string, not an API contract. A wording change upstream would silently break the fallback (a re-create would error instead of opening the existing table). Match the typed `lance::Error::DatasetAlreadyExists { .. }` variant instead — the same discipline as the v3→v4 migration's not-found classifier — pinned by the new `lance_surface_guards.rs::lance_error_dataset_already_exists_variant_exists` guard so a Lance rename turns red instead of silently regressing. * refactor(engine): consolidate now_micros into one crate::db helper Four `fn now_micros() -> Result<i64>` copies (commit_graph, recovery_audit, graph_coordinator, manifest/graph) had already drifted: three mapped the clock error to `OmniError::manifest("...UNIX_EPOCH...")` while recovery_audit used `OmniError::manifest_internal("...unix epoch...")`. Replace all four with one `pub(crate) fn now_micros()` in `db/mod.rs` (the majority `manifest` variant), and repoint the eight call sites at `crate::db::now_micros()`. No test asserts on the failure message, so unifying the variant is behavior-safe; the timestamp-mapping contract can no longer fork across the rows it stamps. * refactor(engine): drop the dead snapshot param from roll_back_sidecar `roll_back_sidecar` took `snapshot: &Snapshot` only to discard it with `let _ = snapshot;` — rollbacks now always publish (the restored HEAD plus a recovery-commit lineage row), so the snapshot is never read to decide whether to skip a publish. Remove the parameter, the two call-site arguments, and the suppressor. A signature must not advertise inputs it does not consume. The `Snapshot` import stays — `process_sidecar`, `roll_forward_all`, and `record_audit_recovery_rollforward` still take it. * test(engine): red — open_at_branch wedges a branch on a missing commit-graph ref A v4 graph keeps its graph lineage in `__manifest` (RFC-013 Phase 7); the `_graph_commits.lance` branch ref is a derived artifact. An interrupted fork-reclaim or a `cleanup` race can drop that derived ref while the manifest lineage stays intact. Per invariants 7 + 15 a missing derived ref must not fail a logical read of the lineage. This wedge builds a real v4 `feature` branch (its `graph_head:feature` row in `__manifest`), force-deletes ONLY the `_graph_commits.lance` `feature` ref, then asserts the branch reads (`open_at_branch` / list-commits / `merge_base`) succeed from `__manifest` while a write that needs the derived ref (`create_branch`) fails loudly with the typed actionable error. Red against current code: `open_at_branch`'s hard `checkout_branch(branch)?` on the missing ref errors `OmniError::Lance` (Lance "Not found: _graph_commits.lance/tree/feature/_versions"), wedging the logical read. * fix(engine): read manifest lineage independent of the derived _graph_commits ref `CommitGraph::open_at_branch` did a hard `checkout_branch(branch)?` on the `_graph_commits.lance` branch ref before reading lineage — so a missing derived ref (an interrupted fork-reclaim, or a `cleanup` race) wedged the branch's commit-list / merge-base / snapshot resolution even though the lineage is readable from the authoritative `__manifest` (RFC-013 Phase 7). That is a derived/physical artifact failing a logical read — invariants 7 and 15. Make the held commits handle `Option<Dataset>` (mirroring `actor_dataset`). `open_at_branch` and `refresh` check out the derived ref best-effort: a typed not-found (`RefNotFound`/`NotFound`) yields a `None` handle while the read re-syncs from `__manifest`; any other open error still propagates. The manifest existence gate is unchanged — `load_commit_cache_for_branch` keeps its hard `?`, so a truly absent branch still fails loudly at the manifest. `create_branch` (the only writer that forks a ref) and the folded-in version lookup return a loud, actionable error on `None`, deferring repair to `cleanup`'s existing orphan reconciler rather than inlining a write on a read-side refresh. Reads (`head_commit`/`load_commits`/`get_commit`/`merge_base`) never touch the handle. Greens the wedge regression from the preceding commit. * fix(engine): v3→v4 retry loops return retryable contention on exhaustion `commit_v4_stamp_idempotently`'s retry loop used `0..=STAMP_RETRY_BUDGET` (6 iterations) with an `attempt < STAMP_RETRY_BUDGET` guard, so the LAST iteration's `IncompatibleTransaction` fell through to `Err(e) => OmniError::Lance(...)` — stringified, non-retryable — instead of the intended `RowLevelCasContention`, and the post-loop contention return was dead code. The publisher's outer retry only re-runs `is_retryable_publish_conflict`, so under sustained concurrent v3→v4 migration the one-time stamp bump could fail instead of converging, defeating the idempotency the migration is supposed to add. Fix the loop to `0..BUDGET` with an UNGUARDED `IncompatibleTransaction` arm: the retryable variant is always handled inside the loop (re-open + same-value check + retry), so it can never reach the stringifying catch-all, and the post-loop is the SINGLE reachable exhaustion path — the typed `RowLevelCasContention`. The `Err(e)` arm now catches only genuine non-contention errors. Apply the same range alignment to the sibling merge loop in `migrate_v3_to_v4` (behaviorally correct today — its `Err(err)` returns the already-typed contention — but it carried the identical off-by-one structure the stamp loop was copied from; aligning both stops the next copy from re-introducing it). Test-first. The exhaustion path is otherwise near-unreachable — a real concurrent winner stamps the same value, so the re-read returns Ok on the first retry — so a new `migration.v4_stamp.force_incompatible` failpoint forces every stamp attempt to lose, driving exhaustion deterministically. Against the pre-fix loop the new `v4_stamp_exhaustion_returns_retryable_contention` test goes red with `Lance("Incompatible transaction: injected failpoint triggered…")`; with the fix it asserts the typed `RowLevelCasContention`. Found by automated review on #299. * feat(engine): minimum-supported internal-schema floor + retirement tripwire The internal-schema migration chain (`migrate_internal_schema`) had a too-new ceiling but no floor, so every old `migrate_vN_…` arm and the v3 legacy readers it needs stay forever — the pile grows by one migration + readers + tests every schema version. Add `MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION` (1 today, a pure no-op: `read_stamp` floors an absent stamp at 1 and no real graph carries 0) as the oldest stamp this binary opens; raising it is how the chain sheds old code. Collapse the one-sided `refuse_if_stamp_too_new` into `refuse_if_stamp_unsupported` checking both bounds, so the floor lands at all three stamp-enforcement sites — the write-path migrate dispatcher, the read-only open guard, and the branch lineage-read path (`commit_graph.rs`) — via one compiler-enforced rename. A hand-wired floor twin would have had to touch each site, and the branch-read path is easy to miss; one combined guard cannot half-enforce. Rename the read-only wrapper `refuse_if_internal_schema_unsupported` to match. A compile-time tripwire (`const _: () = assert!(LOWEST_REGISTERED_MIGRATION_SOURCE == MIN_SUPPORTED…)`) fails the build if a future floor bump forgets to delete the now-dead migration arm (or vice versa) — stronger than a runtime test, impossible to skip, and it doubles as the use that keeps the mirror const live. Tests: a sub-floor graph is refused in both open modes (twin of `future_stamp_is_refused_in_both_open_modes`); the guard accepts exactly [MIN, CURRENT]. No behavior change for any real graph. The retirement runbook lives on the `MIN_SUPPORTED` doc-comment + invariants.md. * fix(engine): compose migration contention with publisher retry; precise recovery-converge audit commit Three review-surfaced fixes on the RFC-013 Phase 7 path. Publisher retry vs migration contention: `publish()` propagated a `load_publish_state` error fatally via `?`, so a `RowLevelCasContention` surfaced by the v3->v4 migration's exhausted merge/stamp budgets aborted the publish instead of being retried — only `merge_rows` conflicts hit the retry. This contradicted the migration's own design, which returns that typed error EXPECTING the publisher to re-run the load (by which point a concurrent winner has usually finished the migration, so the next scan is a no-op). Route a retryable load error through the same retry path as a retryable `merge_rows` conflict. Regression test (failpoints): a one-shot retryable contention injected into `load_publish_state` now commits via the retry; red without the fix (the write fails with the injected contention). Recovery-converge audit commit id: `converge_or_defer_roll_forward` recorded the branch HEAD as the audit row's `graph_commit_id`, but a concurrent user write can advance `graph_head` past the recovery commit between the winner's publish and this read — attributing the audit to a later, wrong commit. Use the latest `RECOVERY_ACTOR`-authored commit (what `publish_recovery_commit` mints), which is the recovery commit by construction. The audit's actor was already correct (it comes from `sidecar.actor_id`, not the commit). Dead param: drop the unused `snapshot` from `record_audit_recovery_rollforward` (removing the `let _ = snapshot;` suppressor). `storage` stays — it is used to delete the sidecar.
2073 lines
77 KiB
Rust
2073 lines
77 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs::{self};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use omnigraph::db::{Omnigraph, ReadTarget, SchemaApplyOptions};
|
|
use omnigraph_compiler::SchemaMigrationPlan;
|
|
use omnigraph_compiler::build_catalog;
|
|
use omnigraph_compiler::query::parser::parse_query;
|
|
use omnigraph_compiler::query::typecheck::typecheck_query_decl;
|
|
use omnigraph_compiler::schema::parser::parse_schema;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
use ulid::Ulid;
|
|
|
|
pub mod failpoints;
|
|
|
|
mod config;
|
|
mod diff;
|
|
mod serve;
|
|
mod store;
|
|
mod sweep;
|
|
mod types;
|
|
use config::{
|
|
QueriesDecl, graph_address, initial_import_state, load_desired, observe_declared_graphs, parse_cluster_config, preview_schema_migration, schema_address, state_resource_digests, validate_cluster_header,
|
|
};
|
|
use diff::{
|
|
FailedGraphOrigin, ResourceKind, append_embedding_profile_changes,
|
|
append_policy_binding_changes, approved_resources, classify_changes, compute_approvals,
|
|
compute_blast_radius, demote_dependents_of_failed_graphs, diff_resources, resource_kind,
|
|
};
|
|
pub use serve::{
|
|
ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_graph_ids,
|
|
cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage,
|
|
resolve_graph_storage_uri,
|
|
};
|
|
use store::ClusterStore;
|
|
use sweep::{
|
|
mark_approvals_consumed, record_approval_consumed, sweep_recovery_sidecars,
|
|
tombstone_graph_subtree, warn_pending_recovery_sidecars,
|
|
};
|
|
pub use types::*;
|
|
|
|
pub const CLUSTER_CONFIG_FILE: &str = "cluster.yaml";
|
|
pub const CLUSTER_GRAPHS_DIR: &str = "graphs";
|
|
pub const CLUSTER_STATE_DIR: &str = "__cluster";
|
|
pub const CLUSTER_STATE_FILE: &str = "__cluster/state.json";
|
|
pub const CLUSTER_LOCK_FILE: &str = "__cluster/lock.json";
|
|
pub const CLUSTER_RESOURCES_DIR: &str = "__cluster/resources";
|
|
pub const CLUSTER_RECOVERIES_DIR: &str = "__cluster/recoveries";
|
|
pub const CLUSTER_APPROVALS_DIR: &str = "__cluster/approvals";
|
|
|
|
/// The store for a load outcome: the declared `storage:` root when present,
|
|
/// the config directory itself otherwise. A bad root is a loud error.
|
|
fn store_for(config_dir: &Path, storage_root: Option<&str>) -> Result<ClusterStore, Diagnostic> {
|
|
match storage_root {
|
|
Some(root) => ClusterStore::for_storage_root(root),
|
|
None => Ok(ClusterStore::for_config_dir(config_dir)),
|
|
}
|
|
}
|
|
|
|
pub fn validate_config_dir(config_dir: impl AsRef<Path>) -> ValidateOutput {
|
|
let outcome = load_desired(config_dir.as_ref());
|
|
let (resource_digests, resources, dependencies) = match outcome.desired {
|
|
Some(desired) => (
|
|
desired.resource_digests,
|
|
desired.resources,
|
|
desired.dependencies,
|
|
),
|
|
None => (BTreeMap::new(), Vec::new(), Vec::new()),
|
|
};
|
|
let ok = !has_errors(&outcome.diagnostics);
|
|
|
|
ValidateOutput {
|
|
ok,
|
|
config_dir: display_path(&outcome.config_dir),
|
|
config_file: display_path(&outcome.config_file),
|
|
resource_digests,
|
|
resources,
|
|
dependencies,
|
|
diagnostics: outcome.diagnostics,
|
|
}
|
|
}
|
|
|
|
pub async fn plan_config_dir(config_dir: impl AsRef<Path>) -> PlanOutput {
|
|
let outcome = load_desired(config_dir.as_ref());
|
|
let mut diagnostics = outcome.diagnostics;
|
|
let storage_root = outcome
|
|
.desired
|
|
.as_ref()
|
|
.and_then(|desired| desired.storage_root.clone());
|
|
let backend = match store_for(&outcome.config_dir, storage_root.as_deref()) {
|
|
Ok(backend) => backend,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
ClusterStore::for_config_dir(&outcome.config_dir)
|
|
}
|
|
};
|
|
let mut observations = backend.observations();
|
|
|
|
let Some(desired) = outcome.desired else {
|
|
return PlanOutput {
|
|
ok: false,
|
|
config_dir: display_path(&outcome.config_dir),
|
|
desired_revision: DesiredRevision {
|
|
config_digest: None,
|
|
},
|
|
resource_digests: BTreeMap::new(),
|
|
dependencies: Vec::new(),
|
|
state_observations: observations,
|
|
changes: Vec::new(),
|
|
blast_radius: Vec::new(),
|
|
approvals_required: Vec::new(),
|
|
diagnostics,
|
|
};
|
|
};
|
|
|
|
if has_errors(&diagnostics) {
|
|
return PlanOutput {
|
|
ok: false,
|
|
config_dir: display_path(&desired.config_dir),
|
|
desired_revision: DesiredRevision {
|
|
config_digest: Some(desired.config_digest),
|
|
},
|
|
resource_digests: desired.resource_digests,
|
|
dependencies: desired.dependencies,
|
|
state_observations: observations,
|
|
changes: Vec::new(),
|
|
blast_radius: Vec::new(),
|
|
approvals_required: Vec::new(),
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
let _lock_guard = if desired.state_lock {
|
|
match backend.acquire_lock("plan", &mut observations).await {
|
|
Ok(guard) => Some(guard),
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"state_lock_disabled",
|
|
"state.lock",
|
|
"state.lock is false; plan read state without acquiring the cluster state lock",
|
|
));
|
|
None
|
|
};
|
|
|
|
// Plan is read-only: pending sidecars are reported, never acted on
|
|
// (RFC-004 open question 3 keeps read-only commands warn-only).
|
|
warn_pending_recovery_sidecars(&backend, &mut diagnostics).await;
|
|
|
|
let mut prior_resources = BTreeMap::new();
|
|
let mut prior_state: Option<ClusterState> = None;
|
|
if !has_errors(&diagnostics) {
|
|
match backend.read_state(&mut observations).await {
|
|
Ok(snapshot) => {
|
|
if let Some(state) = snapshot.state {
|
|
prior_resources = state_resource_digests(&state);
|
|
prior_state = Some(state);
|
|
}
|
|
}
|
|
Err(diagnostic) => diagnostics.push(diagnostic),
|
|
}
|
|
}
|
|
|
|
let mut changes = if has_errors(&diagnostics) {
|
|
Vec::new()
|
|
} else {
|
|
diff_resources(&prior_resources, &desired.resource_digests)
|
|
};
|
|
if !has_errors(&diagnostics) {
|
|
append_policy_binding_changes(&mut changes, prior_state.as_ref(), &desired);
|
|
append_embedding_profile_changes(&mut changes, prior_state.as_ref(), &desired);
|
|
}
|
|
// Plan previews dispositions without sweeping; a pending recovery is
|
|
// surfaced as the cluster_recovery_pending warning above instead.
|
|
let artifacts = backend.list_approval_artifacts(&mut diagnostics).await;
|
|
let approved = approved_resources(
|
|
&artifacts,
|
|
&changes,
|
|
&desired.config_digest,
|
|
&mut diagnostics,
|
|
);
|
|
classify_changes(
|
|
&mut changes,
|
|
&desired.dependencies,
|
|
&BTreeSet::new(),
|
|
&approved,
|
|
);
|
|
|
|
// Embed real migration steps for schema updates so plan is a data-aware
|
|
// preview; failures degrade to the digest diff with a warning.
|
|
for change in &mut changes {
|
|
if change.operation != PlanOperation::Update {
|
|
continue;
|
|
}
|
|
let ResourceKind::Schema(graph_id) = resource_kind(&change.resource) else {
|
|
continue;
|
|
};
|
|
let graph_uri = backend.graph_root(&graph_id);
|
|
let source_path = desired
|
|
.resources
|
|
.iter()
|
|
.find(|resource| resource.address == change.resource)
|
|
.and_then(|resource| resource.path.clone());
|
|
let preview = match source_path {
|
|
Some(path) => preview_schema_migration(&graph_uri, &path).await,
|
|
None => Err("no schema source recorded".to_string()),
|
|
};
|
|
match preview {
|
|
Ok(migration) => change.migration = Some(migration),
|
|
Err(err) => diagnostics.push(Diagnostic::warning(
|
|
"schema_preview_unavailable",
|
|
change.resource.clone(),
|
|
format!("could not preview the schema migration: {err}"),
|
|
)),
|
|
}
|
|
}
|
|
let blast_radius = compute_blast_radius(&changes, &desired.dependencies);
|
|
let approvals_required = compute_approvals(&changes, &approved);
|
|
let ok = !has_errors(&diagnostics);
|
|
|
|
PlanOutput {
|
|
ok,
|
|
config_dir: display_path(&desired.config_dir),
|
|
desired_revision: DesiredRevision {
|
|
config_digest: Some(desired.config_digest),
|
|
},
|
|
resource_digests: desired.resource_digests,
|
|
dependencies: desired.dependencies,
|
|
state_observations: observations,
|
|
changes,
|
|
blast_radius,
|
|
approvals_required,
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
/// Config-only `cluster apply` (Stage 3A): execute the query/policy subset of
|
|
/// the plan against the local cluster catalog. The plan is recomputed under
|
|
/// the state lock, so freshness is structural; the state CAS inside
|
|
/// `write_state` is the second fence. Graph/schema changes are never executed
|
|
/// here — they are deferred to the graph-lifecycle phase and reported loudly.
|
|
///
|
|
/// Payloads are content-addressed and written BEFORE the state CAS because
|
|
/// state is the publish point: a failure after payload writes leaves inert
|
|
/// digest-named blobs and no success acknowledgement; re-running apply is the
|
|
/// repair.
|
|
/// Options for `cluster apply`. `actor` attributes graph-moving operations
|
|
/// (recorded in sidecars and audit entries, threaded to the engine's
|
|
/// `apply_schema_as` so Cedar enforcement fires wherever a policy checker is
|
|
/// installed).
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ApplyOptions {
|
|
pub actor: Option<String>,
|
|
}
|
|
|
|
pub async fn apply_config_dir(config_dir: impl AsRef<Path>) -> ApplyOutput {
|
|
apply_config_dir_with_options(config_dir, ApplyOptions::default()).await
|
|
}
|
|
|
|
pub async fn apply_config_dir_with_options(
|
|
config_dir: impl AsRef<Path>,
|
|
options: ApplyOptions,
|
|
) -> ApplyOutput {
|
|
let outcome = load_desired(config_dir.as_ref());
|
|
let mut diagnostics = outcome.diagnostics;
|
|
let storage_root = outcome
|
|
.desired
|
|
.as_ref()
|
|
.and_then(|desired| desired.storage_root.clone());
|
|
let backend = match store_for(&outcome.config_dir, storage_root.as_deref()) {
|
|
Ok(backend) => backend,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
ClusterStore::for_config_dir(&outcome.config_dir)
|
|
}
|
|
};
|
|
let mut observations = backend.observations();
|
|
|
|
let actor_for_output = options.actor.clone();
|
|
let early_return = |config_dir: String,
|
|
config_digest: Option<String>,
|
|
observations: StateObservations,
|
|
changes: Vec<PlanChange>,
|
|
resource_statuses: BTreeMap<String, ResourceStatusRecord>,
|
|
diagnostics: Vec<Diagnostic>| {
|
|
ApplyOutput {
|
|
ok: !has_errors(&diagnostics),
|
|
config_dir,
|
|
actor: actor_for_output.clone(),
|
|
desired_revision: DesiredRevision { config_digest },
|
|
state_observations: observations,
|
|
changes,
|
|
applied_count: 0,
|
|
deferred_count: 0,
|
|
converged: false,
|
|
state_written: false,
|
|
resource_statuses,
|
|
diagnostics,
|
|
}
|
|
};
|
|
|
|
let Some(desired) = outcome.desired else {
|
|
return early_return(
|
|
display_path(&outcome.config_dir),
|
|
None,
|
|
observations,
|
|
Vec::new(),
|
|
BTreeMap::new(),
|
|
diagnostics,
|
|
);
|
|
};
|
|
|
|
if has_errors(&diagnostics) {
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
Vec::new(),
|
|
BTreeMap::new(),
|
|
diagnostics,
|
|
);
|
|
}
|
|
|
|
// Named guard: the lock must be held until the state outcome is recorded.
|
|
let _lock_guard = if desired.state_lock {
|
|
match backend.acquire_lock("apply", &mut observations).await {
|
|
Ok(guard) => Some(guard),
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"state_lock_disabled",
|
|
"state.lock",
|
|
"state.lock is false; apply wrote state without acquiring the cluster state lock",
|
|
));
|
|
None
|
|
};
|
|
|
|
if has_errors(&diagnostics) {
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
Vec::new(),
|
|
BTreeMap::new(),
|
|
diagnostics,
|
|
);
|
|
}
|
|
|
|
let snapshot = match backend.read_state(&mut observations).await {
|
|
Ok(snapshot) => snapshot,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
Vec::new(),
|
|
BTreeMap::new(),
|
|
diagnostics,
|
|
);
|
|
}
|
|
};
|
|
let expected_cas = snapshot.state_cas;
|
|
let Some(mut state) = snapshot.state else {
|
|
diagnostics.push(Diagnostic::error(
|
|
"state_missing",
|
|
CLUSTER_STATE_FILE,
|
|
"apply requires an existing state.json; run `cluster import` to bootstrap state",
|
|
));
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
Vec::new(),
|
|
BTreeMap::new(),
|
|
diagnostics,
|
|
);
|
|
};
|
|
|
|
// Snapshot the as-read state BEFORE the sweep so sweep mutations count as
|
|
// changes for the final dirty check and get persisted by the state CAS.
|
|
let before_value =
|
|
serde_json::to_value(&state).expect("cluster state must serialize deterministically");
|
|
let sweep = sweep_recovery_sidecars(&backend, &mut state, &mut diagnostics).await;
|
|
|
|
let prior_resources = state_resource_digests(&state);
|
|
let mut changes = diff_resources(&prior_resources, &desired.resource_digests);
|
|
append_policy_binding_changes(&mut changes, Some(&state), &desired);
|
|
append_embedding_profile_changes(&mut changes, Some(&state), &desired);
|
|
let approval_artifacts = backend.list_approval_artifacts(&mut diagnostics).await;
|
|
let approved = approved_resources(
|
|
&approval_artifacts,
|
|
&changes,
|
|
&desired.config_digest,
|
|
&mut diagnostics,
|
|
);
|
|
classify_changes(
|
|
&mut changes,
|
|
&desired.dependencies,
|
|
&sweep.pending_graphs,
|
|
&approved,
|
|
);
|
|
|
|
// Defensive invariant: nothing the approval gate covers may be executable
|
|
// WITHOUT a matching approval. Gated changes with a valid artifact are the
|
|
// sanctioned exception (stage 4C).
|
|
let approvals = compute_approvals(&changes, &approved);
|
|
let approval_violation = changes.iter().any(|change| {
|
|
change.disposition == Some(ApplyDisposition::Applied)
|
|
&& approvals
|
|
.iter()
|
|
.any(|approval| approval.resource == change.resource && !approval.satisfied)
|
|
});
|
|
if approval_violation {
|
|
diagnostics.push(Diagnostic::error(
|
|
"apply_approval_invariant_violation",
|
|
"changes",
|
|
"an executable change requires approval; refusing to apply",
|
|
));
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
changes,
|
|
state.resource_statuses,
|
|
diagnostics,
|
|
);
|
|
}
|
|
|
|
// Graph creates execute first (RFC-004 §D5), sequentially, sidecar-fenced:
|
|
// sidecar written before the init, rewritten with the post-init manifest
|
|
// version, deleted only after the final state CAS lands. A failure stops
|
|
// further graph-moving work and demotes that graph's dependents.
|
|
let source_paths: BTreeMap<&str, &str> = desired
|
|
.resources
|
|
.iter()
|
|
.filter_map(|resource| {
|
|
resource
|
|
.path
|
|
.as_deref()
|
|
.map(|path| (resource.address.as_str(), path))
|
|
})
|
|
.collect();
|
|
let graph_creates_to_run: Vec<String> = changes
|
|
.iter()
|
|
.filter(|change| {
|
|
change.disposition == Some(ApplyDisposition::Applied)
|
|
&& change.operation == PlanOperation::Create
|
|
&& matches!(resource_kind(&change.resource), ResourceKind::Graph(_))
|
|
})
|
|
.filter_map(|change| change.resource.strip_prefix("graph.").map(str::to_string))
|
|
.collect();
|
|
let mut completed_op_sidecars: Vec<String> = Vec::new();
|
|
let mut failed_graphs: BTreeMap<String, FailedGraphOrigin> = BTreeMap::new();
|
|
let mut graph_moving_aborted = false;
|
|
for graph_id in &graph_creates_to_run {
|
|
if graph_moving_aborted {
|
|
// A prior create failed: stop graph-moving work (loud partials).
|
|
diagnostics.push(Diagnostic::warning(
|
|
"graph_create_skipped",
|
|
graph_address(graph_id),
|
|
"skipped after an earlier graph create failed in this run",
|
|
));
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphCreate);
|
|
continue;
|
|
}
|
|
let Some(desired_graph) = desired.graphs.iter().find(|graph| &graph.id == graph_id) else {
|
|
continue;
|
|
};
|
|
let graph_uri = backend.graph_root(graph_id);
|
|
let mut sidecar = RecoverySidecar {
|
|
schema_version: 1,
|
|
operation_id: Ulid::new().to_string(),
|
|
started_at: now_rfc3339(),
|
|
actor: options.actor.clone(),
|
|
kind: RecoverySidecarKind::GraphCreate,
|
|
graph_id: graph_id.clone(),
|
|
graph_uri: graph_uri.clone(),
|
|
observed_manifest_version: None,
|
|
expected_manifest_version: None,
|
|
desired_schema_digest: desired_graph.schema_digest.clone(),
|
|
state_cas_base: expected_cas.clone(),
|
|
approval_id: None,
|
|
};
|
|
let sidecar_path = match backend.write_recovery_sidecar(&sidecar).await {
|
|
Ok(path) => path,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphCreate);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
};
|
|
if let Err(diagnostic) = failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_BEFORE_GRAPH_CREATE) {
|
|
// Simulated crash before the init: the sidecar stays for the
|
|
// sweep (row 1: root absent -> intent removed next run).
|
|
diagnostics.push(diagnostic);
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphCreate);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
// Re-read + re-verify the schema source under the lock — the same
|
|
// TOCTOU posture as write_resource_payload.
|
|
let schema_source = source_paths
|
|
.get(schema_address(graph_id).as_str())
|
|
.ok_or_else(|| {
|
|
Diagnostic::error(
|
|
"graph_create_failed",
|
|
graph_address(graph_id),
|
|
"no schema source recorded for graph",
|
|
)
|
|
})
|
|
.and_then(|path| {
|
|
fs::read_to_string(Path::new(path)).map_err(|err| {
|
|
Diagnostic::error(
|
|
"graph_create_failed",
|
|
graph_address(graph_id),
|
|
format!("could not read schema source '{path}': {err}"),
|
|
)
|
|
})
|
|
})
|
|
.and_then(|source| {
|
|
if sha256_hex(source.as_bytes()) == desired_graph.schema_digest {
|
|
Ok(source)
|
|
} else {
|
|
Err(Diagnostic::error(
|
|
"resource_content_changed",
|
|
schema_address(graph_id),
|
|
"schema source changed while apply was running; re-run `cluster apply`",
|
|
))
|
|
}
|
|
});
|
|
let schema_source = match schema_source {
|
|
Ok(source) => source,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
backend.delete_object(&sidecar_path).await; // nothing moved
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphCreate);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
};
|
|
match Omnigraph::init(&graph_uri, &schema_source).await {
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"graph_create_failed",
|
|
graph_address(graph_id),
|
|
format!("could not initialize graph at '{graph_uri}': {err}"),
|
|
));
|
|
// The sidecar stays: the sweep classifies whether the failed
|
|
// init left a partial root (row 5) or nothing (row 1).
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphCreate);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
}
|
|
// Record the post-init pin in the sidecar (best effort — a failure
|
|
// here leaves expected = null and the sweep classifies by digest).
|
|
if let Ok(db) = Omnigraph::open_read_only(&graph_uri).await {
|
|
if let Ok(snapshot) = db.snapshot_of(ReadTarget::branch("main")).await {
|
|
sidecar.expected_manifest_version = Some(snapshot.version());
|
|
if let Err(diagnostic) = backend.write_recovery_sidecar(&sidecar).await {
|
|
diagnostics.push(diagnostic);
|
|
}
|
|
}
|
|
}
|
|
// Crash point: the graph exists, the cluster state does not record it
|
|
// yet. A failure here must acknowledge nothing; the next run's sweep
|
|
// rolls the ledger forward (row 4).
|
|
if let Err(diagnostic) = failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_AFTER_GRAPH_CREATE) {
|
|
diagnostics.push(diagnostic);
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
changes,
|
|
state.resource_statuses,
|
|
diagnostics,
|
|
);
|
|
}
|
|
completed_op_sidecars.push(sidecar_path);
|
|
}
|
|
|
|
// Schema applies execute next (RFC-004 §D5): the first cluster operation
|
|
// that moves an EXISTING graph manifest, sidecar-fenced the same way.
|
|
let schema_updates_to_run: Vec<String> = changes
|
|
.iter()
|
|
.filter(|change| {
|
|
change.disposition == Some(ApplyDisposition::Applied)
|
|
&& change.operation == PlanOperation::Update
|
|
&& matches!(resource_kind(&change.resource), ResourceKind::Schema(_))
|
|
})
|
|
.filter_map(|change| change.resource.strip_prefix("schema.").map(str::to_string))
|
|
.collect();
|
|
for graph_id in &schema_updates_to_run {
|
|
if graph_moving_aborted {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"schema_apply_skipped",
|
|
schema_address(graph_id),
|
|
"skipped after an earlier graph-moving operation failed in this run",
|
|
));
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::SchemaApply);
|
|
continue;
|
|
}
|
|
let Some(desired_graph) = desired.graphs.iter().find(|graph| &graph.id == graph_id) else {
|
|
continue;
|
|
};
|
|
let graph_uri = backend.graph_root(graph_id);
|
|
// Read-write open: the engine's own recovery sweep runs here, which
|
|
// is exactly what we want before moving its manifest.
|
|
let db = match Omnigraph::open(&graph_uri).await {
|
|
Ok(db) => db,
|
|
Err(err) => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"schema_apply_failed",
|
|
schema_address(graph_id),
|
|
format!("could not open graph at '{graph_uri}': {err}"),
|
|
));
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::SchemaApply);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
};
|
|
// Re-read + digest-verify the desired schema source before the
|
|
// cluster sidecar exists. Parser/planner rejections cannot have
|
|
// moved graph state, so they must not leave recovery work behind.
|
|
let schema_source = source_paths
|
|
.get(schema_address(graph_id).as_str())
|
|
.ok_or_else(|| {
|
|
Diagnostic::error(
|
|
"schema_apply_failed",
|
|
schema_address(graph_id),
|
|
"no schema source recorded for graph",
|
|
)
|
|
})
|
|
.and_then(|path| {
|
|
fs::read_to_string(Path::new(path)).map_err(|err| {
|
|
Diagnostic::error(
|
|
"schema_apply_failed",
|
|
schema_address(graph_id),
|
|
format!("could not read schema source '{path}': {err}"),
|
|
)
|
|
})
|
|
})
|
|
.and_then(|source| {
|
|
if sha256_hex(source.as_bytes()) == desired_graph.schema_digest {
|
|
Ok(source)
|
|
} else {
|
|
Err(Diagnostic::error(
|
|
"resource_content_changed",
|
|
schema_address(graph_id),
|
|
"schema source changed while apply was running; re-run `cluster apply`",
|
|
))
|
|
}
|
|
});
|
|
let schema_source = match schema_source {
|
|
Ok(source) => source,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::SchemaApply);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
};
|
|
if let Err(err) = db
|
|
.preview_schema_apply_with_options(&schema_source, SchemaApplyOptions::default())
|
|
.await
|
|
{
|
|
diagnostics.push(Diagnostic::error(
|
|
"schema_apply_failed",
|
|
schema_address(graph_id),
|
|
format!("schema apply is not supported on '{graph_uri}': {err}"),
|
|
));
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::SchemaApply);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
let observed_manifest_version = match db.snapshot_of(ReadTarget::branch("main")).await {
|
|
Ok(snapshot) => Some(snapshot.version()),
|
|
Err(_) => None,
|
|
};
|
|
let recorded_schema_digest = state
|
|
.applied_revision
|
|
.resources
|
|
.get(&schema_address(graph_id))
|
|
.map(|entry| entry.digest.clone());
|
|
let mut sidecar = RecoverySidecar {
|
|
schema_version: 1,
|
|
operation_id: Ulid::new().to_string(),
|
|
started_at: now_rfc3339(),
|
|
actor: options.actor.clone(),
|
|
kind: RecoverySidecarKind::SchemaApply,
|
|
graph_id: graph_id.clone(),
|
|
graph_uri: graph_uri.clone(),
|
|
observed_manifest_version,
|
|
expected_manifest_version: None,
|
|
desired_schema_digest: desired_graph.schema_digest.clone(),
|
|
state_cas_base: expected_cas.clone(),
|
|
approval_id: None,
|
|
};
|
|
let sidecar_path = match backend.write_recovery_sidecar(&sidecar).await {
|
|
Ok(path) => path,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::SchemaApply);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
};
|
|
if let Err(diagnostic) = failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_BEFORE_SCHEMA_APPLY) {
|
|
// Simulated crash before the engine call: the sidecar stays; the
|
|
// sweep retires it next run (ledger still consistent with live).
|
|
diagnostics.push(diagnostic);
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::SchemaApply);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
// Soft drops only: allow_data_loss stays false until the approval
|
|
// artifacts of stage 4C exist (RFC-004 §D4).
|
|
match db
|
|
.apply_schema_as(
|
|
&schema_source,
|
|
SchemaApplyOptions::default(),
|
|
options.actor.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
sidecar.expected_manifest_version = Some(result.manifest_version);
|
|
if let Err(diagnostic) = backend.write_recovery_sidecar(&sidecar).await {
|
|
diagnostics.push(diagnostic);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"schema_apply_failed",
|
|
schema_address(graph_id),
|
|
format!("schema apply failed on '{graph_uri}': {err}"),
|
|
));
|
|
if live_schema_matches_recorded_digest(
|
|
&graph_uri,
|
|
recorded_schema_digest.as_deref(),
|
|
observed_manifest_version,
|
|
)
|
|
.await
|
|
{
|
|
// Pre-movement rejection: nothing moved, so retire the
|
|
// sidecar eagerly. A delete failure leaves it safe (the
|
|
// graph is quarantined until the next sweep), but surface
|
|
// it so an operator isn't left debugging a silent stick.
|
|
if let Err(err) = backend.try_delete_object(&sidecar_path).await {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"recovery_sidecar_cleanup_failed",
|
|
sidecar_path.clone(),
|
|
format!(
|
|
"could not delete the stale recovery sidecar after a pre-movement \
|
|
schema-apply rejection; graph `{graph_id}` stays quarantined until \
|
|
a state-mutating cluster command sweeps it: {err}"
|
|
),
|
|
));
|
|
}
|
|
}
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::SchemaApply);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
}
|
|
// Crash point: the manifest moved, the ledger does not record it yet.
|
|
// A failure here acknowledges nothing; the sweep rolls forward.
|
|
if let Err(diagnostic) = failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_AFTER_SCHEMA_APPLY) {
|
|
diagnostics.push(diagnostic);
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
changes,
|
|
state.resource_statuses,
|
|
diagnostics,
|
|
);
|
|
}
|
|
completed_op_sidecars.push(sidecar_path);
|
|
}
|
|
|
|
if !failed_graphs.is_empty() {
|
|
demote_dependents_of_failed_graphs(&mut changes, &failed_graphs, &desired.dependencies);
|
|
}
|
|
|
|
for change in &changes {
|
|
match change.disposition {
|
|
Some(ApplyDisposition::Deferred) => diagnostics.push(Diagnostic::warning(
|
|
"apply_unsupported_change",
|
|
change.resource.clone(),
|
|
"graph/schema changes are not applied in this stage; they are deferred to the graph-lifecycle phase",
|
|
)),
|
|
Some(ApplyDisposition::Blocked) => diagnostics.push(Diagnostic::warning(
|
|
"apply_dependency_blocked",
|
|
change.resource.clone(),
|
|
format!(
|
|
"blocked by an unapplied or missing dependency ({})",
|
|
change.reason.as_deref().unwrap_or("dependency")
|
|
),
|
|
)),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Payload phase: content-addressed writes before the state CAS. Any
|
|
// failure aborts before state moves; blobs already written are inert.
|
|
// Gate on payload-phase errors only — sweep errors (e.g. a kept row-5
|
|
// sidecar) must not abort the run, or their statuses would never persist.
|
|
let errors_before_payloads = count_errors(&diagnostics);
|
|
for change in &changes {
|
|
if change.disposition != Some(ApplyDisposition::Applied)
|
|
|| change.operation == PlanOperation::Delete
|
|
{
|
|
continue;
|
|
}
|
|
let kind = resource_kind(&change.resource);
|
|
let digest = change
|
|
.after_digest
|
|
.as_deref()
|
|
.expect("create/update always carries an after digest");
|
|
if ClusterStore::payload_relative(&kind, digest).is_none() {
|
|
continue;
|
|
}
|
|
let Some(source) = source_paths.get(change.resource.as_str()) else {
|
|
diagnostics.push(Diagnostic::error(
|
|
"resource_payload_write_error",
|
|
change.resource.clone(),
|
|
"no source file recorded for resource",
|
|
));
|
|
continue;
|
|
};
|
|
if let Err(diagnostic) =
|
|
write_resource_payload(&backend, &kind, Path::new(source), digest, &change.resource)
|
|
.await
|
|
{
|
|
diagnostics.push(diagnostic);
|
|
}
|
|
}
|
|
if count_errors(&diagnostics) > errors_before_payloads {
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
changes,
|
|
state.resource_statuses,
|
|
diagnostics,
|
|
);
|
|
}
|
|
|
|
// Crash point: payloads are on disk, state has not moved. A failure here
|
|
// must leave state.json byte-identical and acknowledge nothing; re-running
|
|
// apply repairs via the skip-if-exists blob reuse.
|
|
if let Err(diagnostic) = failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_AFTER_PAYLOAD_PHASE) {
|
|
diagnostics.push(diagnostic);
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
changes,
|
|
state.resource_statuses,
|
|
diagnostics,
|
|
);
|
|
}
|
|
|
|
// Approved graph deletes execute LAST (RFC-004 §D5): catalog writes for
|
|
// surviving resources land first, then the irreversible work.
|
|
let graph_deletes_to_run: Vec<String> = changes
|
|
.iter()
|
|
.filter(|change| {
|
|
change.disposition == Some(ApplyDisposition::Applied)
|
|
&& change.operation == PlanOperation::Delete
|
|
&& matches!(resource_kind(&change.resource), ResourceKind::Graph(_))
|
|
})
|
|
.filter_map(|change| change.resource.strip_prefix("graph.").map(str::to_string))
|
|
.collect();
|
|
let mut executed_deletes: Vec<(String, Option<String>)> = Vec::new(); // (graph_id, approval_id)
|
|
let mut consumed_approval_ids: Vec<String> = Vec::new();
|
|
for graph_id in &graph_deletes_to_run {
|
|
if graph_moving_aborted {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"graph_delete_skipped",
|
|
graph_address(graph_id),
|
|
"skipped after an earlier graph-moving operation failed in this run",
|
|
));
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphDelete);
|
|
continue;
|
|
}
|
|
let graph_addr = graph_address(graph_id);
|
|
// Re-locate the consumable approval (classification verified one exists).
|
|
let approval_id = approval_artifacts
|
|
.iter()
|
|
.map(|(_, artifact)| artifact)
|
|
.find(|artifact| {
|
|
artifact.consumed_at.is_none()
|
|
&& artifact.resource == graph_addr
|
|
&& artifact.bound_config_digest == desired.config_digest
|
|
})
|
|
.map(|artifact| artifact.approval_id.clone());
|
|
let graph_uri = backend.graph_root(graph_id);
|
|
let observed_manifest_version = match Omnigraph::open_read_only(&graph_uri).await {
|
|
Ok(db) => match db.snapshot_of(ReadTarget::branch("main")).await {
|
|
Ok(snapshot) => Some(snapshot.version()),
|
|
Err(_) => None,
|
|
},
|
|
Err(_) => None, // partial/unopenable roots still get deleted
|
|
};
|
|
let sidecar = RecoverySidecar {
|
|
schema_version: 1,
|
|
operation_id: Ulid::new().to_string(),
|
|
started_at: now_rfc3339(),
|
|
actor: options.actor.clone(),
|
|
kind: RecoverySidecarKind::GraphDelete,
|
|
graph_id: graph_id.clone(),
|
|
graph_uri: graph_uri.clone(),
|
|
observed_manifest_version,
|
|
expected_manifest_version: None, // no post-op manifest exists
|
|
desired_schema_digest: String::new(),
|
|
state_cas_base: expected_cas.clone(),
|
|
approval_id: approval_id.clone(),
|
|
};
|
|
let sidecar_path = match backend.write_recovery_sidecar(&sidecar).await {
|
|
Ok(path) => path,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphDelete);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
};
|
|
if let Err(diagnostic) = failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_BEFORE_GRAPH_DELETE) {
|
|
// Simulated crash before removal: row 8 retires the intent and
|
|
// the still-valid approval lets a later run retry.
|
|
diagnostics.push(diagnostic);
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphDelete);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
// Prefix delete through the storage layer: remove_dir_all locally,
|
|
// list+delete on object stores (idempotent; already-gone is fine).
|
|
match backend.delete_graph_root(&graph_uri).await {
|
|
Ok(()) => {}
|
|
Err(err) => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"graph_delete_failed",
|
|
graph_addr.clone(),
|
|
format!("could not remove graph root '{graph_uri}': {err}"),
|
|
));
|
|
failed_graphs.insert(graph_id.clone(), FailedGraphOrigin::GraphDelete);
|
|
graph_moving_aborted = true;
|
|
continue;
|
|
}
|
|
}
|
|
// Crash point: the root is gone, the ledger does not record it yet.
|
|
// The sweep rolls forward (row 7b) and consumes the approval.
|
|
if let Err(diagnostic) = failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_AFTER_GRAPH_DELETE) {
|
|
diagnostics.push(diagnostic);
|
|
return early_return(
|
|
display_path(&desired.config_dir),
|
|
Some(desired.config_digest),
|
|
observations,
|
|
changes,
|
|
state.resource_statuses,
|
|
diagnostics,
|
|
);
|
|
}
|
|
executed_deletes.push((graph_id.clone(), approval_id.clone()));
|
|
if let Some(approval_id) = approval_id {
|
|
consumed_approval_ids.push(approval_id);
|
|
}
|
|
completed_op_sidecars.push(sidecar_path);
|
|
}
|
|
if !failed_graphs.is_empty() {
|
|
demote_dependents_of_failed_graphs(&mut changes, &failed_graphs, &desired.dependencies);
|
|
}
|
|
|
|
// State mutation. Apply owns query/policy statuses only; graph/schema
|
|
// statuses belong to refresh/import observation and must not be clobbered
|
|
// (the sweep above is the one exception: it owns recovery statuses).
|
|
let mut new_state = state.clone();
|
|
for change in &changes {
|
|
match change.disposition {
|
|
Some(ApplyDisposition::Applied) => match change.operation {
|
|
PlanOperation::Create | PlanOperation::Update => {
|
|
new_state.applied_revision.resources.insert(
|
|
change.resource.clone(),
|
|
StateResource {
|
|
digest: change
|
|
.after_digest
|
|
.clone()
|
|
.expect("create/update always carries an after digest"),
|
|
// Policies record their applied bindings so the
|
|
// ledger is serving-sufficient (RFC-005 §D3).
|
|
applies_to: desired.policy_bindings.get(&change.resource).cloned(),
|
|
embedding_provider: None,
|
|
embedding_profile: desired
|
|
.embedding_providers
|
|
.get(&change.resource)
|
|
.cloned(),
|
|
},
|
|
);
|
|
set_resource_status_applied(&mut new_state, &change.resource);
|
|
}
|
|
PlanOperation::Delete => {
|
|
new_state
|
|
.applied_revision
|
|
.resources
|
|
.remove(&change.resource);
|
|
new_state.resource_statuses.remove(&change.resource);
|
|
}
|
|
},
|
|
Some(ApplyDisposition::Blocked) => {
|
|
// The sweep owns recovery statuses (Drifted/Error with their
|
|
// conditions); a generic Blocked must not clobber them.
|
|
if change.reason.as_deref() != Some("cluster_recovery_pending") {
|
|
set_resource_status(
|
|
&mut new_state,
|
|
&change.resource,
|
|
ResourceLifecycleStatus::Blocked,
|
|
change.reason.as_deref().unwrap_or("dependency_not_applied"),
|
|
"waiting on an unapplied or missing dependency",
|
|
);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
for (graph_id, approval_id) in &executed_deletes {
|
|
tombstone_graph_subtree(
|
|
&mut new_state,
|
|
graph_id,
|
|
approval_id.as_deref(),
|
|
options.actor.as_deref(),
|
|
);
|
|
if let Some(approval_id) = approval_id {
|
|
record_approval_consumed(&mut new_state, approval_id, "apply");
|
|
}
|
|
}
|
|
recompute_state_graph_digests(&mut new_state, &desired);
|
|
|
|
let mut residual = diff_resources(
|
|
&state_resource_digests(&new_state),
|
|
&desired.resource_digests,
|
|
);
|
|
append_policy_binding_changes(&mut residual, Some(&new_state), &desired);
|
|
append_embedding_profile_changes(&mut residual, Some(&new_state), &desired);
|
|
let converged = residual.is_empty();
|
|
if converged {
|
|
new_state.applied_revision.config_digest = Some(desired.config_digest.clone());
|
|
}
|
|
|
|
let after_value =
|
|
serde_json::to_value(&new_state).expect("cluster state must serialize deterministically");
|
|
let mut state_written = false;
|
|
let mut state_write_failed = false;
|
|
if after_value != before_value {
|
|
new_state.state_revision = new_state.state_revision.saturating_add(1);
|
|
// The failpoint error routes through state_write_failed so the
|
|
// persisted-statuses revert contract below is exercised; a cfg_callback
|
|
// on this point can mutate state.json to simulate a concurrent writer,
|
|
// making write_state's CAS check fail organically.
|
|
let write_result = match failpoints::maybe_fail(crate::failpoints::names::CLUSTER_APPLY_BEFORE_STATE_WRITE) {
|
|
Ok(()) => {
|
|
backend
|
|
.write_state(&new_state, expected_cas.as_deref(), &mut observations)
|
|
.await
|
|
}
|
|
Err(diagnostic) => Err(diagnostic),
|
|
};
|
|
match write_result {
|
|
Ok(()) => state_written = true,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
state_write_failed = true;
|
|
}
|
|
}
|
|
}
|
|
// Completed (rows 2/4) sweep sidecars are deleted only once their outcome
|
|
// is durably recorded; on a failed write they stay and re-sweep next run.
|
|
if !state_write_failed {
|
|
for sidecar_uri in sweep
|
|
.completed_sidecars
|
|
.iter()
|
|
.chain(completed_op_sidecars.iter())
|
|
{
|
|
backend.delete_object(sidecar_uri).await;
|
|
}
|
|
let mut all_consumed = sweep.consumed_approvals.clone();
|
|
all_consumed.extend(consumed_approval_ids.iter().cloned());
|
|
mark_approvals_consumed(&backend, &all_consumed).await;
|
|
}
|
|
// On a failed state write, report the statuses that are actually on disk
|
|
// (the pre-apply snapshot), not the in-memory mutations that were never
|
|
// persisted — automation reading `resource_statuses` independently of `ok`
|
|
// must not see phantom status updates.
|
|
let resource_statuses = if state_write_failed {
|
|
state.resource_statuses
|
|
} else {
|
|
new_state.resource_statuses
|
|
};
|
|
|
|
let applied_count = changes
|
|
.iter()
|
|
.filter(|change| change.disposition == Some(ApplyDisposition::Applied))
|
|
.count();
|
|
let deferred_count = changes
|
|
.iter()
|
|
.filter(|change| {
|
|
matches!(
|
|
change.disposition,
|
|
Some(ApplyDisposition::Deferred) | Some(ApplyDisposition::Blocked)
|
|
)
|
|
})
|
|
.count();
|
|
|
|
ApplyOutput {
|
|
ok: !has_errors(&diagnostics),
|
|
config_dir: display_path(&desired.config_dir),
|
|
actor: options.actor.clone(),
|
|
desired_revision: DesiredRevision {
|
|
config_digest: Some(desired.config_digest),
|
|
},
|
|
state_observations: observations,
|
|
changes,
|
|
applied_count,
|
|
deferred_count,
|
|
converged,
|
|
state_written,
|
|
resource_statuses,
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
/// Record a digest-bound human approval for a gated (irreversible) change —
|
|
/// today: graph deletes. The artifact binds to the exact desired config
|
|
/// digest and the change's before/after digests, so config or state drift
|
|
/// invalidates it automatically (a stale approval can never authorize a
|
|
/// different change).
|
|
pub async fn approve_config_dir(
|
|
config_dir: impl AsRef<Path>,
|
|
resource: &str,
|
|
approved_by: &str,
|
|
) -> ApproveOutput {
|
|
let outcome = load_desired(config_dir.as_ref());
|
|
let mut diagnostics = outcome.diagnostics;
|
|
let storage_root = outcome
|
|
.desired
|
|
.as_ref()
|
|
.and_then(|desired| desired.storage_root.clone());
|
|
let backend = match store_for(&outcome.config_dir, storage_root.as_deref()) {
|
|
Ok(backend) => backend,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
ClusterStore::for_config_dir(&outcome.config_dir)
|
|
}
|
|
};
|
|
let mut observations = backend.observations();
|
|
|
|
let fail = |config_dir: String, diagnostics: Vec<Diagnostic>| ApproveOutput {
|
|
ok: false,
|
|
config_dir,
|
|
approval_id: None,
|
|
resource: None,
|
|
operation: None,
|
|
approved_by: None,
|
|
diagnostics,
|
|
};
|
|
|
|
let Some(desired) = outcome.desired else {
|
|
return fail(display_path(&outcome.config_dir), diagnostics);
|
|
};
|
|
if has_errors(&diagnostics) {
|
|
return fail(display_path(&desired.config_dir), diagnostics);
|
|
}
|
|
|
|
let _lock_guard = if desired.state_lock {
|
|
match backend.acquire_lock("approve", &mut observations).await {
|
|
Ok(guard) => Some(guard),
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
return fail(display_path(&desired.config_dir), diagnostics);
|
|
}
|
|
}
|
|
} else {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"state_lock_disabled",
|
|
"state.lock",
|
|
"state.lock is false; approve ran without acquiring the cluster state lock",
|
|
));
|
|
None
|
|
};
|
|
|
|
let state = match backend.read_state(&mut observations).await {
|
|
Ok(snapshot) => match snapshot.state {
|
|
Some(state) => state,
|
|
None => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"state_missing",
|
|
CLUSTER_STATE_FILE,
|
|
"approve requires an existing state.json; run `cluster import` first",
|
|
));
|
|
return fail(display_path(&desired.config_dir), diagnostics);
|
|
}
|
|
},
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
return fail(display_path(&desired.config_dir), diagnostics);
|
|
}
|
|
};
|
|
|
|
let prior_resources = state_resource_digests(&state);
|
|
let changes = diff_resources(&prior_resources, &desired.resource_digests);
|
|
let gates = compute_approvals(&changes, &BTreeSet::new());
|
|
let Some(change) = changes.iter().find(|change| {
|
|
change.resource == resource && gates.iter().any(|gate| gate.resource == resource)
|
|
}) else {
|
|
diagnostics.push(Diagnostic::error(
|
|
"approval_not_required",
|
|
resource,
|
|
"no pending change for this resource requires approval (check `cluster plan`)",
|
|
));
|
|
return fail(display_path(&desired.config_dir), diagnostics);
|
|
};
|
|
|
|
let artifact = ApprovalArtifact {
|
|
schema_version: 1,
|
|
approval_id: Ulid::new().to_string(),
|
|
resource: change.resource.clone(),
|
|
operation: match change.operation {
|
|
PlanOperation::Create => "create",
|
|
PlanOperation::Update => "update",
|
|
PlanOperation::Delete => "delete",
|
|
}
|
|
.to_string(),
|
|
reason: gates
|
|
.iter()
|
|
.find(|gate| gate.resource == resource)
|
|
.map(|gate| gate.reason.clone())
|
|
.unwrap_or_default(),
|
|
bound_config_digest: desired.config_digest.clone(),
|
|
bound_before_digest: change.before_digest.clone(),
|
|
bound_after_digest: change.after_digest.clone(),
|
|
approved_by: approved_by.to_string(),
|
|
created_at: now_rfc3339(),
|
|
consumed_at: None,
|
|
consumed_by_operation: None,
|
|
};
|
|
if let Err(diagnostic) = backend.write_approval_artifact(&artifact).await {
|
|
diagnostics.push(diagnostic);
|
|
return fail(display_path(&desired.config_dir), diagnostics);
|
|
}
|
|
|
|
ApproveOutput {
|
|
ok: !has_errors(&diagnostics),
|
|
config_dir: display_path(&desired.config_dir),
|
|
approval_id: Some(artifact.approval_id),
|
|
resource: Some(artifact.resource),
|
|
operation: Some(change.operation.clone()),
|
|
approved_by: Some(artifact.approved_by),
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
pub async fn status_config_dir(config_dir: impl AsRef<Path>) -> StatusOutput {
|
|
let parsed = parse_cluster_config(config_dir.as_ref());
|
|
let mut diagnostics = parsed.diagnostics;
|
|
let storage_root = parsed.raw.as_ref().and_then(|raw| {
|
|
raw.storage
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|root| !root.is_empty())
|
|
.map(|root| root.trim_end_matches('/').to_string())
|
|
});
|
|
let backend = match store_for(&parsed.config_dir, storage_root.as_deref()) {
|
|
Ok(backend) => backend,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
ClusterStore::for_config_dir(&parsed.config_dir)
|
|
}
|
|
};
|
|
let mut observations = backend.observations();
|
|
backend
|
|
.observe_lock(&mut observations, &mut diagnostics)
|
|
.await;
|
|
warn_pending_recovery_sidecars(&backend, &mut diagnostics).await;
|
|
|
|
let mut resource_digests = BTreeMap::new();
|
|
let mut resource_statuses = BTreeMap::new();
|
|
let mut state_observation_records = BTreeMap::new();
|
|
|
|
if let Some(raw) = parsed.raw.as_ref() {
|
|
let _settings = validate_cluster_header(raw, &mut diagnostics);
|
|
if !has_errors(&diagnostics) {
|
|
match backend.read_state(&mut observations).await {
|
|
Ok(snapshot) => {
|
|
if let Some(state) = snapshot.state {
|
|
// Read-only point-in-time catalog check: report the
|
|
// findings as diagnostics; persisting Drifted statuses
|
|
// is refresh's job. Status never writes state.
|
|
for (address, finding) in verify_catalog_payloads(&backend, &state).await {
|
|
diagnostics.push(payload_finding_diagnostic(&address, &finding));
|
|
}
|
|
resource_digests = state_resource_digests(&state);
|
|
resource_statuses = state.resource_statuses;
|
|
state_observation_records = state.observations;
|
|
} else {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"state_missing",
|
|
CLUSTER_STATE_FILE,
|
|
"state.json is missing; no applied cluster revision has been recorded",
|
|
));
|
|
}
|
|
}
|
|
Err(diagnostic) => diagnostics.push(diagnostic),
|
|
}
|
|
}
|
|
}
|
|
|
|
StatusOutput {
|
|
ok: !has_errors(&diagnostics),
|
|
config_dir: display_path(&parsed.config_dir),
|
|
state_observations: observations,
|
|
resource_digests,
|
|
resource_statuses,
|
|
observations: state_observation_records,
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
pub async fn force_unlock_config_dir(
|
|
config_dir: impl AsRef<Path>,
|
|
lock_id: impl AsRef<str>,
|
|
) -> ForceUnlockOutput {
|
|
let parsed = parse_cluster_config(config_dir.as_ref());
|
|
let mut diagnostics = parsed.diagnostics;
|
|
let storage_root = parsed.raw.as_ref().and_then(|raw| {
|
|
raw.storage
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|root| !root.is_empty())
|
|
.map(|root| root.trim_end_matches('/').to_string())
|
|
});
|
|
let backend = match store_for(&parsed.config_dir, storage_root.as_deref()) {
|
|
Ok(backend) => backend,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
ClusterStore::for_config_dir(&parsed.config_dir)
|
|
}
|
|
};
|
|
let mut observations = backend.observations();
|
|
let mut lock_removed = false;
|
|
|
|
if let Some(raw) = parsed.raw.as_ref() {
|
|
let _settings = validate_cluster_header(raw, &mut diagnostics);
|
|
if !has_errors(&diagnostics) {
|
|
match backend
|
|
.force_unlock(lock_id.as_ref(), &mut observations)
|
|
.await
|
|
{
|
|
Ok(()) => lock_removed = true,
|
|
Err(diagnostic) => diagnostics.push(diagnostic),
|
|
}
|
|
}
|
|
}
|
|
|
|
ForceUnlockOutput {
|
|
ok: !has_errors(&diagnostics),
|
|
config_dir: display_path(&parsed.config_dir),
|
|
state_observations: observations,
|
|
lock_removed,
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
pub async fn refresh_config_dir(config_dir: impl AsRef<Path>) -> StateSyncOutput {
|
|
sync_config_dir(config_dir.as_ref(), StateSyncOperation::Refresh).await
|
|
}
|
|
|
|
pub async fn import_config_dir(config_dir: impl AsRef<Path>) -> StateSyncOutput {
|
|
sync_config_dir(config_dir.as_ref(), StateSyncOperation::Import).await
|
|
}
|
|
|
|
async fn sync_config_dir(config_dir: &Path, operation: StateSyncOperation) -> StateSyncOutput {
|
|
let outcome = load_desired(config_dir);
|
|
let mut diagnostics = outcome.diagnostics;
|
|
let storage_root = outcome
|
|
.desired
|
|
.as_ref()
|
|
.and_then(|desired| desired.storage_root.clone());
|
|
let backend = match store_for(&outcome.config_dir, storage_root.as_deref()) {
|
|
Ok(backend) => backend,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
ClusterStore::for_config_dir(&outcome.config_dir)
|
|
}
|
|
};
|
|
let mut observations = backend.observations();
|
|
|
|
let Some(desired) = outcome.desired else {
|
|
return StateSyncOutput {
|
|
ok: false,
|
|
operation,
|
|
config_dir: display_path(&outcome.config_dir),
|
|
state_observations: observations,
|
|
resource_digests: BTreeMap::new(),
|
|
resource_statuses: BTreeMap::new(),
|
|
observations: BTreeMap::new(),
|
|
diagnostics,
|
|
};
|
|
};
|
|
|
|
if has_errors(&diagnostics) {
|
|
return StateSyncOutput {
|
|
ok: false,
|
|
operation,
|
|
config_dir: display_path(&desired.config_dir),
|
|
state_observations: observations,
|
|
resource_digests: desired.resource_digests,
|
|
resource_statuses: BTreeMap::new(),
|
|
observations: BTreeMap::new(),
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
let operation_label = state_sync_operation_label(operation);
|
|
let _lock_guard = if desired.state_lock {
|
|
match backend
|
|
.acquire_lock(operation_label, &mut observations)
|
|
.await
|
|
{
|
|
Ok(guard) => Some(guard),
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
diagnostics.push(Diagnostic::warning(
|
|
"state_lock_disabled",
|
|
"state.lock",
|
|
format!(
|
|
"state.lock is false; {operation_label} wrote state without acquiring the cluster state lock"
|
|
),
|
|
));
|
|
None
|
|
};
|
|
|
|
if has_errors(&diagnostics) {
|
|
return StateSyncOutput {
|
|
ok: false,
|
|
operation,
|
|
config_dir: display_path(&desired.config_dir),
|
|
state_observations: observations,
|
|
resource_digests: desired.resource_digests,
|
|
resource_statuses: BTreeMap::new(),
|
|
observations: BTreeMap::new(),
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
let snapshot = match backend.read_state(&mut observations).await {
|
|
Ok(snapshot) => snapshot,
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
return StateSyncOutput {
|
|
ok: false,
|
|
operation,
|
|
config_dir: display_path(&desired.config_dir),
|
|
state_observations: observations,
|
|
resource_digests: desired.resource_digests,
|
|
resource_statuses: BTreeMap::new(),
|
|
observations: BTreeMap::new(),
|
|
diagnostics,
|
|
};
|
|
}
|
|
};
|
|
|
|
let expected_cas = snapshot.state_cas;
|
|
let mut state = match (operation, snapshot.state) {
|
|
(StateSyncOperation::Refresh, Some(state)) => state,
|
|
(StateSyncOperation::Refresh, None) => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"state_missing",
|
|
CLUSTER_STATE_FILE,
|
|
"refresh requires an existing state.json; run `cluster import` to bootstrap state",
|
|
));
|
|
return StateSyncOutput {
|
|
ok: false,
|
|
operation,
|
|
config_dir: display_path(&desired.config_dir),
|
|
state_observations: observations,
|
|
resource_digests: BTreeMap::new(),
|
|
resource_statuses: BTreeMap::new(),
|
|
observations: BTreeMap::new(),
|
|
diagnostics,
|
|
};
|
|
}
|
|
(StateSyncOperation::Import, Some(state)) => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"state_already_exists",
|
|
CLUSTER_STATE_FILE,
|
|
"import creates initial state only when state.json is missing; use `cluster refresh` for an existing state ledger",
|
|
));
|
|
return StateSyncOutput {
|
|
ok: false,
|
|
operation,
|
|
config_dir: display_path(&desired.config_dir),
|
|
state_observations: observations,
|
|
resource_digests: state_resource_digests(&state),
|
|
resource_statuses: state.resource_statuses,
|
|
observations: state.observations,
|
|
diagnostics,
|
|
};
|
|
}
|
|
(StateSyncOperation::Import, None) => initial_import_state(&desired),
|
|
};
|
|
|
|
// Recovery sweep first (RFC-004 §D3): classify any interrupted graph
|
|
// operation before observation/verification so a rolled-forward outcome
|
|
// is what those passes see.
|
|
let sweep = sweep_recovery_sidecars(&backend, &mut state, &mut diagnostics).await;
|
|
|
|
// Catalog payload verification must run BEFORE graph observation: removing
|
|
// a drifted query digest first means the live-graph composite recompute
|
|
// below already excludes it, so the persisted graph.<id> composite stays
|
|
// consistent and the next plan shows exactly the create + derived update.
|
|
for (address, finding) in verify_catalog_payloads(&backend, &state).await {
|
|
diagnostics.push(payload_finding_diagnostic(&address, &finding));
|
|
match finding {
|
|
PayloadFinding::Missing => {
|
|
state.applied_revision.resources.remove(&address);
|
|
set_resource_status(
|
|
&mut state,
|
|
&address,
|
|
ResourceLifecycleStatus::Drifted,
|
|
"payload_missing",
|
|
"catalog payload blob is missing; re-run `cluster apply` to republish",
|
|
);
|
|
}
|
|
PayloadFinding::Mismatch { .. } => {
|
|
state.applied_revision.resources.remove(&address);
|
|
set_resource_status(
|
|
&mut state,
|
|
&address,
|
|
ResourceLifecycleStatus::Drifted,
|
|
"payload_mismatch",
|
|
"catalog payload blob does not match the recorded digest; re-run `cluster apply` to republish",
|
|
);
|
|
}
|
|
// Transient IO must not trigger a spurious republish: keep the
|
|
// digest, surface the error, let a later clean refresh converge.
|
|
PayloadFinding::ReadError(error) => {
|
|
set_resource_status(
|
|
&mut state,
|
|
&address,
|
|
ResourceLifecycleStatus::Error,
|
|
"payload_read_error",
|
|
&error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let graph_error_count = observe_declared_graphs(&desired, &backend, &mut state).await;
|
|
if graph_error_count > 0 {
|
|
diagnostics.push(Diagnostic::error(
|
|
"graph_observation_error",
|
|
CLUSTER_GRAPHS_DIR,
|
|
format!("{graph_error_count} graph observation(s) failed"),
|
|
));
|
|
}
|
|
|
|
if operation == StateSyncOperation::Import && has_errors(&diagnostics) {
|
|
return StateSyncOutput {
|
|
ok: false,
|
|
operation,
|
|
config_dir: display_path(&desired.config_dir),
|
|
state_observations: observations,
|
|
resource_digests: state_resource_digests(&state),
|
|
resource_statuses: state.resource_statuses,
|
|
observations: state.observations,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
if operation == StateSyncOperation::Import {
|
|
state.state_revision = 1;
|
|
} else {
|
|
state.state_revision = state.state_revision.saturating_add(1);
|
|
}
|
|
|
|
match backend
|
|
.write_state(&state, expected_cas.as_deref(), &mut observations)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
// Completed sweep sidecars are deleted only after their outcome
|
|
// is durably recorded; on failure they stay and re-sweep.
|
|
for sidecar_uri in &sweep.completed_sidecars {
|
|
backend.delete_object(sidecar_uri).await;
|
|
}
|
|
mark_approvals_consumed(&backend, &sweep.consumed_approvals).await;
|
|
}
|
|
Err(diagnostic) => diagnostics.push(diagnostic),
|
|
}
|
|
|
|
let resource_digests = state_resource_digests(&state);
|
|
let ok = !has_errors(&diagnostics);
|
|
|
|
StateSyncOutput {
|
|
ok,
|
|
operation,
|
|
config_dir: display_path(&desired.config_dir),
|
|
state_observations: observations,
|
|
resource_digests,
|
|
resource_statuses: state.resource_statuses,
|
|
observations: state.observations,
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum PayloadFinding {
|
|
Missing,
|
|
Mismatch { actual_digest: String },
|
|
ReadError(String),
|
|
}
|
|
|
|
/// Verify every catalog-backed resource digest in state against its
|
|
/// content-addressed blob under `__cluster/resources/`. Graph, schema, and
|
|
/// unknown addresses have no payloads and are skipped. Read-only; findings
|
|
/// are deterministic (BTreeMap order). Payloads are small (queries, policy
|
|
/// bundles), so a full digest re-hash is cheap.
|
|
async fn verify_catalog_payloads(
|
|
backend: &ClusterStore,
|
|
state: &ClusterState,
|
|
) -> Vec<(String, PayloadFinding)> {
|
|
let mut findings = Vec::new();
|
|
for (address, resource) in &state.applied_revision.resources {
|
|
let kind = resource_kind(address);
|
|
if ClusterStore::payload_relative(&kind, &resource.digest).is_none() {
|
|
continue;
|
|
}
|
|
match backend.read_payload(&kind, &resource.digest).await {
|
|
Ok(Some(text)) => {
|
|
let actual_digest = sha256_hex(text.as_bytes());
|
|
if actual_digest != resource.digest {
|
|
findings.push((address.clone(), PayloadFinding::Mismatch { actual_digest }));
|
|
}
|
|
}
|
|
Ok(None) => findings.push((address.clone(), PayloadFinding::Missing)),
|
|
Err(err) => {
|
|
findings.push((address.clone(), PayloadFinding::ReadError(err)));
|
|
}
|
|
}
|
|
}
|
|
findings
|
|
}
|
|
|
|
fn payload_finding_diagnostic(address: &str, finding: &PayloadFinding) -> Diagnostic {
|
|
match finding {
|
|
PayloadFinding::Missing => Diagnostic::warning(
|
|
"catalog_payload_missing",
|
|
address,
|
|
"catalog payload blob is missing; re-run `cluster apply` to republish",
|
|
),
|
|
PayloadFinding::Mismatch { actual_digest } => Diagnostic::warning(
|
|
"catalog_payload_mismatch",
|
|
address,
|
|
format!(
|
|
"catalog payload blob does not match the recorded digest (actual sha256:{actual_digest}); re-run `cluster apply` to republish"
|
|
),
|
|
),
|
|
// An unverifiable blob must not report healthy.
|
|
PayloadFinding::ReadError(error) => {
|
|
Diagnostic::error("catalog_payload_read_error", address, error.clone())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write one content-addressed payload blob. Idempotent: an existing
|
|
/// digest-named file is trusted as-is. The digest re-check is the apply-side
|
|
/// TOCTOU detector — the source file changing between `load_desired` and the
|
|
/// payload write must fail loudly, never publish mismatched content.
|
|
async fn write_resource_payload(
|
|
backend: &ClusterStore,
|
|
kind: &ResourceKind,
|
|
source: &Path,
|
|
expected_digest: &str,
|
|
resource: &str,
|
|
) -> Result<(), Diagnostic> {
|
|
if backend.payload_exists(kind, expected_digest).await {
|
|
// Content-addressed: an existing digest-named object is identical.
|
|
return Ok(());
|
|
}
|
|
let bytes = fs::read(source).map_err(|err| {
|
|
Diagnostic::error(
|
|
"resource_payload_write_error",
|
|
resource,
|
|
format!(
|
|
"could not read resource source '{}': {err}",
|
|
source.display()
|
|
),
|
|
)
|
|
})?;
|
|
if sha256_hex(&bytes) != expected_digest {
|
|
// The apply-side TOCTOU detector: the source changing between
|
|
// load_desired and this write must fail loudly, never publish
|
|
// mismatched content.
|
|
return Err(Diagnostic::error(
|
|
"resource_content_changed",
|
|
resource,
|
|
format!(
|
|
"resource source '{}' changed while apply was running; re-run `cluster apply`",
|
|
source.display()
|
|
),
|
|
));
|
|
}
|
|
let content = String::from_utf8(bytes).map_err(|err| {
|
|
Diagnostic::error(
|
|
"resource_payload_write_error",
|
|
resource,
|
|
format!("resource source is not valid UTF-8: {err}"),
|
|
)
|
|
})?;
|
|
backend
|
|
.write_payload(kind, expected_digest, &content)
|
|
.await
|
|
.map_err(|err| {
|
|
Diagnostic::error(
|
|
"resource_payload_write_error",
|
|
resource,
|
|
format!("could not write payload: {err}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Recompute the composite `graph.<id>` digests for state-resident graphs from
|
|
/// state's own schema/query components. Without this, an applied query change
|
|
/// would leave the prior composite digest in state and `graph.<id>` would show
|
|
/// a phantom update in every later plan — apply could never converge.
|
|
fn recompute_state_graph_digests(state: &mut ClusterState, desired: &DesiredCluster) {
|
|
for graph in &desired.graphs {
|
|
let graph_address = graph_address(&graph.id);
|
|
if !state
|
|
.applied_revision
|
|
.resources
|
|
.contains_key(&graph_address)
|
|
{
|
|
continue;
|
|
}
|
|
let schema_digest = state
|
|
.applied_revision
|
|
.resources
|
|
.get(&schema_address(&graph.id))
|
|
.map(|resource| resource.digest.clone());
|
|
let query_digests = state_query_digests_for_graph(state, &graph.id);
|
|
let embedding_provider = graph.embedding_provider.as_deref();
|
|
let embedding_provider_digest = embedding_provider
|
|
.and_then(|address| state.applied_revision.resources.get(address))
|
|
.map(|resource| resource.digest.clone());
|
|
let digest = graph_digest(
|
|
&graph.id,
|
|
schema_digest.as_ref(),
|
|
Some(&query_digests),
|
|
embedding_provider,
|
|
embedding_provider_digest.as_ref(),
|
|
);
|
|
state.applied_revision.resources.insert(
|
|
graph_address,
|
|
StateResource {
|
|
digest,
|
|
applies_to: None,
|
|
embedding_provider: graph.embedding_provider.clone(),
|
|
embedding_profile: None,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
fn duplicate_key_diagnostics(text: &str) -> Vec<Diagnostic> {
|
|
#[derive(Debug)]
|
|
struct Frame {
|
|
indent: isize,
|
|
path: String,
|
|
keys: BTreeSet<String>,
|
|
}
|
|
|
|
let mut diagnostics = Vec::new();
|
|
let mut stack = vec![Frame {
|
|
indent: -1,
|
|
path: String::new(),
|
|
keys: BTreeSet::new(),
|
|
}];
|
|
|
|
for (line_idx, line) in text.lines().enumerate() {
|
|
let line_without_comment = strip_comment(line);
|
|
if line_without_comment.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let indent = line_without_comment
|
|
.chars()
|
|
.take_while(|ch| *ch == ' ')
|
|
.count() as isize;
|
|
let trimmed = line_without_comment.trim_start();
|
|
if trimmed.starts_with('-') {
|
|
continue;
|
|
}
|
|
let Some((raw_key, raw_value)) = trimmed.split_once(':') else {
|
|
continue;
|
|
};
|
|
let key = raw_key.trim();
|
|
if key.is_empty() || key.starts_with('{') || key.starts_with('[') {
|
|
continue;
|
|
}
|
|
|
|
while stack.last().is_some_and(|frame| indent <= frame.indent) {
|
|
stack.pop();
|
|
}
|
|
let parent = stack.last_mut().expect("root frame is always present");
|
|
let full_path = if parent.path.is_empty() {
|
|
key.to_string()
|
|
} else {
|
|
format!("{}.{}", parent.path, key)
|
|
};
|
|
if !parent.keys.insert(key.to_string()) {
|
|
diagnostics.push(Diagnostic::error(
|
|
"duplicate_yaml_key",
|
|
full_path.clone(),
|
|
format!("duplicate YAML key `{key}` on line {}", line_idx + 1),
|
|
));
|
|
}
|
|
if raw_value.trim().is_empty() {
|
|
stack.push(Frame {
|
|
indent,
|
|
path: full_path,
|
|
keys: BTreeSet::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
diagnostics
|
|
}
|
|
|
|
fn strip_comment(line: &str) -> String {
|
|
let mut in_single_quote = false;
|
|
let mut in_double_quote = false;
|
|
let mut escaped = false;
|
|
|
|
for (idx, ch) in line.char_indices() {
|
|
if escaped {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
match ch {
|
|
'\\' if in_double_quote => escaped = true,
|
|
'\'' if !in_double_quote => in_single_quote = !in_single_quote,
|
|
'"' if !in_single_quote => in_double_quote = !in_double_quote,
|
|
'#' if !in_single_quote && !in_double_quote => return line[..idx].to_string(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
line.to_string()
|
|
}
|
|
|
|
fn state_query_digests_for_graph(state: &ClusterState, graph_id: &str) -> BTreeMap<String, String> {
|
|
let prefix = format!("query.{graph_id}.");
|
|
state
|
|
.applied_revision
|
|
.resources
|
|
.iter()
|
|
.filter_map(|(address, resource)| {
|
|
address
|
|
.strip_prefix(&prefix)
|
|
.map(|name| (name.to_string(), resource.digest.clone()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn state_graph_embedding_provider(state: &ClusterState, graph_id: &str) -> Option<String> {
|
|
state
|
|
.applied_revision
|
|
.resources
|
|
.get(&graph_address(graph_id))
|
|
.and_then(|resource| resource.embedding_provider.clone())
|
|
}
|
|
|
|
fn state_embedding_provider_digest(
|
|
state: &ClusterState,
|
|
embedding_provider: Option<&str>,
|
|
) -> Option<String> {
|
|
embedding_provider
|
|
.and_then(|address| state.applied_revision.resources.get(address))
|
|
.map(|resource| resource.digest.clone())
|
|
}
|
|
|
|
fn set_resource_status_applied(state: &mut ClusterState, address: &str) {
|
|
state.resource_statuses.insert(
|
|
address.to_string(),
|
|
ResourceStatusRecord {
|
|
status: ResourceLifecycleStatus::Applied,
|
|
conditions: Vec::new(),
|
|
message: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn set_resource_status(
|
|
state: &mut ClusterState,
|
|
address: &str,
|
|
status: ResourceLifecycleStatus,
|
|
condition: &str,
|
|
message: &str,
|
|
) {
|
|
state.resource_statuses.insert(
|
|
address.to_string(),
|
|
ResourceStatusRecord {
|
|
status,
|
|
conditions: vec![condition.to_string()],
|
|
message: Some(message.to_string()),
|
|
},
|
|
);
|
|
}
|
|
|
|
fn graph_digest(
|
|
graph_id: &str,
|
|
schema_digest: Option<&String>,
|
|
query_digests: Option<&BTreeMap<String, String>>,
|
|
embedding_provider: Option<&str>,
|
|
embedding_provider_digest: Option<&String>,
|
|
) -> String {
|
|
let mut input = format!(
|
|
"graph\0{graph_id}\0schema\0{}\0",
|
|
schema_digest.map_or("", String::as_str)
|
|
);
|
|
if let Some(query_digests) = query_digests {
|
|
for (name, digest) in query_digests {
|
|
input.push_str("query\0");
|
|
input.push_str(name);
|
|
input.push('\0');
|
|
input.push_str(digest);
|
|
input.push('\0');
|
|
}
|
|
}
|
|
if let Some(provider) = embedding_provider {
|
|
input.push_str("embedding_provider\0");
|
|
input.push_str(provider);
|
|
input.push('\0');
|
|
input.push_str(embedding_provider_digest.map_or("", String::as_str));
|
|
input.push('\0');
|
|
}
|
|
sha256_hex(input.as_bytes())
|
|
}
|
|
|
|
fn embedding_provider_digest(profile: &EmbeddingProviderConfig) -> String {
|
|
let mut input = String::from("embedding-provider\0");
|
|
let config_semantics =
|
|
serde_json::to_string(profile).expect("embedding provider config must serialize");
|
|
input.push_str(&config_semantics);
|
|
sha256_hex(input.as_bytes())
|
|
}
|
|
|
|
async fn live_schema_matches_recorded_digest(
|
|
graph_uri: &str,
|
|
recorded_schema_digest: Option<&str>,
|
|
observed_manifest_version: Option<u64>,
|
|
) -> bool {
|
|
let Some(recorded_schema_digest) = recorded_schema_digest else {
|
|
return false;
|
|
};
|
|
let Some(observed_manifest_version) = observed_manifest_version else {
|
|
return false;
|
|
};
|
|
let Ok(db) = Omnigraph::open_read_only(graph_uri).await else {
|
|
return false;
|
|
};
|
|
let Ok(snapshot) = db.snapshot_of(ReadTarget::branch("main")).await else {
|
|
return false;
|
|
};
|
|
if snapshot.version() != observed_manifest_version {
|
|
return false;
|
|
}
|
|
sha256_hex(db.schema_source().as_bytes()) == recorded_schema_digest
|
|
}
|
|
|
|
fn desired_config_digest(
|
|
raw: &RawClusterConfig,
|
|
resource_digests: &BTreeMap<String, String>,
|
|
) -> String {
|
|
let mut input = String::from("cluster-config\0");
|
|
// Hash parsed semantics, not raw YAML bytes, so comments and formatting do
|
|
// not create a new desired revision and the digest cannot drift from parse.
|
|
let config_semantics =
|
|
serde_json::to_string(raw).expect("raw cluster config must serialize deterministically");
|
|
input.push_str(&config_semantics);
|
|
input.push('\0');
|
|
for (address, digest) in resource_digests {
|
|
input.push_str(address);
|
|
input.push('\0');
|
|
input.push_str(digest);
|
|
input.push('\0');
|
|
}
|
|
sha256_hex(input.as_bytes())
|
|
}
|
|
|
|
fn sha256_hex(bytes: &[u8]) -> String {
|
|
let digest = Sha256::digest(bytes);
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let mut out = String::with_capacity(digest.len() * 2);
|
|
for byte in digest {
|
|
out.push(HEX[(byte >> 4) as usize] as char);
|
|
out.push(HEX[(byte & 0x0f) as usize] as char);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn now_rfc3339() -> String {
|
|
OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
|
}
|
|
|
|
fn lock_age_seconds(created_at: &str) -> Option<u64> {
|
|
let created_at = OffsetDateTime::parse(created_at, &Rfc3339).ok()?;
|
|
Some(
|
|
(OffsetDateTime::now_utc() - created_at)
|
|
.whole_seconds()
|
|
.max(0) as u64,
|
|
)
|
|
}
|
|
|
|
fn state_sync_operation_label(operation: StateSyncOperation) -> &'static str {
|
|
match operation {
|
|
StateSyncOperation::Refresh => "refresh",
|
|
StateSyncOperation::Import => "import",
|
|
}
|
|
}
|
|
|
|
fn has_errors(diagnostics: &[Diagnostic]) -> bool {
|
|
diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
|
}
|
|
|
|
fn count_errors(diagnostics: &[Diagnostic]) -> usize {
|
|
diagnostics
|
|
.iter()
|
|
.filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
|
|
.count()
|
|
}
|
|
|
|
fn display_path(path: &Path) -> String {
|
|
path.display().to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests.rs"]
|
|
mod tests;
|