Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions crates/omnigraph-cli/tests/crossversion_upgrade.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! Cross-version upgrade: prove the CURRENT binary handles a GENUINE old-format
//! (internal schema v3) graph minted by omnigraph 0.7.2 — not a v4-shaped graph
//! with a rewound stamp. Two things the stamp-rewind stand-in
//! (`sub_current_graph_is_refused_then_rebuilt_via_export_import`) cannot prove:
//!
//! 1. the open-refusal fires on the REAL on-disk v3 shape (lineage in
//! `_graph_commits.lance`, lineage-free `__manifest`) and NAMES the writing
//! release, and
//! 2. the documented `export → init → load` rebuild round-trips the data,
//! including a `Vector` column, off a genuine v3 export.
//!
//! Gated: requires `OMNIGRAPH_OLD_BIN` (an absolute path to a 0.7.2 `omnigraph`
//! binary). Skips gracefully when unset — the same convention as the S3 and
//! system-e2e gates — so the default `cargo test --workspace` stays green
//! without it. CI sets it in the `crossversion_upgrade` job (see
//! `docs/dev/testing.md`).

mod support;

use std::path::{Path, PathBuf};
use std::process::Command;

use support::{HERMETIC_OPERATOR_HOME, cli, fixture, output_failure, output_success};
use tempfile::tempdir;

/// Resolve the old (0.7.2) binary. `None` ONLY when `OMNIGRAPH_OLD_BIN` is
/// unset — the legitimate skip. A var that is SET but points at a missing path
/// is a misconfiguration (wrong install path / renamed binary) and must fail
/// loudly, never skip vacuously: in CI the var is deliberately set so the test
/// is expected to run.
fn old_bin() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("OMNIGRAPH_OLD_BIN")?);
assert!(
path.exists(),
"OMNIGRAPH_OLD_BIN is set but does not exist: {} \
(unset it to skip, or point it at a real 0.7.2 omnigraph binary)",
path.display(),
);
Some(path)
}

/// Run the OLD (0.7.2) binary hermetically (no developer `~/.omnigraph`).
fn run_old(bin: &Path, args: &[&str]) -> std::process::Output {
Command::new(bin)
.env("OMNIGRAPH_HOME", HERMETIC_OPERATOR_HOME)
.env_remove("OMNIGRAPH_CONFIG")
.args(args)
.output()
.expect("spawn old omnigraph binary")
}

fn assert_ok(label: &str, out: &std::process::Output) {
assert!(
out.status.success(),
"old binary `{label}` failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}

fn nonblank_lines(bytes: &[u8]) -> usize {
String::from_utf8_lossy(bytes)
.lines()
.filter(|l| !l.trim().is_empty())
.count()
}

#[test]
fn current_binary_refuses_and_rebuilds_a_genuine_v3_graph() {
let Some(old) = old_bin() else {
eprintln!(
"skipping cross-version upgrade test: OMNIGRAPH_OLD_BIN is not set to a 0.7.2 binary"
);
return;
};

let temp = tempdir().unwrap();
let old_graph = temp.path().join("old-v3.omni");
// `search.pg` / `search.jsonl` are byte-identical in v0.7.2 and exercise a
// `Vector(4)` column plus indexed strings — a fixture both binaries parse.
let schema = fixture("search.pg");
let data = fixture("search.jsonl");
let og = old_graph.to_str().unwrap();

// 1. Mint a GENUINE v3 graph with the old binary.
assert_ok(
"init",
&run_old(&old, &["init", "--schema", schema.to_str().unwrap(), og]),
);
assert_ok(
"load",
&run_old(
&old,
&["load", "--mode", "overwrite", "--data", data.to_str().unwrap(), og],
),
);

// Prove it is really v3 on disk: pre-v4 graphs carry the now-retired
// `_graph_commits.lance` lineage dataset (a v4 graph has neither).
assert!(
old_graph.join("_graph_commits.lance").exists(),
"a genuine v3 graph must have the legacy _graph_commits.lance dataset",
);

// 2. Old binary export → JSONL.
let export = run_old(&old, &["export", og]);
assert_ok("export", &export);
assert!(!export.stdout.is_empty(), "old export produced no rows");
let v3_jsonl = temp.path().join("v3.jsonl");
std::fs::write(&v3_jsonl, &export.stdout).unwrap();

// 3. The CURRENT binary refuses the genuine v3 graph, names the writing
// release, and nudges to export — on the real on-disk shape.
let refusal = output_failure(cli().arg("snapshot").arg(&old_graph));
let stderr = String::from_utf8_lossy(&refusal.stderr);
assert!(
stderr.contains("export"),
"refusal must nudge the operator to export, got: {stderr}",
);
assert!(
stderr.contains("0.6.2 to 0.7.2"),
"refusal must name the full release range that wrote this stamp (v3 → 0.6.2 to 0.7.2), \
got: {stderr}",
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// 4. The CURRENT binary rebuilds: fresh init + load the v3 export.
let new_graph = temp.path().join("new-v4.omni");
output_success(cli().arg("init").arg("--schema").arg(&schema).arg(&new_graph));
output_success(
cli()
.arg("load")
.arg("--mode")
.arg("overwrite")
.arg("--data")
.arg(&v3_jsonl)
.arg(&new_graph),
);

// 5. Round-trip fidelity: re-export with the current binary and compare.
let reexport = output_success(cli().arg("export").arg(&new_graph));
assert_eq!(
nonblank_lines(&export.stdout),
nonblank_lines(&reexport.stdout),
"row count must round-trip v3 → v4",
);
let rebuilt = String::from_utf8_lossy(&reexport.stdout);
assert!(
rebuilt.contains("embedding"),
"the Vector column must survive the rebuild, got: {rebuilt}",
);
assert!(
rebuilt.contains("ml-intro"),
"the rebuilt graph must preserve node data, got: {rebuilt}",
);
}
47 changes: 44 additions & 3 deletions crates/omnigraph/src/db/manifest/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ pub(crate) const INTERNAL_MANIFEST_SCHEMA_VERSION: u32 = 4;
/// module doc).
pub(crate) const MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION: u32 = INTERNAL_MANIFEST_SCHEMA_VERSION;

/// The omnigraph release line that wrote a given internal-schema stamp. The
/// open-refusal uses it to tell an operator exactly which binary to use to
/// export a sub-CURRENT graph (the export side of the strand-model upgrade —
/// see `docs/user/operations/upgrade.md`). Ranges are the release tags that
/// stamped each version (verify with
/// `git show vX.Y.Z:crates/omnigraph/src/db/manifest/migrations.rs`):
/// v1 ≤ 0.3.1, v2 0.4.1–0.6.1, v3 0.6.2–0.7.2, v4 0.8.x.
pub(crate) fn release_for_internal_schema_version(stamp: u32) -> &'static str {
match stamp {
1 => "0.3.1 or earlier",
2 => "0.4.1 to 0.6.1",
3 => "0.6.2 to 0.7.2",
4 => "0.8.x",
// Unreachable today (1–3 are mapped; ≥ CURRENT is caught by the ceiling
// guard before this is consulted). Worded to read naturally after
// "created by omnigraph " if a future bump ever leaves a gap.
_ => "an unrecognized older release",
}
}

const INTERNAL_SCHEMA_VERSION_KEY: &str = "omnigraph:internal_schema_version";

/// Read the on-disk stamp from `__manifest`'s schema-level metadata.
Expand Down Expand Up @@ -109,10 +129,14 @@ pub(crate) fn refuse_if_stamp_unsupported(stamp: u32) -> Result<()> {
if stamp < MIN_SUPPORTED_INTERNAL_SCHEMA_VERSION {
return Err(OmniError::manifest(format!(
"__manifest is stamped at internal schema v{stamp}, but this omnigraph reads only v{current}. \
This graph was created by an older omnigraph release; rebuild it: run `omnigraph export` with \
the older omnigraph binary that created it, then `omnigraph init` + `omnigraph load` with this one. \
(Data, vectors, and blobs are preserved; commit history and branches are not.)",
This graph was created by omnigraph {release}. Rebuild it: with an omnigraph {release} binary run \
`omnigraph export <graph> > graph.jsonl`, then with this binary run \
`omnigraph init --schema <schema.pg> <new-graph>` and \
`omnigraph load --mode overwrite --data graph.jsonl <new-graph>`. \
(Data, vectors, and blobs are preserved; commit history and branches are not.) \
See docs/user/operations/upgrade.md.",
current = INTERNAL_MANIFEST_SCHEMA_VERSION,
release = release_for_internal_schema_version(stamp),
)));
}
Ok(())
Expand Down Expand Up @@ -160,4 +184,21 @@ mod tests {
"a future stamp must be refused"
);
}

/// The refusal names the release line that wrote each stamp so an operator
/// knows which binary to use for the export step; unknown stamps fall back
/// without panicking.
#[test]
fn release_names_the_writing_line_for_each_stamp() {
assert_eq!(release_for_internal_schema_version(3), "0.6.2 to 0.7.2");
assert_eq!(release_for_internal_schema_version(4), "0.8.x");
assert_eq!(
release_for_internal_schema_version(99),
"an unrecognized older release"
);
// The sub-CURRENT refusal embeds the named release.
let err = refuse_if_stamp_unsupported(3).unwrap_err().to_string();
assert!(err.contains("0.6.2 to 0.7.2"), "got: {err}");
assert!(err.contains("omnigraph export"), "got: {err}");
}
}
8 changes: 7 additions & 1 deletion crates/omnigraph/src/db/manifest/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1826,10 +1826,16 @@ async fn sub_current_graph_is_refused_then_rebuilt_via_export_import() {
Ok(_) => panic!("a sub-CURRENT graph must be refused on open"),
Err(err) => err,
};
let msg = err.to_string();
assert!(
err.to_string().contains("export"),
msg.contains("export"),
"the refusal must nudge the operator to `omnigraph export`, got: {err}",
);
assert!(
msg.contains("0.7.2"),
"the refusal must name the release that wrote this stamp (v3 → 0.6.2 to 0.7.2) so the \
operator knows which binary to use, got: {err}",
);

// Rebuild with this binary: fresh init + load the export.
let dir_new = tempfile::tempdir().unwrap();
Expand Down
6 changes: 5 additions & 1 deletion docs/dev/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This file is the always-on map of the test surface. **Consult it before every ta
| Crate | Path | Style |
|---|---|---|
| `omnigraph` (engine) | `crates/omnigraph/tests/` | Integration tests (36 files), fixture-driven, share `tests/helpers/mod.rs` |
| `omnigraph-cli` | `crates/omnigraph-cli/tests/` | Per-area suites (post-modularization): `cli_cluster.rs` (cluster command surface + operator-actor cascade), `cli_cluster_e2e.rs` (spawned-binary lifecycle compositions — lost-state re-import recovery, out-of-band drift, graph-root destruction, multi-graph mixed-disposition convergence), `cli_data.rs` (load/read/change/branch/commit/export/snapshot/policy/embed/maintenance + operator format cascade), `cli_schema_config.rs` (init/config, schema plan/apply), `cli_queries.rs`, `parity_matrix.rs` (RFC-009 Phase 1: the embedded-vs-remote referee — every forked verb run against both arms with matched Cedar policy and the same actor, scrubbed-JSON + exit-code equality; divergences are pinned in its `KNOWN_DIVERGENCES` ledger, never silently repaired), `system_local.rs` (full-cycle cluster lifecycle with a spawned `--cluster` server, applied-policy enforcement over HTTP, keyed-credential auth, operator aliases), `system_remote.rs`; share `tests/support/mod.rs` (hermetic `OMNIGRAPH_HOME` by default) |
| `omnigraph-cli` | `crates/omnigraph-cli/tests/` | Per-area suites (post-modularization): `cli_cluster.rs` (cluster command surface + operator-actor cascade), `cli_cluster_e2e.rs` (spawned-binary lifecycle compositions — lost-state re-import recovery, out-of-band drift, graph-root destruction, multi-graph mixed-disposition convergence), `cli_data.rs` (load/read/change/branch/commit/export/snapshot/policy/embed/maintenance + operator format cascade), `cli_schema_config.rs` (init/config, schema plan/apply), `cli_queries.rs`, `parity_matrix.rs` (RFC-009 Phase 1: the embedded-vs-remote referee — every forked verb run against both arms with matched Cedar policy and the same actor, scrubbed-JSON + exit-code equality; divergences are pinned in its `KNOWN_DIVERGENCES` ledger, never silently repaired), `system_local.rs` (full-cycle cluster lifecycle with a spawned `--cluster` server, applied-policy enforcement over HTTP, keyed-credential auth, operator aliases), `system_remote.rs`, `crossversion_upgrade.rs` (gated genuine-v3 upgrade round-trip — see "Cross-version upgrade" below); share `tests/support/mod.rs` (hermetic `OMNIGRAPH_HOME` by default) |
| `omnigraph-cluster` | mostly in-source `#[cfg(test)] mod tests`; `tests/failpoints.rs` (feature-gated); `tests/s3_cluster.rs` (bucket-gated full lifecycle on object storage) | Cluster config parser, local JSON state diff, state CAS/lock handling/recovery, read-only validate/plan/status plus explicit refresh/import graph observations, config-only apply (content-addressed payload publish, disposition gating, composite-digest convergence, idempotent re-apply), catalog payload verification (status read-only, refresh drift + self-heal), failpoint crash-mid-apply / CAS-race coverage, Stage 4A graph creation (create executor, recovery sidecars + sweep rows, create crash windows), Stage 4B schema apply (migration previews in plan, schema executor, schema-apply sweep classification, schema crash windows), Stage 4C gated deletes (digest-bound approvals, delete executor + tombstones, delete sweep rows, delete crash windows), and 5A policy binding metadata (applies_to in the applied revision, binding-change diffing + convergence, pre-5A backfill), and the 5B serving-snapshot read API (converged read, refusal rows) |
| `omnigraph-server` | `crates/omnigraph-server/tests/` | Per-area suites (post-modularization): `auth_policy.rs`, `data_routes.rs`, `schema_routes.rs`, `stored_queries.rs`, `multi_graph.rs` (cluster-mode boot — converged serving, policy binding wiring, boot refusals — + the concurrent branch-ops matrix), `boot_settings.rs` (mode inference, PolicySource), `s3.rs` (bucket-gated: single-graph serving + config-free `--cluster s3://` boot), `openapi.rs` (OpenAPI drift / regeneration); share `tests/support/mod.rs` |
| `omnigraph-compiler` | mostly in-source `#[cfg(test)] mod tests` | Parser, type-checker, IR lowering, lint |
Expand Down Expand Up @@ -90,6 +90,10 @@ CI runs these S3-backed **correctness** tests against a containerized RustFS ser

Locally, set `OMNIGRAPH_S3_TEST_BUCKET` (and the usual `AWS_*` vars including `AWS_ENDPOINT_URL_S3` for non-AWS) before running. Without those, S3 tests skip gracefully.

## Cross-version upgrade (genuine v3 → current)

`crates/omnigraph-cli/tests/crossversion_upgrade.rs` is the empirical proof that the strict-single-version refusal works on a **real** old-format graph — not the stamp-rewind stand-in in `db/manifest/tests.rs::sub_current_graph_is_refused_then_rebuilt_via_export_import` (which only rewinds the stamp number on a v4-shaped graph). It mints a genuine internal-schema-v3 graph with an omnigraph **0.7.2** binary (real `_graph_commits.lance`, lineage-free `__manifest`), then asserts the current binary refuses it with the release-named message and that the documented `export → init → load` rebuild round-trips (row count + the `Vector` column). It is **gated on `OMNIGRAPH_OLD_BIN`** (an absolute path to a 0.7.2 `omnigraph`) and skips gracefully when unset, so the default `cargo test --workspace` stays green without it. **Deliberately NOT in CI** (same disposition as `write_cost_s3`): compiling 0.7.2 from source is a 15–25 min job, and the format-stamp seam changes a few times a year — run it on demand when touching `migrations.rs`, the loader/export surfaces, or at a release boundary. To run: `cargo install omnigraph-cli@0.7.2 --locked --root /tmp/og-old` then `OMNIGRAPH_OLD_BIN=/tmp/og-old/bin/omnigraph cargo test -p omnigraph-cli --test crossversion_upgrade`.

## System e2e requirements and suppression

The CLI system tests (`system_local.rs`) spawn the workspace-built `omnigraph` and `omnigraph-server` binaries (cargo provides paths via `CARGO_BIN_EXE_*`), bind ephemeral localhost ports, and use local-FS temp dirs — no external services, no env vars required; they run in the default `cargo test --workspace`. The comprehensive cluster lifecycle e2es (multi-server-restart flows) honor an opt-out for constrained sandboxes: set `OMNIGRAPH_SKIP_SYSTEM_E2E=1` to skip them with a logged message (the same graceful-skip pattern as the S3 gate). Cargo-native filtering also works: `cargo test --test system_local -- --skip local_cluster`.
Expand Down
5 changes: 5 additions & 0 deletions docs/dev/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ it cannot serve:
- a stamp **above** CURRENT → refused with "upgrade omnigraph", so an old binary
cannot silently misread a newer format.

The below-CURRENT refusal names the release line that wrote the stamp
(`release_for_internal_schema_version` in `db/manifest/migrations.rs`) and prints
the exact `export` / `init` / `load` commands, so the upgrade is fail-closed **and**
self-service — the operator can fetch the right old binary without guessing.

There is no in-place migration dispatcher. The single source file
`db/manifest/migrations.rs` holds only the version constant, the stamp read/write,
and `refuse_if_stamp_unsupported`.
Expand Down
Loading