diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9af248e00..9fcbbae35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 with: components: rustfmt, clippy @@ -90,7 +90,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 - uses: actions/cache@v6 with: path: | @@ -112,7 +112,7 @@ jobs: with: python-version: '3.14' - uses: astral-sh/setup-uv@v7 - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 - uses: actions/cache@v6 with: path: | @@ -143,7 +143,7 @@ jobs: - uses: actions/setup-node@v7 with: node-version: '22' - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 - uses: actions/cache@v6 with: path: | @@ -170,7 +170,7 @@ jobs: with: go-version: '1.22' cache: false - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 - uses: actions/cache@v6 with: path: | @@ -196,7 +196,7 @@ jobs: - uses: actions/setup-dotnet@v6 with: dotnet-version: '8.0' - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 - uses: actions/cache@v6 with: path: | @@ -224,7 +224,7 @@ jobs: - uses: dart-lang/setup-dart@v1 with: sdk: stable - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 - uses: actions/cache@v6 with: path: | @@ -254,7 +254,7 @@ jobs: - uses: actions/setup-node@v7 with: node-version: '22' - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 with: targets: wasm32-unknown-unknown - uses: cargo-bins/cargo-binstall@main @@ -300,7 +300,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Install cargo-nextest uses: taiki-e/install-action@nextest @@ -352,7 +352,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Install cargo-nextest uses: taiki-e/install-action@nextest @@ -421,7 +421,7 @@ jobs: - profile: enterprise features: "--features tracing" engine_features: "--features metrics,tracing,async-storage" - # Full: all features combined (deprecated in 0.6.0, replace with persona combos) + # Full: all features combined (`full` is a deprecated alias, removed in 0.8.0 per #468; use persona combos) - profile: full features: "--features lpg,languages,triple-store,algos,parallel,vector-index,text-index,hybrid-search,spill,mmap,regex,jsonl-import,parquet-import" engine_features: "--features full" @@ -429,7 +429,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Install cargo-nextest uses: taiki-e/install-action@nextest @@ -490,11 +490,11 @@ jobs: miri: name: Miri UB Check runs-on: ubuntu-latest - # Pin nightly due to cargo regression in nightly-2026-02-12+ - # See: https://github.com/rust-lang/miri/issues/4855 - # Remove pin once rust-lang/cargo#16631 lands in nightly + # Pinned nightly, so a Miri change cannot break CI unannounced. Cargo refuses a + # toolchain older than `rust-version`, so the nightly's rustc must be at least + # the MSRV: bump this pin together with `rust-version` in Cargo.toml. env: - NIGHTLY: nightly-2026-02-11 + NIGHTLY: nightly-2026-10-03 steps: - uses: actions/checkout@v7 @@ -533,7 +533,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 with: components: llvm-tools-preview @@ -580,7 +580,7 @@ jobs: uses: astral-sh/setup-uv@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache cargo registry uses: actions/cache@v6 @@ -633,7 +633,7 @@ jobs: node-version: ${{ matrix.node }} - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache cargo registry uses: actions/cache@v6 @@ -679,7 +679,7 @@ jobs: cache: false - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache cargo registry uses: actions/cache@v6 @@ -738,7 +738,7 @@ jobs: dotnet-version: '8.0' - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache cargo registry uses: actions/cache@v6 @@ -803,7 +803,7 @@ jobs: sdk: stable - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache cargo registry uses: actions/cache@v6 @@ -860,7 +860,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 with: targets: wasm32-unknown-unknown @@ -908,7 +908,7 @@ jobs: fetch-depth: 0 # Need full history to checkout base commit - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache cargo registry uses: actions/cache@v6 @@ -976,7 +976,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache cargo registry uses: actions/cache@v6 @@ -1024,11 +1024,11 @@ jobs: - uses: actions/checkout@v7 - name: Install MSRV toolchain - uses: dtolnay/rust-toolchain@1.91.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Check compilation with MSRV - # Explicit `+1.91.1` so rust-toolchain.toml does not override the MSRV toolchain - run: cargo +1.91.1 check --workspace --all-features + # Explicit `+1.99.0` so rust-toolchain.toml does not override the MSRV toolchain + run: cargo +1.99.0 check --workspace --all-features # Typo detection typos: @@ -1052,7 +1052,7 @@ jobs: with: python-version: '3.14' - uses: astral-sh/setup-uv@v7 - - uses: dtolnay/rust-toolchain@1.98.1 + - uses: dtolnay/rust-toolchain@1.99.0 - uses: actions/cache@v6 with: path: | diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 3629565ef..5157deebc 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -30,7 +30,7 @@ jobs: args: --release --out dist -m crates/bindings/python/Cargo.toml sccache: "true" manylinux: auto - rust-toolchain: "1.98.1" + rust-toolchain: "1.99.0" - name: Upload wheels uses: actions/upload-artifact@v7 with: @@ -58,7 +58,7 @@ jobs: args: --release --out dist -m crates/bindings/python/Cargo.toml sccache: "true" manylinux: musllinux_1_2 - rust-toolchain: "1.98.1" + rust-toolchain: "1.99.0" - name: Upload wheels uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c54e82b2..4313305d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 with: targets: ${{ matrix.target }} @@ -206,7 +206,7 @@ jobs: - uses: actions/checkout@v7 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 - name: Verify version consistency run: | @@ -305,7 +305,7 @@ jobs: node-version: '24' - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 with: targets: ${{ matrix.target }} @@ -419,7 +419,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 with: targets: wasm32-unknown-unknown @@ -461,7 +461,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.98.1 + uses: dtolnay/rust-toolchain@1.99.0 with: targets: wasm32-unknown-unknown diff --git a/CHANGELOG.md b/CHANGELOG.md index 5360843f6..d9be3d082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,23 @@ All notable changes to Grafeo, for future reference (and enjoyment). -## [0.5.45] - Unreleased +## [0.6.0] - Unreleased + +### Changed + +- **Versioning**: before 1.0, a release that changes the file format or breaks the stable surface (the `grafeo` crate, the bindings, the CLI) bumps the minor version, as Cargo and npm expect for `0.x`. This release, first planned as 0.5.45, is 0.6.0 for that reason. See [Versioning and Compatibility](https://grafeo.dev/versioning/). +- **Breaking: the minimum Rust version is 1.99.0** (was 1.91.1). +- **Rust (`grafeo-engine`): `TransactionState::Committing`**: `TransactionManager::state` reports it while a commit is being completed, between its commit epoch and its WAL records. + +### Fixed + +- **Writes during a commit could land in the middle of it** ([#548](https://github.com/GrafeoDB/grafeo/issues/548)): a direct write, or another transaction writing what the committing one wrote, could hide the committed value from point-in-time reads, remove it on rollback or mark an uncommitted value as committed; a transaction that began during a commit could miss it; and after a crash, two commits could be replayed in the wrong order. A commit now completes before anything that comes after it. ## [0.5.44] - 2026-10-04 Durability and consistency release. Crash-safe checkpoints and WAL recovery, indexes and constraints that survive a reopen, schema checks on every write path, per-graph conflicts and grants, commit and rollback in O(changes), and fixes for shortest paths, variable-length edges, list comprehensions, subqueries, `OPTIONAL MATCH` and `MERGE`. Plus graph handles, upserts by key and write counters. -> **Heads-up: 0.5.45 changes the on-disk format.** 0.5.45 migrates a database automatically on first open (WAL-directory databases become a single `.grafeo` file); after that, 0.5.44 and older can no longer open it, so keep a backup if you may need to go back. +> **Heads-up: 0.6.0 (first announced as 0.5.45) changes the on-disk format.** 0.6.0 migrates a database automatically on first open (WAL-directory databases become a single `.grafeo` file); after that, 0.5.44 and older can no longer open it, so keep a backup if you may need to go back. ### Changed @@ -37,7 +47,7 @@ Every query result in the differential test corpus that differs from 0.5.43 is l - **`DurabilityMode::Adaptive` never synced the WAL**, so it behaved like `NoSync`, and backups missed writes made while they ran; those now land in this backup or the next. - **Schema changes, constraints and `create_graph()` / `drop_graph()` were lost on WAL replay** ([#421](https://github.com/GrafeoDB/grafeo/issues/421), [#422](https://github.com/GrafeoDB/grafeo/issues/422)). An unknown WAL record now fails the open instead of being skipped. - **`DROP CONSTRAINT` did nothing** ([#420](https://github.com/GrafeoDB/grafeo/issues/420)). Constraints are now stored by name: `CREATE` and `DROP CONSTRAINT` support `IF NOT EXISTS` / `IF EXISTS`, `SHOW CONSTRAINTS` lists them, and unnamed ones get a name such as `City_name_not_null`. Constraints from 0.5.43 files have no name and cannot be dropped by name. -- **Reopening a `.grafeo` database or `to_memory()` lost its indexes** (lookups on a reopened file scanned every node, [#459](https://github.com/GrafeoDB/grafeo/issues/459)), and `to_memory()` also dropped the schema, constraints and property history. WAL-directory databases still lose their indexes until 0.5.45 ([#401](https://github.com/GrafeoDB/grafeo/issues/401)). +- **Reopening a `.grafeo` database or `to_memory()` lost its indexes** (lookups on a reopened file scanned every node, [#459](https://github.com/GrafeoDB/grafeo/issues/459)), and `to_memory()` also dropped the schema, constraints and property history. WAL-directory databases still lose their indexes until 0.6.0 ([#401](https://github.com/GrafeoDB/grafeo/issues/401)). - **Rust builds with only the `rdf` profile kept no data across a reopen** ([#544](https://github.com/GrafeoDB/grafeo/issues/544)); the facade's `rdf` profile now includes the LPG store. - **Embeddings changed while their vector index was spilled reverted on reload** ([#522](https://github.com/GrafeoDB/grafeo/issues/522)). - **Sessions and direct reads after `compact()` missed parts of the database**: SPARQL writes were lost, CDC missed queries, the selected graph was ignored, and `get_node`, `node_count`, `info()`, `schema()` and similar did not see pre-compaction data (or panicked on a `with_store` database). Queries after `compact()` are still not written to the WAL, so a crash loses them; direct calls are logged. @@ -133,7 +143,7 @@ Stabilization release. Fixes for silent wrong results (`ORDER BY` + `LIMIT`, `UN - **GQL ran only the first statement and ignored the rest** ([#380](https://github.com/GrafeoDB/grafeo/issues/380)): `INSERT ... INSERT ...` (as in the quickstart) created only the first node and `INSERT ... RETURN` ignored its `RETURN`; both now work. Gremlin and Cypher had similar gaps. - **GQL `^` (power) was not parsed**: `RETURN 2 ^ 10` returned `2`. It now computes the power, binding tighter than `*` and right associative. - **`ALTER NODE TYPE / ALTER EDGE TYPE ... ADD PROPERTY name TYPE` added a property called `PROPERTY`** of type `name` and dropped the real type; `DROP PROPERTY name` dropped the wrong property. `PROPERTY` is now an optional keyword. -- **Databases over the storage format's limits were written corrupt** ([#392](https://github.com/GrafeoDB/grafeo/issues/392)): an LPG section over 4 GiB or 65,535 blocks, or over 65,535 labels on one node or versions of one property, wrapped a size field, and the file failed to open with `CRC mismatch`. Such a checkpoint now fails with an error naming the limit and keeps the sidecar WAL, so nothing is lost. Larger sections are planned for 0.5.45. +- **Databases over the storage format's limits were written corrupt** ([#392](https://github.com/GrafeoDB/grafeo/issues/392)): an LPG section over 4 GiB or 65,535 blocks, or over 65,535 labels on one node or versions of one property, wrapped a size field, and the file failed to open with `CRC mismatch`. Such a checkpoint now fails with an error naming the limit and keeps the sidecar WAL, so nothing is lost. Larger sections are planned for 0.6.0. - **Rolling back a transaction on a persistent database did not undo `SET`, `REMOVE` or label changes**, and single-file databases wrote them to disk on `close()`. - **`ORDER BY ... LIMIT` over a whole-node `RETURN` returned raw NodeIds** instead of node maps, both for property sort keys ([#335](https://github.com/GrafeoDB/grafeo/issues/335)) and for expression keys such as `text_score(...)`, `CASE` or arithmetic ([#347](https://github.com/GrafeoDB/grafeo/issues/347)). ([#337](https://github.com/GrafeoDB/grafeo/pull/337), [#349](https://github.com/GrafeoDB/grafeo/pull/349), [@temporaryfix](https://github.com/temporaryfix)) - **Duplicate column names silently lost data** ([#371](https://github.com/GrafeoDB/grafeo/issues/371)), e.g. `RETURN id(s), id(t)`. Distinct expressions now get distinct names, and a result that would still repeat a name is an error asking for an alias; in SPARQL, an `AS ?x` that repeats a projected or bound name is a parse error. ([#372](https://github.com/GrafeoDB/grafeo/pull/372), [@teipsum](https://github.com/teipsum); [#350](https://github.com/GrafeoDB/grafeo/pull/350), [@temporaryfix](https://github.com/temporaryfix)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59408f6b0..f35d43752 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ cd grafeo cargo build --workspace ``` -You'll need **Rust 1.91.1+** and optionally **Python 3.12+** / **Node.js 20+** for the bindings. +You'll need **Rust 1.99.0+** and optionally **Python 3.12+** / **Node.js 20+** for the bindings. ## Branching @@ -170,6 +170,19 @@ npm run build npm test ``` +## Versioning + +Grafeo follows the rules in [Versioning and Compatibility](https://grafeo.dev/versioning/). In short, for a +pull request: + +- A breaking change to the stable surface (the `grafeo` crate and its re-exports, the bindings, the CLI, the file + format) or a higher minimum Rust version goes into a minor release (`0.6` to `0.7`). Mark its CHANGELOG entry + **Breaking**; patch releases have none. +- Changes inside the implementation crates (`grafeo-common`, `grafeo-core`, `grafeo-storage`, `grafeo-adapters`, + `grafeo-engine`) that do not reach the stable surface can go into any release. +- Bug fixes go into patch releases, also when they change query results. +- Deprecate before removing: name the replacement, and remove it at the earliest in the next minor release. + ## Ecosystem Projects These companion projects live in separate repositories under the [GrafeoDB](https://github.com/GrafeoDB) organization: diff --git a/Cargo.toml b/Cargo.toml index 72ff4a0b8..7bda8ba9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ [workspace.package] version = "0.5.44" edition = "2024" -rust-version = "1.91.1" +rust-version = "1.99.0" license = "Apache-2.0" repository = "https://github.com/GrafeoDB/grafeo" authors = ["S.T. Grond"] diff --git a/README.md b/README.md index 61d27f96f..2579367ac 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Web](https://img.shields.io/npm/v/@grafeo-db/web.svg?label=web&color=7c4dff)](https://www.npmjs.com/package/@grafeo-db/web) [![Server](https://img.shields.io/github/v/release/GrafeoDB/grafeo-server?label=server&color=7c4dff)](https://github.com/GrafeoDB/grafeo-server) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![MSRV](https://img.shields.io/badge/MSRV-1.91.1-blue)](https://www.rust-lang.org) +[![MSRV](https://img.shields.io/badge/MSRV-1.99.0-blue)](https://www.rust-lang.org) [![Python](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/nqU6RUVaxW) diff --git a/crates/bindings/c/src/database.rs b/crates/bindings/c/src/database.rs index 0e5a66be6..f61c699fb 100644 --- a/crates/bindings/c/src/database.rs +++ b/crates/bindings/c/src/database.rs @@ -2399,7 +2399,7 @@ mod tests { assert!(!v.is_null()); // SAFETY: Static string, always valid. let version_str = unsafe { std::ffi::CStr::from_ptr(v) }.to_str().unwrap(); - assert!(!version_str.is_empty()); + assert!(!version_str.is_empty(), "version_str is empty"); } // ── Edge property CRUD ── diff --git a/crates/bindings/common/src/error.rs b/crates/bindings/common/src/error.rs index 7f5475a80..cc2617e5d 100644 --- a/crates/bindings/common/src/error.rs +++ b/crates/bindings/common/src/error.rs @@ -105,7 +105,7 @@ mod tests { fn error_message_is_non_empty() { let err = Error::Internal("something broke".into()); let msg = error_message(&err); - assert!(!msg.is_empty()); + assert!(!msg.is_empty(), "msg is empty"); assert!(msg.contains("something broke")); } } diff --git a/crates/bindings/python/src/database.rs b/crates/bindings/python/src/database.rs index a340a354f..86125f627 100644 --- a/crates/bindings/python/src/database.rs +++ b/crates/bindings/python/src/database.rs @@ -3533,7 +3533,7 @@ fn extract_isolation_level(value: Option<&Bound<'_, PyAny>>) -> PyResult &'static str { + fn as_str(&self) -> &'static str { match self { Self::ReadCommitted => "read_committed", Self::Snapshot => "snapshot", @@ -3555,7 +3555,6 @@ impl PyIsolationLevel { #[pymethods] impl PyIsolationLevel { - #[allow(clippy::trivially_copy_pass_by_ref)] fn __repr__(&self) -> &'static str { match self { Self::ReadCommitted => "IsolationLevel.READ_COMMITTED", @@ -3564,7 +3563,6 @@ impl PyIsolationLevel { } } - #[allow(clippy::trivially_copy_pass_by_ref)] fn __str__(&self) -> &'static str { self.as_str() } diff --git a/crates/grafeo-adapters/src/plugins/algorithms/centrality.rs b/crates/grafeo-adapters/src/plugins/algorithms/centrality.rs index decc8423a..f2856005d 100644 --- a/crates/grafeo-adapters/src/plugins/algorithms/centrality.rs +++ b/crates/grafeo-adapters/src/plugins/algorithms/centrality.rs @@ -833,7 +833,10 @@ mod tests { // Test trait methods assert_eq!(algo.name(), "pagerank"); - assert!(!algo.description().is_empty()); + assert!( + !algo.description().is_empty(), + "algo.description() is empty" + ); assert_eq!(algo.parameters().len(), 3); // Test execute with default params @@ -858,7 +861,10 @@ mod tests { // Test trait methods assert_eq!(algo.name(), "betweenness_centrality"); - assert!(!algo.description().is_empty()); + assert!( + !algo.description().is_empty(), + "algo.description() is empty" + ); assert_eq!(algo.parameters().len(), 1); // Test execute with default params (normalized=true) @@ -881,7 +887,10 @@ mod tests { // Test trait methods assert_eq!(algo.name(), "closeness_centrality"); - assert!(!algo.description().is_empty()); + assert!( + !algo.description().is_empty(), + "algo.description() is empty" + ); assert_eq!(algo.parameters().len(), 1); // Test execute with default params (wf_improved=false) @@ -904,7 +913,10 @@ mod tests { // Test trait methods assert_eq!(algo.name(), "degree_centrality"); - assert!(!algo.description().is_empty()); + assert!( + !algo.description().is_empty(), + "algo.description() is empty" + ); assert_eq!(algo.parameters().len(), 1); // Test execute with default params (normalized=false) - returns 4 columns diff --git a/crates/grafeo-adapters/src/plugins/algorithms/clustering.rs b/crates/grafeo-adapters/src/plugins/algorithms/clustering.rs index 7ba12591b..1fd4fa7f2 100644 --- a/crates/grafeo-adapters/src/plugins/algorithms/clustering.rs +++ b/crates/grafeo-adapters/src/plugins/algorithms/clustering.rs @@ -956,7 +956,10 @@ mod tests { let algo = ClusteringCoefficientAlgorithm; assert_eq!(algo.name(), "clustering_coefficient"); - assert!(!algo.description().is_empty()); + assert!( + !algo.description().is_empty(), + "algo.description() is empty" + ); assert_eq!(algo.parameters().len(), 2); let params = Parameters::new(); diff --git a/crates/grafeo-adapters/src/plugins/algorithms/flow.rs b/crates/grafeo-adapters/src/plugins/algorithms/flow.rs index cdd526c0e..703391a0c 100644 --- a/crates/grafeo-adapters/src/plugins/algorithms/flow.rs +++ b/crates/grafeo-adapters/src/plugins/algorithms/flow.rs @@ -771,7 +771,7 @@ mod tests { // Flow edges should be non-empty if there's positive flow assert!(result.max_flow > 0.0); - assert!(!result.flow_edges.is_empty()); + assert!(!result.flow_edges.is_empty(), "result.flow_edges is empty"); // All flow values should be positive for (_, _, flow) in &result.flow_edges { diff --git a/crates/grafeo-adapters/src/plugins/algorithms/mst.rs b/crates/grafeo-adapters/src/plugins/algorithms/mst.rs index 91d999f41..4a1fc9bb5 100644 --- a/crates/grafeo-adapters/src/plugins/algorithms/mst.rs +++ b/crates/grafeo-adapters/src/plugins/algorithms/mst.rs @@ -464,7 +464,7 @@ mod tests { let store = LpgStore::new().unwrap(); let result = kruskal(&store, None); - assert!(result.edges.is_empty()); + assert!(result.edges.is_empty(), "{:?}", result.edges); assert_eq!(result.total_weight, 0.0); } @@ -475,7 +475,7 @@ mod tests { let result = kruskal(&store, None); - assert!(result.edges.is_empty()); + assert!(result.edges.is_empty(), "{:?}", result.edges); assert!(result.is_spanning_tree(1)); } @@ -514,7 +514,7 @@ mod tests { let store = LpgStore::new().unwrap(); let result = prim(&store, None, None); - assert!(result.edges.is_empty()); + assert!(result.edges.is_empty(), "{:?}", result.edges); } #[test] diff --git a/crates/grafeo-adapters/src/plugins/algorithms/structure.rs b/crates/grafeo-adapters/src/plugins/algorithms/structure.rs index c6b23cc59..7284e1334 100644 --- a/crates/grafeo-adapters/src/plugins/algorithms/structure.rs +++ b/crates/grafeo-adapters/src/plugins/algorithms/structure.rs @@ -990,14 +990,14 @@ mod tests { let br = bridges(&store); // Diamond has no bridges (every edge is part of a cycle) - assert!(br.is_empty()); + assert!(br.is_empty(), "{br:?}"); } #[test] fn test_bridges_empty() { let store = LpgStore::new().unwrap(); let br = bridges(&store); - assert!(br.is_empty()); + assert!(br.is_empty(), "{br:?}"); } #[test] @@ -1173,7 +1173,7 @@ mod tests { let edges_5 = k_truss(&store, 5); // No edges should be in 5-truss for K_4 - assert!(edges_5.is_empty()); + assert!(edges_5.is_empty(), "{edges_5:?}"); } #[test] diff --git a/crates/grafeo-adapters/src/plugins/algorithms/traits.rs b/crates/grafeo-adapters/src/plugins/algorithms/traits.rs index 20c60d1f3..5f25e670f 100644 --- a/crates/grafeo-adapters/src/plugins/algorithms/traits.rs +++ b/crates/grafeo-adapters/src/plugins/algorithms/traits.rs @@ -615,7 +615,7 @@ mod tests { fn node_value_result_builder_empty() { let b = NodeValueResultBuilder::with_capacity("val", 0); let result = b.build(); - assert!(result.rows.is_empty()); + assert!(result.rows.is_empty(), "{:?}", result.rows); } // ---- ComponentResultBuilder ---- @@ -643,6 +643,6 @@ mod tests { fn component_result_builder_default() { let b: ComponentResultBuilder = Default::default(); let result = b.build(); - assert!(result.rows.is_empty()); + assert!(result.rows.is_empty(), "{:?}", result.rows); } } diff --git a/crates/grafeo-adapters/src/plugins/algorithms/traversal.rs b/crates/grafeo-adapters/src/plugins/algorithms/traversal.rs index c4ae7c086..76de88f2f 100644 --- a/crates/grafeo-adapters/src/plugins/algorithms/traversal.rs +++ b/crates/grafeo-adapters/src/plugins/algorithms/traversal.rs @@ -487,7 +487,7 @@ mod tests { let store = create_test_graph(); let visited = bfs(&store, NodeId::new(0)); - assert!(!visited.is_empty()); + assert!(!visited.is_empty(), "visited is empty"); assert_eq!(visited[0], NodeId::new(0)); // Node 0 should be first } @@ -497,7 +497,7 @@ mod tests { let store = create_test_graph(); let layers = bfs_layers(&store, NodeId::new(0)); - assert!(!layers.is_empty()); + assert!(!layers.is_empty(), "layers is empty"); assert_eq!(layers[0], vec![NodeId::new(0)]); // Distance 0: just the start node } @@ -507,7 +507,7 @@ mod tests { let store = create_test_graph(); let finished = dfs(&store, NodeId::new(0)); - assert!(!finished.is_empty()); + assert!(!finished.is_empty(), "finished is empty"); // Post-order means leaves are finished first } @@ -515,14 +515,14 @@ mod tests { fn test_bfs_nonexistent_start() { let store = LpgStore::new().unwrap(); let visited = bfs(&store, NodeId::new(999)); - assert!(visited.is_empty()); + assert!(visited.is_empty(), "{visited:?}"); } #[test] fn test_dfs_nonexistent_start() { let store = LpgStore::new().unwrap(); let finished = dfs(&store, NodeId::new(999)); - assert!(finished.is_empty()); + assert!(finished.is_empty(), "{finished:?}"); } #[test] @@ -568,7 +568,7 @@ mod tests { fn test_bfs_layers_empty_graph() { let store = LpgStore::new().unwrap(); let layers = bfs_layers(&store, NodeId::new(0)); - assert!(layers.is_empty()); + assert!(layers.is_empty(), "{layers:?}"); } #[test] @@ -632,7 +632,7 @@ mod tests { }); // There's at least one non-tree edge (3->4 or 1->4) - assert!(!non_tree_edges.is_empty()); + assert!(!non_tree_edges.is_empty(), "non_tree_edges is empty"); } #[test] @@ -739,7 +739,7 @@ mod tests { fn test_dfs_all_empty_graph() { let store = LpgStore::new().unwrap(); let finished = dfs_all(&store); - assert!(finished.is_empty()); + assert!(finished.is_empty(), "{finished:?}"); } #[test] diff --git a/crates/grafeo-adapters/src/query/cypher/parser.rs b/crates/grafeo-adapters/src/query/cypher/parser.rs index 7996a240f..b39f4062c 100644 --- a/crates/grafeo-adapters/src/query/cypher/parser.rs +++ b/crates/grafeo-adapters/src/query/cypher/parser.rs @@ -2880,7 +2880,7 @@ mod tests { { if let Pattern::Path(path) = &patterns[0] { assert!(path.chain[0].where_clause.is_some()); - assert!(path.chain[0].types.is_empty()); + assert!(path.chain[0].types.is_empty(), "expected empty"); } else { panic!("Expected Path pattern"); } diff --git a/crates/grafeo-adapters/src/query/gql/parser.rs b/crates/grafeo-adapters/src/query/gql/parser.rs index 634182f2e..a9cd1d8df 100644 --- a/crates/grafeo-adapters/src/query/gql/parser.rs +++ b/crates/grafeo-adapters/src/query/gql/parser.rs @@ -8719,7 +8719,7 @@ mod tests { let edges = get_first_path_edges(&result); assert_eq!(edges[0].direction, EdgeDirection::Undirected); assert!(edges[0].variable.is_none()); - assert!(edges[0].types.is_empty()); + assert!(edges[0].types.is_empty(), "expected empty"); } #[test] @@ -8742,7 +8742,7 @@ mod tests { assert_eq!(edges.len(), 1); assert_eq!(edges[0].direction, EdgeDirection::Outgoing); assert!(edges[0].variable.is_none()); - assert!(edges[0].types.is_empty()); + assert!(edges[0].types.is_empty(), "expected empty"); } #[test] @@ -8754,7 +8754,7 @@ mod tests { assert_eq!(edges.len(), 1); assert_eq!(edges[0].direction, EdgeDirection::Incoming); assert!(edges[0].variable.is_none()); - assert!(edges[0].types.is_empty()); + assert!(edges[0].types.is_empty(), "expected empty"); } #[test] diff --git a/crates/grafeo-adapters/src/query/sparql/parser.rs b/crates/grafeo-adapters/src/query/sparql/parser.rs index e0e5e8c5e..516b9b59c 100644 --- a/crates/grafeo-adapters/src/query/sparql/parser.rs +++ b/crates/grafeo-adapters/src/query/sparql/parser.rs @@ -2709,7 +2709,7 @@ mod tests { if let QueryForm::Select(select) = &query.query_form { let triples = extract_triples(&select.where_clause); if let PropertyPath::Negation(iris) = &triples[0].predicate { - assert!(iris.is_empty()); + assert!(iris.is_empty(), "{iris:?}"); } else { panic!("expected Negation path"); } diff --git a/crates/grafeo-cli/src/commands/data.rs b/crates/grafeo-cli/src/commands/data.rs index 866210c5c..d1ed16117 100644 --- a/crates/grafeo-cli/src/commands/data.rs +++ b/crates/grafeo-cli/src/commands/data.rs @@ -470,7 +470,7 @@ mod tests { .expect("dump with explicit json format should succeed"); let content = std::fs::read_to_string(&dump_path).unwrap(); - assert!(!content.is_empty()); + assert!(!content.is_empty(), "content is empty"); } #[test] @@ -759,7 +759,7 @@ mod tests { .expect("dump with jsonl format should succeed"); let content = std::fs::read_to_string(&dump_path).unwrap(); - assert!(!content.is_empty()); + assert!(!content.is_empty(), "content is empty"); } #[test] diff --git a/crates/grafeo-cli/src/commands/import.rs b/crates/grafeo-cli/src/commands/import.rs index d0a89a13e..fcd40f7b7 100644 --- a/crates/grafeo-cli/src/commands/import.rs +++ b/crates/grafeo-cli/src/commands/import.rs @@ -455,7 +455,7 @@ mod tests { std::fs::write(&file_path, "Alix,30\n").unwrap(); let columns = read_csv_columns(&file_path, false, None).unwrap(); - assert!(columns.is_empty()); + assert!(columns.is_empty(), "{columns:?}"); } #[test] @@ -475,7 +475,7 @@ mod tests { std::fs::write(&file_path, "").unwrap(); let columns = read_csv_columns(&file_path, true, None).unwrap(); - assert!(columns.is_empty()); + assert!(columns.is_empty(), "{columns:?}"); } // ── read_jsonl_keys ────────────────────────────────────────────── @@ -501,7 +501,7 @@ mod tests { std::fs::write(&file_path, "").unwrap(); let keys = read_jsonl_keys(&file_path).unwrap(); - assert!(keys.is_empty()); + assert!(keys.is_empty(), "{keys:?}"); } #[test] @@ -523,7 +523,7 @@ mod tests { std::fs::write(&file_path, "not valid json\n").unwrap(); let keys = read_jsonl_keys(&file_path).unwrap(); - assert!(keys.is_empty()); + assert!(keys.is_empty(), "{keys:?}"); } // ── import_csv error cases ─────────────────────────────────────── diff --git a/crates/grafeo-cli/src/commands/version.rs b/crates/grafeo-cli/src/commands/version.rs index beef802e3..f7f33f0ad 100644 --- a/crates/grafeo-cli/src/commands/version.rs +++ b/crates/grafeo-cli/src/commands/version.rs @@ -83,7 +83,7 @@ mod tests { #[test] fn test_rustc_version_not_empty() { let v = rustc_version(); - assert!(!v.is_empty()); + assert!(!v.is_empty(), "v is empty"); } #[test] diff --git a/crates/grafeo-cli/src/output/mod.rs b/crates/grafeo-cli/src/output/mod.rs index a77043e0e..9b071dfe8 100644 --- a/crates/grafeo-cli/src/output/mod.rs +++ b/crates/grafeo-cli/src/output/mod.rs @@ -206,12 +206,12 @@ mod tests { use super::*; #[test] - fn test_format_clone_and_copy() { + fn test_format_is_copy() { let format = Format::Table; let copied = format; - let cloned = Clone::clone(&format); + // `format` is still usable after the copy. assert!(matches!(copied, Format::Table)); - assert!(matches!(cloned, Format::Table)); + assert!(matches!(format, Format::Table)); } #[test] diff --git a/crates/grafeo-common/src/encryption.rs b/crates/grafeo-common/src/encryption.rs index e50f630e8..e8dc31238 100644 --- a/crates/grafeo-common/src/encryption.rs +++ b/crates/grafeo-common/src/encryption.rs @@ -556,7 +556,7 @@ mod tests { let nonce = build_nonce(0, 0); let encrypted = encryptor.encrypt(b"", &nonce, b"").unwrap(); let decrypted = encryptor.decrypt(&encrypted, b"").unwrap(); - assert!(decrypted.is_empty()); + assert!(decrypted.is_empty(), "{decrypted:?}"); } #[test] diff --git a/crates/grafeo-common/src/temporal.rs b/crates/grafeo-common/src/temporal.rs index 9bb450ad1..63802f752 100644 --- a/crates/grafeo-common/src/temporal.rs +++ b/crates/grafeo-common/src/temporal.rs @@ -244,7 +244,7 @@ mod tests { assert!(log.latest_entry().is_none()); assert!(log.latest_epoch().is_none()); assert!(log.at(epoch(0)).is_none()); - assert!(log.history().is_empty()); + assert!(log.history().is_empty(), "{:?}", log.history()); } #[test] diff --git a/crates/grafeo-common/src/testing/commit_hook.rs b/crates/grafeo-common/src/testing/commit_hook.rs new file mode 100644 index 000000000..98d383285 --- /dev/null +++ b/crates/grafeo-common/src/testing/commit_hook.rs @@ -0,0 +1,75 @@ +//! Runs a test's code inside a commit, right after the commit epoch is +//! assigned and before the commit's versions, events and WAL records are +//! written, to check what other threads can do while a commit completes. +//! +//! With the `testing-statement-injection` feature, +//! [`run_after_commit_epoch`] runs the hook that [`after_next_commit_epoch`] +//! armed on the calling thread, once. The hook is thread-local, so only a +//! commit on the thread that armed it runs it. Without the feature both +//! functions are no-ops. + +#[cfg(feature = "testing-statement-injection")] +mod inner { + use std::cell::RefCell; + + type Hook = Box; + + thread_local! { + static HOOK: RefCell> = const { RefCell::new(None) }; + } + + /// Arms `hook` to run once, inside the next commit on this thread, right + /// after its commit epoch is assigned. + pub fn after_next_commit_epoch(hook: impl FnOnce() + 'static) { + HOOK.with(|slot| *slot.borrow_mut() = Some(Box::new(hook))); + } + + /// Runs the hook armed on this thread, if any. Called by the engine's + /// commit. + #[inline] + pub fn run_after_commit_epoch() { + if let Some(hook) = HOOK.with(|slot| slot.borrow_mut().take()) { + hook(); + } + } +} + +#[cfg(not(feature = "testing-statement-injection"))] +mod inner { + /// No-op when injection is disabled. + pub fn after_next_commit_epoch(_hook: impl FnOnce() + 'static) {} + + /// No-op when injection is disabled. + #[inline] + pub fn run_after_commit_epoch() {} +} + +pub use inner::*; + +#[cfg(all(test, feature = "testing-statement-injection"))] +mod tests { + use super::*; + use std::cell::Cell; + use std::rc::Rc; + + #[test] + fn the_hook_runs_once() { + let runs = Rc::new(Cell::new(0)); + let counter = Rc::clone(&runs); + after_next_commit_epoch(move || counter.set(counter.get() + 1)); + run_after_commit_epoch(); + run_after_commit_epoch(); + assert_eq!(runs.get(), 1); + } + + #[test] + fn the_hook_belongs_to_the_thread_that_armed_it() { + let ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = std::sync::Arc::clone(&ran); + after_next_commit_epoch(move || flag.store(true, std::sync::atomic::Ordering::SeqCst)); + std::thread::spawn(run_after_commit_epoch).join().unwrap(); + assert!(!ran.load(std::sync::atomic::Ordering::SeqCst)); + run_after_commit_epoch(); + assert!(ran.load(std::sync::atomic::Ordering::SeqCst)); + } +} diff --git a/crates/grafeo-common/src/testing/mod.rs b/crates/grafeo-common/src/testing/mod.rs index 998a7fed0..77139391e 100644 --- a/crates/grafeo-common/src/testing/mod.rs +++ b/crates/grafeo-common/src/testing/mod.rs @@ -1,9 +1,11 @@ //! Testing utilities for Grafeo internals. //! -//! `crash` and `statement_failure` are feature-gated and compile to no-ops in -//! production builds. `child_process` is always compiled: the storage layer -//! takes its database locks through it, uncontended outside tests. +//! `crash`, `statement_failure` and `commit_hook` are feature-gated and compile +//! to no-ops in production builds. `child_process` is always compiled: the +//! storage layer takes its database locks through it, uncontended outside +//! tests. pub mod child_process; +pub mod commit_hook; pub mod crash; pub mod statement_failure; diff --git a/crates/grafeo-core/src/codec/bitpack.rs b/crates/grafeo-core/src/codec/bitpack.rs index 5287841a7..bfa0a489f 100644 --- a/crates/grafeo-core/src/codec/bitpack.rs +++ b/crates/grafeo-core/src/codec/bitpack.rs @@ -960,7 +960,7 @@ mod tests { // Force the zero-bits path by constructing manually. let zero_bits = BitPackedInts::from_raw_parts(Vec::new(), 0, 64); assert_eq!(zero_bits.scan_eq(0), (0..64).collect::>()); - assert!(zero_bits.scan_eq(1).is_empty()); + assert!(zero_bits.scan_eq(1).is_empty(), "expected empty"); } #[test] @@ -970,13 +970,13 @@ mod tests { let values: Vec = (0..10).map(|i| i % 16).collect(); let packed = BitPackedInts::pack(&values); assert!(packed.bits_per_value() <= 4); - assert!(packed.scan_eq(100).is_empty()); + assert!(packed.scan_eq(100).is_empty(), "expected empty"); } #[test] fn test_scan_eq_empty_column() { let packed = BitPackedInts::pack(&[]); - assert!(packed.scan_eq(0).is_empty()); - assert!(packed.scan_eq(42).is_empty()); + assert!(packed.scan_eq(0).is_empty(), "expected empty"); + assert!(packed.scan_eq(42).is_empty(), "expected empty"); } } diff --git a/crates/grafeo-core/src/codec/runlength.rs b/crates/grafeo-core/src/codec/runlength.rs index 8d615cff2..0965e6770 100644 --- a/crates/grafeo-core/src/codec/runlength.rs +++ b/crates/grafeo-core/src/codec/runlength.rs @@ -468,7 +468,7 @@ mod tests { assert!(encoded.is_empty()); let decoded = encoded.decode(); - assert!(decoded.is_empty()); + assert!(decoded.is_empty(), "{decoded:?}"); } #[test] diff --git a/crates/grafeo-core/src/execution/factorized_chunk.rs b/crates/grafeo-core/src/execution/factorized_chunk.rs index fad62748e..c88be0ca2 100644 --- a/crates/grafeo-core/src/execution/factorized_chunk.rs +++ b/crates/grafeo-core/src/execution/factorized_chunk.rs @@ -1468,7 +1468,7 @@ mod tests { fn test_compute_path_multiplicities_empty() { let chunk = FactorizedChunk::empty(); let mults = chunk.compute_path_multiplicities(); - assert!(mults.is_empty()); + assert!(mults.is_empty(), "{mults:?}"); } #[test] diff --git a/crates/grafeo-core/src/execution/factorized_vector.rs b/crates/grafeo-core/src/execution/factorized_vector.rs index fed2822c0..2ef6a966f 100644 --- a/crates/grafeo-core/src/execution/factorized_vector.rs +++ b/crates/grafeo-core/src/execution/factorized_vector.rs @@ -776,7 +776,7 @@ mod tests { let vec = FactorizedVector::flat(data); let items: Vec<_> = vec.iter_with_parent().collect(); - assert!(items.is_empty()); + assert!(items.is_empty(), "{items:?}"); } #[test] diff --git a/crates/grafeo-core/src/execution/operators/set_ops.rs b/crates/grafeo-core/src/execution/operators/set_ops.rs index 1a2439f7c..06c68ebe4 100644 --- a/crates/grafeo-core/src/execution/operators/set_ops.rs +++ b/crates/grafeo-core/src/execution/operators/set_ops.rs @@ -457,7 +457,7 @@ mod tests { let mut op = IntersectOperator::new(Box::new(left), Box::new(right), false); let result = collect_ints(&mut op); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -487,7 +487,7 @@ mod tests { let mut op = OtherwiseOperator::new(Box::new(left), Box::new(right)); let result = collect_ints(&mut op); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] diff --git a/crates/grafeo-core/src/execution/parallel/merge.rs b/crates/grafeo-core/src/execution/parallel/merge.rs index bdbecd544..dd166504e 100644 --- a/crates/grafeo-core/src/execution/parallel/merge.rs +++ b/crates/grafeo-core/src/execution/parallel/merge.rs @@ -468,7 +468,7 @@ mod tests { fn test_merge_sorted_runs_empty() { let runs: Vec>> = Vec::new(); let result = merge_sorted_runs(runs, &[]); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] diff --git a/crates/grafeo-core/src/execution/parallel/morsel.rs b/crates/grafeo-core/src/execution/parallel/morsel.rs index 480fc9906..1cfb7b1b3 100644 --- a/crates/grafeo-core/src/execution/parallel/morsel.rs +++ b/crates/grafeo-core/src/execution/parallel/morsel.rs @@ -251,8 +251,8 @@ mod tests { #[test] fn test_generate_morsels_empty() { - assert!(generate_morsels(0, 100, 0).is_empty()); - assert!(generate_morsels(100, 0, 0).is_empty()); + assert!(generate_morsels(0, 100, 0).is_empty(), "expected empty"); + assert!(generate_morsels(100, 0, 0).is_empty(), "expected empty"); } #[test] diff --git a/crates/grafeo-core/src/execution/spill/external_sort.rs b/crates/grafeo-core/src/execution/spill/external_sort.rs index 6e2e7deb4..1e36b4541 100644 --- a/crates/grafeo-core/src/execution/spill/external_sort.rs +++ b/crates/grafeo-core/src/execution/spill/external_sort.rs @@ -423,7 +423,7 @@ mod tests { let mut sort = ExternalSort::new(manager, 1, vec![SortKey::ascending(0)]); let result = sort.merge_all(Vec::new()).unwrap(); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] diff --git a/crates/grafeo-core/src/graph/compact/builder.rs b/crates/grafeo-core/src/graph/compact/builder.rs index dc3669048..1657c14b6 100644 --- a/crates/grafeo-core/src/graph/compact/builder.rs +++ b/crates/grafeo-core/src/graph/compact/builder.rs @@ -1405,7 +1405,7 @@ mod tests { // Verify backward edges (incoming to cities). for &cid in &city_ids { let incoming = converted.edges_from(cid, crate::graph::Direction::Incoming); - assert!(!incoming.is_empty()); + assert!(!incoming.is_empty(), "incoming is empty"); } } diff --git a/crates/grafeo-core/src/graph/compact/column.rs b/crates/grafeo-core/src/graph/compact/column.rs index 883f39f99..0c20ef2ba 100644 --- a/crates/grafeo-core/src/graph/compact/column.rs +++ b/crates/grafeo-core/src/graph/compact/column.rs @@ -2162,7 +2162,7 @@ mod tests { let col = ColumnCodec::BitPacked(BitPackedInts::pack(&values)); let result = col.find_in_range(None, Some(&Value::Int64(-1)), false, true); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -2189,7 +2189,7 @@ mod tests { ); // Fallback uses compare_values which returns None for Int vs String, // so no rows match. - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -2201,7 +2201,7 @@ mod tests { // Range scan on list values uses compare_values, which returns None // for lists, so nothing matches. let result = col.find_in_range(Some(&Value::Int64(0)), Some(&Value::Int64(10)), true, true); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -2747,7 +2747,7 @@ mod tests { // Bitmap column + Int64 range -> compare returns None -> rows excluded. let col = ColumnCodec::Bitmap(BitVector::from_bools(&[true, false, true])); let result = col.find_in_range(Some(&Value::Int64(0)), Some(&Value::Int64(5)), true, true); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } // ----------------------------------------------------------------------- @@ -2805,7 +2805,7 @@ mod tests { // Target "Prague" does not exist in the dictionary, so encode returns None. let result = col.find_eq(&Value::String(ArcStr::from("Prague"))); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } // ----------------------------------------------------------------------- @@ -2889,11 +2889,11 @@ mod tests { // min alone: the None (Uncomparable) branch returns false. let result = col.find_in_range(Some(&min), None, true, true); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); // max alone: same story for the max arm. let result = col.find_in_range(None, Some(&max), true, true); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } // ----------------------------------------------------------------------- @@ -3555,7 +3555,7 @@ mod tests { // verify find_eq agrees on both forms. let target = col.get(42).expect("row 42 exists"); assert_eq!(decoded.find_eq(&target), col.find_eq(&target)); - assert!(!col.find_eq(&target).is_empty()); + assert!(!col.find_eq(&target).is_empty(), "expected non-empty"); } #[test] @@ -3582,7 +3582,7 @@ mod tests { // we know is present. let target = Value::Float64(0.0); assert_eq!(decoded.find_eq(&target), col.find_eq(&target)); - assert!(!col.find_eq(&target).is_empty()); + assert!(!col.find_eq(&target).is_empty(), "expected non-empty"); } // ── Phase 3a: Bytes-backed fixed-width codecs ───────────────────── @@ -3777,7 +3777,7 @@ mod tests { let result: Vec = col .range_iter(Some(&zm), Some(&min), None, true, false) .collect(); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] diff --git a/crates/grafeo-core/src/graph/compact/deletions_section.rs b/crates/grafeo-core/src/graph/compact/deletions_section.rs index a61c637fe..85a0a09f8 100644 --- a/crates/grafeo-core/src/graph/compact/deletions_section.rs +++ b/crates/grafeo-core/src/graph/compact/deletions_section.rs @@ -295,8 +295,16 @@ mod tests { let mut roundtrip = OverlayDeletionsSection::empty(); roundtrip.deserialize(&bytes).unwrap(); assert!(roundtrip.is_empty()); - assert!(roundtrip.deleted_node_ids().is_empty()); - assert!(roundtrip.deleted_edge_ids().is_empty()); + assert!( + roundtrip.deleted_node_ids().is_empty(), + "{:?}", + roundtrip.deleted_node_ids() + ); + assert!( + roundtrip.deleted_edge_ids().is_empty(), + "{:?}", + roundtrip.deleted_edge_ids() + ); } #[test] diff --git a/crates/grafeo-core/src/graph/compact/layered.rs b/crates/grafeo-core/src/graph/compact/layered.rs index 712b69bf3..8b23afa7f 100644 --- a/crates/grafeo-core/src/graph/compact/layered.rs +++ b/crates/grafeo-core/src/graph/compact/layered.rs @@ -1,6 +1,6 @@ //! Two-layer graph store: read-only columnar base + mutable LPG overlay. //! -//! `LayeredStore` coordinates reads between a [`CompactStore`](crate::graph::compact::CompactStore) (cold, columnar) +//! `LayeredStore` coordinates reads between a [`CompactStore`] (cold, columnar) //! and an [`LpgStore`](crate::graph::lpg::LpgStore) (hot, HashMap-based). All writes go to the overlay. //! Reads check the overlay first and fall through to the compact base for //! unmodified entities. @@ -3681,16 +3681,25 @@ mod tests { // Only the overlay copy is deleted; the base copy must stay hidden. assert!(layered.get_edge(eid).is_none()); - assert!(layered.edges_from(alix, Direction::Outgoing).is_empty()); + assert!( + layered.edges_from(alix, Direction::Outgoing).is_empty(), + "expected empty" + ); assert!( !layered .edges_from(amsterdam, Direction::Incoming) .iter() .any(|(_, e)| *e == eid) ); - assert!(layered.neighbors(alix, Direction::Outgoing).is_empty()); + assert!( + layered.neighbors(alix, Direction::Outgoing).is_empty(), + "expected empty" + ); assert_eq!(layered.neighbors(amsterdam, Direction::Incoming), vec![gus]); - assert!(layered.neighbors(alix, Direction::Both).is_empty()); + assert!( + layered.neighbors(alix, Direction::Both).is_empty(), + "expected empty" + ); assert_eq!(layered.out_degree(alix), 0); } @@ -3702,7 +3711,10 @@ mod tests { layered.set_edge_property(eid, "since", Value::Int64(2024)); assert!(layered.delete_edge_versioned(eid, EpochId::from(1), TransactionId::from(1))); - assert!(layered.edges_from(alix, Direction::Outgoing).is_empty()); + assert!( + layered.edges_from(alix, Direction::Outgoing).is_empty(), + "expected empty" + ); assert_eq!(layered.neighbors(amsterdam, Direction::Incoming), vec![gus]); } @@ -3716,8 +3728,14 @@ mod tests { // Both endpoints still exist; only the edge is gone. assert!(layered.get_node(alix).is_some()); assert!(layered.get_node(amsterdam).is_some()); - assert!(layered.neighbors(alix, Direction::Outgoing).is_empty()); - assert!(layered.neighbors(alix, Direction::Both).is_empty()); + assert!( + layered.neighbors(alix, Direction::Outgoing).is_empty(), + "expected empty" + ); + assert!( + layered.neighbors(alix, Direction::Both).is_empty(), + "expected empty" + ); assert_eq!(layered.neighbors(amsterdam, Direction::Incoming), vec![gus]); } @@ -3732,8 +3750,14 @@ mod tests { layered.set_node_property(alix, "age", Value::Int64(31)); layered.set_node_property(amsterdam, "touched", Value::Bool(true)); - assert!(layered.edges_from(alix, Direction::Outgoing).is_empty()); - assert!(layered.neighbors(alix, Direction::Outgoing).is_empty()); + assert!( + layered.edges_from(alix, Direction::Outgoing).is_empty(), + "expected empty" + ); + assert!( + layered.neighbors(alix, Direction::Outgoing).is_empty(), + "expected empty" + ); assert_eq!(layered.neighbors(amsterdam, Direction::Incoming), vec![gus]); } diff --git a/crates/grafeo-core/src/graph/compact/node_table.rs b/crates/grafeo-core/src/graph/compact/node_table.rs index 6a8a93fb3..cfc2c881d 100644 --- a/crates/grafeo-core/src/graph/compact/node_table.rs +++ b/crates/grafeo-core/src/graph/compact/node_table.rs @@ -269,7 +269,7 @@ mod tests { let table = NodeTable::new(schema); assert_eq!(table.len(), 0); assert!(table.is_empty()); - assert!(table.node_ids().is_empty()); + assert!(table.node_ids().is_empty(), "{:?}", table.node_ids()); } #[test] diff --git a/crates/grafeo-core/src/graph/compact/tests.rs b/crates/grafeo-core/src/graph/compact/tests.rs index 163f6b838..a6b960be7 100644 --- a/crates/grafeo-core/src/graph/compact/tests.rs +++ b/crates/grafeo-core/src/graph/compact/tests.rs @@ -378,7 +378,7 @@ fn test_nodes_by_label() { #[test] fn test_nodes_by_label_nonexistent() { let store = build_test_store(); - assert!(store.nodes_by_label("Vehicle").is_empty()); + assert!(store.nodes_by_label("Vehicle").is_empty(), "expected empty"); } #[test] @@ -538,7 +538,7 @@ fn test_find_nodes_by_properties_multi() { ("name", Value::String(ArcStr::from("Alix"))), ("age", Value::Int64(99)), ]); - assert!(no_match.is_empty()); + assert!(no_match.is_empty(), "{no_match:?}"); } #[test] @@ -739,9 +739,13 @@ fn test_empty_store() { let store = CompactStoreBuilder::new().build().unwrap(); assert_eq!(store.node_count(), 0); assert_eq!(store.edge_count(), 0); - assert!(store.node_ids().is_empty()); - assert!(store.all_labels().is_empty()); - assert!(store.all_edge_types().is_empty()); + assert!(store.node_ids().is_empty(), "{:?}", store.node_ids()); + assert!(store.all_labels().is_empty(), "{:?}", store.all_labels()); + assert!( + store.all_edge_types().is_empty(), + "{:?}", + store.all_edge_types() + ); assert!(store.get_node(NodeId::new(0)).is_none()); assert!(store.get_edge(EdgeId::new(0)).is_none()); assert_eq!(store.current_epoch(), EpochId(1)); @@ -810,7 +814,7 @@ fn test_edges_from_both_combines_directions() { fn test_find_nodes_by_property_no_match() { let store = build_test_store(); let results = store.find_nodes_by_property("age", &Value::Int64(999)); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -957,7 +961,7 @@ fn test_value_in_range_incomparable() { true, true, ); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } // --------------------------------------------------------------------------- @@ -1027,7 +1031,7 @@ fn test_find_nodes_by_property_zone_map_prunes() { // Search for age = 999, which is outside the zone map [25, 45]. // The zone map should prune the Person table entirely. let results = store.find_nodes_by_property("age", &Value::Int64(999)); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -1035,7 +1039,7 @@ fn test_find_nodes_by_property_nonexistent_property() { let store = build_test_store(); // Property "color" doesn't exist: no zone map, no column, no results. let results = store.find_nodes_by_property("color", &Value::Int64(1)); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -1049,7 +1053,7 @@ fn test_find_nodes_in_range_zone_map_prunes_min() { true, true, ); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -1063,7 +1067,7 @@ fn test_find_nodes_in_range_zone_map_prunes_max() { true, true, ); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } // --------------------------------------------------------------------------- @@ -1126,7 +1130,7 @@ fn vincent_find_nodes_in_range_iter_whole_table_pruned() { let result: Vec = store .find_nodes_in_range_iter("age", Some(&min), Some(&max), true, true) .collect(); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -1148,7 +1152,7 @@ fn mia_find_nodes_in_range_iter_missing_property_yields_nothing() { let result: Vec = store .find_nodes_in_range_iter("nonexistent", Some(&min), Some(&max), true, true) .collect(); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -1301,9 +1305,9 @@ fn test_csr_neighbors_out_of_bounds() { let csr = CsrAdjacency::from_sorted_edges(2, &[(0, 1)]); assert_eq!(csr.neighbors(0), &[1]); - assert!(csr.neighbors(1).is_empty()); + assert!(csr.neighbors(1).is_empty(), "expected empty"); // Node 99 is out of bounds: should return empty. - assert!(csr.neighbors(99).is_empty()); + assert!(csr.neighbors(99).is_empty(), "expected empty"); } #[test] @@ -1422,7 +1426,10 @@ fn test_from_graph_store_nodes_without_edges() { let ids = compact.nodes_by_label("Orphan"); assert_eq!(ids.len(), 1); assert_eq!(compact.edge_count(), 0); - assert!(compact.edges_from(ids[0], Direction::Outgoing).is_empty()); + assert!( + compact.edges_from(ids[0], Direction::Outgoing).is_empty(), + "expected empty" + ); } /// Regression test for GrafeoDB/grafeo#221: `compact()` fails with @@ -1822,5 +1829,5 @@ fn test_raw_i64_inline_and_mapped_find_eq_match() { let target = Value::Int64(7); assert_eq!(inline.find_eq(&target), mapped.find_eq(&target)); - assert!(!inline.find_eq(&target).is_empty()); + assert!(!inline.find_eq(&target).is_empty(), "expected non-empty"); } diff --git a/crates/grafeo-core/src/graph/lpg/block.rs b/crates/grafeo-core/src/graph/lpg/block.rs index e9273ad5c..3fcfcecc2 100644 --- a/crates/grafeo-core/src/graph/lpg/block.rs +++ b/crates/grafeo-core/src/graph/lpg/block.rs @@ -1837,7 +1837,7 @@ mod tests { read_blocks(&data, &mut |decoded, _, _, _| { assert_eq!(decoded.len(), 2); - assert!(decoded[0].labels.is_empty()); + assert!(decoded[0].labels.is_empty(), "expected empty"); assert_eq!(decoded[1].labels.len(), 3); Ok(()) }) diff --git a/crates/grafeo-core/src/graph/lpg/section.rs b/crates/grafeo-core/src/graph/lpg/section.rs index 878de000d..0769fa763 100644 --- a/crates/grafeo-core/src/graph/lpg/section.rs +++ b/crates/grafeo-core/src/graph/lpg/section.rs @@ -243,7 +243,7 @@ mod tests { let section = LpgStoreSection::new(Arc::clone(&store)); let bytes = section.serialize().expect("serialize should succeed"); - assert!(!bytes.is_empty()); + assert!(!bytes.is_empty(), "bytes is empty"); assert!(block::is_block_format(&bytes)); // Deserialize into a fresh store diff --git a/crates/grafeo-core/src/graph/lpg/store/tests.rs b/crates/grafeo-core/src/graph/lpg/store/tests.rs index 75d8327d7..a9fde082b 100644 --- a/crates/grafeo-core/src/graph/lpg/store/tests.rs +++ b/crates/grafeo-core/src/graph/lpg/store/tests.rs @@ -643,7 +643,7 @@ fn test_nodes_by_label_nonexistent() { store.create_node(&["Person"]); let empty = store.nodes_by_label("NonExistent"); - assert!(empty.is_empty()); + assert!(empty.is_empty(), "{empty:?}"); } #[test] @@ -994,7 +994,7 @@ fn test_property_index_maintained_on_update() { // Old value should not find it let pending = store.find_nodes_by_property("status", &Value::from("pending")); - assert!(pending.is_empty()); + assert!(pending.is_empty(), "{pending:?}"); // New value should find it let done = store.find_nodes_by_property("status", &Value::from("done")); @@ -1020,7 +1020,7 @@ fn test_property_index_maintained_on_remove() { // Should no longer find it let found = store.find_nodes_by_property("tag", &Value::from("important")); - assert!(found.is_empty()); + assert!(found.is_empty(), "{found:?}"); } #[test] @@ -1072,7 +1072,8 @@ fn test_property_index_restored_when_delete_rolls_back() { assert!( store .find_nodes_by_property("id", &Value::from("a")) - .is_empty() + .is_empty(), + "expected no nodes" ); store.rollback_transaction_properties(tx); @@ -1149,7 +1150,7 @@ fn test_property_index_multiple_values() { assert_eq!(age_30.len(), 1); let age_40 = store.find_nodes_by_property("age", &Value::from(40i64)); - assert!(age_40.is_empty()); + assert!(age_40.is_empty(), "{age_40:?}"); } #[test] @@ -1202,7 +1203,7 @@ fn test_get_node_property_batch_empty() { let key = PropertyKey::new("any"); let values = store.get_node_property_batch(&[], &key); - assert!(values.is_empty()); + assert!(values.is_empty(), "{values:?}"); } #[test] @@ -1240,7 +1241,7 @@ fn test_get_nodes_properties_batch_empty() { let store = LpgStore::new().unwrap(); let all_props = store.get_nodes_properties_batch(&[]); - assert!(all_props.is_empty()); + assert!(all_props.is_empty(), "{all_props:?}"); } #[test] @@ -1401,7 +1402,7 @@ fn test_find_nodes_in_range_empty_result() { true, true, ); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -1417,7 +1418,7 @@ fn test_find_nodes_in_range_nonexistent_property() { true, true, ); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } // === Multi-Property Query Tests === @@ -1465,7 +1466,7 @@ fn test_find_nodes_by_properties_no_match() { store.create_node_with_props(&["Person"], [("name", Value::from("Alix"))]); let result = store.find_nodes_by_properties(&[("name", Value::from("Nobody"))]); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -1811,14 +1812,14 @@ mod version_gc { ); store.gc_versions(epochs[2]); - assert!(labels_at(vincent, epochs[0]).is_empty()); + assert!(labels_at(vincent, epochs[0]).is_empty(), "expected empty"); assert_eq!(labels_at(vincent, epochs[1]), ["Employee", "Person"]); assert_eq!(labels_at(vincent, epochs[2]), ["Person"]); assert_eq!(labels_at(vincent, epochs[3]), ["Manager", "Person"]); let later = store.new_epoch(); store.gc_versions(later); - assert!(labels_at(vincent, epochs[1]).is_empty()); + assert!(labels_at(vincent, epochs[1]).is_empty(), "expected empty"); assert_eq!(labels_at(vincent, later), ["Manager", "Person"]); assert_eq!(labels_at(jules, epochs[0]), ["Person"]); assert!(store.gc_candidates.lock().labels.is_empty()); diff --git a/crates/grafeo-core/src/graph/lpg/store/versioning.rs b/crates/grafeo-core/src/graph/lpg/store/versioning.rs index 3122bc2a5..e6770f5eb 100644 --- a/crates/grafeo-core/src/graph/lpg/store/versioning.rs +++ b/crates/grafeo-core/src/graph/lpg/store/versioning.rs @@ -538,7 +538,7 @@ impl LpgStore { let id_val = id.as_u64(); let _ = self .next_node_id - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| { if id_val >= current { Some(id_val + 1) } else { @@ -586,7 +586,7 @@ impl LpgStore { let id_val = id.as_u64(); let _ = self .next_node_id - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| { if id_val >= current { Some(id_val + 1) } else { @@ -633,7 +633,7 @@ impl LpgStore { let id_val = id.as_u64(); let _ = self .next_edge_id - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| { if id_val >= current { Some(id_val + 1) } else { @@ -689,7 +689,7 @@ impl LpgStore { let id_val = id.as_u64(); let _ = self .next_edge_id - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + .try_update(Ordering::SeqCst, Ordering::SeqCst, |current| { if id_val >= current { Some(id_val + 1) } else { diff --git a/crates/grafeo-core/src/graph/projection.rs b/crates/grafeo-core/src/graph/projection.rs index eb6ccfbc5..083b47c87 100644 --- a/crates/grafeo-core/src/graph/projection.rs +++ b/crates/grafeo-core/src/graph/projection.rs @@ -549,8 +549,8 @@ mod tests { assert_eq!(proj.node_count(), 2); assert_eq!(proj.nodes_by_label("Person").len(), 2); - assert!(proj.nodes_by_label("City").is_empty()); - assert!(proj.nodes_by_label("Software").is_empty()); + assert!(proj.nodes_by_label("City").is_empty(), "expected empty"); + assert!(proj.nodes_by_label("Software").is_empty(), "expected empty"); } #[test] @@ -924,7 +924,7 @@ mod tests { // Amsterdam (City) is filtered out, so edges_from returns empty let amsterdam_edges = proj.edges_from(nodes[2], Direction::Outgoing); - assert!(amsterdam_edges.is_empty()); + assert!(amsterdam_edges.is_empty(), "{amsterdam_edges:?}"); } // 9. out_degree and in_degree with filtered projection @@ -1030,7 +1030,7 @@ mod tests { // "name" = "Amsterdam" exists but on a City node, which is filtered let found = proj.find_nodes_by_property("name", &Value::from("Amsterdam")); - assert!(found.is_empty()); + assert!(found.is_empty(), "{found:?}"); } // 13. find_nodes_by_properties with label filter @@ -1049,7 +1049,7 @@ mod tests { // Search for city name, filtered out let conditions = vec![("name", Value::from("Amsterdam"))]; let found = proj.find_nodes_by_properties(&conditions); - assert!(found.is_empty()); + assert!(found.is_empty(), "{found:?}"); } // 14. find_nodes_in_range with label filter @@ -1079,7 +1079,7 @@ mod tests { let min = Value::from(20); let max = Value::from(30); let found = proj.find_nodes_in_range("age", Some(&min), Some(&max), true, true); - assert!(found.is_empty()); + assert!(found.is_empty(), "{found:?}"); } // 15. node_property_might_match and edge_property_might_match diff --git a/crates/grafeo-core/src/graph/rdf/graph_store_adapter.rs b/crates/grafeo-core/src/graph/rdf/graph_store_adapter.rs index aabdb610c..131caefa3 100644 --- a/crates/grafeo-core/src/graph/rdf/graph_store_adapter.rs +++ b/crates/grafeo-core/src/graph/rdf/graph_store_adapter.rs @@ -709,7 +709,7 @@ mod tests { let adapter = RdfGraphStoreAdapter::new(&store); assert_eq!(adapter.node_count(), 0); assert_eq!(adapter.edge_count(), 0); - assert!(adapter.node_ids().is_empty()); + assert!(adapter.node_ids().is_empty(), "{:?}", adapter.node_ids()); } #[test] @@ -733,7 +733,10 @@ mod tests { assert_eq!(inc, vec![alix]); // No outgoing from Gus - assert!(adapter.neighbors(gus, Direction::Outgoing).is_empty()); + assert!( + adapter.neighbors(gus, Direction::Outgoing).is_empty(), + "expected empty" + ); } #[test] diff --git a/crates/grafeo-core/src/graph/rdf/nquads.rs b/crates/grafeo-core/src/graph/rdf/nquads.rs index bc9da9cff..a98a95e9a 100644 --- a/crates/grafeo-core/src/graph/rdf/nquads.rs +++ b/crates/grafeo-core/src/graph/rdf/nquads.rs @@ -130,6 +130,6 @@ mod tests { fn test_nquads_empty() { let store = RdfStore::new(); let output = to_nquads_string(&store).unwrap(); - assert!(output.is_empty()); + assert!(output.is_empty(), "{output:?}"); } } diff --git a/crates/grafeo-core/src/graph/rdf/section.rs b/crates/grafeo-core/src/graph/rdf/section.rs index 5006038bc..5089d2130 100644 --- a/crates/grafeo-core/src/graph/rdf/section.rs +++ b/crates/grafeo-core/src/graph/rdf/section.rs @@ -458,7 +458,7 @@ mod tests { let section = RdfStoreSection::new(Arc::clone(&store)); let bytes = section.serialize().expect("serialize should succeed"); - assert!(!bytes.is_empty()); + assert!(!bytes.is_empty(), "bytes is empty"); assert_eq!(&bytes[0..4], b"RDFB"); let store2 = Arc::new(RdfStore::new()); diff --git a/crates/grafeo-core/src/graph/rdf/shacl/target.rs b/crates/grafeo-core/src/graph/rdf/shacl/target.rs index 845764767..04bd1be87 100644 --- a/crates/grafeo-core/src/graph/rdf/shacl/target.rs +++ b/crates/grafeo-core/src/graph/rdf/shacl/target.rs @@ -189,7 +189,7 @@ mod tests { vec![Target::Class(Term::iri("http://ex.org/NonExistent"))], ); let nodes = resolve_targets(&shape, &data); - assert!(nodes.is_empty()); + assert!(nodes.is_empty(), "{nodes:?}"); } #[test] diff --git a/crates/grafeo-core/src/graph/rdf/store.rs b/crates/grafeo-core/src/graph/rdf/store.rs index 23ea39c75..f0021c944 100644 --- a/crates/grafeo-core/src/graph/rdf/store.rs +++ b/crates/grafeo-core/src/graph/rdf/store.rs @@ -2395,7 +2395,7 @@ mod tests { // Serialize to Turtle and re-parse. let output = store.to_turtle().unwrap(); - assert!(!output.is_empty()); + assert!(!output.is_empty(), "output is empty"); let store2 = RdfStore::new(); let result2 = store2.load_turtle(&output).unwrap(); diff --git a/crates/grafeo-core/src/graph/rdf/turtle/serializer.rs b/crates/grafeo-core/src/graph/rdf/turtle/serializer.rs index 39d3f1d66..909008832 100644 --- a/crates/grafeo-core/src/graph/rdf/turtle/serializer.rs +++ b/crates/grafeo-core/src/graph/rdf/turtle/serializer.rs @@ -435,7 +435,7 @@ mod tests { fn test_serialize_empty() { let serializer = TurtleSerializer::new(); let output = serializer.to_string(&[]).unwrap(); - assert!(output.is_empty()); + assert!(output.is_empty(), "{output:?}"); } #[test] diff --git a/crates/grafeo-core/src/graph/traits.rs b/crates/grafeo-core/src/graph/traits.rs index 1dcbd0616..d97af2e2b 100644 --- a/crates/grafeo-core/src/graph/traits.rs +++ b/crates/grafeo-core/src/graph/traits.rs @@ -876,8 +876,14 @@ mod tests { let store = NullGraphStore; let nid = NodeId(1); - assert!(store.neighbors(nid, Direction::Outgoing).is_empty()); - assert!(store.edges_from(nid, Direction::Incoming).is_empty()); + assert!( + store.neighbors(nid, Direction::Outgoing).is_empty(), + "expected empty" + ); + assert!( + store.edges_from(nid, Direction::Incoming).is_empty(), + "expected empty" + ); assert_eq!(store.out_degree(nid), 0); assert_eq!(store.in_degree(nid), 0); assert!(!store.has_backward_adjacency()); @@ -887,9 +893,13 @@ mod tests { fn null_graph_store_scans_and_counts() { let store = NullGraphStore; - assert!(store.node_ids().is_empty()); - assert!(store.all_node_ids().is_empty()); - assert!(store.nodes_by_label("Person").is_empty()); + assert!(store.node_ids().is_empty(), "{:?}", store.node_ids()); + assert!( + store.all_node_ids().is_empty(), + "{:?}", + store.all_node_ids() + ); + assert!(store.nodes_by_label("Person").is_empty(), "expected empty"); assert_eq!(store.node_count(), 0); assert_eq!(store.edge_count(), 0); } @@ -904,9 +914,17 @@ mod tests { assert!(store.edge_type(eid).is_none()); assert!(store.edge_type_versioned(eid, epoch, txn).is_none()); assert!(!store.has_property_index("name")); - assert!(store.all_labels().is_empty()); - assert!(store.all_edge_types().is_empty()); - assert!(store.all_property_keys().is_empty()); + assert!(store.all_labels().is_empty(), "{:?}", store.all_labels()); + assert!( + store.all_edge_types().is_empty(), + "{:?}", + store.all_edge_types() + ); + assert!( + store.all_property_keys().is_empty(), + "{:?}", + store.all_property_keys() + ); } #[test] @@ -915,16 +933,21 @@ mod tests { let key = PropertyKey::from("age"); let val = Value::Int64(30); - assert!(store.find_nodes_by_property("age", &val).is_empty()); + assert!( + store.find_nodes_by_property("age", &val).is_empty(), + "expected empty" + ); assert!( store .find_nodes_by_properties(&[("age", val.clone())]) - .is_empty() + .is_empty(), + "expected no nodes" ); assert!( store .find_nodes_in_range("age", Some(&val), None, true, false) - .is_empty() + .is_empty(), + "expected no nodes" ); assert!(!store.node_property_might_match(&key, CompareOp::Eq, &val)); assert!(!store.edge_property_might_match(&key, CompareOp::Eq, &val)); @@ -956,12 +979,14 @@ mod tests { assert!( store .filter_visible_node_ids(&[nid, NodeId(2)], epoch) - .is_empty() + .is_empty(), + "expected no visible nodes" ); assert!( store .filter_visible_node_ids_versioned(&[nid], epoch, txn) - .is_empty() + .is_empty(), + "expected no visible nodes" ); } @@ -1417,7 +1442,7 @@ mod tests { let store: Arc = Arc::new(TestMutStore::new()); assert_eq!(store.node_count(), 0); assert_eq!(store.edge_count(), 0); - assert!(store.node_ids().is_empty()); + assert!(store.node_ids().is_empty(), "{:?}", store.node_ids()); assert!(store.get_node(NodeId(1)).is_none()); assert_eq!(store.current_epoch(), EpochId(0)); } diff --git a/crates/grafeo-core/src/index/adjacency.rs b/crates/grafeo-core/src/index/adjacency.rs index d02684003..5ffce1ae2 100644 --- a/crates/grafeo-core/src/index/adjacency.rs +++ b/crates/grafeo-core/src/index/adjacency.rs @@ -1391,7 +1391,7 @@ mod tests { // Out-of-range query should return empty let empty = adj.edges_in_range(NodeId::new(0), NodeId::new(200), NodeId::new(300)); - assert!(empty.is_empty()); + assert!(empty.is_empty(), "{empty:?}"); } #[test] diff --git a/crates/grafeo-core/src/index/ring/leapfrog.rs b/crates/grafeo-core/src/index/ring/leapfrog.rs index dd0b93240..2e884c838 100644 --- a/crates/grafeo-core/src/index/ring/leapfrog.rs +++ b/crates/grafeo-core/src/index/ring/leapfrog.rs @@ -559,7 +559,7 @@ mod tests { let iter = RingIterator::with_subject(&ring, &Term::iri("nonexistent")); let results: Vec = iter.collect(); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -613,7 +613,7 @@ mod tests { let iter = RingIterator::with_object(&ring, &Term::iri("nonexistent")); let results: Vec = iter.collect(); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] diff --git a/crates/grafeo-core/src/index/ring/section.rs b/crates/grafeo-core/src/index/ring/section.rs index ac0751649..97e4afd95 100644 --- a/crates/grafeo-core/src/index/ring/section.rs +++ b/crates/grafeo-core/src/index/ring/section.rs @@ -200,7 +200,7 @@ mod tests { let store = Arc::new(RdfStore::new()); let section = RdfRingSection::new(store); let bytes = section.serialize().unwrap(); - assert!(bytes.is_empty()); + assert!(bytes.is_empty(), "{bytes:?}"); } #[test] @@ -210,7 +210,7 @@ mod tests { // Serialize let bytes = section.serialize().unwrap(); - assert!(!bytes.is_empty()); + assert!(!bytes.is_empty(), "bytes is empty"); // Create a fresh store and deserialize into it let store2 = Arc::new(RdfStore::new()); diff --git a/crates/grafeo-core/src/index/ring/triple_ring.rs b/crates/grafeo-core/src/index/ring/triple_ring.rs index 48dcfc52d..6e9eaaa9a 100644 --- a/crates/grafeo-core/src/index/ring/triple_ring.rs +++ b/crates/grafeo-core/src/index/ring/triple_ring.rs @@ -1162,7 +1162,7 @@ mod tests { let pattern = TriplePattern::with_subject(Term::iri("nonexistent")); let results: Vec = ring.find(&pattern).collect(); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -1239,7 +1239,7 @@ mod tests { // Find on empty ring let results: Vec = ring.find(&TriplePattern::any()).collect(); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -1268,7 +1268,7 @@ mod tests { // Save to buffer let mut buf = Vec::new(); ring.save(&mut buf).expect("save should succeed"); - assert!(!buf.is_empty()); + assert!(!buf.is_empty(), "buf is empty"); // Load from buffer let loaded = TripleRing::load(&buf[..]).expect("load should succeed"); diff --git a/crates/grafeo-core/src/index/text/fusion.rs b/crates/grafeo-core/src/index/text/fusion.rs index 0654d0a3b..3a058abc5 100644 --- a/crates/grafeo-core/src/index/text/fusion.rs +++ b/crates/grafeo-core/src/index/text/fusion.rs @@ -160,7 +160,7 @@ mod tests { let results = fuse_results(&[source_a, source_b], &FusionMethod::Rrf { k: 60 }, 10); // Node 2 appears in both at rank 2 and rank 1 → highest fused score - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); // Find node 2's score, should be highest since it's in both lists let node2_score = results.iter().find(|(id, _)| *id == NodeId::new(2)); let node1_score = results.iter().find(|(id, _)| *id == NodeId::new(1)); @@ -207,7 +207,7 @@ mod tests { 10, ); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); // Node 2 appears in both sources with good scores let node2 = results.iter().find(|(id, _)| *id == NodeId::new(2)); assert!(node2.is_some()); @@ -235,7 +235,7 @@ mod tests { #[test] fn test_empty_sources() { let results = fuse_results(&[], &FusionMethod::default(), 10); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] diff --git a/crates/grafeo-core/src/index/text/inverted_index.rs b/crates/grafeo-core/src/index/text/inverted_index.rs index e2e528c90..0db8f5579 100644 --- a/crates/grafeo-core/src/index/text/inverted_index.rs +++ b/crates/grafeo-core/src/index/text/inverted_index.rs @@ -392,7 +392,7 @@ mod tests { index.insert(NodeId::new(3), "the brown dog sleeps all day"); let results = index.search("brown dog", 10); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); // Node 3 mentions both "brown" and "dog" in a shorter document assert_eq!(results[0].0, NodeId::new(3)); } @@ -401,7 +401,7 @@ mod tests { fn test_empty_index_search() { let index = InvertedIndex::new(BM25Config::default()); let results = index.search("anything", 10); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -409,7 +409,7 @@ mod tests { let mut index = InvertedIndex::new(BM25Config::default()); index.insert(NodeId::new(1), "hello world"); let results = index.search("", 10); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -417,7 +417,7 @@ mod tests { let mut index = InvertedIndex::new(BM25Config::default()); index.insert(NodeId::new(1), "hello world"); let results = index.search("the a an", 10); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -449,7 +449,7 @@ mod tests { assert_eq!(index.len(), 1); let results = index.search("old", 10); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); let results = index.search("completely different", 10); assert_eq!(results.len(), 1); @@ -509,7 +509,7 @@ mod tests { let mut index = InvertedIndex::new(BM25Config::default()); index.insert(NodeId::new(1), "hello world"); let results = index.search("nonexistent term", 10); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] diff --git a/crates/grafeo-core/src/index/text/section.rs b/crates/grafeo-core/src/index/text/section.rs index b887bde5f..1c41560ac 100644 --- a/crates/grafeo-core/src/index/text/section.rs +++ b/crates/grafeo-core/src/index/text/section.rs @@ -164,7 +164,7 @@ mod tests { )]); let bytes = section.serialize().expect("serialize should succeed"); - assert!(!bytes.is_empty()); + assert!(!bytes.is_empty(), "bytes is empty"); // Restore into a fresh index let fresh = InvertedIndex::new(BM25Config::default()); diff --git a/crates/grafeo-core/src/index/text/tokenizer.rs b/crates/grafeo-core/src/index/text/tokenizer.rs index e6216c91b..d4b63cca2 100644 --- a/crates/grafeo-core/src/index/text/tokenizer.rs +++ b/crates/grafeo-core/src/index/text/tokenizer.rs @@ -167,13 +167,13 @@ mod tests { #[test] fn test_empty_string() { let t = SimpleTokenizer::new(); - assert!(t.tokenize("").is_empty()); + assert!(t.tokenize("").is_empty(), "expected empty"); } #[test] fn test_only_stop_words() { let t = SimpleTokenizer::new(); - assert!(t.tokenize("the a an is").is_empty()); + assert!(t.tokenize("the a an is").is_empty(), "expected empty"); } #[test] diff --git a/crates/grafeo-core/src/index/vector/hnsw.rs b/crates/grafeo-core/src/index/vector/hnsw.rs index b87bad3ce..f1cd20614 100644 --- a/crates/grafeo-core/src/index/vector/hnsw.rs +++ b/crates/grafeo-core/src/index/vector/hnsw.rs @@ -1477,7 +1477,8 @@ mod tests { assert!( index .search(&[0.0, 0.0, 0.0, 0.0], 10, &accessor) - .is_empty() + .is_empty(), + "expected no results" ); } @@ -1802,7 +1803,7 @@ mod tests { let results = index.batch_search(&queries, 10, &accessor); assert_eq!(results.len(), 1); - assert!(results[0].is_empty()); + assert!(results[0].is_empty(), "expected empty"); } /// Brute-force k-NN for recall verification. @@ -2013,7 +2014,7 @@ mod tests { let accessor = make_accessor(&map); let results = index.search(&[1.0, 0.0, 0.0], 5, &accessor); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } /// Builds a seeded 2-D index where every node is inserted once. @@ -2170,7 +2171,8 @@ mod tests { assert!( index .search(&[0.0, 0.0], 1, &make_accessor(&map)) - .is_empty() + .is_empty(), + "expected no results" ); } @@ -2355,7 +2357,7 @@ mod tests { let allowlist: HashSet = (1..=50).filter(|i| i % 2 == 0).map(NodeId::new).collect(); let results = index.search_with_filter(&vectors[25], 5, &allowlist, &accessor); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); assert!(results.len() <= 5); // Every result must be in the allowlist @@ -2385,7 +2387,7 @@ mod tests { let allowlist: HashSet = HashSet::new(); let results = index.search_with_filter(&vectors[5], 5, &allowlist, &accessor); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -2596,7 +2598,7 @@ mod tests { let results = index.search_with_filter(&[0.9, 0.1, 0.0, 0.0], 2, &allowlist, &accessor); // Node 1 is closest overall but not in allowlist - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); for (id, _) in &results { assert!(allowlist.contains(id)); } @@ -2635,7 +2637,7 @@ mod tests { // Reference: search results from heap-backed index. let query: Vec = vec![0.1, 0.4, 0.6, 0.2, 0.8, 0.5, 0.3, 0.7]; let heap_results = heap_index.search(&query, 5, &accessor); - assert!(!heap_results.is_empty()); + assert!(!heap_results.is_empty(), "heap_results is empty"); // Snapshot + serialize + load back as mmap topology. let (ep, ml, nodes) = heap_index.snapshot_topology(); @@ -2911,7 +2913,7 @@ mod tests { let allowlist: HashSet = HashSet::new(); let results = mmap_index.search_with_filter(&[0.1; 4], 5, &allowlist, &accessor); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } /// Mmap-backed search on an empty index must not panic. @@ -2928,7 +2930,7 @@ mod tests { let accessor = |id: NodeId| -> Option> { map.get(&id).cloned() }; let results = mmap_index.search(&[0.1; 4], 5, &accessor); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); assert_eq!(mmap_index.len(), 0); assert!(mmap_index.is_empty()); } diff --git a/crates/grafeo-core/src/index/vector/mmr.rs b/crates/grafeo-core/src/index/vector/mmr.rs index a4d4b7657..b4e92832b 100644 --- a/crates/grafeo-core/src/index/vector/mmr.rs +++ b/crates/grafeo-core/src/index/vector/mmr.rs @@ -130,7 +130,7 @@ mod tests { #[test] fn test_empty_candidates() { let result = mmr_select(&[1.0, 0.0], &[], 5, 0.5, DistanceMetric::Euclidean); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] @@ -138,7 +138,7 @@ mod tests { let v = [1.0f32, 0.0]; let candidates = vec![(NodeId::new(1), 0.0, v.as_slice())]; let result = mmr_select(&[1.0, 0.0], &candidates, 0, 0.5, DistanceMetric::Euclidean); - assert!(result.is_empty()); + assert!(result.is_empty(), "{result:?}"); } #[test] diff --git a/crates/grafeo-core/src/index/vector/mod.rs b/crates/grafeo-core/src/index/vector/mod.rs index 9996a3efc..2451c2e2a 100644 --- a/crates/grafeo-core/src/index/vector/mod.rs +++ b/crates/grafeo-core/src/index/vector/mod.rs @@ -589,7 +589,7 @@ mod tests { let vectors: Vec<(NodeId, &[f32])> = vec![]; let query = [0.0f32, 0.0]; let results = brute_force_knn(vectors.into_iter(), &query, 10, DistanceMetric::Cosine); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); } #[test] @@ -705,7 +705,7 @@ mod tests { let allowlist: HashSet = (1..=10).map(NodeId::new).collect(); let query = vec![0.1, 0.1, 0.0, 0.0]; let results = kind.search_with_filter(&query, 5, &allowlist, &NoopAccessor); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); for (id, _) in &results { assert!(allowlist.contains(id)); } diff --git a/crates/grafeo-core/src/index/vector/paged_topology.rs b/crates/grafeo-core/src/index/vector/paged_topology.rs index e0b6bd214..4a410d1cb 100644 --- a/crates/grafeo-core/src/index/vector/paged_topology.rs +++ b/crates/grafeo-core/src/index/vector/paged_topology.rs @@ -786,7 +786,7 @@ mod tests { let (ep, lvl, nodes) = round_trip(None, 0, Vec::new()); assert_eq!(ep, None); assert_eq!(lvl, 0); - assert!(nodes.is_empty()); + assert!(nodes.is_empty(), "{nodes:?}"); } #[test] @@ -1030,7 +1030,7 @@ mod tests { .neighbors_at(NodeId::new(40), 0) .expect("layer 0 exists") .collect(); - assert!(layer0.is_empty()); + assert!(layer0.is_empty(), "{layer0:?}"); } #[test] diff --git a/crates/grafeo-core/src/index/vector/quantized_hnsw.rs b/crates/grafeo-core/src/index/vector/quantized_hnsw.rs index 7a5263d6a..73d847ae7 100644 --- a/crates/grafeo-core/src/index/vector/quantized_hnsw.rs +++ b/crates/grafeo-core/src/index/vector/quantized_hnsw.rs @@ -984,7 +984,7 @@ mod tests { let allowlist: std::collections::HashSet = [1, 10, 25, 40].iter().map(|&i| NodeId::new(i)).collect(); let results = index.search_with_filter(&vectors[24], 3, &allowlist); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); assert!(results.len() <= 3); for (id, _) in &results { assert!(allowlist.contains(id), "result {id:?} not in allowlist"); @@ -1004,7 +1004,7 @@ mod tests { let allowlist: std::collections::HashSet = [5, 15, 30].iter().map(|&i| NodeId::new(i)).collect(); let results = index.search_with_ef_and_filter(&vectors[14], 2, 50, &allowlist); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); assert!(results.len() <= 2); for (id, _) in &results { assert!(allowlist.contains(id)); diff --git a/crates/grafeo-core/src/index/vector/section.rs b/crates/grafeo-core/src/index/vector/section.rs index da845a922..d0aa7e2f3 100644 --- a/crates/grafeo-core/src/index/vector/section.rs +++ b/crates/grafeo-core/src/index/vector/section.rs @@ -416,7 +416,7 @@ mod tests { let section = VectorStoreSection::new(vec![(key.clone(), Arc::clone(&index))]); let bytes = section.serialize().expect("serialize should succeed"); - assert!(!bytes.is_empty()); + assert!(!bytes.is_empty(), "bytes is empty"); // Create a fresh index with same config to restore into let config = index.config().clone(); diff --git a/crates/grafeo-engine/Cargo.toml b/crates/grafeo-engine/Cargo.toml index 07055e681..bce688129 100644 --- a/crates/grafeo-engine/Cargo.toml +++ b/crates/grafeo-engine/Cargo.toml @@ -125,7 +125,7 @@ arrow-export = ["dep:arrow-schema", "dep:arrow-array", "dep:arrow-ipc"] # Arrow # Testing utilities (zero overhead when disabled) testing-crash-injection = ["dep:grafeo-storage", "grafeo-common/testing-crash-injection", "grafeo-storage/testing-crash-injection"] # Crash injection for recovery testing -testing-statement-injection = ["grafeo-common/testing-statement-injection"] # Failure injection at statement / commit entry for rollback atomicity tests +testing-statement-injection = ["grafeo-common/testing-statement-injection"] # Failure injection at statement / commit entry for rollback atomicity tests, and a hook inside commit [lints] workspace = true diff --git a/crates/grafeo-engine/src/catalog/mod.rs b/crates/grafeo-engine/src/catalog/mod.rs index b9e5c36e6..124c3d7c8 100644 --- a/crates/grafeo-engine/src/catalog/mod.rs +++ b/crates/grafeo-engine/src/catalog/mod.rs @@ -2795,7 +2795,8 @@ mod tests { .get_node_type("Person") .unwrap() .constraints - .is_empty() + .is_empty(), + "expected no constraints" ); assert_eq!( catalog.drop_constraint("email_key"), @@ -3033,11 +3034,15 @@ mod tests { let invalid_label = LabelId::new(999); let invalid_property = PropertyKeyId::new(999); - assert!(catalog.indexes_for_label(invalid_label).is_empty()); + assert!( + catalog.indexes_for_label(invalid_label).is_empty(), + "expected empty" + ); assert!( catalog .indexes_for_label_property(invalid_label, invalid_property) - .is_empty() + .is_empty(), + "expected no indexes" ); } diff --git a/crates/grafeo-engine/src/config.rs b/crates/grafeo-engine/src/config.rs index 4b87c20a6..92e5d13d9 100644 --- a/crates/grafeo-engine/src/config.rs +++ b/crates/grafeo-engine/src/config.rs @@ -1224,7 +1224,10 @@ mod tests { // Ensure it implements std::error::Error (no source) let dyn_err: &dyn std::error::Error = &err; assert!(dyn_err.source().is_none()); - assert!(!dyn_err.to_string().is_empty()); + assert!( + !dyn_err.to_string().is_empty(), + "dyn_err.to_string() is empty" + ); } // --- Validate accepts non-zero memory limit --- diff --git a/crates/grafeo-engine/src/database/arrow.rs b/crates/grafeo-engine/src/database/arrow.rs index 0de5872bb..d47aae443 100644 --- a/crates/grafeo-engine/src/database/arrow.rs +++ b/crates/grafeo-engine/src/database/arrow.rs @@ -831,7 +831,7 @@ mod tests { ); let batch = query_result_to_record_batch(&cols, &types, &rows).unwrap(); let ipc_bytes = record_batch_to_ipc_stream(&batch).unwrap(); - assert!(!ipc_bytes.is_empty()); + assert!(!ipc_bytes.is_empty(), "ipc_bytes is empty"); // Read back let cursor = std::io::Cursor::new(ipc_bytes); @@ -943,7 +943,7 @@ mod tests { .insert(PropertyKey::new("name"), Value::String("Alix".into())); let ipc_bytes = crate::database::arrow::nodes_to_ipc_stream(&[node]).unwrap(); - assert!(!ipc_bytes.is_empty()); + assert!(!ipc_bytes.is_empty(), "ipc_bytes is empty"); let cursor = std::io::Cursor::new(ipc_bytes); let reader = arrow_ipc::reader::StreamReader::try_new(cursor, None).unwrap(); @@ -1034,7 +1034,7 @@ mod tests { fn test_edges_ipc_roundtrip() { let edge = make_edge(1, 10, 20, "KNOWS"); let ipc_bytes = crate::database::arrow::edges_to_ipc_stream(&[edge]).unwrap(); - assert!(!ipc_bytes.is_empty()); + assert!(!ipc_bytes.is_empty(), "ipc_bytes is empty"); let cursor = std::io::Cursor::new(ipc_bytes); let reader = arrow_ipc::reader::StreamReader::try_new(cursor, None).unwrap(); diff --git a/crates/grafeo-engine/src/database/catalog_section.rs b/crates/grafeo-engine/src/database/catalog_section.rs index 243671d30..cc4574e2c 100644 --- a/crates/grafeo-engine/src/database/catalog_section.rs +++ b/crates/grafeo-engine/src/database/catalog_section.rs @@ -482,7 +482,7 @@ mod tests { fn empty_catalog_roundtrip() { let section = make_section(); let bytes = section.serialize().expect("serialize empty catalog"); - assert!(!bytes.is_empty()); + assert!(!bytes.is_empty(), "bytes is empty"); let catalog2 = Arc::new(Catalog::new()); let store2 = Arc::new(grafeo_core::graph::lpg::LpgStore::new().unwrap()); @@ -533,7 +533,8 @@ mod tests { .get_node_type("City") .unwrap() .constraints - .is_empty() + .is_empty(), + "expected no constraints" ); } diff --git a/crates/grafeo-engine/src/database/cdc_store.rs b/crates/grafeo-engine/src/database/cdc_store.rs index 4314ea00d..036bb2f88 100644 --- a/crates/grafeo-engine/src/database/cdc_store.rs +++ b/crates/grafeo-engine/src/database/cdc_store.rs @@ -1155,7 +1155,7 @@ mod tests { let (cdc, _log) = setup(); let a = cdc.create_node(&["City"]); assert!(cdc.nodes_by_label("City").contains(&a)); - assert!(cdc.nodes_by_label("Unknown").is_empty()); + assert!(cdc.nodes_by_label("Unknown").is_empty(), "expected empty"); } #[test] @@ -1174,7 +1174,10 @@ mod tests { let b = cdc.create_node(&[]); cdc.create_edge(a, b, "E"); assert!(cdc.neighbors(a, Direction::Outgoing).contains(&b)); - assert!(!cdc.edges_from(a, Direction::Outgoing).is_empty()); + assert!( + !cdc.edges_from(a, Direction::Outgoing).is_empty(), + "expected non-empty" + ); } #[test] @@ -2138,7 +2141,7 @@ mod tests { fn batch_create_edges_empty_slice_records_nothing() { let (cdc, _log) = setup(); let ids = cdc.batch_create_edges(&[]); - assert!(ids.is_empty()); + assert!(ids.is_empty(), "{ids:?}"); // No transactional buffering either (batch uses direct recording). assert!(cdc.pending_events().lock().is_empty()); } @@ -2157,7 +2160,7 @@ mod tests { .labels .as_ref() .expect("labels must be Some even with empty input"); - assert!(labels.is_empty()); + assert!(labels.is_empty(), "{labels:?}"); } // --------------------------------------------------------------- diff --git a/crates/grafeo-engine/src/database/compact_tiered.rs b/crates/grafeo-engine/src/database/compact_tiered.rs index ac780d00c..205c1904b 100644 --- a/crates/grafeo-engine/src/database/compact_tiered.rs +++ b/crates/grafeo-engine/src/database/compact_tiered.rs @@ -362,7 +362,7 @@ mod tests { // the heap now. let store = tiered.store(); let person_ids = store.nodes_by_label("Person"); - assert!(!person_ids.is_empty()); + assert!(!person_ids.is_empty(), "person_ids is empty"); for id in person_ids.iter().take(4) { assert!( store @@ -477,7 +477,7 @@ mod tests { let reopened = CompactStoreTiered::open_mmap(&path).expect("open_mmap"); let store = reopened.store(); let person_ids = store.nodes_by_label("Person"); - assert!(!person_ids.is_empty()); + assert!(!person_ids.is_empty(), "person_ids is empty"); // Reads work; values come from the mmap-backed Bytes via // `data.slice(range)` constructors in `read_from_v3`. diff --git a/crates/grafeo-engine/src/database/mod.rs b/crates/grafeo-engine/src/database/mod.rs index 52ccd3e8a..ec6c6b27f 100644 --- a/crates/grafeo-engine/src/database/mod.rs +++ b/crates/grafeo-engine/src/database/mod.rs @@ -4127,7 +4127,7 @@ mod tests { let db = GrafeoDB::new_in_memory(); let prom = db.metrics_prometheus(); // Should contain at least some metric names - assert!(!prom.is_empty()); + assert!(!prom.is_empty(), "prom is empty"); } #[cfg(feature = "metrics")] diff --git a/crates/grafeo-engine/src/embedding/mod.rs b/crates/grafeo-engine/src/embedding/mod.rs index 4e58e05ed..e23907f41 100644 --- a/crates/grafeo-engine/src/embedding/mod.rs +++ b/crates/grafeo-engine/src/embedding/mod.rs @@ -116,7 +116,7 @@ mod tests { fn trait_empty_input_returns_empty() { let model = MockEmbeddingModel::new(64); let vecs = model.embed(&[]).unwrap(); - assert!(vecs.is_empty()); + assert!(vecs.is_empty(), "{vecs:?}"); } #[test] diff --git a/crates/grafeo-engine/src/export/mod.rs b/crates/grafeo-engine/src/export/mod.rs index adcd487cf..2aa472fb0 100644 --- a/crates/grafeo-engine/src/export/mod.rs +++ b/crates/grafeo-engine/src/export/mod.rs @@ -296,7 +296,7 @@ mod tests { let dur = Duration::new(2, 5, 0); let v = Value::Duration(dur); let result = value_to_xml_string(&v).unwrap(); - assert!(!result.is_empty()); + assert!(!result.is_empty(), "result is empty"); } #[test] @@ -305,7 +305,7 @@ mod tests { let zdt = ZonedDatetime::from_timestamp_offset(Timestamp::from_micros(0), 3600); let v = Value::ZonedDatetime(zdt); let result = value_to_xml_string(&v).unwrap(); - assert!(!result.is_empty()); + assert!(!result.is_empty(), "result is empty"); } #[test] diff --git a/crates/grafeo-engine/src/procedures.rs b/crates/grafeo-engine/src/procedures.rs index 8dd9fa823..60b4eb61e 100644 --- a/crates/grafeo-engine/src/procedures.rs +++ b/crates/grafeo-engine/src/procedures.rs @@ -1350,7 +1350,7 @@ mod tests { vec!["name", "description", "parameters", "output_columns"], "column shape is part of the public CALL grafeo.procedures() contract" ); - assert!(!result.rows.is_empty()); + assert!(!result.rows.is_empty(), "result.rows is empty"); } #[test] diff --git a/crates/grafeo-engine/src/query/plan.rs b/crates/grafeo-engine/src/query/plan.rs index ab7bde130..3c65cc1db 100644 --- a/crates/grafeo-engine/src/query/plan.rs +++ b/crates/grafeo-engine/src/query/plan.rs @@ -4735,7 +4735,7 @@ mod tests { silent: false, }); let out = graph_op.explain_tree(); - assert!(!out.is_empty()); + assert!(!out.is_empty(), "out is empty"); } // ==================== fmt_expr helper ==================== diff --git a/crates/grafeo-engine/src/query/planner/rdf/mod.rs b/crates/grafeo-engine/src/query/planner/rdf/mod.rs index 4d056db74..942986132 100644 --- a/crates/grafeo-engine/src/query/planner/rdf/mod.rs +++ b/crates/grafeo-engine/src/query/planner/rdf/mod.rs @@ -7312,7 +7312,7 @@ mod tests { let physical = planner .plan(&LogicalPlan::new(LogicalOperator::Empty)) .unwrap(); - assert!(physical.columns.is_empty()); + assert!(physical.columns.is_empty(), "{:?}", physical.columns); } #[test] diff --git a/crates/grafeo-engine/src/session/mod.rs b/crates/grafeo-engine/src/session/mod.rs index a6c73c616..de3b2c366 100644 --- a/crates/grafeo-engine/src/session/mod.rs +++ b/crates/grafeo-engine/src/session/mod.rs @@ -4213,8 +4213,12 @@ impl Session { // track_graph_touch() for this transaction (it checks current_transaction // first), so this is safe. let touched = std::mem::take(&mut *self.touched_graphs.lock()); - let commit_epoch = match self.transaction_manager.commit(transaction_id) { - Ok(epoch) => epoch, + // Until `commit` is dropped, the commit holds its writes and no other + // commit, transaction start or write outside a transaction can run: + // the versions, events and WAL records below are complete before + // anything that comes after the commit (#548). + let commit = match self.transaction_manager.start_commit(transaction_id) { + Ok(commit) => commit, Err(e) => { // Conflict detected: abort the transaction completely so its // entities are released and its versions discarded (#409). @@ -4236,6 +4240,10 @@ impl Session { return Err(e); } }; + let commit_epoch = commit.epoch(); + + #[cfg(feature = "testing-statement-injection")] + grafeo_common::testing::commit_hook::run_after_commit_epoch(); // Finalize PENDING epochs: make uncommitted versions visible at the commit epoch. for graph_name in &touched { @@ -4292,6 +4300,7 @@ impl Session { let store = self.resolve_store(graph_name); store.sync_epoch(current_epoch); } + drop(commit); // Reset read-only flag and clear savepoints. // touched_graphs was already emptied by mem::take above. @@ -6497,7 +6506,7 @@ mod tests { // No edges of this type let no_neighbors = session.get_neighbors_outgoing_by_type(alix, "LIKES"); - assert!(no_neighbors.is_empty()); + assert!(no_neighbors.is_empty(), "{no_neighbors:?}"); } #[test] @@ -6656,12 +6665,19 @@ mod tests { let lonely = session.create_node(&["Person"]).unwrap(); - assert!(session.get_neighbors_outgoing(lonely).is_empty()); - assert!(session.get_neighbors_incoming(lonely).is_empty()); + assert!( + session.get_neighbors_outgoing(lonely).is_empty(), + "expected empty" + ); + assert!( + session.get_neighbors_incoming(lonely).is_empty(), + "expected empty" + ); assert!( session .get_neighbors_outgoing_by_type(lonely, "KNOWS") - .is_empty() + .is_empty(), + "expected no neighbors" ); } } @@ -7062,7 +7078,7 @@ mod tests { session.execute("ROLLBACK").unwrap(); let result = session.execute("MATCH (n:Person) RETURN n.name").unwrap(); - assert!(result.rows.is_empty()); + assert!(result.rows.is_empty(), "{:?}", result.rows); } #[test] diff --git a/crates/grafeo-engine/src/transaction/manager.rs b/crates/grafeo-engine/src/transaction/manager.rs index 1ebb592ab..36764e718 100644 --- a/crates/grafeo-engine/src/transaction/manager.rs +++ b/crates/grafeo-engine/src/transaction/manager.rs @@ -15,6 +15,10 @@ use parking_lot::{Mutex, MutexGuard, RwLock}; pub enum TransactionState { /// Transaction is active. Active, + /// The commit is decided (its epoch is assigned) and its versions, events + /// and WAL records are being written; it becomes `Committed` when that is + /// done. Its writes still conflict with other transactions' writes. + Committing, /// Transaction is committed. Committed, /// Transaction is aborted. @@ -182,6 +186,50 @@ pub struct TransactionManager { /// and briefly by every [`begin`](Self::begin), so no transaction starts /// while such a write runs. idle_gate: Mutex<()>, + /// Held by a commit from its epoch until it is complete (see + /// [`CommitGuard`]) and briefly by every [`begin`](Self::begin): commits + /// complete one at a time, in epoch order, and no transaction starts in + /// the middle of one. + commit_lock: Mutex<()>, +} + +/// A commit in progress, from [`TransactionManager::start_commit`] until it +/// is dropped. Until then the transaction is [`TransactionState::Committing`]: +/// it still counts as open, so no write outside a transaction can start (see +/// [`TransactionManager::idle_gate`]), its writes still conflict with other +/// transactions' writes, and no other commit and no +/// [`begin`](TransactionManager::begin) can run. Drop it once the commit's +/// versions, events and WAL records are written. +#[must_use = "the commit is complete when the guard is dropped"] +pub(crate) struct CommitGuard<'a> { + manager: &'a TransactionManager, + transaction_id: TransactionId, + epoch: EpochId, + _commit: MutexGuard<'a, ()>, +} + +impl CommitGuard<'_> { + /// The commit epoch. + pub(crate) fn epoch(&self) -> EpochId { + self.epoch + } +} + +impl Drop for CommitGuard<'_> { + fn drop(&mut self) { + if let Some(info) = self + .manager + .transactions + .write() + .get_mut(&self.transaction_id) + { + info.state = TransactionState::Committed; + } + // Release pairs with the acquire in `idle_gate`: a write outside a + // transaction sees everything the commit wrote. + self.manager.active_count.fetch_sub(1, Ordering::Release); + // The commit lock is released after this, with `_commit`. + } } impl TransactionManager { @@ -197,6 +245,7 @@ impl TransactionManager { transactions: RwLock::new(FxHashMap::default()), committed_epochs: RwLock::new(FxHashMap::default()), idle_gate: Mutex::new(()), + commit_lock: Mutex::new(()), } } @@ -207,8 +256,11 @@ impl TransactionManager { /// Begins a new transaction with the specified isolation level. pub fn begin_with_isolation(&self, isolation_level: IsolationLevel) -> TransactionId { - // Wait for a write outside any transaction to finish. + // Wait for a write outside any transaction to finish, and for a + // commit in progress: the snapshot holds every commit up to its + // epoch, complete. let _gate = self.idle_gate.lock(); + let _commit = self.commit_lock.lock(); let transaction_id = TransactionId::new(self.next_transaction_id.fetch_add(1, Ordering::Relaxed)); let epoch = EpochId::new(self.current_epoch.load(Ordering::Acquire)); @@ -262,11 +314,15 @@ impl TransactionManager { let mut txns = self.transactions.write(); // First-writer-wins conflict detection. Skip the scan when only one - // transaction is active (common case for auto-commit). + // transaction is active (common case for auto-commit). A commit in + // progress still holds its writes. if self.active_count.load(Ordering::Relaxed) > 1 { for (other_tx, other_info) in txns.iter() { if *other_tx != transaction_id - && other_info.state == TransactionState::Active + && matches!( + other_info.state, + TransactionState::Active | TransactionState::Committing + ) && other_info.write_set.contains(&entity) { return Err(Error::Transaction(TransactionError::WriteConflict( @@ -338,6 +394,18 @@ impl TransactionManager { /// - There's a write-write conflict with another committed transaction /// - (Serializable only) There's a read-write conflict (SSI violation) pub fn commit(&self, transaction_id: TransactionId) -> Result { + Ok(self.start_commit(transaction_id)?.epoch()) + } + + /// Commits a transaction like [`commit`](Self::commit), but completes the + /// commit only when the returned guard is dropped (see [`CommitGuard`]): + /// the caller writes the commit's versions, events and WAL records first. + /// + /// # Errors + /// + /// As [`commit`](Self::commit); the transaction then stays active. + pub(crate) fn start_commit(&self, transaction_id: TransactionId) -> Result> { + let commit_lock = self.commit_lock.lock(); // Lock ordering: transactions first, then committed_epochs (matches gc()). // Both held as write locks to ensure state and epoch are updated atomically, // preventing a race where another thread sees state == Committed but the @@ -422,14 +490,20 @@ impl TransactionManager { // SeqCst ensures all threads see commits in a consistent total order. let commit_epoch = EpochId::new(self.current_epoch.fetch_add(1, Ordering::SeqCst) + 1); - // Update state and record commit epoch atomically (both write locks held). + // Update state and record commit epoch atomically (both write locks + // held). The transaction stays counted as active until the guard is + // dropped. if let Some(info) = txns.get_mut(&transaction_id) { - info.state = TransactionState::Committed; + info.state = TransactionState::Committing; } - self.active_count.fetch_sub(1, Ordering::Relaxed); committed.insert(transaction_id, commit_epoch); - Ok(commit_epoch) + Ok(CommitGuard { + manager: self, + transaction_id, + epoch: commit_epoch, + _commit: commit_lock, + }) } /// Aborts a transaction. @@ -588,7 +662,8 @@ impl TransactionManager { .iter() .filter(|(transaction_id, info)| { match info.state { - TransactionState::Active => false, // Never remove active transactions + // Never remove active transactions or a commit in progress + TransactionState::Active | TransactionState::Committing => false, TransactionState::Aborted => true, // Always safe to remove aborted transactions TransactionState::Committed => { // Only remove committed transactions if their commit epoch @@ -1306,4 +1381,78 @@ mod tests { "committed_epochs must contain tx immediately after commit()" ); } + + /// Until a commit is complete, a write outside any transaction cannot + /// start and another transaction cannot write what the committing one + /// wrote; dropping the guard allows both again. + #[test] + fn a_commit_holds_its_writes_until_it_is_complete() { + let mgr = TransactionManager::new(); + let tx = mgr.begin(); + let other = mgr.begin(); + mgr.record_write(tx, NodeId::new(1)).unwrap(); + + let commit = mgr.start_commit(tx).unwrap(); + assert_eq!(mgr.state(tx), Some(TransactionState::Committing)); + assert_eq!(mgr.committed_epoch(tx), Some(commit.epoch())); + assert!(matches!( + mgr.record_write(other, NodeId::new(1)), + Err(Error::Transaction(TransactionError::WriteConflict(_))) + )); + mgr.record_write(other, NodeId::new(2)).unwrap(); + mgr.abort(other).unwrap(); + assert!( + mgr.idle_gate().is_none(), + "a write outside a transaction waits for the commit" + ); + + drop(commit); + assert_eq!(mgr.state(tx), Some(TransactionState::Committed)); + assert!(mgr.idle_gate().is_some()); + let next = mgr.begin(); + mgr.record_write(next, NodeId::new(1)).unwrap(); + } + + /// A transaction begins between commits, never during one: it waits for + /// the commit in progress and starts at its epoch. + #[test] + fn begin_waits_for_a_commit_in_progress() { + let mgr = TransactionManager::new(); + let tx = mgr.begin(); + let commit = mgr.start_commit(tx).unwrap(); + let epoch = commit.epoch(); + + let manager = &mgr; + std::thread::scope(|scope| { + let (began, waited) = std::sync::mpsc::channel(); + let late = scope.spawn(move || { + let late = manager.begin(); + began.send(()).unwrap(); + late + }); + assert!( + waited + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "begin returned while the commit was in progress" + ); + drop(commit); + let late = late.join().unwrap(); + assert_eq!(mgr.start_epoch(late), Some(epoch)); + }); + } + + /// `commit` completes at once: nothing waits for it afterwards. + #[test] + fn commit_completes_at_once() { + let mgr = TransactionManager::new(); + let tx = mgr.begin(); + mgr.record_write(tx, NodeId::new(1)).unwrap(); + mgr.commit(tx).unwrap(); + + assert_eq!(mgr.state(tx), Some(TransactionState::Committed)); + assert!(mgr.idle_gate().is_some()); + let next = mgr.begin(); + mgr.record_write(next, NodeId::new(1)).unwrap(); + } } diff --git a/crates/grafeo-engine/src/transaction/parallel.rs b/crates/grafeo-engine/src/transaction/parallel.rs index 26fc64e6c..905d4c647 100644 --- a/crates/grafeo-engine/src/transaction/parallel.rs +++ b/crates/grafeo-engine/src/transaction/parallel.rs @@ -859,7 +859,7 @@ mod tests { #[test] fn test_partitioner_empty() { let (clusters, largest) = ConflictPartitioner::partition(&[], &[], &[]); - assert!(clusters.is_empty()); + assert!(clusters.is_empty(), "{clusters:?}"); assert_eq!(largest, 0); } @@ -1374,7 +1374,7 @@ mod tests { }); let failed: Vec = result.failed_indices().collect(); - assert!(failed.is_empty()); + assert!(failed.is_empty(), "{failed:?}"); } #[test] diff --git a/crates/grafeo-engine/tests/auth_permissions.rs b/crates/grafeo-engine/tests/auth_permissions.rs index 7ec4d8efb..f90fa514e 100644 --- a/crates/grafeo-engine/tests/auth_permissions.rs +++ b/crates/grafeo-engine/tests/auth_permissions.rs @@ -208,7 +208,11 @@ fn drop_projection_existing() { db.drop_projection("temp"), "dropping existing projection should return true" ); - assert!(db.list_projections().is_empty()); + assert!( + db.list_projections().is_empty(), + "{:?}", + db.list_projections() + ); } #[test] diff --git a/crates/grafeo-engine/tests/commit_completion.rs b/crates/grafeo-engine/tests/commit_completion.rs new file mode 100644 index 000000000..fdf5eca61 --- /dev/null +++ b/crates/grafeo-engine/tests/commit_completion.rs @@ -0,0 +1,261 @@ +//! A commit completes before anything that comes after it (#548). +//! +//! A commit assigns its epoch, then writes its versions, CDC events and WAL +//! records. These tests run work on another thread from inside a commit, +//! right after its epoch is assigned (the `testing-statement-injection` +//! commit hook), and check that the work lands after the commit, never in the +//! middle of it. The WAL tests crash a child process (it exits without +//! `close()`, so nothing is checkpointed) and reopen, so the WAL is replayed. +//! +//! ```bash +//! cargo test -p grafeo-engine --all-features --test commit_completion +//! ``` + +#![cfg(feature = "testing-statement-injection")] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread::JoinHandle; +use std::time::Duration; + +use grafeo_common::testing::commit_hook::after_next_commit_epoch; +use grafeo_common::types::{NodeId, Value}; +use grafeo_engine::GrafeoDB; + +/// Work started from inside a commit, joined after it. +struct DuringCommit(Arc>>>); + +impl DuringCommit { + fn join(self) -> T { + let handle = self + .0 + .lock() + .unwrap() + .take() + .expect("the commit did not run the hook"); + handle + .join() + .expect("the work started during the commit panicked") + } +} + +/// Starts `work` on another thread from inside the next commit on this +/// thread, right after its epoch is assigned, and gives it time to finish +/// there: work that does not wait for the commit runs in the middle of it. +fn during_next_commit( + work: impl FnOnce() -> T + Send + 'static, +) -> DuringCommit { + let slot = Arc::new(Mutex::new(None)); + let handle_slot = Arc::clone(&slot); + after_next_commit_epoch(move || { + let (done, finished) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let result = work(); + let _ = done.send(()); + result + }); + let _ = finished.recv_timeout(Duration::from_millis(300)); + *handle_slot.lock().unwrap() = Some(handle); + }); + DuringCommit(slot) +} + +fn by(db: &GrafeoDB, node: NodeId) -> Option { + db.get_node(node)?.get_property("by").cloned() +} + +/// A transaction that sets `by` on `hub` and starts `work` (given the +/// database and the hub) from inside its commit. Returns the work's result. +fn commit_with( + db: &Arc, + hub: NodeId, + work: impl FnOnce(Arc, NodeId) -> T + Send + 'static, +) -> T { + let mut session = db.session(); + session.begin_transaction().unwrap(); + session + .set_node_property(hub, "by", Value::from("transaction")) + .unwrap(); + let during = { + let db = Arc::clone(db); + during_next_commit(move || work(db, hub)) + }; + session.commit().unwrap(); + during.join() +} + +/// A direct write that arrives while a transaction commits lands after the +/// commit: the committed value holds at the commit's epoch and the direct +/// write's value is the latest. +#[cfg(feature = "temporal")] +#[test] +fn a_direct_write_during_a_commit_lands_after_it() { + let db = Arc::new(GrafeoDB::new_in_memory()); + let hub = db.create_node(&["Hub"]).unwrap(); + let commit_epoch = grafeo_common::types::EpochId::new(db.current_epoch().as_u64() + 1); + commit_with(&db, hub, |db, hub| { + db.set_node_property(hub, "by", Value::from("direct")) + }) + .unwrap(); + + assert_eq!( + db.get_node_property_at_epoch(hub, "by", commit_epoch), + Some(Value::from("transaction")) + ); + assert_eq!(by(&db, hub), Some(Value::from("direct"))); +} + +/// A transaction that begins while another commits sees what that commit +/// created. +#[test] +fn a_transaction_that_begins_during_a_commit_sees_it() { + let db = Arc::new(GrafeoDB::new_in_memory()); + let mut session = db.session(); + session.begin_transaction().unwrap(); + let doc = session.create_node(&["Doc"]).unwrap(); + let reader = { + let db = Arc::clone(&db); + during_next_commit(move || { + let mut reader = db.session(); + reader.begin_transaction().unwrap(); + let seen = reader.get_node(doc).is_some(); + reader.commit().unwrap(); + seen + }) + }; + session.commit().unwrap(); + + assert!(reader.join(), "the new transaction did not see the commit"); +} + +/// An open transaction that writes what a committing one wrote gets a write +/// conflict, and its rollback leaves the committed value alone. +#[test] +fn a_write_during_a_commit_to_what_it_wrote_conflicts() { + let db = Arc::new(GrafeoDB::new_in_memory()); + let hub = db.create_node(&["Hub"]).unwrap(); + let mut other = db.session(); + other.begin_transaction().unwrap(); + let written = commit_with(&db, hub, move |_, hub| { + let result = other.set_node_property(hub, "by", Value::from("other")); + other.rollback().unwrap(); + result.map_err(|error| error.to_string()) + }); + + let error = written.unwrap_err(); + assert!(error.to_lowercase().contains("conflict"), "got: {error}"); + assert_eq!(by(&db, hub), Some(Value::from("transaction"))); +} + +// --------------------------------------------------------------------------- +// WAL order: a crashed child process, then a reopen that replays the WAL +// --------------------------------------------------------------------------- + +#[cfg(feature = "wal")] +mod wal { + use super::*; + use grafeo_common::testing::child_process; + use grafeo_engine::Config; + use grafeo_engine::config::StorageFormat; + + const SCENARIO_VAR: &str = "GRAFEO_COMMIT_COMPLETION_SCENARIO"; + const PATH_VAR: &str = "GRAFEO_COMMIT_COMPLETION_PATH"; + const FORMAT_VAR: &str = "GRAFEO_COMMIT_COMPLETION_FORMAT"; + + fn formats(dir: &Path) -> Vec<(&'static str, PathBuf)> { + let mut formats = vec![("wal-directory", dir.join("dir-db"))]; + #[cfg(feature = "grafeo-file")] + formats.push(("single-file", dir.join("single.grafeo"))); + formats + } + + fn open(path: &Path, format: &str) -> GrafeoDB { + let format = match format { + "wal-directory" => StorageFormat::WalDirectory, + "single-file" => StorageFormat::SingleFile, + other => panic!("unknown format {other}"), + }; + GrafeoDB::with_config(Config::persistent(path).with_storage_format(format)).unwrap() + } + + /// Runs `scenario` in a child process that exits without closing the + /// database, then reopens it and returns the hub's `by` (the hub is the + /// first node the scenario creates). + fn by_after_crash(scenario: &str, path: &Path, format: &str) -> Option { + let status = child_process::run( + std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "wal::crash_child", "--nocapture"]) + .env(SCENARIO_VAR, scenario) + .env(PATH_VAR, path) + .env(FORMAT_VAR, format), + ) + .unwrap(); + assert!(status.success(), "{format}: scenario {scenario} failed"); + let db = open(path, format); + let hub = db.execute("MATCH (h:Hub) RETURN id(h)").unwrap().rows()[0][0].clone(); + let Value::Int64(hub) = hub else { + panic!("no hub: {hub:?}"); + }; + by(&db, NodeId::new(u64::try_from(hub).unwrap())) + } + + /// Child-process entry for [`by_after_crash`]; a no-op when run directly. + #[test] + fn crash_child() { + let Ok(scenario) = std::env::var(SCENARIO_VAR) else { + return; + }; + let path = PathBuf::from(std::env::var_os(PATH_VAR).unwrap()); + let db = Arc::new(open(&path, &std::env::var(FORMAT_VAR).unwrap())); + let hub = db.create_node(&["Hub"]).unwrap(); + match scenario.as_str() { + "direct_write" => { + commit_with(&db, hub, |db, hub| { + db.set_node_property(hub, "by", Value::from("later")) + }) + .unwrap(); + } + "transaction" => { + commit_with(&db, hub, |db, hub| { + let mut later = db.session(); + later.begin_transaction().unwrap(); + later + .set_node_property(hub, "by", Value::from("later")) + .unwrap(); + later.commit().unwrap(); + }); + } + other => panic!("unknown scenario {other}"), + } + // Crash: no close(), no destructors. + std::process::exit(0); + } + + /// A direct write that arrives while a transaction commits is logged + /// after the commit, so replay ends with the direct write's value. + #[test] + fn a_direct_write_during_a_commit_is_replayed_after_it() { + let dir = tempfile::tempdir().unwrap(); + for (format, path) in formats(dir.path()) { + assert_eq!( + by_after_crash("direct_write", &path, format), + Some(Value::from("later")), + "{format}" + ); + } + } + + /// A transaction that begins and commits while another commits is + /// logged after it, so replay ends with its value. + #[test] + fn a_transaction_during_a_commit_is_replayed_after_it() { + let dir = tempfile::tempdir().unwrap(); + for (format, path) in formats(dir.path()) { + assert_eq!( + by_after_crash("transaction", &path, format), + Some(Value::from("later")), + "{format}" + ); + } + } +} diff --git a/crates/grafeo-engine/tests/coverage_project.rs b/crates/grafeo-engine/tests/coverage_project.rs index d08ba681f..6639e7e83 100644 --- a/crates/grafeo-engine/tests/coverage_project.rs +++ b/crates/grafeo-engine/tests/coverage_project.rs @@ -116,7 +116,7 @@ fn return_star_expands_all_user_columns() { .unwrap(); assert_eq!(result.rows().len(), 1); // `n` should be the single expanded column. - assert!(!result.columns.is_empty()); + assert!(!result.columns.is_empty(), "result.columns is empty"); assert!( result .columns @@ -145,7 +145,7 @@ fn return_star_skips_internal_underscore_columns() { "internal column leaked into RETURN *: {col}" ); } - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); } // ============================================================================ @@ -177,7 +177,7 @@ fn return_length_of_path_variable() { RETURN length(p) AS len ORDER BY len", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); let lengths: Vec = result .rows() .iter() diff --git a/crates/grafeo-engine/tests/coverage_session.rs b/crates/grafeo-engine/tests/coverage_session.rs index e1c5bf597..04d14ec81 100644 --- a/crates/grafeo-engine/tests/coverage_session.rs +++ b/crates/grafeo-engine/tests/coverage_session.rs @@ -325,7 +325,7 @@ fn test_session_recovers_after_parse_error() { let result = session .execute("MATCH (n:Person) RETURN n.name ORDER BY n.name") .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); } #[test] diff --git a/crates/grafeo-engine/tests/database_api.rs b/crates/grafeo-engine/tests/database_api.rs index f45d47015..9f1175a93 100644 --- a/crates/grafeo-engine/tests/database_api.rs +++ b/crates/grafeo-engine/tests/database_api.rs @@ -176,7 +176,7 @@ fn test_property_index_lifecycle() { // No matches let results = db.find_nodes_by_property("name", &Value::String("Harm".into())); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); // Drop index assert!(db.drop_property_index("name")); diff --git a/crates/grafeo-engine/tests/direct_api.rs b/crates/grafeo-engine/tests/direct_api.rs index 9714ac68c..aa76d3e55 100644 --- a/crates/grafeo-engine/tests/direct_api.rs +++ b/crates/grafeo-engine/tests/direct_api.rs @@ -59,7 +59,7 @@ fn writes_go_to_the_selected_graph() { // The default graph got none of it. db.set_current_graph(None).unwrap(); - assert!(ids(&db).is_empty()); + assert!(ids(&db).is_empty(), "expected empty"); assert!(db.get_node(component).is_none()); } @@ -230,7 +230,8 @@ fn a_failing_batch_creates_nothing() { assert_eq!(db.node_count(), 0, "with_index: {with_index}"); assert!( db.find_nodes_by_property("id", &Value::from("a")) - .is_empty() + .is_empty(), + "expected no nodes" ); let created = db diff --git a/crates/grafeo-engine/tests/direct_reads.rs b/crates/grafeo-engine/tests/direct_reads.rs index 3195df255..ed2338d9d 100644 --- a/crates/grafeo-engine/tests/direct_reads.rs +++ b/crates/grafeo-engine/tests/direct_reads.rs @@ -232,7 +232,8 @@ fn reads_of_a_dropped_selected_graph_find_nothing() { assert!(db.get_node_labels(component).is_none()); assert!( db.find_nodes_by_property("name", &Value::from("Alix")) - .is_empty() + .is_empty(), + "expected no nodes" ); db.set_current_graph(None).unwrap(); diff --git a/crates/grafeo-engine/tests/expression_and_projection.rs b/crates/grafeo-engine/tests/expression_and_projection.rs index a8e880722..b0309792c 100644 --- a/crates/grafeo-engine/tests/expression_and_projection.rs +++ b/crates/grafeo-engine/tests/expression_and_projection.rs @@ -445,7 +445,7 @@ fn test_exists_multi_hop_no_match() { WHERE EXISTS { MATCH (n)-[:MANAGES]->(m)-[:LIVES_IN]->(c:City) } \ RETURN n.name", ); - assert!(names.is_empty()); + assert!(names.is_empty(), "{names:?}"); } #[test] @@ -994,7 +994,7 @@ fn test_return_type_function() { .execute("MATCH (a:Person)-[r:KNOWS]->(b:Person) RETURN type(r)") .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); for row in result.rows() { assert_eq!(row[0], Value::String("KNOWS".into())); } @@ -1620,7 +1620,7 @@ mod cypher_filter_ops { .unwrap(); // Harm has no outgoing KNOWS edges in the create_test_graph fixture - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); } #[test] diff --git a/crates/grafeo-engine/tests/filter_planner_branches.rs b/crates/grafeo-engine/tests/filter_planner_branches.rs index eb71324c1..11f0cb64a 100644 --- a/crates/grafeo-engine/tests/filter_planner_branches.rs +++ b/crates/grafeo-engine/tests/filter_planner_branches.rs @@ -485,7 +485,7 @@ fn equality_with_null_literal_is_not_pushed_down() { let result = session .execute("MATCH (n:Person) WHERE n.city = NULL RETURN n.name") .unwrap(); - assert!(result.rows().is_empty()); + assert!(result.rows().is_empty(), "{:?}", result.rows()); } // --------------------------------------------------------------------------- diff --git a/crates/grafeo-engine/tests/filter_pushdown.rs b/crates/grafeo-engine/tests/filter_pushdown.rs index 6b6cd778d..09d3e89ef 100644 --- a/crates/grafeo-engine/tests/filter_pushdown.rs +++ b/crates/grafeo-engine/tests/filter_pushdown.rs @@ -104,7 +104,7 @@ fn mixed_equality_and_range_no_match() { .execute("MATCH (n:Person) WHERE n.city = 'London' AND n.age > 50 RETURN n.name") .unwrap(); - assert!(result.rows().is_empty()); + assert!(result.rows().is_empty(), "{:?}", result.rows()); } // ── Range-only pushdown ── @@ -187,7 +187,7 @@ fn label_narrows_scan_correctly() { .execute("MATCH (n:Person) WHERE n.name = 'Acme' RETURN n.name") .unwrap(); - assert!(result.rows().is_empty()); + assert!(result.rows().is_empty(), "{:?}", result.rows()); } #[test] @@ -231,7 +231,7 @@ fn or_filter_matches_no_side() { .execute("MATCH (n:Person) WHERE n.name = 'Nobody' OR n.name = 'Ghost' RETURN n.name") .unwrap(); - assert!(result.rows().is_empty()); + assert!(result.rows().is_empty(), "{:?}", result.rows()); } #[test] diff --git a/crates/grafeo-engine/tests/gql_spec_compliance.rs b/crates/grafeo-engine/tests/gql_spec_compliance.rs index 10e69ee68..325cf6295 100644 --- a/crates/grafeo-engine/tests/gql_spec_compliance.rs +++ b/crates/grafeo-engine/tests/gql_spec_compliance.rs @@ -51,7 +51,7 @@ fn test_return_star() { .unwrap(); assert_eq!(result.rows().len(), 1); // Should have at least the 'n' variable - assert!(!result.columns.is_empty()); + assert!(!result.columns.is_empty(), "result.columns is empty"); } #[test] @@ -217,7 +217,7 @@ fn test_like_no_match() { let result = session .execute("MATCH (n:Person) WHERE n.name LIKE 'X%' RETURN n.name") .unwrap(); - assert!(result.rows().is_empty()); + assert!(result.rows().is_empty(), "{:?}", result.rows()); } // --------------------------------------------------------------------------- @@ -1247,7 +1247,7 @@ fn test_path_length_function() { RETURN length(p) AS len", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); assert_eq!( result.rows()[0][0], Value::Int64(1), @@ -1267,7 +1267,7 @@ fn test_path_nodes_function() { RETURN nodes(p) AS node_list", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); match &result.rows()[0][0] { Value::List(items) => { assert_eq!(items.len(), 2, "Single-hop path should have 2 nodes"); @@ -1288,7 +1288,7 @@ fn test_path_edges_function() { RETURN edges(p) AS edge_list", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); match &result.rows()[0][0] { Value::List(items) => { assert_eq!(items.len(), 1, "Single-hop path should have 1 edge"); @@ -1309,7 +1309,7 @@ fn test_path_is_acyclic() { RETURN isAcyclic(p) AS is_acyclic_result", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); assert_eq!( result.rows()[0][0], Value::Bool(true), @@ -1329,7 +1329,7 @@ fn test_path_is_simple() { RETURN isSimple(p) AS is_simple_result", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); assert_eq!( result.rows()[0][0], Value::Bool(true), @@ -1349,7 +1349,7 @@ fn test_path_is_trail() { RETURN isTrail(p) AS is_trail_result", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); assert_eq!( result.rows()[0][0], Value::Bool(true), @@ -1367,7 +1367,7 @@ fn test_path_constructor() { let result = session .execute("MATCH (n:Person {name: 'Alix'}) RETURN path([1, 2, 3], ['a', 'b']) AS p") .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); match &result.rows()[0][0] { Value::Path { nodes, edges } => { assert_eq!(nodes.len(), 3, "path should have 3 nodes"); @@ -1390,7 +1390,7 @@ fn test_path_constructor_from_match() { RETURN isSimple(rebuilt) AS is_simple", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); assert_eq!( result.rows()[0][0], Value::Bool(true), @@ -1411,7 +1411,7 @@ fn test_path_equality() { RETURN p = p AS self_equal", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); assert_eq!( result.rows()[0][0], Value::Bool(true), @@ -1540,7 +1540,7 @@ fn test_aggregate_in_order_by() { ORDER BY friend_count DESC", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); } // ISO: GF20 @@ -1556,7 +1556,7 @@ fn test_aggregate_order_by_alias() { ORDER BY friend_count DESC", ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); } // ========================================================================= @@ -2389,7 +2389,7 @@ fn test_db_labels_with_yield() { let result = session .execute("CALL db.labels() YIELD label RETURN label") .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); assert_eq!(result.columns, vec!["label"]); } @@ -3164,7 +3164,7 @@ fn test_viewing_epoch_limits_visibility() { session.set_viewing_epoch(grafeo_common::types::EpochId(1)); // Query with viewing epoch set should not panic let result = session.execute("MATCH (m:Marker) RETURN count(m)").unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); session.clear_viewing_epoch(); } diff --git a/crates/grafeo-engine/tests/grafeo_memory_support.rs b/crates/grafeo-engine/tests/grafeo_memory_support.rs index c15002e01..0963a4989 100644 --- a/crates/grafeo-engine/tests/grafeo_memory_support.rs +++ b/crates/grafeo-engine/tests/grafeo_memory_support.rs @@ -99,7 +99,7 @@ mod batch_create_with_props { let ids = db .batch_create_nodes_with_props("Memory", Vec::new()) .unwrap(); - assert!(ids.is_empty()); + assert!(ids.is_empty(), "{ids:?}"); } #[test] @@ -429,7 +429,7 @@ mod temporal_versioning { fn property_history_empty_for_nonexistent() { let db = db(); let history = db.get_node_property_history(NodeId(999), "name"); - assert!(history.is_empty()); + assert!(history.is_empty(), "{history:?}"); } #[test] diff --git a/crates/grafeo-engine/tests/graph_handles.rs b/crates/grafeo-engine/tests/graph_handles.rs index b96668598..02566ba60 100644 --- a/crates/grafeo-engine/tests/graph_handles.rs +++ b/crates/grafeo-engine/tests/graph_handles.rs @@ -55,7 +55,10 @@ fn handles_interleave_without_switching() { assert_eq!(ids(extraction.execute(ALL_IDS).unwrap()), ["file::a"]); assert_eq!(ids(model.execute(ALL_IDS).unwrap()), ["ac::a", "ac::b"]); - assert!(ids(db.execute(ALL_IDS).unwrap()).is_empty()); + assert!( + ids(db.execute(ALL_IDS).unwrap()).is_empty(), + "expected empty" + ); assert_eq!(db.current_graph(), None); assert_eq!( extraction @@ -141,7 +144,10 @@ fn use_graph_in_a_query_stays_in_that_query() { model.execute("INSERT (:Component {id: 'ac::a'})").unwrap(); assert_eq!(ids(model.execute(ALL_IDS).unwrap()), ["ac::a"]); - assert!(ids(db.graph("extraction").unwrap().execute(ALL_IDS).unwrap()).is_empty()); + assert!( + ids(db.graph("extraction").unwrap().execute(ALL_IDS).unwrap()).is_empty(), + "expected empty" + ); assert_eq!(db.current_graph(), None); } @@ -201,7 +207,10 @@ fn a_transaction_on_a_handle_session_groups_writes() { assert_eq!(ids(session.execute(ALL_IDS).unwrap()), ["ac::a", "ac::b"]); session.rollback().unwrap(); - assert!(ids(model.execute(ALL_IDS).unwrap()).is_empty()); + assert!( + ids(model.execute(ALL_IDS).unwrap()).is_empty(), + "expected empty" + ); let ids_created = model .session() @@ -321,7 +330,10 @@ fn direct_calls_on_a_handle_stay_in_its_graph() { Some(&Value::from("ac::audit")) ); assert!(model.get_edge(uses).unwrap().is_some()); - assert!(ids(db.execute(ALL_IDS).unwrap()).is_empty()); + assert!( + ids(db.execute(ALL_IDS).unwrap()).is_empty(), + "expected empty" + ); assert!( db.get_node(billing).is_none(), "the default graph got nothing" @@ -331,7 +343,10 @@ fn direct_calls_on_a_handle_stay_in_its_graph() { let err = model.create_node(&["Component"]).unwrap_err(); assert!(err.to_string().contains("does not exist"), "{err}"); assert!(model.get_node(billing).is_err()); - assert!(ids(db.execute(ALL_IDS).unwrap()).is_empty()); + assert!( + ids(db.execute(ALL_IDS).unwrap()).is_empty(), + "expected empty" + ); } #[test] diff --git a/crates/grafeo-engine/tests/index_persistence.rs b/crates/grafeo-engine/tests/index_persistence.rs index c51516aa2..f25bb6a4a 100644 --- a/crates/grafeo-engine/tests/index_persistence.rs +++ b/crates/grafeo-engine/tests/index_persistence.rs @@ -65,7 +65,8 @@ fn to_memory_keeps_indexes_and_constraints() { assert_built(&db); assert!( db.find_nodes_by_property("id", &Value::from("f1")) - .is_empty() + .is_empty(), + "expected no nodes" ); } diff --git a/crates/grafeo-engine/tests/label_rollback.rs b/crates/grafeo-engine/tests/label_rollback.rs index 6a175d745..f2d660db0 100644 --- a/crates/grafeo-engine/tests/label_rollback.rs +++ b/crates/grafeo-engine/tests/label_rollback.rs @@ -161,7 +161,7 @@ fn test_label_remove_undo_on_transaction_rollback() { session.execute("MATCH (n:Animal) REMOVE n:Pet").unwrap(); let during = session.execute("MATCH (n:Pet) RETURN n.species").unwrap(); - assert!(during.rows().is_empty()); + assert!(during.rows().is_empty(), "{:?}", during.rows()); session.rollback().unwrap(); diff --git a/crates/grafeo-engine/tests/mutation_planning.rs b/crates/grafeo-engine/tests/mutation_planning.rs index 2f3ec5fda..06976da01 100644 --- a/crates/grafeo-engine/tests/mutation_planning.rs +++ b/crates/grafeo-engine/tests/mutation_planning.rs @@ -552,7 +552,7 @@ fn test_remove_label() { .unwrap(); let result = session.execute("MATCH (n:Temp) RETURN n.name").unwrap(); - assert!(result.rows().is_empty()); + assert!(result.rows().is_empty(), "{:?}", result.rows()); } // ── Regression: SET/REMOVE label variable binding (#178, #182) ────────────── @@ -1468,7 +1468,7 @@ fn test_call_list_procedures() { let result = session.execute("CALL grafeo.procedures()").unwrap(); // Should return a list of available procedures - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); } #[cfg(feature = "algos")] @@ -1480,7 +1480,7 @@ fn test_call_degree_centrality() { let result = session.execute("CALL grafeo.degree_centrality()").unwrap(); // Should return results for each node - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); } #[cfg(feature = "algos")] @@ -1493,7 +1493,7 @@ fn test_call_procedure_with_yield() { .execute("CALL grafeo.pagerank() YIELD node_id, score RETURN node_id, score") .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); // Each row should have node_id and score assert!(result.columns.len() >= 2); } @@ -1930,7 +1930,7 @@ mod cypher_mutations { ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); for row in result.rows() { assert_eq!(row[1], Value::String("TechCorp".into())); } diff --git a/crates/grafeo-engine/tests/node_seek.rs b/crates/grafeo-engine/tests/node_seek.rs index 8518fe00b..0055e31a9 100644 --- a/crates/grafeo-engine/tests/node_seek.rs +++ b/crates/grafeo-engine/tests/node_seek.rs @@ -232,7 +232,10 @@ fn id_lookups_do_not_scan() { let params = [("s", four.clone()), ("d", five.clone())]; assert_eq!(rows(&db, edge, ¶ms), [vec![Value::Int64(4)]]); assert_eq!(rows(&db, edge, ¶ms), rows(&scanned, edge, ¶ms)); - assert!(rows(&db, edge, &[("s", five), ("d", four.clone())]).is_empty()); + assert!( + rows(&db, edge, &[("s", five), ("d", four.clone())]).is_empty(), + "expected empty" + ); let explained = plan( &db, @@ -244,8 +247,14 @@ fn id_lookups_do_not_scan() { rows(&db, single, &[("id", four)]), [vec![Value::from("d4")]] ); - assert!(rows(&db, single, &[("id", Value::Int64(-1))]).is_empty()); - assert!(rows(&db, single, &[("id", Value::Null)]).is_empty()); + assert!( + rows(&db, single, &[("id", Value::Int64(-1))]).is_empty(), + "expected empty" + ); + assert!( + rows(&db, single, &[("id", Value::Null)]).is_empty(), + "expected empty" + ); } /// A key that reads the clock is not looked up: the lookup and the filter @@ -297,14 +306,18 @@ fn a_seek_sees_what_the_transaction_sees() { .execute("UNWIND ['d1'] AS k MATCH (n:Doc {id: k}) RETURN n") .unwrap() .rows() - .is_empty() + .is_empty(), + "expected no rows" ); assert!( db.execute(lookup).unwrap().rows().is_empty(), "not committed" ); session.rollback().unwrap(); - assert!(db.execute(lookup).unwrap().rows().is_empty()); + assert!( + db.execute(lookup).unwrap().rows().is_empty(), + "expected empty" + ); assert_eq!( db.execute("UNWIND ['d1'] AS k MATCH (n:Doc {id: k}) RETURN n") .unwrap() diff --git a/crates/grafeo-engine/tests/planner_lpg_coverage.rs b/crates/grafeo-engine/tests/planner_lpg_coverage.rs index 468189226..0cfef812f 100644 --- a/crates/grafeo-engine/tests/planner_lpg_coverage.rs +++ b/crates/grafeo-engine/tests/planner_lpg_coverage.rs @@ -81,7 +81,7 @@ fn test_project_type_function() { let r = session .execute("MATCH ()-[r:KNOWS]->() RETURN type(r) AS t") .unwrap(); - assert!(!r.rows().is_empty()); + assert!(!r.rows().is_empty(), "r.rows() is empty"); for row in r.rows() { assert_eq!(row[0], Value::String("KNOWS".into())); } @@ -97,7 +97,7 @@ fn test_project_length_function() { RETURN length(p) AS len ORDER BY len", ) .unwrap(); - assert!(!r.rows().is_empty()); + assert!(!r.rows().is_empty(), "r.rows() is empty"); for row in r.rows() { match &row[0] { Value::Int64(n) => assert!((1..=3).contains(n)), @@ -116,7 +116,7 @@ fn test_project_nodes_and_edges_functions() { RETURN nodes(p) AS ns, edges(p) AS es", ) .unwrap(); - assert!(!r.rows().is_empty()); + assert!(!r.rows().is_empty(), "r.rows() is empty"); for row in r.rows() { let (nodes, edges) = match (&row[0], &row[1]) { (Value::List(a), Value::List(b)) => (a, b), @@ -137,7 +137,7 @@ fn test_project_case_expression_ok() { ORDER BY name", ) .unwrap(); - assert!(!r.rows().is_empty()); + assert!(!r.rows().is_empty(), "r.rows() is empty"); for row in r.rows() { match &row[1] { Value::String(s) => { diff --git a/crates/grafeo-engine/tests/project_coverage.rs b/crates/grafeo-engine/tests/project_coverage.rs index e2dd426d0..83e6b3eca 100644 --- a/crates/grafeo-engine/tests/project_coverage.rs +++ b/crates/grafeo-engine/tests/project_coverage.rs @@ -469,7 +469,7 @@ fn skip_beyond_result_set_yields_empty() { let r = session .execute("MATCH (n:Person) RETURN n.name SKIP 10") .unwrap(); - assert!(r.rows().is_empty()); + assert!(r.rows().is_empty(), "{:?}", r.rows()); } // --------------------------------------------------------------------------- diff --git a/crates/grafeo-engine/tests/push_pipeline_integration.rs b/crates/grafeo-engine/tests/push_pipeline_integration.rs index 5fc9dccc0..b6bc0a359 100644 --- a/crates/grafeo-engine/tests/push_pipeline_integration.rs +++ b/crates/grafeo-engine/tests/push_pipeline_integration.rs @@ -762,7 +762,7 @@ fn test_full_pipeline_chain() { ) .unwrap(); - assert!(!result.rows().is_empty()); + assert!(!result.rows().is_empty(), "result.rows() is empty"); // Eng has 2 employees, should be first assert_eq!(result.rows()[0][0].as_str().unwrap(), "Eng"); assert_eq!(result.rows()[0][1], Value::Int64(2)); diff --git a/crates/grafeo-engine/tests/rosetta_quick.rs b/crates/grafeo-engine/tests/rosetta_quick.rs index c6c05b3d4..26149b0d9 100644 --- a/crates/grafeo-engine/tests/rosetta_quick.rs +++ b/crates/grafeo-engine/tests/rosetta_quick.rs @@ -21,7 +21,7 @@ fn gql_count_star() { let db = setup_db(); let r = db.execute("MATCH ()-[r:RELATED_TO]->() RETURN r.relationship AS rel_type, count(*) AS cnt ORDER BY cnt DESC LIMIT 20"); assert!(r.is_ok(), "GQL count(*) failed: {:?}", r.err()); - assert!(!r.unwrap().rows().is_empty()); + assert!(!r.unwrap().rows().is_empty(), "r.unwrap().rows() is empty"); } #[test] diff --git a/crates/grafeo-engine/tests/search_operations.rs b/crates/grafeo-engine/tests/search_operations.rs index 2de8edd0e..d64b6d50c 100644 --- a/crates/grafeo-engine/tests/search_operations.rs +++ b/crates/grafeo-engine/tests/search_operations.rs @@ -95,7 +95,7 @@ mod vector { // Each query should return up to 2 results for result_set in &results { assert!(result_set.len() <= 2); - assert!(!result_set.is_empty()); + assert!(!result_set.is_empty(), "result_set is empty"); } } @@ -493,7 +493,7 @@ mod text { // Search works let r1 = db.text_search("Article", "title", "Rust", 10).unwrap(); - assert!(!r1.is_empty()); + assert!(!r1.is_empty(), "r1 is empty"); // Drop index assert!(db.drop_text_index("Article", "title")); @@ -507,7 +507,7 @@ mod text { // Search works again let r2 = db.text_search("Article", "title", "Rust", 10).unwrap(); - assert!(!r2.is_empty()); + assert!(!r2.is_empty(), "r2 is empty"); } } diff --git a/crates/grafeo-engine/tests/snapshot.rs b/crates/grafeo-engine/tests/snapshot.rs index 5d663b514..cff47ffe4 100644 --- a/crates/grafeo-engine/tests/snapshot.rs +++ b/crates/grafeo-engine/tests/snapshot.rs @@ -397,7 +397,7 @@ fn export_import_moderate_dataset() { } let bytes = db.export_snapshot().unwrap(); - assert!(!bytes.is_empty()); + assert!(!bytes.is_empty(), "bytes is empty"); let restored = GrafeoDB::import_snapshot(&bytes).unwrap(); diff --git a/crates/grafeo-engine/tests/sparql_transactions.rs b/crates/grafeo-engine/tests/sparql_transactions.rs index 587299e4c..f0e198134 100644 --- a/crates/grafeo-engine/tests/sparql_transactions.rs +++ b/crates/grafeo-engine/tests/sparql_transactions.rs @@ -159,9 +159,9 @@ mod tests { session .execute_sparql(r#"DELETE WHERE { ?o }"#) .unwrap(); - assert!(default_graph(&session).is_empty()); + assert!(default_graph(&session).is_empty(), "expected empty"); session.commit().unwrap(); - assert!(default_graph(&db.session()).is_empty()); + assert!(default_graph(&db.session()).is_empty(), "expected empty"); } #[cfg(feature = "wal")] diff --git a/crates/grafeo-engine/tests/streaming_results.rs b/crates/grafeo-engine/tests/streaming_results.rs index 5dd82189e..2e12041ed 100644 --- a/crates/grafeo-engine/tests/streaming_results.rs +++ b/crates/grafeo-engine/tests/streaming_results.rs @@ -204,7 +204,7 @@ fn streaming_empty_result_yields_no_rows() { .into_row_iter() .collect::, _>>() .expect("row iter"); - assert!(rows.is_empty()); + assert!(rows.is_empty(), "{rows:?}"); } // -------- Session-scoped streaming (ResultStream<'s> / RowIterator<'s>) ---- diff --git a/crates/grafeo-engine/tests/text_index_mutations.rs b/crates/grafeo-engine/tests/text_index_mutations.rs index 0a55213f6..6f8f1ccde 100644 --- a/crates/grafeo-engine/tests/text_index_mutations.rs +++ b/crates/grafeo-engine/tests/text_index_mutations.rs @@ -127,7 +127,7 @@ fn test_text_index_add_label() { // Not searchable under Article index let results = db.text_search("Article", "content", "Rust", 10).unwrap(); - assert!(results.is_empty()); + assert!(results.is_empty(), "{results:?}"); // Add the Article label let added = db.add_node_label(id, "Article").unwrap(); diff --git a/crates/grafeo-engine/tests/time_travel.rs b/crates/grafeo-engine/tests/time_travel.rs index 272f5c9d8..1fe4e96e7 100644 --- a/crates/grafeo-engine/tests/time_travel.rs +++ b/crates/grafeo-engine/tests/time_travel.rs @@ -124,7 +124,7 @@ fn test_deleted_node_history_preserves_epoch() { let result = session .execute("MATCH (p:Person {name: 'Vincent'}) RETURN p") .unwrap(); - assert!(result.rows().is_empty()); + assert!(result.rows().is_empty(), "{:?}", result.rows()); // The version history API still tracks the deletion metadata. // Note: MATCH-based time-travel on deleted nodes is not supported @@ -251,7 +251,8 @@ fn test_writes_fail_while_reading_an_earlier_epoch() { .execute("MATCH (p:Person) WHERE p.age IS NOT NULL RETURN p") .unwrap() .rows() - .is_empty() + .is_empty(), + "expected no rows" ); // With the viewing epoch cleared, the session writes again. diff --git a/crates/grafeo-engine/tests/transaction_changes.rs b/crates/grafeo-engine/tests/transaction_changes.rs index 588027672..b295d4ddf 100644 --- a/crates/grafeo-engine/tests/transaction_changes.rs +++ b/crates/grafeo-engine/tests/transaction_changes.rs @@ -91,7 +91,8 @@ fn rollback_restores_counts_and_statistics() { assert_eq!(int(&db, "MATCH (n:Admin) RETURN count(n)"), 0); assert!( db.find_nodes_by_property("name", &Value::from("Vincent")) - .is_empty() + .is_empty(), + "expected no nodes" ); assert_eq!( db.execute("MATCH (a:Person {name: 'Alix'}) RETURN a.age") diff --git a/crates/grafeo-engine/tests/vector_filtered.rs b/crates/grafeo-engine/tests/vector_filtered.rs index 07333afe1..f41fe5fb5 100644 --- a/crates/grafeo-engine/tests/vector_filtered.rs +++ b/crates/grafeo-engine/tests/vector_filtered.rs @@ -81,7 +81,7 @@ fn test_filtered_vector_search_by_user_id() { .expect("filtered search"); // Should only return user_id=2 nodes (n3, n4) - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); assert!(results.len() <= 2); // Verify all results have user_id=2 @@ -186,7 +186,7 @@ fn test_mmr_search_with_filters() { ) .expect("mmr filtered search"); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); assert!(results.len() <= 2); for (id, _) in &results { @@ -600,7 +600,7 @@ fn test_filtered_vector_search_results_ordered_by_distance() { .vector_search("Doc", "emb", &[0.5, 0.5, 0.0], 5, None, Some(&filters)) .expect("filtered search"); - assert!(!results.is_empty()); + assert!(!results.is_empty(), "results is empty"); for window in results.windows(2) { let (_, dist_a) = window[0]; let (_, dist_b) = window[1]; diff --git a/crates/grafeo-storage/src/container/directory.rs b/crates/grafeo-storage/src/container/directory.rs index f32997b46..8d22f553c 100644 --- a/crates/grafeo-storage/src/container/directory.rs +++ b/crates/grafeo-storage/src/container/directory.rs @@ -516,7 +516,7 @@ mod tests { let dir = SectionDirectory::default(); assert!(dir.is_empty()); assert_eq!(dir.len(), 0); - assert!(dir.entries().is_empty()); + assert!(dir.entries().is_empty(), "{:?}", dir.entries()); } #[test] diff --git a/crates/grafeo-storage/src/file/manager.rs b/crates/grafeo-storage/src/file/manager.rs index 9fc76e537..e393533e0 100644 --- a/crates/grafeo-storage/src/file/manager.rs +++ b/crates/grafeo-storage/src/file/manager.rs @@ -1243,7 +1243,7 @@ mod tests { let manager = GrafeoFileManager::create(&path).unwrap(); let data = manager.read_snapshot().unwrap(); - assert!(data.is_empty()); + assert!(data.is_empty(), "{data:?}"); } #[test] diff --git a/crates/grafeo-storage/src/wal/async_typed.rs b/crates/grafeo-storage/src/wal/async_typed.rs index 24bb7dd03..78082420d 100644 --- a/crates/grafeo-storage/src/wal/async_typed.rs +++ b/crates/grafeo-storage/src/wal/async_typed.rs @@ -320,7 +320,7 @@ mod tests { assert!(wal.checkpoint_epoch().await.is_none()); let files = wal.log_files().await.unwrap(); - assert!(!files.is_empty()); + assert!(!files.is_empty(), "no WAL files"); let _mode = wal.durability_mode(); } diff --git a/crates/grafeo-storage/src/wal/record.rs b/crates/grafeo-storage/src/wal/record.rs index 91cc0353b..a28095bbd 100644 --- a/crates/grafeo-storage/src/wal/record.rs +++ b/crates/grafeo-storage/src/wal/record.rs @@ -713,7 +713,7 @@ mod tests { }; let parsed = roundtrip(&record); match parsed { - WalRecord::CreateNode { labels, .. } => assert!(labels.is_empty()), + WalRecord::CreateNode { labels, .. } => assert!(labels.is_empty(), "{labels:?}"), _ => panic!("Wrong variant"), } } diff --git a/crates/grafeo-storage/src/wal/recovery.rs b/crates/grafeo-storage/src/wal/recovery.rs index 6861f3907..906e458c8 100644 --- a/crates/grafeo-storage/src/wal/recovery.rs +++ b/crates/grafeo-storage/src/wal/recovery.rs @@ -898,7 +898,7 @@ mod tests { } }) .collect(); - assert!(!wal_files.is_empty()); + assert!(!wal_files.is_empty(), "wal_files is empty"); // Append a partial record: just a length prefix, then truncate use std::io::Write; @@ -949,7 +949,7 @@ mod tests { } }) .collect(); - assert!(!wal_files.is_empty()); + assert!(!wal_files.is_empty(), "wal_files is empty"); let mut data = std::fs::read(&wal_files[0]).unwrap(); // Flip a byte in the middle of the data (after the 4-byte length prefix) diff --git a/crates/grafeo-storage/src/wal/typed.rs b/crates/grafeo-storage/src/wal/typed.rs index ebdac896d..2262962e5 100644 --- a/crates/grafeo-storage/src/wal/typed.rs +++ b/crates/grafeo-storage/src/wal/typed.rs @@ -362,7 +362,7 @@ mod tests { assert!(wal.last_checkpoint_timestamp().is_none()); let files = wal.log_files().unwrap(); - assert!(!files.is_empty()); + assert!(!files.is_empty(), "files is empty"); let _path = wal.path(); let _mode = wal.durability_mode(); diff --git a/docs/architecture/feature-profiles.md b/docs/architecture/feature-profiles.md index eb35792e0..6055c47ea 100644 --- a/docs/architecture/feature-profiles.md +++ b/docs/architecture/feature-profiles.md @@ -105,8 +105,8 @@ Production operations. In the engine this enables observability and the async st ## Deprecated Profile Names -!!! warning "Deprecated, removed in 0.7.0" - The deployment-based names `embedded`, `browser`, `server` and `full` still work as aliases, but they are deprecated and will be removed in 0.7.0 ([#468](https://github.com/GrafeoDB/grafeo/issues/468)). Use the persona profiles in new projects. +!!! warning "Deprecated, removed in 0.8.0" + The deployment-based names `embedded`, `browser`, `server` and `full` still work as aliases, but they are deprecated and will be removed in 0.8.0 ([#468](https://github.com/GrafeoDB/grafeo/issues/468)). Use the persona profiles in new projects. | Deprecated name | Use instead | Notes | | --- | --- | --- | diff --git a/docs/architecture/storage/disk-storage-decisions.md b/docs/architecture/storage/disk-storage-decisions.md index d16d3d0f1..af30f0f68 100644 --- a/docs/architecture/storage/disk-storage-decisions.md +++ b/docs/architecture/storage/disk-storage-decisions.md @@ -95,7 +95,7 @@ The two-phase search keeps the quantized codes plus the rotation matrix and norm | Decision | Status | Lands in | |----------|--------|----------| | D1 PageFetcher trait | Implemented | 0.5.42 | -| D2 Packed Ring format | Planned | 0.5.45 | -| D3 Paged HNSW | Planned | 0.5.45 | +| D2 Packed Ring format | Planned | 0.6.0 | +| D3 Paged HNSW | Planned | 0.6.0 | | D4 Per-block zone maps | Planned | not scheduled | | D5 Insertion-order columns | Active default | (no change) | diff --git a/docs/contributing/setup.md b/docs/contributing/setup.md index ed64d0ad8..6097fd811 100644 --- a/docs/contributing/setup.md +++ b/docs/contributing/setup.md @@ -9,7 +9,7 @@ tags: ## Prerequisites -- Rust 1.91.1+ +- Rust 1.99.0+ - Python 3.12+ (for Python bindings, CI tests 3.12, 3.13 and 3.14) - Node.js 22+ (for Node.js bindings, CI tests 22 and 24) - Git diff --git a/docs/getting-started/cli.md b/docs/getting-started/cli.md index 826cb2553..694aff908 100644 --- a/docs/getting-started/cli.md +++ b/docs/getting-started/cli.md @@ -234,7 +234,7 @@ $ grafeo version grafeo 0.5.44 Build: - rustc: 1.91.1 + rustc: 1.99.0 target: x86_64 os: linux features: gql, cypher, sparql, sql-pgq diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index db8289429..ccfd70a86 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -72,7 +72,7 @@ Welcome to Grafeo! This guide covers getting started with the high-performance g ### For Rust Users -- Rust 1.91.1 or later +- Rust 1.99.0 or later - Cargo package manager ## What's Next? diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 75b3c8720..92a0cbe66 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -194,7 +194,7 @@ grafeo = { version = "0.5", features = ["embed"] } | `enterprise` | All features | Full-featured deployments | !!! note "Deprecated profiles" - The old deployment-target profiles (`embedded`, `browser`, `server`, `full`) still work as aliases but are deprecated and scheduled for removal in 0.7.0. Migrate to persona profiles when convenient. + The old deployment-target profiles (`embedded`, `browser`, `server`, `full`) still work as aliases but are deprecated and scheduled for removal in 0.8.0. Migrate to persona profiles when convenient. #### Convenience Groups diff --git a/docs/roadmap.md b/docs/roadmap.md index f74ae9dce..7821b9cd8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -18,19 +18,19 @@ For what has shipped, see the [CHANGELOG](changelog.md). --- -## Planned: finishing the 0.5 beta +## Planned: finishing the beta -The rest of the 0.5 series makes Grafeo dependable by design: durable and crash-safe persistence, real snapshot isolation, and memory use close to what a dense layout needs, followed by driver, protocol and language completeness. +The 0.6 series makes Grafeo dependable by design: durable and crash-safe persistence, real snapshot isolation, and memory use close to what a dense layout needs, followed by driver, protocol and language completeness. | Release | Focus | | --- | --- | -| [**0.5.45**](https://github.com/GrafeoDB/grafeo/milestone/3) | One storage format: WAL v2 and chunked sections without the 4 GiB limit, with automatic migration | -| [**0.5.46**](https://github.com/GrafeoDB/grafeo/milestone/4) | Transactions own their changes: snapshot isolation for properties and labels, one write path; ADBC driver, SPARQL 1.1 Protocol and Graph Store Protocol, GQL vector types | -| [**0.5.47**](https://github.com/GrafeoDB/grafeo/milestone/5) | Compact-core store: a dense memory layout, enforced memory limits, incremental checkpoints | -| [**0.5.48**](https://github.com/GrafeoDB/grafeo/milestone/6) | Push-only execution engine and benchmark-gated parallelism | -| [**0.5.49**](https://github.com/GrafeoDB/grafeo/milestone/7) | API parity across bindings, feature flag cleanup, query language completeness, test depth | +| [**0.6.0**](https://github.com/GrafeoDB/grafeo/milestone/3) | One storage format: WAL v2 and chunked sections without the 4 GiB limit, with automatic migration. Transactions own their changes: versioned properties and labels, a commit that is logged before it becomes visible. A compact-core store: a dense memory layout, enforced memory limits, incremental checkpoints | +| [**0.6.1**](https://github.com/GrafeoDB/grafeo/milestone/4) | Transaction fixes on the new model; ADBC driver, SPARQL 1.1 Protocol and Graph Store Protocol, GQL vector types | +| [**0.6.2**](https://github.com/GrafeoDB/grafeo/milestone/5) | To be planned | +| [**0.6.3**](https://github.com/GrafeoDB/grafeo/milestone/6) | Push-only execution engine and benchmark-gated parallelism | +| [**0.6.4**](https://github.com/GrafeoDB/grafeo/milestone/7) | API parity across bindings, feature flag cleanup, query language completeness, test depth | -The 0.5.45 storage format change is the only planned migration: older files are converted automatically on open. +The 0.6.0 storage format change is the only planned migration: older files are converted automatically on open, and the old file is kept next to the new one (see [Versioning](versioning.md)). --- @@ -38,8 +38,8 @@ The 0.5.45 storage format change is the only planned migration: older files are | Release | Focus | | --- | --- | -| [**0.6.0**](https://github.com/GrafeoDB/grafeo/milestone/8) | Release candidate: no new features, blocker review and final audit. If it works in 0.6, it works in 1.0 | -| [**0.7.0**](https://github.com/GrafeoDB/grafeo/milestone/9) | Per-graph access control with pluggable authentication (JWT, OIDC), reactive event bus, removal of the deprecated feature profile names | +| [**0.7.0**](https://github.com/GrafeoDB/grafeo/milestone/8) | Release candidate: no new features, blocker review and final audit. If it works in 0.7, it works in 1.0 | +| [**0.8.0**](https://github.com/GrafeoDB/grafeo/milestone/9) | Per-graph access control with pluggable authentication (JWT, OIDC), reactive event bus, removal of the deprecated feature profile names | | **1.0** | Stable: semantic versioning commitment, public API frozen | **Later, not scheduled**: enterprise authorization (row-level security, property masking, `GRANT`/`REVOKE`, LDAP and SAML), inbound connectors starting with Kafka, distributed deployment, more language bindings. diff --git a/docs/user-guide/compact-store.md b/docs/user-guide/compact-store.md index 33a78e518..bcd3171a7 100644 --- a/docs/user-guide/compact-store.md +++ b/docs/user-guide/compact-store.md @@ -207,7 +207,7 @@ stores: vector/text scan and search now fall through both layers. same label or type have a property that a node or edge lacks, reading it on that node or edge gives the column's empty value (`''`, `0`, `0.0` or `false`) instead of null, `keys()` lists it and `IS NULL` does not match it. Give every entity the property before compacting, or avoid relying on its absence - ([#542](https://github.com/GrafeoDB/grafeo/issues/542), planned for 0.5.45). + ([#542](https://github.com/GrafeoDB/grafeo/issues/542), planned for 0.6.0). ## Feature Flag diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 000000000..7a6880cd1 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,60 @@ +--- +title: Versioning and Compatibility +description: What each Grafeo release may change, which APIs are stable, and how to pin a version. +tags: + - reference +--- + +# Versioning and Compatibility + +Grafeo is before 1.0. Versions are `0.MINOR.PATCH` and follow the rule Cargo and npm apply to `0.x` versions: a +new minor version may break you, a new patch version does not. From 1.0 the same rules move up one level, so +breaking changes come only in major versions. + +## What a release may change + +| Release | May contain | +| --- | --- | +| Patch (`0.6.0` to `0.6.1`) | Bug fixes, additive features, new options whose default keeps the current behavior, performance work, documentation, deprecations | +| Minor (`0.6` to `0.7`) | All of the above, plus a new database file format, breaking changes to the stable surface, removal of deprecated items, a higher minimum Rust version | + +A bug fix can change query results, or turn a query that returned a wrong answer into an error. It still ships in +a patch release, because the old result was wrong. The [CHANGELOG](changelog.md) lists such changes under +Fixed, and under Changed when a query that used to run now fails. Breaking changes are marked **Breaking** and +appear only in minor releases. + +## The stable surface + +The rules above cover: + +- the `grafeo` Rust crate and everything it re-exports +- the Python, Node.js, C, WebAssembly, C#, Dart and Go bindings +- the `grafeo` command line tool +- the database file format + +`grafeo-common`, `grafeo-core`, `grafeo-storage`, `grafeo-adapters` and `grafeo-engine` are implementation crates. +They are published so that `grafeo` can depend on them, and their APIs may change in any release. Depend on +`grafeo` instead. + +## Database files + +A patch release never changes the file format. A minor release that does migrates a database the first time it is +opened, and keeps a copy of the old file next to it (for example `data.grafeo.pre-0.7`), so you can return to the +previous version. The release before it announces the change in its notes. + +## Deprecation + +Before something is removed, it is deprecated, with its replacement named in the CHANGELOG and the API +documentation. Deprecation can happen in any release; removal happens at the earliest in the next minor release. + +## Pinning a version + +Until 1.0, pin the minor version you test against. For example, to stay on 0.6: + +| Package manager | Requirement | Accepts | +| --- | --- | --- | +| Cargo | `grafeo = "0.6"` | `>=0.6.0, <0.7.0` | +| npm | `"@grafeo-db/js": "^0.6.0"` | `>=0.6.0, <0.7.0` | +| pip / uv | `grafeo~=0.6.0` | `>=0.6.0, <0.7` | + +For pip and uv, write all three parts: `grafeo~=0.6` accepts every version below 1.0. diff --git a/mkdocs.yml b/mkdocs.yml index d6a5eac6f..c1c22e00a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -411,4 +411,5 @@ nav: - anywidget-vector: ecosystem/anywidget-vector.md - anywidget-graph: ecosystem/anywidget-graph.md - Performance Baselines: ecosystem/performance.md + - Versioning: versioning.md - Roadmap: roadmap.md diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f66c33ac7..0cba1605d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,10 +1,10 @@ # Pinned so local builds and CI use the same compiler and clippy lint set. # Bump deliberately (and fix new lints in the same change) instead of tracking `stable`. # Keep this identical to the `dtolnay/rust-toolchain@` refs in .github/workflows: -# rustup treats "1.98" and "1.98.1" as different toolchains, so a mismatch installs a +# rustup treats "1.99" and "1.99.0" as different toolchains, so a mismatch installs a # second toolchain without the CI-installed targets (e.g. wasm32-unknown-unknown). -# The MSRV check in CI uses `cargo +1.91.1` explicitly and is not affected by this file. +# The MSRV check in CI uses `cargo +1.99.0` explicitly and is not affected by this file. [toolchain] -channel = "1.98.1" +channel = "1.99.0" components = ["rustfmt", "clippy"] profile = "minimal"