Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4a551f3
test(engine): pin the long-lived-handle heal contract for sidecar-cov…
ragnorc Jun 11, 2026
c0f1617
fix(engine): heal pending recovery sidecars at the staged-write entry…
ragnorc Jun 11, 2026
e6a2b85
fix(engine): name the right recovery path in the commit-time drift guard
ragnorc Jun 11, 2026
8c4881f
docs: write-entry in-process sidecar heal — contract and coverage
ragnorc Jun 11, 2026
f371cdd
test(engine): pin the entry heal contract for schema apply and branch…
ragnorc Jun 11, 2026
ad1e03f
fix(engine): heal pending recovery sidecars at the schema-apply and b…
ragnorc Jun 11, 2026
c465300
test(engine): pin Phase A sidecar-write failure semantics
ragnorc Jun 12, 2026
ab8dfbc
test(engine): pin Phase D delete, list, and audit-append storage-faul…
ragnorc Jun 12, 2026
dd26ff2
test(engine): refuse corrupt recovery sidecars loudly
ragnorc Jun 12, 2026
dde29f2
test(engine): run the S3 sidecar-lifecycle coverage in CI + document …
ragnorc Jun 12, 2026
3d26638
test(engine): pin read-visibility of acknowledged local if-absent writes
ragnorc Jun 12, 2026
d7a0144
fix(engine): publish local storage writes with atomic visibility
ragnorc Jun 12, 2026
26571a3
refactor(engine): one storage implementation over object_store for ev…
ragnorc Jun 12, 2026
7cb3c94
test(engine): one executable storage contract, run against every backend
ragnorc Jun 12, 2026
acb0bc3
refactor(cluster): drop put_json's per-backend atomicity branch
ragnorc Jun 12, 2026
aa7d7c4
docs: storage adapter collapse — contract, in-memory backend, local C…
ragnorc Jun 12, 2026
7181eaa
docs: finish renaming the storage adapters in user docs and test comm…
ragnorc Jun 12, 2026
f251b16
Merge origin/main: flush fix superseded by the atomic-visibility rede…
ragnorc Jun 12, 2026
69dfb88
test(engine): pin branch-awareness of the drift guard's recovery advice
ragnorc Jun 12, 2026
829334a
fix(engine): branch-aware sidecar matching in the drift guard's advice
ragnorc Jun 12, 2026
0f1dc55
test(engine): pin heal non-interference with a live schema apply
ragnorc Jun 12, 2026
9f99d26
fix(engine): serialize the heal's schema-staging reconcile with live …
ragnorc Jun 12, 2026
8565390
test(engine): pin catalog reload after the heal recovers a schema apply
ragnorc Jun 12, 2026
743fe43
fix(engine): reload the in-memory catalog after the heal recovers a s…
ragnorc Jun 12, 2026
7e4ee0a
test(engine): pin that a deleted-branch sidecar cannot wedge the graph
ragnorc Jun 12, 2026
e01fd43
fix(engine): classify deleted-branch sidecars as orphaned instead of …
ragnorc Jun 12, 2026
e231168
chore: harden review nits — vacuous CI filter, root-runner skip, live…
ragnorc Jun 12, 2026
8fcf0a7
Merge remote-tracking branch 'origin/main' into ragnorc/correctness-v…
ragnorc Jun 12, 2026
6fadebc
test(engine): pin orphan-discard idempotency across a delete fault
ragnorc Jun 12, 2026
4cb8551
fix(engine): orphan-discard idempotency + heal reports acted-vs-deferred
ragnorc Jun 12, 2026
f2e902e
test(engine): pin the orphan-discard audit-append fault leg as docume…
ragnorc Jun 12, 2026
5474a59
test(engine): pin honest drift-guard advice when sidecar listing fails
ragnorc Jun 12, 2026
974a27e
fix(engine): admit ambiguity in the drift guard when sidecar listing …
ragnorc Jun 12, 2026
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,20 @@ jobs:
- name: Run RustFS CLI smoke
run: cargo test --locked -p omnigraph-cli --test system_local local_cli_s3_end_to_end_init_load_read_flow -- --nocapture

- name: Run RustFS recovery-sidecar lifecycle
# Sidecar put/list/delete through the S3 storage backend on a
# real bucket (the failpoint only wedges the publisher; the
# sidecar I/O is exercised for real). Name filter `s3_` matches
# the bucket-gated tests in the failpoints target only; the
# grep guards against the filter going vacuous (cargo passes
# with 0 tests matched) if those tests are ever renamed.
run: |
output=$(cargo test --locked -p omnigraph-engine --features failpoints --test failpoints s3_ -- --nocapture 2>&1); status=$?
echo "$output"
[ "$status" -eq 0 ] || exit "$status"
echo "$output" | grep -Eq "test result: ok\. [1-9][0-9]* passed" \
|| { echo "::error::filter 's3_' matched no tests — vacuous pass"; exit 1; }

- name: Dump RustFS logs on failure
if: failure()
run: docker logs rustfs
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ omnigraph policy explain --actor act-alice --action change --branch main
| Columnar storage on object store | ✅ Arrow/Lance | URI normalization, S3 env-var plumbing |
| Per-dataset versioning + time travel | ✅ | `snapshot_at_version`, `entity_at`, snapshot-pinned reads across many tables |
| Per-dataset branches | ✅ | **Graph-level** branches (atomic across all sub-tables), lazy fork, system branch filtering |
| Atomic single-dataset commits | ✅ | **Multi-table publish via three layers**, NOT a single Lance primitive: (1) per-table Lance `commit_staged` for the data write, (2) `__manifest` row-level CAS via `ManifestBatchPublisher` for cross-table ordering, (3) the open-time recovery sweep for the residual gap between (1) and (2). All three layers ship; the five migrated writers (`MutationStaging::finalize`, `schema_apply`, `branch_merge`, `ensure_indices`, `optimize_all_tables`) write a `__recovery/{ulid}.json` sidecar before Phase B and delete it after Phase C. The next `Omnigraph::open` (gated on `OpenMode::ReadWrite`) runs the sweep in `db/manifest/recovery.rs`: classify, decide all-or-nothing per sidecar, roll forward via single `ManifestBatchPublisher::publish` or roll back via `Dataset::restore` followed by a manifest publish of the restored version (so both directions converge to `manifest == HEAD` — no residual drift), and record an audit row in `_graph_commit_recoveries.lance` (queryable via `omnigraph commit list --filter actor=omnigraph:recovery`). Continuous in-process recovery (no restart needed between Phase B failure and recovery) is the goal of a future background reconciler. Engine writes route through a sealed `TableStorage` trait (`db.storage()`) exposing only `stage_*` + `commit_staged` + reads; the inline-commit residuals (`delete_where`, `create_vector_index`) are split onto a separate sealed `InlineCommitResidual` trait reached via `db.storage_inline_residual()` (MR-854), so the default surface cannot couple a write with a HEAD advance — §1 holds by construction. `delete_where` and `create_vector_index` stay inline until upstream Lance ships a public two-phase API ([#6658](https://github.com/lance-format/lance/issues/6658), [#6666](https://github.com/lance-format/lance/issues/6666)); `LoadMode::Overwrite` uses Lance `Overwrite` staged transactions. |
| Atomic single-dataset commits | ✅ | **Multi-table publish via three layers**, NOT a single Lance primitive: (1) per-table Lance `commit_staged` for the data write, (2) `__manifest` row-level CAS via `ManifestBatchPublisher` for cross-table ordering, (3) the open-time recovery sweep for the residual gap between (1) and (2). All three layers ship; the five migrated writers (`MutationStaging::finalize`, `schema_apply`, `branch_merge`, `ensure_indices`, `optimize_all_tables`) write a `__recovery/{ulid}.json` sidecar before Phase B and delete it after Phase C. The next `Omnigraph::open` (gated on `OpenMode::ReadWrite`) runs the sweep in `db/manifest/recovery.rs`: classify, decide all-or-nothing per sidecar, roll forward via single `ManifestBatchPublisher::publish` or roll back via `Dataset::restore` followed by a manifest publish of the restored version (so both directions converge to `manifest == HEAD` — no residual drift), and record an audit row in `_graph_commit_recoveries.lance` (queryable via `omnigraph commit list --filter actor=omnigraph:recovery`). The write entry points (`load_as`, `mutate_as`, `apply_schema_as`, `branch_merge_as`) and `refresh` additionally run an in-process roll-forward-only heal (serialized against live writers via the per-table write queues), so a long-lived server converges on its next write without restart; only rollback-eligible sidecars still defer to the next read-write open (a future background reconciler's goal). Engine writes route through a sealed `TableStorage` trait (`db.storage()`) exposing only `stage_*` + `commit_staged` + reads; the inline-commit residuals (`delete_where`, `create_vector_index`) are split onto a separate sealed `InlineCommitResidual` trait reached via `db.storage_inline_residual()` (MR-854), so the default surface cannot couple a write with a HEAD advance — §1 holds by construction. `delete_where` and `create_vector_index` stay inline until upstream Lance ships a public two-phase API ([#6658](https://github.com/lance-format/lance/issues/6658), [#6666](https://github.com/lance-format/lance/issues/6666)); `LoadMode::Overwrite` uses Lance `Overwrite` staged transactions. |
| Compaction (`compact_files`) | ✅ | `omnigraph optimize` orchestrates over all node/edge tables, bounded concurrency; **publishes each compacted table's new version to `__manifest`** (so the manifest tracks the Lance HEAD — required for reads to observe compaction and for schema apply / strict writes to pass their HEAD-vs-manifest precondition), under the per-`(table, main)` write queue with `SidecarKind::Optimize` recovery coverage; **refuses on an unrecovered graph** (errors if a `__recovery` sidecar is pending); **skips uncovered HEAD > manifest drift** with `DriftNeedsRepair` instead of interpreting it; **skips blob-bearing tables** (reported via `TableOptimizeStats.skipped`, not silent), gated on `LANCE_SUPPORTS_BLOB_COMPACTION` until the upstream blob-v2 compaction-decode bug is fixed (see [docs/dev/invariants.md](docs/dev/invariants.md) Known Gaps) |
| Repair uncovered drift | — | `omnigraph repair` explicitly classifies uncovered table `HEAD > manifest` drift: verified maintenance drift (`ReserveFragments`/`Rewrite`) can be published with `--confirm`; suspicious or unverifiable drift requires `--force --confirm`. Sidecar-covered crash residuals still recover automatically on open. |
| Cleanup (`cleanup_old_versions`) | ✅ | `omnigraph cleanup` with `--keep` / `--older-than` policy |
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ base64 = "0.22"
ariadne = "0.4"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
object_store = { version = "0.12.5", default-features = false, features = ["aws"] }
object_store = { version = "0.12.5", default-features = false, features = ["aws", "fs"] }
fail = "0.5"
time = { version = "0.3", features = ["formatting"] }
axum = { version = "0.8", features = ["json", "macros"] }
Expand Down
31 changes: 8 additions & 23 deletions crates/omnigraph-cluster/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,31 +169,16 @@ impl ClusterStore {
.map_err(|err| err.to_string())
}

/// JSON object write with the strongest atomicity the backend offers:
/// temp + rename on the filesystem (no torn JSON after a crash; the
/// pre-port behavior), a single atomic PUT on object stores (where
/// copy+delete would be weaker, not stronger).
/// JSON object write. Atomic visibility is the storage adapter's
/// contract on every backend (staged temp + rename on the filesystem,
/// a single atomic PUT on object stores) — no torn JSON after a crash,
/// no per-backend branch needed here.
async fn put_json(&self, relative: &str, payload: &str) -> Result<(), String> {
let target = self.uri(relative);
match self.kind() {
StorageKind::Local => {
let tmp = format!("{target}.tmp.{}", Ulid::new());
self.adapter
.write_text(&tmp, payload)
.await
.map_err(|err| err.to_string())?;
if let Err(err) = self.adapter.rename_text(&tmp, &target).await {
let _ = self.adapter.delete(&tmp).await;
return Err(err.to_string());
}
Ok(())
}
StorageKind::S3 => self
.adapter
.write_text(&target, payload)
.await
.map_err(|err| err.to_string()),
}
self.adapter
.write_text(&target, payload)
.await
.map_err(|err| err.to_string())
}

/// Shared list-and-parse for the sidecar/approval directories: id
Expand Down
22 changes: 19 additions & 3 deletions crates/omnigraph-cluster/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1950,13 +1950,29 @@ graphs:
}

#[tokio::test]
#[cfg(unix)]
async fn refresh_flags_unreadable_payload_as_error() {
let dir = fixture();
init_derived_graph(dir.path()).await;
let blob = converge_fixture(dir.path()).await;
// A same-named directory yields a non-NotFound IO error portably.
fs::remove_file(&blob).unwrap();
fs::create_dir(&blob).unwrap();
// Make the payload unreadable without removing it: permission
// denied is a genuine non-NotFound IO error. (A same-named
// directory no longer triggers this path: object-store semantics
// classify a directory at an object path as NotFound — "only
// objects exist" — which is the missing-payload case, not the
// unreadable one.)
let mut perms = fs::metadata(&blob).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o000);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two portability nits: (1) this fails when the test runner is root (chmod 0o000 doesn't stop root reads — Docker dev containers commonly run as root); a euid guard with a logged skip would degrade gracefully instead of failing. (2) std::os::unix::fs::PermissionsExt is unix-only — fine today, but a #[cfg(unix)] would keep a hypothetical Windows test build compiling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed: #[cfg(unix)] on the test, plus a probe-based skip-with-log when mode 000 is still readable (root runners) — the contract under test needs a genuine permission error, so degrading beats failing.

fs::set_permissions(&blob, perms).unwrap();
// Root reads straight through mode 000 (container dev runners
// commonly run as root): skip rather than fail — the contract
// under test needs a genuine permission error.
if fs::read(&blob).is_ok() {
eprintln!(
"skipping refresh_flags_unreadable_payload_as_error: running as root (mode 000 is still readable)"
);
return;
}

let out = refresh_config_dir(dir.path()).await;
assert!(!out.ok);
Expand Down
3 changes: 2 additions & 1 deletion crates/omnigraph/src/db/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ use publisher::{GraphNamespacePublisher, ManifestBatchPublisher};
pub(crate) use recovery::{
RecoveryMode, RecoverySidecar, RecoverySidecarHandle, SidecarKind, SidecarTablePin,
SidecarTableRegistration, SidecarTombstone, delete_sidecar, has_schema_apply_sidecar,
list_sidecars, new_sidecar, recover_manifest_drift, write_sidecar,
heal_pending_sidecars_roll_forward, list_sidecars, new_sidecar, recover_manifest_drift,
schema_apply_serial_queue_key, write_sidecar,
};
pub use state::SubTableEntry;
#[cfg(test)]
Expand Down
Loading
Loading