diff --git a/.github/workflows/publish-to-crates-vip.yml b/.github/workflows/publish-to-crates-vip.yml new file mode 100644 index 00000000..30a2fdb5 --- /dev/null +++ b/.github/workflows/publish-to-crates-vip.yml @@ -0,0 +1,63 @@ +name: publish-to-crates.vip + +# Publishes this workspace's crates to the private registry at crates.vip. +# +# The heavy lifting lives in api.crates.vip's reusable workflow: it packages the +# crate, derives the sparse-index entry from `cargo metadata`, and writes both to +# Tigris under conditional requests. Nothing is installed — the runner image +# already ships `aws` and `jq`. +# +# Publishing is manual on purpose. A published version is immutable, and +# `cargo package` embeds the git sha, so a version republished from a different +# commit is refused rather than silently replaced. +# +# The reusable workflow is pinned to a commit sha rather than `@master`. This +# job hands it the registry's Tigris credentials, so whoever can move that +# branch can read the secrets; a sha is the only ref that cannot be moved under +# us. Updating it is deliberate: read the diff on the pinned range first, then +# bump the three `uses:` lines together, since they are one workflow and a +# split pin means two versions publishing one workspace. + +on: + workflow_dispatch: + inputs: + dry-run: + description: Derive the index entry and print it, but write nothing + required: false + type: boolean + default: false + +jobs: + # The workspace publishes bottom-up. `worktable_codegen` depends on + # `worktable_dsl` by path *and* version, so the DSL entry has to exist in the + # registry before codegen's is written, exactly as it does on crates.io. + dsl: + uses: pathscale/crates.vip-backend/.github/workflows/publish-crate.yml@017e29a705cb8b4780dd6079b3739b4b5a84fda5 + with: + manifest-path: dsl/Cargo.toml + dry-run: ${{ inputs.dry-run }} + secrets: + tigris-key-id: ${{ secrets.CRATES_VIP_TIGRIS_KEY_ID }} + tigris-key-secret: ${{ secrets.CRATES_VIP_TIGRIS_KEY_SECRET }} + + codegen: + needs: dsl + uses: pathscale/crates.vip-backend/.github/workflows/publish-crate.yml@017e29a705cb8b4780dd6079b3739b4b5a84fda5 + with: + manifest-path: codegen/Cargo.toml + dry-run: ${{ inputs.dry-run }} + secrets: + tigris-key-id: ${{ secrets.CRATES_VIP_TIGRIS_KEY_ID }} + tigris-key-secret: ${{ secrets.CRATES_VIP_TIGRIS_KEY_SECRET }} + + # `worktable` depends on `worktable_codegen`, so the codegen crate has to be + # resolvable in the registry before this entry is written. + worktable: + needs: codegen + uses: pathscale/crates.vip-backend/.github/workflows/publish-crate.yml@017e29a705cb8b4780dd6079b3739b4b5a84fda5 + with: + manifest-path: Cargo.toml + dry-run: ${{ inputs.dry-run }} + secrets: + tigris-key-id: ${{ secrets.CRATES_VIP_TIGRIS_KEY_ID }} + tigris-key-secret: ${{ secrets.CRATES_VIP_TIGRIS_KEY_SECRET }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4450e61d..a750df49 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -62,9 +62,43 @@ jobs: - name: Clippy (deny warnings) run: cargo clippy --workspace --all-targets ${{ matrix.args }} -- -D warnings + duplicate_index_crates: + name: One version of each shared index crate + runs-on: ubicloud-standard-2 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: "true" + add-job-id-key: "false" + # Two versions of WorkTablesIndex in one graph split the type identity of + # `Pair` and `ChangeEvent`, and the build then fails with "expected + # ChangeEvent>, found a different ChangeEvent>" across dozens of unrelated-looking lines. It happens whenever + # data_bucket and worktable disagree about which version they want, which + # is every time one is bumped and released without the other. + # + # The caret requirements are correct and stay. This turns the mismatch + # into one named failure instead of a wall of trait errors, which is the + # guard WT-8 asked for. + - name: Refuse a duplicated WorkTablesIndex or data_bucket + run: | + duplicates=$(cargo tree --duplicates --edges normal 2>/dev/null \ + | grep -E '^(WorkTablesIndex|data_bucket) v' | sort -u) + if [ -n "$duplicates" ]; then + echo "More than one version of a shared index crate is in the graph:" + echo "$duplicates" + echo + echo "WorkTablesIndex, data_bucket and worktable move as one train." + echo "Publish them in lockstep, or pin them to agree." + exit 1 + fi + echo "one version of each: ok" + publish: if: github.event_name == 'push' && github.ref == 'refs/heads/master' - needs: [build, clippy_check] + needs: [build, clippy_check, duplicate_index_crates] runs-on: ubicloud-standard-2 timeout-minutes: 45 steps: @@ -74,16 +108,29 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | - if [ -z "$CARGO_REGISTRY_TOKEN" ]; then echo "CARGO_REGISTRY_TOKEN not set; skipping publish"; exit 0; fi - v=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1) - if curl -fsSL "https://index.crates.io/wo/rk/worktable" | sed -n 's/.*"vers":"\([^"]*\)".*/\1/p' | grep -qx "$v"; then - echo "worktable $v is already on crates.io; nothing to publish" - exit 0 - fi - # The main crate pins its codegen twin exactly, so the twin must land - # on the registry first; cargo waits for availability between the two. - cv=$(sed -n 's/^version = "\(.*\)"$/\1/p' codegen/Cargo.toml | head -1) - if ! curl -fsSL "https://index.crates.io/wo/rk/worktable_codegen" | sed -n 's/.*"vers":"\([^"]*\)".*/\1/p' | grep -qx "$cv"; then - cargo publish -p worktable_codegen - fi - cargo publish -p worktable + set -euo pipefail + if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then echo "CARGO_REGISTRY_TOKEN not set; skipping publish"; exit 0; fi + + # Publish in dependency order. worktable_codegen depends on + # worktable_dsl and worktable depends on worktable_codegen, both by + # path with an exact version, so each must be on the registry before + # the next is packaged. Omitting worktable_dsl here is what made + # `cargo publish -p worktable_codegen` fail with "no matching package + # named `worktable_dsl` found" the moment the DSL extraction landed. + publish_if_new() { + crate="$1" + manifest="$2" + version=$(sed -n 's/^version = "\(.*\)"$/\1/p' "$manifest" | head -1) + # crates.io index paths: four or more characters is {first two}/{next two}/{name}. + if curl -fsSL "https://index.crates.io/wo/rk/$crate" \ + | sed -n 's/.*"vers":"\([^"]*\)".*/\1/p' | grep -qx "$version"; then + echo "$crate $version is already on crates.io; skipping" + return 0 + fi + echo "publishing $crate $version" + cargo publish -p "$crate" + } + + publish_if_new worktable_dsl dsl/Cargo.toml + publish_if_new worktable_codegen codegen/Cargo.toml + publish_if_new worktable Cargo.toml diff --git a/AGENTS.md b/AGENTS.md index 8a0474f1..b7f0dc9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ success. error is not something you introduced, and saying so requires checking. - A build that finishes suspiciously fast was cached, not rebuilt. Force a real rebuild when the rebuild is the thing you're verifying. +- **`ci-local.sh` runs your default toolchain; CI runs the newest stable.** A clippy lint + added between the two is invisible here and fails there, on code nobody touched. The + script prints both so a local pass is not mistaken for a CI pass. If they differ and the + lint matters, get the toolchain updated rather than pushing to find out. ## PR discipline diff --git a/Cargo.toml b/Cargo.toml index c2788e24..c074df20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] -members = ["codegen", "examples", "performance_measurement", "performance_measurement/codegen"] +members = ["codegen", "dsl", "examples", "performance_measurement", "performance_measurement/codegen"] [package] name = "worktable" -version = "1.0.0-beta.16" +version = "1.0.0-beta.17" edition = "2024" authors = ["Handy-caT"] license = "MIT" @@ -33,49 +33,56 @@ versioned-row-publication = ["worktable_codegen/versioned-row-publication"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -async-trait = "0.1.89" -arctic = { package = "arctic-wt", version = "0.1.6" } -congee = { package = "congee-wt", version = "0.4.3" } -convert_case = "0.6.0" -crc32fast = "1.5.0" +async-trait = "0.1" +arctic = { package = "arctic-wt", version = "0.1" } +congee = { package = "congee-wt", version = "0.4" } +convert_case = "0.6" +crc32fast = "1" # Already in the dependency graph transitively (indexset's concurrent # structures); used directly for read-side grace periods. -data_bucket = "=0.5.5" +data_bucket = "0.5" # data_bucket = { git = "https://github.com/pathscale/DataBucket", branch = "page_cdc_correction", version = "0.2.7" } # data_bucket = { path = "../DataBucket", version = "0.3.14" } -derive_more = { version = "2.0.1", features = ["from", "error", "display", "debug", "into"] } -eyre = "0.6.12" -fastrand = "2.3.0" -futures = "0.3.30" -indexset = { package = "WorkTablesIndex", version = "=0.0.9", default-features = false, features = ["concurrent", "cdc", "multimap"] } -vanilla_indexset = { package = "indexset", version = "=0.15.0", features = ["concurrent", "cdc", "multimap"] } +derive_more = { version = "2", features = ["from", "error", "display", "debug", "into"] } +eyre = "0.6" +fastrand = "2" +futures = "0.3" +indexset = { package = "WorkTablesIndex", version = "0.0", default-features = false, features = ["concurrent", "cdc", "multimap"] } +vanilla_indexset = { package = "indexset", version = "0.15", features = ["concurrent", "cdc", "multimap"] } # indexset = { path = "../indexset", version = "0.15.0", features = ["concurrent", "cdc", "multimap"] } # indexset = { package = "wt-indexset", version = "=0.12.12", features = ["concurrent", "cdc", "multimap"] } -log = "0.4.29" -ordered-float = "5.0.0" -parking_lot = "0.12.3" +log = "0.4" +ordered-float = "5" +parking_lot = "0.12" performance_measurement = { path = "performance_measurement", version = "0.1.0", optional = true } performance_measurement_codegen = { path = "performance_measurement/codegen", version = "0.1.0", optional = true } -prettytable-rs = "^0.10" -psc-nanoid = { version = "3.1.1", features = ["rkyv", "packed"] } -rkyv = { version = "0.8.17", features = ["uuid-1"] } +prettytable-rs = "0.10" +psc-nanoid = { version = "3", features = ["rkyv", "packed"] } +rkyv = { version = "0.8", features = ["uuid-1"] } reqwest = { version = "0.12", optional = true, default-features = false, features = ["rustls-tls-webpki-roots", "charset", "http2"] } -ps-reclaim = "0.1.0" -rustc-hash = "2.1.1" -rusty-s3 = { version = "0.10.2", optional = true } -smart-default = "0.7.1" +ps-reclaim = "0.1" +rustc-hash = "2" +rusty-s3 = { version = "0.10", optional = true } +smart-default = "0.7" tokio = { version = "1", features = ["full"] } tracing = "0.1" url = { version = "2", optional = true } -uuid = { version = "1.24.0", features = ["v4", "v7"] } +uuid = { version = "1", features = ["v4", "v7"] } walkdir = { version = "2", optional = true } -worktable_codegen = { path = "codegen", version = "=1.0.0-beta.15" } +# Exact for the same reason as `worktable_dsl` in codegen/Cargo.toml: carets do +# not match pre-releases, and these move as one train. +worktable_codegen = { path = "codegen", version = "=1.0.0-beta.17" } +# Re-exported below. Each generated table carries its declaration as a const +# whose documentation says to read it with `worktable_dsl::Schema::parse`; that +# instruction is only true if a plain `worktable` dependency can reach the +# crate. Exact for the same pre-release reason as codegen. +worktable_dsl = { path = "dsl", version = "=1.0.0-beta.18" } [dev-dependencies] -chrono = "0.4.43" +chrono = "0.4" criterion = { version = "0.5", features = ["async_tokio"] } -rand = "0.9.1" -tracing-subscriber = "0.3.23" +rand = "0.9" +tracing-subscriber = "0.3" # Only under `--cfg loom`, so a normal build and a normal `cargo test` never # resolve it. See src/partition/loom_tests.rs for how to run the models. diff --git a/benches/cases/full_featured.rs b/benches/cases/full_featured.rs index 30b29cac..0e5b222d 100644 --- a/benches/cases/full_featured.rs +++ b/benches/cases/full_featured.rs @@ -19,7 +19,7 @@ fn insert(c: &mut Criterion) { another: format!("another_{}", fastrand::u64(..)), something: fastrand::u64(..), }; - table.insert(black_box(row)) + futures::executor::block_on(table.insert(black_box(row))) }, BatchSize::SmallInput, ) @@ -37,7 +37,7 @@ fn select_by_pk(c: &mut Criterion) { another: format!("another_{}", fastrand::u64(..)), something: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); @@ -60,7 +60,7 @@ fn select_by_unique_index(c: &mut Criterion) { another: format!("another_{}", i), something: i, }; - table.insert(row).unwrap(); + futures::executor::block_on(table.insert(row)).unwrap(); } c.bench_function("full_featured_select_by_val1", |b| { @@ -82,7 +82,7 @@ fn select_by_non_unique_index(c: &mut Criterion) { another: format!("cat_{}", i % 10), something: i, }; - table.insert(row).unwrap(); + futures::executor::block_on(table.insert(row)).unwrap(); } c.bench_function("full_featured_select_by_another", |b| { @@ -107,7 +107,7 @@ fn update(c: &mut Criterion) { another: format!("another_{}", i), something: i, }; - pks.push(table.insert(row).unwrap()); + pks.push(table.insert(row).await.unwrap()); } pks }); @@ -141,7 +141,7 @@ fn update_by_pk_query(c: &mut Criterion) { another: format!("another_{}", i), something: i, }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } }); @@ -169,7 +169,7 @@ fn update_by_unique_index_query(c: &mut Criterion) { another: format!("another_{}", i), something: i, }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } }); @@ -196,7 +196,7 @@ fn in_place_update(c: &mut Criterion) { another: "test".to_string(), something: 0, }; - table.insert(row).unwrap().into() + futures::executor::block_on(table.insert(row)).unwrap().into() }; c.bench_function("full_featured_in_place_update_val", |b| { @@ -219,7 +219,7 @@ fn delete(c: &mut Criterion) { another: format!("temp_{}", fastrand::u64(..)), something: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }, |pk: FullFeaturedPrimaryKey| rt.block_on(async { table.delete(black_box(pk)).await.unwrap() }), BatchSize::SmallInput, @@ -242,7 +242,7 @@ fn delete_by_index_query(c: &mut Criterion) { another: another.clone(), something: fastrand::u64(..), }; - table.insert(row).unwrap(); + futures::executor::block_on(table.insert(row)).unwrap(); another }, |another: String| rt.block_on(async { table.delete_by_another(another).await.unwrap() }), @@ -319,7 +319,7 @@ fn batch_insert(c: &mut Criterion) { another: format!("another_{}", i), something: i as u64, }; - table.insert(black_box(row)).unwrap(); + futures::executor::block_on(table.insert(black_box(row))).unwrap(); } black_box(table) }, @@ -347,7 +347,7 @@ fn batch_select_pk(c: &mut Criterion) { another: format!("another_{}", fastrand::u64(..)), something: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); diff --git a/benches/cases/non_unique_index.rs b/benches/cases/non_unique_index.rs index 5b313409..7e5f2038 100644 --- a/benches/cases/non_unique_index.rs +++ b/benches/cases/non_unique_index.rs @@ -33,7 +33,7 @@ fn select_by_pk(c: &mut Criterion) { value: fastrand::u64(..), category: fastrand::u64(0..10), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); @@ -54,7 +54,7 @@ fn select_by_non_unique_index(c: &mut Criterion) { value: fastrand::u64(..), category: i % 10, }; - table.insert(row).unwrap(); + futures::executor::block_on(table.insert(row)).unwrap(); } c.bench_function("non_unique_index_select_by_category", |b| { @@ -77,7 +77,7 @@ fn update(c: &mut Criterion) { value: i, category: i % 10, }; - pks.push(table.insert(row).unwrap()); + pks.push(table.insert(row).await.unwrap()); } pks }); @@ -108,7 +108,7 @@ fn delete(c: &mut Criterion) { value: fastrand::u64(..), category: fastrand::u64(0..10), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }, |pk: NonUniqueIndexPrimaryKey| rt.block_on(async { table.delete(black_box(pk)).await.unwrap() }), BatchSize::SmallInput, @@ -176,7 +176,7 @@ fn batch_insert(c: &mut Criterion) { value: i as u64, category: (i % 10) as u64, }; - table.insert(black_box(row)).unwrap(); + futures::executor::block_on(table.insert(black_box(row))).unwrap(); } black_box(table) }, @@ -202,7 +202,7 @@ fn batch_select_pk(c: &mut Criterion) { value: fastrand::u64(..), category: fastrand::u64(0..10), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); diff --git a/benches/cases/nonunique_arctic_vs_wti.rs b/benches/cases/nonunique_arctic_vs_wti.rs index c9dee5a2..6ea1d8a7 100644 --- a/benches/cases/nonunique_arctic_vs_wti.rs +++ b/benches/cases/nonunique_arctic_vs_wti.rs @@ -49,7 +49,7 @@ fn string_key(key: u64) -> String { format!("{:032x}", hash_of(key)) } -fn populated_wti(fan_out: u64, keys: u64) -> WtiStringAdjacencyWorkTable { +async fn populated_wti(fan_out: u64, keys: u64) -> WtiStringAdjacencyWorkTable { let table = WtiStringAdjacencyWorkTable::default(); for key in 0..keys { for copy in 0..fan_out { @@ -58,13 +58,13 @@ fn populated_wti(fan_out: u64, keys: u64) -> WtiStringAdjacencyWorkTable { source: string_key(key), payload: copy, }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } } table } -fn populated_arctic(fan_out: u64, keys: u64) -> ArcticHashAdjacencyWorkTable { +async fn populated_arctic(fan_out: u64, keys: u64) -> ArcticHashAdjacencyWorkTable { let table = ArcticHashAdjacencyWorkTable::default(); for key in 0..keys { for copy in 0..fan_out { @@ -73,7 +73,7 @@ fn populated_arctic(fan_out: u64, keys: u64) -> ArcticHashAdjacencyWorkTable { source: hash_of(key), payload: copy, }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } } table @@ -84,7 +84,7 @@ fn select_by_key(c: &mut Criterion) { for (fan_out, keys) in SHAPES { group.throughput(Throughput::Elements(fan_out)); - let table = populated_wti(fan_out, keys); + let table = futures::executor::block_on(populated_wti(fan_out, keys)); group.bench_with_input(BenchmarkId::new("wti_string", fan_out), &fan_out, |b, _| { b.iter(|| { let key = string_key(fastrand::u64(0..keys)); @@ -92,7 +92,7 @@ fn select_by_key(c: &mut Criterion) { }) }); - let table = populated_arctic(fan_out, keys); + let table = futures::executor::block_on(populated_arctic(fan_out, keys)); group.bench_with_input(BenchmarkId::new("arctic_u128", fan_out), &fan_out, |b, _| { b.iter(|| { let key = hash_of(fastrand::u64(0..keys)); @@ -108,7 +108,7 @@ fn insert(c: &mut Criterion) { for (fan_out, keys) in SHAPES { // Steady state: the table already holds `fan_out` rows per key and // each measured insert lands on an existing key. - let table = populated_wti(fan_out, keys); + let table = futures::executor::block_on(populated_wti(fan_out, keys)); group.bench_with_input(BenchmarkId::new("wti_string", fan_out), &fan_out, |b, _| { b.iter_batched( || WtiStringAdjacencyRow { @@ -116,12 +116,12 @@ fn insert(c: &mut Criterion) { source: string_key(fastrand::u64(0..keys)), payload: u64::MAX, }, - |row| table.insert(black_box(row)).unwrap(), + |row| futures::executor::block_on(table.insert(black_box(row))).unwrap(), BatchSize::SmallInput, ) }); - let table = populated_arctic(fan_out, keys); + let table = futures::executor::block_on(populated_arctic(fan_out, keys)); group.bench_with_input(BenchmarkId::new("arctic_u128", fan_out), &fan_out, |b, _| { b.iter_batched( || ArcticHashAdjacencyRow { @@ -129,7 +129,7 @@ fn insert(c: &mut Criterion) { source: hash_of(fastrand::u64(0..keys)), payload: u64::MAX, }, - |row| table.insert(black_box(row)).unwrap(), + |row| futures::executor::block_on(table.insert(black_box(row))).unwrap(), BatchSize::SmallInput, ) }); diff --git a/benches/cases/partition_routing.rs b/benches/cases/partition_routing.rs index 15f8a4ff..7d59b03a 100644 --- a/benches/cases/partition_routing.rs +++ b/benches/cases/partition_routing.rs @@ -28,7 +28,7 @@ worktable!( const PARTITIONS: u16 = 500; -fn populated() -> RoutePartitions { +async fn populated() -> RoutePartitions { let routes = RoutePartitions::new(); for k in 0..PARTITIONS { let table = routes.partition_or_create(k).unwrap(); @@ -38,6 +38,7 @@ fn populated() -> RoutePartitions { bid: k as f64, ask: k as f64 + 1.0, }) + .await .unwrap(); } routes @@ -45,7 +46,7 @@ fn populated() -> RoutePartitions { /// The four ways to reach a partition, single threaded, one hot key. fn lookup(c: &mut Criterion) { - let routes = populated(); + let routes = futures::executor::block_on(populated()); let cached = routes.partition(7).unwrap(); let mut group = c.benchmark_group("partition_lookup"); @@ -86,7 +87,7 @@ fn contended(c: &mut Criterion, name: &str, same_key: bool) { for api in ["partition_ref", "pinned_get", "partition_arc"] { group.bench_with_input(BenchmarkId::new(api, threads), &threads, |b, &threads| { b.iter_custom(|iters| { - let routes = Arc::new(populated()); + let routes = Arc::new(futures::executor::block_on(populated())); let go = Arc::new(AtomicBool::new(false)); let workers: Vec<_> = (0..threads) @@ -150,7 +151,7 @@ fn distinct_key_readers(c: &mut Criterion) { /// Accounting over 500 partitions. These were routed through `system_info`, /// which copied every data page, and through `keys` then `partition` per key. fn metrics(c: &mut Criterion) { - let routes = populated(); + let routes = futures::executor::block_on(populated()); let mut group = c.benchmark_group("partition_metrics"); group.bench_function("memory_total", |b| b.iter(|| black_box(routes.memory_total()))); group.bench_function("rows_by_key", |b| b.iter(|| black_box(routes.rows_by_key()))); diff --git a/benches/cases/simple.rs b/benches/cases/simple.rs index 4ce590c8..a035b53a 100644 --- a/benches/cases/simple.rs +++ b/benches/cases/simple.rs @@ -15,7 +15,7 @@ fn insert(c: &mut Criterion) { id: table.get_next_pk().into(), value: fastrand::u64(..), }; - table.insert(black_box(row)) + futures::executor::block_on(table.insert(black_box(row))) }, BatchSize::SmallInput, ) @@ -30,7 +30,7 @@ fn select_by_pk(c: &mut Criterion) { id: table.get_next_pk().into(), value: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); @@ -53,7 +53,7 @@ fn update(c: &mut Criterion) { id: table.get_next_pk().into(), value: i, }; - pks.push(table.insert(row).unwrap()); + pks.push(table.insert(row).await.unwrap()); } pks }); @@ -82,7 +82,7 @@ fn delete(c: &mut Criterion) { id: table.get_next_pk().into(), value: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }, |pk: SimplePrimaryKey| rt.block_on(async { table.delete(black_box(pk)).await.unwrap() }), BatchSize::SmallInput, @@ -146,7 +146,7 @@ fn batch_insert(c: &mut Criterion) { id: table.get_next_pk().into(), value: i as u64, }; - table.insert(black_box(row)).unwrap(); + futures::executor::block_on(table.insert(black_box(row))).unwrap(); } black_box(table) }, @@ -171,7 +171,7 @@ fn batch_select_pk(c: &mut Criterion) { id: table.get_next_pk().into(), value: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); diff --git a/benches/cases/unique_index.rs b/benches/cases/unique_index.rs index 7a4b2c42..7509ae63 100644 --- a/benches/cases/unique_index.rs +++ b/benches/cases/unique_index.rs @@ -52,7 +52,7 @@ fn select_by_pk(c: &mut Criterion) { test: fastrand::i64(..), another: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); @@ -73,7 +73,7 @@ fn select_by_unique_index(c: &mut Criterion) { test: i, another: i as u64, }; - table.insert(row).unwrap(); + futures::executor::block_on(table.insert(row)).unwrap(); } c.bench_function("unique_index_select_by_test", |b| { @@ -93,7 +93,7 @@ fn select_by_unique_index_range(c: &mut Criterion) { test: i, another: i as u64, }; - table.insert(row).unwrap(); + futures::executor::block_on(table.insert(row)).unwrap(); } c.bench_function("unique_index_select_by_test_range", |b| { @@ -109,8 +109,8 @@ fn art_primary_key_ranges(c: &mut Criterion) { let congee = CongeeRangeBenchmarkWorkTable::default(); let arctic = ArcticRangeBenchmarkWorkTable::default(); for id in 0..ROWS { - congee.insert(CongeeRangeBenchmarkRow { id, value: id }).unwrap(); - arctic.insert(ArcticRangeBenchmarkRow { id, value: id }).unwrap(); + futures::executor::block_on(congee.insert(CongeeRangeBenchmarkRow { id, value: id })).unwrap(); + futures::executor::block_on(arctic.insert(ArcticRangeBenchmarkRow { id, value: id })).unwrap(); } let mut group = c.benchmark_group("art_primary_key_single_row_range"); @@ -142,7 +142,7 @@ fn update(c: &mut Criterion) { test: i as i64, another: i, }; - pks.push(table.insert(row).unwrap()); + pks.push(table.insert(row).await.unwrap()); } pks }); @@ -173,7 +173,7 @@ fn delete(c: &mut Criterion) { test: fastrand::i64(..), another: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }, |pk: UniqueIndexPrimaryKey| rt.block_on(async { table.delete(black_box(pk)).await.unwrap() }), BatchSize::SmallInput, @@ -241,7 +241,7 @@ fn batch_insert(c: &mut Criterion) { test: i as i64, another: i as u64, }; - table.insert(black_box(row)).unwrap(); + futures::executor::block_on(table.insert(black_box(row))).unwrap(); } black_box(table) }, @@ -267,7 +267,7 @@ fn batch_select_pk(c: &mut Criterion) { test: fastrand::i64(..), another: fastrand::u64(..), }; - table.insert(row).unwrap() + futures::executor::block_on(table.insert(row)).unwrap() }) .collect(); diff --git a/benches/cases/update_contention.rs b/benches/cases/update_contention.rs index 043fd82f..eb3ed8c0 100644 --- a/benches/cases/update_contention.rs +++ b/benches/cases/update_contention.rs @@ -19,7 +19,7 @@ fn single_row_update_contention(c: &mut Criterion) { another: "test".to_string(), something: 0, }; - table.insert(row).unwrap().into() + table.insert(row).await.unwrap().into() }); for contention_level in [2, 4, 8, 16, 32] { @@ -66,7 +66,7 @@ fn single_row_in_place_contention(c: &mut Criterion) { another: "test".to_string(), something: 0, }; - table.insert(row).unwrap().into() + table.insert(row).await.unwrap().into() }); for contention_level in [2, 4, 8, 16, 32] { diff --git a/codegen/Cargo.toml b/codegen/Cargo.toml index 54bdee5b..ec43fb4f 100644 --- a/codegen/Cargo.toml +++ b/codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "worktable_codegen" -version = "1.0.0-beta.15" +version = "1.0.0-beta.17" edition = "2024" license = "MIT" description = "Proc-macro companion crate for worktable: the worktable! macro and its derives." @@ -18,9 +18,17 @@ path = "src/lib.rs" proc-macro = true [dependencies] -rkyv = { version = "0.8.17" } -syn = { version = "2.0.74", features = ["full"] } -quote = "1.0.36" -proc-macro2 = "1.0.86" -convert_case = "0.6.0" +# The schema language, extracted so consumers other than this macro can read +# a declaration. See its crate docs for why that needed a separate crate. +# Exact, deliberately, while these are pre-releases. A caret requirement does +# not match a pre-release at all (`^1.0` will not resolve `1.0.0-beta.17`), and +# these three crates are one release train: the macro generates code against a +# specific runtime, so they must move together. Every other dependency here is +# a caret at minor granularity. +worktable_dsl = { path = "../dsl", version = "=1.0.0-beta.18" } +rkyv = { version = "0.8" } +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" +convert_case = "0.6" indexmap = "2" diff --git a/codegen/src/common/mod.rs b/codegen/src/common/mod.rs index ec73bce8..c7ad9e1d 100644 --- a/codegen/src/common/mod.rs +++ b/codegen/src/common/mod.rs @@ -1,7 +1,13 @@ -pub mod model; +//! What stayed behind when the schema language moved out. +//! +//! `model` and `parser` are `worktable_dsl` now, so anything can read a +//! declaration. `name_generator` is not part of that language: it invents Rust +//! identifiers for generated code, which is this crate's concern and nobody +//! else's. +//! +//! It also could not have gone. Generators here define inherent `impl`s on +//! `WorktableNameGenerator`, and the orphan rule forbids that for a type owned +//! by another crate. The compiler makes the same argument the design does. pub mod name_generator; -pub mod parser; -#[allow(unused_imports)] -pub use model::*; -pub use parser::Parser; +pub use worktable_dsl::{Parser, model, parser}; diff --git a/codegen/src/common/model/queries.rs b/codegen/src/common/model/queries.rs deleted file mode 100644 index 7ad81643..00000000 --- a/codegen/src/common/model/queries.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::collections::HashMap; - -use proc_macro2::Ident; - -use crate::common::model::Operation; - -#[derive(Debug, Default)] -pub struct Queries { - pub updates: HashMap, - pub deletes: HashMap, - pub in_place: HashMap, -} diff --git a/codegen/src/common/name_generator.rs b/codegen/src/common/name_generator.rs index 715e8535..d671f03a 100644 --- a/codegen/src/common/name_generator.rs +++ b/codegen/src/common/name_generator.rs @@ -120,6 +120,18 @@ impl WorktableNameGenerator { ) } + /// The name of the const carrying the table's own declaration. + /// + /// It follows `get_version_const_ident`'s shape because it answers the + /// question next to it: the version says *which* schema, and this says + /// *what* that schema is. + pub fn get_schema_const_ident(&self) -> Ident { + let upper_snake_case_name = self.name.from_case(Case::Pascal).to_case(Case::UpperSnake); + Ident::new( + format!("{}_SCHEMA", upper_snake_case_name.to_uppercase()).as_str(), + Span::mixed_site(), + ) + } pub fn get_space_secondary_index_ident(&self) -> Ident { Ident::new(format!("{}SpaceSecondaryIndex", self.name).as_str(), Span::mixed_site()) } diff --git a/codegen/src/generators/in_memory/primary_key.rs b/codegen/src/generators/in_memory/primary_key.rs index 389484a6..8d58b84f 100644 --- a/codegen/src/generators/in_memory/primary_key.rs +++ b/codegen/src/generators/in_memory/primary_key.rs @@ -148,8 +148,28 @@ impl InMemoryGenerator { "i16" => quote! { std::sync::atomic::AtomicI16 }, "i32" => quote! { std::sync::atomic::AtomicI32 }, "i64" => quote! { std::sync::atomic::AtomicI64 }, + // The accepted set is `worktable_dsl::AUTOINCREMENT_TYPES`, and the + // arms above must stay equal to it. `check` uses that list to + // answer "would the macro accept this", so a second copy drifting + // shows up as `check` passing a declaration that then fails to + // build, which is the failure it exists to prevent. + // + // Asserted rather than commented: the debug assertion below fires + // during any codegen build if the two ever disagree. _ => { - return Err(syn::Error::new(i.span(), "Type is not supported for autoincrement")); + debug_assert!( + !worktable_dsl::AUTOINCREMENT_TYPES.contains(&type_.to_string().as_str()), + "`{}` is in worktable_dsl::AUTOINCREMENT_TYPES but has no atomic here; `check` \ + will accept a declaration this refuses", + type_ + ); + return Err(syn::Error::new( + i.span(), + format!( + "type is not supported for autoincrement; supported types: {}", + worktable_dsl::AUTOINCREMENT_TYPES.join(", ") + ), + )); } }) } diff --git a/codegen/src/generators/in_memory/queries/delete.rs b/codegen/src/generators/in_memory/queries/delete.rs index 38d71a5f..911f7e9a 100644 --- a/codegen/src/generators/in_memory/queries/delete.rs +++ b/codegen/src/generators/in_memory/queries/delete.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -156,7 +155,7 @@ impl InMemoryGenerator { } } - fn gen_custom_deletes(&mut self, deleted: HashMap) -> TokenStream { + fn gen_custom_deletes(&mut self, deleted: IndexMap) -> TokenStream { let defs = deleted .iter() .map(|(name, op)| { @@ -186,17 +185,22 @@ impl InMemoryGenerator { fn gen_brute_force_delete_field(field: &Ident, type_: &TokenStream, name: &Ident) -> TokenStream { quote! { pub async fn #name(&self, by: #type_) -> core::result::Result<(), WorkTableError> { - self.iter_with_async(|row| { + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); + let pks = std::cell::RefCell::new(Vec::new()); + self.iter_with(|row| { if row.#field == by { - futures::future::Either::Left(async move { - self.delete::<_>(row.get_primary_key()).await - }) - } else { - futures::future::Either::Right(async { - Ok(()) - }) + pks.borrow_mut().push(row.get_primary_key()); } - }).await?; + Ok(()) + })?; + let pks = pks.into_inner(); + for pk in pks { + match self.delete(pk).await { + core::result::Result::Ok(()) => {} + core::result::Result::Err(WorkTableError::NotFound) => {} + core::result::Result::Err(e) => return core::result::Result::Err(e), + } + } core::result::Result::Ok(()) } } @@ -216,6 +220,9 @@ impl InMemoryGenerator { }; quote! { pub async fn #name(&self, by: #type_) -> core::result::Result<(), WorkTableError> { + // A non-unique predicate can delete many rows. Signal the + // whole operation without holding its row locks all at once. + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); // Snapshot the matching rows as validated primary keys before // deleting anything. Storage links are not stable identities: // a concurrent delete can free a slot and an insert can reuse @@ -276,8 +283,11 @@ impl InMemoryGenerator { pub async fn #name(&self, by: #type_) -> core::result::Result<(), WorkTableError> { let row_to_update = self.0.indexes.#index.get_value(#by).map(Into::into); if let Some(link) = row_to_update { - let row = self.0.data.select_non_ghosted(link).map_err(WorkTableError::PagesError)?; - self.delete(row.get_primary_key()).await?; + let pk = { + let row = self.0.data.select_non_ghosted(link).map_err(WorkTableError::PagesError)?; + row.get_primary_key() + }; + self.delete(pk).await?; } core::result::Result::Ok(()) } diff --git a/codegen/src/generators/in_memory/queries/in_place.rs b/codegen/src/generators/in_memory/queries/in_place.rs index 5b4e30dd..1c4c3e3c 100644 --- a/codegen/src/generators/in_memory/queries/in_place.rs +++ b/codegen/src/generators/in_memory/queries/in_place.rs @@ -2,10 +2,9 @@ use crate::common::model::Operation; use crate::common::name_generator::WorktableNameGenerator; use crate::generators::in_memory::InMemoryGenerator; use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; -use std::collections::HashMap; - impl InMemoryGenerator { pub fn gen_query_in_place_impl(&self) -> syn::Result { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); @@ -27,7 +26,7 @@ impl InMemoryGenerator { }) } - fn gen_in_place_queries(&self, in_place_queries: HashMap) -> TokenStream { + fn gen_in_place_queries(&self, in_place_queries: IndexMap) -> TokenStream { let defs = in_place_queries .iter() .map(|(name, op)| { diff --git a/codegen/src/generators/in_memory/queries/locks.rs b/codegen/src/generators/in_memory/queries/locks.rs index b20d2f4e..cf80019f 100644 --- a/codegen/src/generators/in_memory/queries/locks.rs +++ b/codegen/src/generators/in_memory/queries/locks.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -28,7 +27,7 @@ impl InMemoryGenerator { } } - fn gen_in_place_update_query_locks(updates: &HashMap) -> TokenStream { + fn gen_in_place_update_query_locks(updates: &IndexMap) -> TokenStream { let fns = updates .keys() .map(|name| { @@ -50,7 +49,7 @@ impl InMemoryGenerator { } } - fn gen_update_query_locks(updates: &HashMap) -> TokenStream { + fn gen_update_query_locks(updates: &IndexMap) -> TokenStream { let fns = updates .keys() .map(|name| { diff --git a/codegen/src/generators/in_memory/queries/type.rs b/codegen/src/generators/in_memory/queries/type.rs index 35491ce7..05ded8f3 100644 --- a/codegen/src/generators/in_memory/queries/type.rs +++ b/codegen/src/generators/in_memory/queries/type.rs @@ -1,5 +1,4 @@ -use std::collections::HashSet; - +use indexmap::IndexSet; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -30,7 +29,7 @@ impl InMemoryGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let avt_type_ident = name_generator.get_available_type_ident(); - let unique_types: HashSet = self + let unique_types: IndexSet = self .columns .indexes .iter() diff --git a/codegen/src/generators/in_memory/queries/unsized_.rs b/codegen/src/generators/in_memory/queries/unsized_.rs index 765928f7..4e6664a0 100644 --- a/codegen/src/generators/in_memory/queries/unsized_.rs +++ b/codegen/src/generators/in_memory/queries/unsized_.rs @@ -61,7 +61,7 @@ impl InMemoryGenerator { let unsized_fields: Vec<_> = op .columns .iter() - .filter(|c| self.columns.columns_map.get(c).unwrap().to_string() == "String") + .filter(|c| self.columns.columns_map.get(*c).unwrap().to_string() == "String") .map(|c| { let fn_ident = Ident::new(format!("get_{c}_size").as_str(), Span::call_site()); quote! { diff --git a/codegen/src/generators/in_memory/queries/update.rs b/codegen/src/generators/in_memory/queries/update.rs index b0dc3ff2..04af1554 100644 --- a/codegen/src/generators/in_memory/queries/update.rs +++ b/codegen/src/generators/in_memory/queries/update.rs @@ -1,10 +1,9 @@ -use proc_macro2::Literal; -use std::collections::HashMap; - use crate::common::model::{Index, Operation}; use crate::common::name_generator::{WorktableNameGenerator, is_float}; use crate::generators::in_memory::InMemoryGenerator; use convert_case::{Case, Casing}; +use indexmap::IndexMap; +use proc_macro2::Literal; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -172,7 +171,7 @@ impl InMemoryGenerator { } } - fn gen_custom_updates(&mut self, updates: HashMap) -> TokenStream { + fn gen_custom_updates(&mut self, updates: IndexMap) -> TokenStream { let defs = updates .iter() .map(|(name, op)| { @@ -196,7 +195,7 @@ impl InMemoryGenerator { let fields = op .columns .iter() - .filter(|c| self.columns.columns_map.get(c).unwrap().to_string() == "String") + .filter(|c| self.columns.columns_map.get(*c).unwrap().to_string() == "String") .collect::>(); if fields.is_empty() { None } else { Some(fields) } }; @@ -801,6 +800,10 @@ impl InMemoryGenerator { quote! { pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { + // This query may update many rows. Keep vacuum out across the + // snapshot, lock acquisition, and per-row mutation gaps + // without adding work to each row. + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); // Snapshot the matching rows' primary keys once; the same set // is locked and then processed. Locking one index scan and // processing a fresh second scan would let rows that joined the @@ -1019,7 +1022,7 @@ impl InMemoryGenerator { #[cfg(test)] mod tests { - use std::collections::HashMap; + use indexmap::IndexMap; use proc_macro2::{Ident, Span}; use quote::quote; @@ -1057,7 +1060,7 @@ mod tests { queries: None, config: None, }; - let mut updates = HashMap::new(); + let mut updates = IndexMap::new(); let name = Ident::new("CodeById", Span::call_site()); updates.insert( name.clone(), @@ -1069,8 +1072,8 @@ mod tests { ); generator.queries = Some(Queries { updates, - deletes: HashMap::new(), - in_place: HashMap::new(), + deletes: IndexMap::new(), + in_place: IndexMap::new(), }); generator.gen_primary_key_def().unwrap(); diff --git a/codegen/src/generators/in_memory/table/impls.rs b/codegen/src/generators/in_memory/table/impls.rs index 6f35ff6c..198e390a 100644 --- a/codegen/src/generators/in_memory/table/impls.rs +++ b/codegen/src/generators/in_memory/table/impls.rs @@ -17,6 +17,7 @@ impl InMemoryGenerator { let select_range_fn = self.gen_table_select_range_fn(); let insert_fn = self.gen_table_insert_fn(); let insert_many_fn = self.gen_table_insert_many_fn(); + let delete_many_fn = self.gen_table_delete_many_fn(); let reinsert_fn = self.gen_table_reinsert_fn(); let upsert_fn = self.gen_table_upsert_fn(); let get_next_fn = self.gen_table_get_next_fn(); @@ -35,6 +36,7 @@ impl InMemoryGenerator { #select_range_fn #insert_fn #insert_many_fn + #delete_many_fn #reinsert_fn #upsert_fn #count_fn @@ -137,7 +139,7 @@ impl InMemoryGenerator { let primary_key_type = name_generator.get_primary_key_type_ident(); quote! { - pub fn insert(&self, row: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { + pub async fn insert(&self, row: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { self.0.insert(row) } } @@ -155,12 +157,69 @@ impl InMemoryGenerator { /// concurrent readers ever observing a value, and the error names /// the offending row and index. After `Ok`, every row is visible /// to reads. - pub fn insert_many(&self, rows: Vec<#row_type>) -> core::result::Result, BatchInsertError> { + pub async fn insert_many(&self, rows: Vec<#row_type>) -> core::result::Result, BatchInsertError> { self.0.insert_many(rows) } } } + fn gen_table_delete_many_fn(&self) -> TokenStream { + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let primary_key_type = name_generator.get_primary_key_type_ident(); + + quote! { + /// Deletes every row named by `pks`, behind one grace marker. + /// + /// `async` although it awaits nothing today. Every other write on + /// this table is async, because cell-level locking makes an update + /// wait on the readers of the cells it touches, and a write surface + /// where the caller has to know which operations happen to need + /// that is a surface where `let _ = table.upsert(row)` silently + /// drops a write. Uniformity is worth more here than the marginal + /// honesty of a sync signature, and the batch paths may need to + /// wait once they take cell locks rather than the striped gate. + /// + /// A delete is a bit flip: the row is marked deleted in place, its + /// index entries are removed, and its storage becomes reusable once + /// no reader can still reach it. `vacuum` is what later compacts + /// pages and hands whole ones back. + /// + /// Batching matters because the per-row cost is dominated by the + /// reclamation bookkeeping each retirement takes, not by the bit + /// flip: `n` deletes take `n` domain advances where a batch takes + /// one. + /// + /// Unlike `insert_many` this is **not** all-or-nothing. A delete + /// that fails partway has already ghosted rows and removed their + /// index entries, and those rows are genuinely gone, so the error + /// reports how many succeeded rather than pretending to rewind. + /// A key that is not present is skipped rather than failing the + /// batch. + /// + /// Returns the keys actually deleted, in the order given. + /// Deletes every row whose primary key falls in `range`. + /// + /// The shape bulk eviction has: a caller dropping a generation + /// knows the span it wants gone rather than the individual keys. + /// The span is collected from the primary index in one ordered + /// walk and then deleted exactly as `delete_many` would, keys + /// still resolved under their mutation guards. + pub async fn delete_range(&self, range: R) + -> core::result::Result, BatchDeleteError<#primary_key_type>> + where R: core::ops::RangeBounds<#primary_key_type> + { + self.0.delete_range(range) + } + + pub async fn delete_many(&self, pks: Vec) + -> core::result::Result, BatchDeleteError<#primary_key_type>> + where #primary_key_type: From + { + self.0.delete_many(pks.into_iter().map(core::convert::Into::into).collect()) + } + } + } + fn gen_table_reinsert_fn(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let row_type = name_generator.get_row_type_ident(); @@ -190,7 +249,7 @@ impl InMemoryGenerator { pub async fn upsert(&self, row: #row_type) -> core::result::Result<(), WorkTableError> { let pk = row.get_primary_key(); if !self.0.primary_index.pk_map.contains_key(&pk) { - match self.insert(row.clone()) { + match self.insert(row.clone()).await { core::result::Result::Ok(_) => return core::result::Result::Ok(()), core::result::Result::Err(WorkTableError::PrimaryAlreadyExists) => {} core::result::Result::Err(e) => return core::result::Result::Err(e), @@ -208,17 +267,23 @@ impl InMemoryGenerator { let result = if self.0.primary_index.pk_map.contains_key(&pk) { self.update_with_guard(row.clone(), guard).await } else { - // `insert` acquires the same per-key mutation gate as - // `guard`; release the row operation before entering - // the synchronous insertion protocol, then retry the - // locked decision if another writer won the race. - drop(guard); - match self.insert(row.clone()) { + // Insert without letting go. + // + // This used to `drop(guard)` first and retry if it lost + // the race, because `insert` re-acquires the same + // per-key mutation gate the guard already holds and + // would deadlock against itself. `insert_locked` is + // that same insert without the acquisition, so the + // decision and the write are one critical section and + // there is no window to lose. + let result = match self.0.insert_locked(row.clone()) { core::result::Result::Ok(_) => core::result::Result::Ok(()), core::result::Result::Err(WorkTableError::PrimaryAlreadyExists) => core::result::Result::Err(WorkTableError::NotFound), core::result::Result::Err(e) => core::result::Result::Err(e), - } + }; + drop(guard); + result }; match result { diff --git a/codegen/src/generators/in_memory/table/index_fns.rs b/codegen/src/generators/in_memory/table/index_fns.rs index 9f30ad7f..35d51744 100644 --- a/codegen/src/generators/in_memory/table/index_fns.rs +++ b/codegen/src/generators/in_memory/table/index_fns.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -57,7 +56,7 @@ impl InMemoryGenerator { fn gen_unique_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, ) -> syn::Result { let type_ = columns_map.get(i).ok_or(syn::Error::new(i.span(), "Row not found"))?; @@ -116,7 +115,7 @@ impl InMemoryGenerator { fn gen_non_unique_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, column_range_type: &Ident, row_fields_ident: &Ident, @@ -161,7 +160,7 @@ impl InMemoryGenerator { fn gen_range_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, column_range_type: &Ident, row_fields_ident: &Ident, diff --git a/codegen/src/generators/in_memory/table/mod.rs b/codegen/src/generators/in_memory/table/mod.rs index ee1765ab..d5bd9e39 100644 --- a/codegen/src/generators/in_memory/table/mod.rs +++ b/codegen/src/generators/in_memory/table/mod.rs @@ -95,7 +95,10 @@ impl InMemoryGenerator { quote! { #derive pub struct #ident( - WorkTable< + // Public because the crate's own internals reach the inner + // table directly: a synchronous internal path cannot call + // the generated wrapper once that wrapper is async. + pub WorkTable< #row_type, #primary_key_type, #avt_type_ident, @@ -112,7 +115,10 @@ impl InMemoryGenerator { quote! { #derive pub struct #ident( - WorkTable< + // Public because the crate's own internals reach the inner + // table directly: a synchronous internal path cannot call + // the generated wrapper once that wrapper is async. + pub WorkTable< #row_type, #primary_key_type, #avt_type_ident, diff --git a/codegen/src/generators/in_memory/table/select_executor.rs b/codegen/src/generators/in_memory/table/select_executor.rs index ba980e86..0dd2560b 100644 --- a/codegen/src/generators/in_memory/table/select_executor.rs +++ b/codegen/src/generators/in_memory/table/select_executor.rs @@ -1,4 +1,5 @@ use convert_case::{Case, Casing}; +use indexmap::IndexSet; use proc_macro2::Ident; use proc_macro2::Span; use proc_macro2::TokenStream; @@ -23,7 +24,7 @@ impl InMemoryGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let column_range_type = name_generator.get_column_range_type_ident(); - let unique_types: std::collections::HashSet = self + let unique_types: IndexSet = self .columns .columns_map .values() diff --git a/codegen/src/generators/index_backend.rs b/codegen/src/generators/index_backend.rs index defcaf68..833e7422 100644 --- a/codegen/src/generators/index_backend.rs +++ b/codegen/src/generators/index_backend.rs @@ -66,7 +66,7 @@ pub(crate) fn primary_key_backend_impl( match backend { IndexBackend::WorktablesIndex | IndexBackend::Indexset => Ok((quote! {}, quote! {})), IndexBackend::Congee => { - let field = single_supported_field(backend, fields, &["u8", "u16", "u32", "u64", "usize"])?; + let field = single_supported_field(backend, fields, supported_types(backend))?; let width_guard = if primitive_name(field).as_deref() == Some("u64") { quote! { #[cfg(not(target_pointer_width = "64"))] @@ -83,12 +83,12 @@ pub(crate) fn primary_key_backend_impl( impl CongeeKey for #primary_key { #[inline] fn into_congee(self) -> usize { - self.0 as usize + CongeeKey::into_congee(self.0) } #[inline] fn from_congee(value: usize) -> Self { - Self(value as #field) + Self(<#field as CongeeKey>::from_congee(value)) } } @@ -107,21 +107,21 @@ pub(crate) fn primary_key_backend_impl( )) } IndexBackend::Arctic => { - let field = single_supported_field(backend, fields, &["u16", "u32", "u64", "u128"])?; + let field = single_supported_field(backend, fields, supported_types(backend))?; Ok(( quote! { Copy, }, quote! { impl ArcticKey for #primary_key { - type Raw = #field; + type Raw = <#field as ArcticKey>::Raw; #[inline] fn to_arctic(&self) -> Self::Raw { - self.0 + ArcticKey::to_arctic(&self.0) } #[inline] fn from_arctic(value: Self::Raw) -> Self { - Self(value) + Self(<#field as ArcticKey>::from_arctic(value)) } } @@ -142,6 +142,15 @@ pub(crate) fn primary_key_backend_impl( } } +/// The key types a backend accepts, from `worktable_dsl` so the macro and the +/// editor-facing check cannot drift. They were two hand-maintained lists, and +/// widening one without the other produced a check that accepted a +/// declaration the macro then refused. +fn supported_types(backend: IndexBackend) -> &'static [&'static str] { + worktable_dsl::validate::supported_key_types(backend) + .expect("a backend reaching this point declares a key-type list") +} + fn single_supported_field<'a>( backend: IndexBackend, fields: &'a [&TokenStream], diff --git a/codegen/src/generators/persist/primary_key.rs b/codegen/src/generators/persist/primary_key.rs index 550fce99..59b002b9 100644 --- a/codegen/src/generators/persist/primary_key.rs +++ b/codegen/src/generators/persist/primary_key.rs @@ -141,8 +141,28 @@ impl PersistGenerator { "i16" => quote! { std::sync::atomic::AtomicI16 }, "i32" => quote! { std::sync::atomic::AtomicI32 }, "i64" => quote! { std::sync::atomic::AtomicI64 }, + // The accepted set is `worktable_dsl::AUTOINCREMENT_TYPES`, and the + // arms above must stay equal to it. `check` uses that list to + // answer "would the macro accept this", so a second copy drifting + // shows up as `check` passing a declaration that then fails to + // build, which is the failure it exists to prevent. + // + // Asserted rather than commented: the debug assertion below fires + // during any codegen build if the two ever disagree. _ => { - return Err(syn::Error::new(i.span(), "Type is not supported for autoincrement")); + debug_assert!( + !worktable_dsl::AUTOINCREMENT_TYPES.contains(&type_.to_string().as_str()), + "`{}` is in worktable_dsl::AUTOINCREMENT_TYPES but has no atomic here; `check` \ + will accept a declaration this refuses", + type_ + ); + return Err(syn::Error::new( + i.span(), + format!( + "type is not supported for autoincrement; supported types: {}", + worktable_dsl::AUTOINCREMENT_TYPES.join(", ") + ), + )); } }) } diff --git a/codegen/src/generators/persist/queries/delete.rs b/codegen/src/generators/persist/queries/delete.rs index ea95a291..c2efa53c 100644 --- a/codegen/src/generators/persist/queries/delete.rs +++ b/codegen/src/generators/persist/queries/delete.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -189,7 +188,7 @@ impl PersistGenerator { } } - fn gen_custom_deletes(&mut self, deleted: HashMap) -> TokenStream { + fn gen_custom_deletes(&mut self, deleted: IndexMap) -> TokenStream { let defs = deleted .iter() .map(|(name, op)| { @@ -219,17 +218,22 @@ impl PersistGenerator { fn gen_brute_force_delete_field(field: &Ident, type_: &TokenStream, name: &Ident) -> TokenStream { quote! { pub async fn #name(&self, by: #type_) -> core::result::Result<(), WorkTableError> { - self.iter_with_async(|row| { + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); + let pks = std::cell::RefCell::new(Vec::new()); + self.iter_with(|row| { if row.#field == by { - futures::future::Either::Left(async move { - self.delete::<_>(row.get_primary_key()).await - }) - } else { - futures::future::Either::Right(async { - Ok(()) - }) + pks.borrow_mut().push(row.get_primary_key()); } - }).await?; + Ok(()) + })?; + let pks = pks.into_inner(); + for pk in pks { + match self.delete(pk).await { + core::result::Result::Ok(()) => {} + core::result::Result::Err(WorkTableError::NotFound) => {} + core::result::Result::Err(e) => return core::result::Result::Err(e), + } + } core::result::Result::Ok(()) } } @@ -249,6 +253,9 @@ impl PersistGenerator { }; quote! { pub async fn #name(&self, by: #type_) -> core::result::Result<(), WorkTableError> { + // A non-unique predicate can delete many rows. Signal the + // whole operation without holding its row locks all at once. + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); // Snapshot the matching rows as validated primary keys before // deleting anything. Storage links are not stable identities: // a concurrent delete can free a slot and an insert can reuse @@ -309,8 +316,11 @@ impl PersistGenerator { pub async fn #name(&self, by: #type_) -> core::result::Result<(), WorkTableError> { let row_to_update = self.0.indexes.#index.get_value(#by).map(Into::into); if let Some(link) = row_to_update { - let row = self.0.data.select_non_ghosted(link).map_err(WorkTableError::PagesError)?; - self.delete(row.get_primary_key()).await?; + let pk = { + let row = self.0.data.select_non_ghosted(link).map_err(WorkTableError::PagesError)?; + row.get_primary_key() + }; + self.delete(pk).await?; } core::result::Result::Ok(()) } diff --git a/codegen/src/generators/persist/queries/in_place.rs b/codegen/src/generators/persist/queries/in_place.rs index 955b49c4..895ef11b 100644 --- a/codegen/src/generators/persist/queries/in_place.rs +++ b/codegen/src/generators/persist/queries/in_place.rs @@ -5,9 +5,8 @@ use crate::common::model::Operation; use crate::common::name_generator::WorktableNameGenerator; use crate::generators::persist::PersistGenerator; use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span}; -use std::collections::HashMap; - impl PersistGenerator { pub fn gen_query_in_place_impl(&self) -> syn::Result { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); @@ -29,7 +28,7 @@ impl PersistGenerator { }) } - fn gen_in_place_queries(&self, in_place_queries: HashMap) -> TokenStream { + fn gen_in_place_queries(&self, in_place_queries: IndexMap) -> TokenStream { let defs = in_place_queries .iter() .map(|(name, op)| { diff --git a/codegen/src/generators/persist/queries/locks.rs b/codegen/src/generators/persist/queries/locks.rs index e79d652a..c6f0c3ee 100644 --- a/codegen/src/generators/persist/queries/locks.rs +++ b/codegen/src/generators/persist/queries/locks.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -28,7 +27,7 @@ impl PersistGenerator { } } - fn gen_in_place_update_query_locks(updates: &HashMap) -> TokenStream { + fn gen_in_place_update_query_locks(updates: &IndexMap) -> TokenStream { let fns = updates .keys() .map(|name| { @@ -50,7 +49,7 @@ impl PersistGenerator { } } - fn gen_update_query_locks(updates: &HashMap) -> TokenStream { + fn gen_update_query_locks(updates: &IndexMap) -> TokenStream { let fns = updates .keys() .map(|name| { diff --git a/codegen/src/generators/persist/queries/type.rs b/codegen/src/generators/persist/queries/type.rs index ec10a1f2..0080bfaf 100644 --- a/codegen/src/generators/persist/queries/type.rs +++ b/codegen/src/generators/persist/queries/type.rs @@ -1,5 +1,4 @@ -use std::collections::HashSet; - +use indexmap::IndexSet; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -30,7 +29,7 @@ impl PersistGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let avt_type_ident = name_generator.get_available_type_ident(); - let unique_types: HashSet = self + let unique_types: IndexSet = self .columns .indexes .iter() diff --git a/codegen/src/generators/persist/queries/unsized_.rs b/codegen/src/generators/persist/queries/unsized_.rs index 108cbe13..d66f9c9e 100644 --- a/codegen/src/generators/persist/queries/unsized_.rs +++ b/codegen/src/generators/persist/queries/unsized_.rs @@ -61,7 +61,7 @@ impl PersistGenerator { let unsized_fields: Vec<_> = op .columns .iter() - .filter(|c| self.columns.columns_map.get(c).unwrap().to_string() == "String") + .filter(|c| self.columns.columns_map.get(*c).unwrap().to_string() == "String") .map(|c| { let fn_ident = Ident::new(format!("get_{c}_size").as_str(), Span::call_site()); quote! { diff --git a/codegen/src/generators/persist/queries/update.rs b/codegen/src/generators/persist/queries/update.rs index 9594dce2..ffd41658 100644 --- a/codegen/src/generators/persist/queries/update.rs +++ b/codegen/src/generators/persist/queries/update.rs @@ -1,10 +1,9 @@ -use proc_macro2::Literal; -use std::collections::HashMap; - use crate::common::model::{Index, Operation}; use crate::common::name_generator::{WorktableNameGenerator, is_float}; use crate::generators::persist::PersistGenerator; use convert_case::{Case, Casing}; +use indexmap::IndexMap; +use proc_macro2::Literal; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -180,7 +179,7 @@ impl PersistGenerator { } } - fn gen_custom_updates(&mut self, updates: HashMap) -> TokenStream { + fn gen_custom_updates(&mut self, updates: IndexMap) -> TokenStream { let defs = updates .iter() .map(|(name, op)| { @@ -204,7 +203,7 @@ impl PersistGenerator { let fields = op .columns .iter() - .filter(|c| self.columns.columns_map.get(c).unwrap().to_string() == "String") + .filter(|c| self.columns.columns_map.get(*c).unwrap().to_string() == "String") .collect::>(); if fields.is_empty() { None } else { Some(fields) } }; @@ -831,6 +830,10 @@ impl PersistGenerator { quote! { pub async fn #method_ident(&self, row: #query_ident, by: #by_ident) -> core::result::Result<(), WorkTableError> { + // This query may update many rows. Keep vacuum out across the + // snapshot, lock acquisition, and per-row mutation gaps + // without adding work to each row. + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); // Snapshot the matching rows' primary keys once; the same set // is locked and then processed. Locking one index scan and // processing a fresh second scan would let rows that joined the @@ -1048,7 +1051,7 @@ impl PersistGenerator { #[cfg(test)] mod tests { - use std::collections::HashMap; + use indexmap::IndexMap; use proc_macro2::{Ident, Span}; use quote::quote; @@ -1080,7 +1083,7 @@ mod tests { columns.indexes = parser.parse_indexes().unwrap(); let mut generator = PersistGenerator::new(Ident::new("RollbackProbe", Span::call_site()), columns, 1); - let mut updates = HashMap::new(); + let mut updates = IndexMap::new(); let name = Ident::new("CodeById", Span::call_site()); updates.insert( name.clone(), @@ -1092,8 +1095,8 @@ mod tests { ); generator.set_queries(Queries { updates, - deletes: HashMap::new(), - in_place: HashMap::new(), + deletes: IndexMap::new(), + in_place: IndexMap::new(), }); generator.gen_primary_key_def().unwrap(); diff --git a/codegen/src/generators/persist/table/impls.rs b/codegen/src/generators/persist/table/impls.rs index f6a1228b..b3f0a06f 100644 --- a/codegen/src/generators/persist/table/impls.rs +++ b/codegen/src/generators/persist/table/impls.rs @@ -18,6 +18,7 @@ impl PersistGenerator { let select_range_fn = self.gen_table_select_range_fn(); let insert_fn = self.gen_table_insert_fn(); let insert_many_fn = self.gen_table_insert_many_fn(); + let delete_many_fn = self.gen_table_delete_many_fn(); let reinsert_fn = self.gen_table_reinsert_fn(); let upsert_fn = self.gen_table_upsert_fn(); let get_next_fn = self.gen_table_get_next_fn(); @@ -39,6 +40,7 @@ impl PersistGenerator { #select_range_fn #insert_fn #insert_many_fn + #delete_many_fn #reinsert_fn #upsert_fn #count_fn @@ -441,7 +443,7 @@ impl PersistGenerator { let secondary_events_ident = name_generator.get_space_secondary_index_events_ident(); quote! { - pub fn insert(&self, row: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { + pub async fn insert(&self, row: #row_type) -> core::result::Result<#primary_key_type, WorkTableError> { self.1.ensure_running()?; let (op, res) = self.0.insert_cdc::<#secondary_events_ident>(row); if let Some(op) = op { @@ -467,7 +469,7 @@ impl PersistGenerator { /// to reads; persisted durability follows the same `wait_for_ops` /// contract as single inserts, with the whole batch coalesced /// into one persistence engine application. - pub fn insert_many(&self, rows: Vec<#row_type>) -> core::result::Result, BatchInsertError> { + pub async fn insert_many(&self, rows: Vec<#row_type>) -> core::result::Result, BatchInsertError> { if let core::result::Result::Err(e) = self.1.ensure_running() { return core::result::Result::Err(BatchInsertError::Table(WorkTableError::PersistenceError(e))); } @@ -483,6 +485,90 @@ impl PersistGenerator { } } + fn gen_table_delete_many_fn(&self) -> TokenStream { + let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); + let primary_key_type = name_generator.get_primary_key_type_ident(); + + quote! { + /// Deletes every row named by `pks`, in the order given. + /// + /// Returns the keys actually deleted. A key that is not present is + /// skipped rather than failing the batch. + /// + /// Unlike `insert_many` this is **not** all-or-nothing. A delete + /// that fails partway has already removed earlier rows and they + /// are genuinely gone, so the error reports how many succeeded + /// rather than pretending to rewind. + /// + /// On a persisted table each row is deleted through the same path + /// as a single `delete`, so every removal produces its own + /// persistence operation. That costs the batching the in-memory + /// generator gets, and it is not optional: routing these straight + /// at the in-memory batch left the rows gone from memory and + /// present on disk, so they came back on the next load. Durability + /// is not something to trade for a per-row constant. + pub async fn delete_many(&self, pks: Vec) + -> core::result::Result, BatchDeleteError<#primary_key_type>> + where #primary_key_type: From + { + let pks: Vec<#primary_key_type> = pks.into_iter().map(core::convert::Into::into).collect(); + if pks.is_empty() { + return core::result::Result::Ok(Vec::new()); + } + // Persisted deletes run one row at a time for durability, so + // keep a cheap operation-wide activity signal across the gaps + // between those row mutations. It holds no row or stripe lock. + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); + let mut deleted = Vec::with_capacity(pks.len()); + for pk in pks { + match self.delete::<#primary_key_type>(pk.clone()).await { + core::result::Result::Ok(()) => deleted.push(pk), + // Absent keys are skipped, matching the in-memory + // contract; anything else stops the batch and reports + // the prefix that did land. + core::result::Result::Err(WorkTableError::NotFound) => {} + core::result::Result::Err(source) => { + return core::result::Result::Err(BatchDeleteError::Key { + key: pk, + deleted: deleted.len(), + source, + }); + } + } + } + core::result::Result::Ok(deleted) + } + + /// Deletes every row whose primary key falls in `range`. + /// + /// The shape bulk eviction has: a caller dropping a generation + /// knows the span it wants gone rather than the individual keys. + /// The span is collected from the primary index in one ordered + /// walk, then deleted exactly as `delete_many` would, which on a + /// persisted table means one persistence operation per row. + /// + /// Only the keys present when the range was walked are deleted. A + /// key inserted into the span afterwards is left alone rather than + /// removed without ever having been seen. + pub async fn delete_range(&self, range: R) + -> core::result::Result, BatchDeleteError<#primary_key_type>> + where R: core::ops::RangeBounds<#primary_key_type> + { + // Cover the range walk as well as the per-row deletes below. + let _bulk_mutation = self.0.lock_manager.bulk_mutation_guard(); + let start = range.start_bound().cloned(); + let end = range.end_bound().cloned(); + let keys: Vec<#primary_key_type> = self.0 + .primary_index + .pk_map + .range_values((start, end)) + .map(|(key, _)| key) + .collect(); + self.delete_many::<#primary_key_type>(keys).await + } + } + } + fn gen_table_reinsert_fn(&self) -> TokenStream { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let row_type = name_generator.get_row_type_ident(); @@ -518,7 +604,7 @@ impl PersistGenerator { pub async fn upsert(&self, row: #row_type) -> core::result::Result<(), WorkTableError> { let pk = row.get_primary_key(); if !self.0.primary_index.pk_map.contains_key(&pk) { - match self.insert(row.clone()) { + match self.insert(row.clone()).await { core::result::Result::Ok(_) => return core::result::Result::Ok(()), core::result::Result::Err(WorkTableError::PrimaryAlreadyExists) => {} core::result::Result::Err(e) => return core::result::Result::Err(e), @@ -541,7 +627,7 @@ impl PersistGenerator { // the synchronous insertion protocol, then retry the // locked decision if another writer won the race. drop(guard); - match self.insert(row.clone()) { + match self.insert(row.clone()).await { core::result::Result::Ok(_) => core::result::Result::Ok(()), core::result::Result::Err(WorkTableError::PrimaryAlreadyExists) => core::result::Result::Err(WorkTableError::NotFound), diff --git a/codegen/src/generators/persist/table/index_fns.rs b/codegen/src/generators/persist/table/index_fns.rs index 0259e010..e6712995 100644 --- a/codegen/src/generators/persist/table/index_fns.rs +++ b/codegen/src/generators/persist/table/index_fns.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -57,7 +56,7 @@ impl PersistGenerator { fn gen_unique_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, ) -> syn::Result { let type_ = columns_map.get(i).ok_or(syn::Error::new(i.span(), "Row not found"))?; @@ -116,7 +115,7 @@ impl PersistGenerator { fn gen_non_unique_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, column_range_type: &Ident, row_fields_ident: &Ident, @@ -161,7 +160,7 @@ impl PersistGenerator { fn gen_range_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, column_range_type: &Ident, row_fields_ident: &Ident, diff --git a/codegen/src/generators/persist/table/mod.rs b/codegen/src/generators/persist/table/mod.rs index 1d339ed3..2efb5714 100644 --- a/codegen/src/generators/persist/table/mod.rs +++ b/codegen/src/generators/persist/table/mod.rs @@ -187,7 +187,10 @@ impl PersistGenerator { #schema_attribute #secondary_schema_attribute pub struct #ident( - WorkTable< + // Public because the crate's own internals reach the inner + // table directly: a synchronous internal path cannot call + // the generated wrapper once that wrapper is async. + pub WorkTable< #row_type, #primary_key_type, #avt_type_ident, @@ -207,7 +210,10 @@ impl PersistGenerator { #schema_attribute #secondary_schema_attribute pub struct #ident( - WorkTable< + // Public because the crate's own internals reach the inner + // table directly: a synchronous internal path cannot call + // the generated wrapper once that wrapper is async. + pub WorkTable< #row_type, #primary_key_type, #avt_type_ident, diff --git a/codegen/src/generators/persist/table/select_executor.rs b/codegen/src/generators/persist/table/select_executor.rs index 4505202d..1499d250 100644 --- a/codegen/src/generators/persist/table/select_executor.rs +++ b/codegen/src/generators/persist/table/select_executor.rs @@ -1,4 +1,5 @@ use convert_case::{Case, Casing}; +use indexmap::IndexSet; use proc_macro2::Ident; use proc_macro2::Span; use proc_macro2::TokenStream; @@ -23,7 +24,7 @@ impl PersistGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let column_range_type = name_generator.get_column_range_type_ident(); - let unique_types: std::collections::HashSet = self + let unique_types: IndexSet = self .columns .columns_map .values() diff --git a/codegen/src/generators/read_only/primary_key.rs b/codegen/src/generators/read_only/primary_key.rs index 995298b4..192a2e44 100644 --- a/codegen/src/generators/read_only/primary_key.rs +++ b/codegen/src/generators/read_only/primary_key.rs @@ -141,8 +141,28 @@ impl ReadOnlyGenerator { "i16" => quote! { std::sync::atomic::AtomicI16 }, "i32" => quote! { std::sync::atomic::AtomicI32 }, "i64" => quote! { std::sync::atomic::AtomicI64 }, + // The accepted set is `worktable_dsl::AUTOINCREMENT_TYPES`, and the + // arms above must stay equal to it. `check` uses that list to + // answer "would the macro accept this", so a second copy drifting + // shows up as `check` passing a declaration that then fails to + // build, which is the failure it exists to prevent. + // + // Asserted rather than commented: the debug assertion below fires + // during any codegen build if the two ever disagree. _ => { - return Err(syn::Error::new(i.span(), "Type is not supported for autoincrement")); + debug_assert!( + !worktable_dsl::AUTOINCREMENT_TYPES.contains(&type_.to_string().as_str()), + "`{}` is in worktable_dsl::AUTOINCREMENT_TYPES but has no atomic here; `check` \ + will accept a declaration this refuses", + type_ + ); + return Err(syn::Error::new( + i.span(), + format!( + "type is not supported for autoincrement; supported types: {}", + worktable_dsl::AUTOINCREMENT_TYPES.join(", ") + ), + )); } }) } diff --git a/codegen/src/generators/read_only/queries/type.rs b/codegen/src/generators/read_only/queries/type.rs index 60b55728..9503bdd0 100644 --- a/codegen/src/generators/read_only/queries/type.rs +++ b/codegen/src/generators/read_only/queries/type.rs @@ -1,5 +1,4 @@ -use std::collections::HashSet; - +use indexmap::IndexSet; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -30,7 +29,7 @@ impl ReadOnlyGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let avt_type_ident = name_generator.get_available_type_ident(); - let unique_types: HashSet = self + let unique_types: IndexSet = self .columns .indexes .iter() diff --git a/codegen/src/generators/read_only/table/index_fns.rs b/codegen/src/generators/read_only/table/index_fns.rs index 23b33dfb..98a1d0f6 100644 --- a/codegen/src/generators/read_only/table/index_fns.rs +++ b/codegen/src/generators/read_only/table/index_fns.rs @@ -1,6 +1,5 @@ -use std::collections::HashMap; - use convert_case::{Case, Casing}; +use indexmap::IndexMap; use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; @@ -57,7 +56,7 @@ impl ReadOnlyGenerator { fn gen_unique_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, ) -> syn::Result { let type_ = columns_map.get(i).ok_or(syn::Error::new(i.span(), "Row not found"))?; @@ -116,7 +115,7 @@ impl ReadOnlyGenerator { fn gen_non_unique_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, column_range_type: &Ident, row_fields_ident: &Ident, @@ -161,7 +160,7 @@ impl ReadOnlyGenerator { fn gen_range_index_fn( i: &Ident, idx: &Index, - columns_map: &HashMap, + columns_map: &IndexMap, row_ident: Ident, column_range_type: &Ident, row_fields_ident: &Ident, diff --git a/codegen/src/generators/read_only/table/select_executor.rs b/codegen/src/generators/read_only/table/select_executor.rs index fbcec3f3..bd8b3f7c 100644 --- a/codegen/src/generators/read_only/table/select_executor.rs +++ b/codegen/src/generators/read_only/table/select_executor.rs @@ -1,4 +1,5 @@ use convert_case::{Case, Casing}; +use indexmap::IndexSet; use proc_macro2::Ident; use proc_macro2::Span; use proc_macro2::TokenStream; @@ -23,7 +24,7 @@ impl ReadOnlyGenerator { let name_generator = WorktableNameGenerator::from_table_name(self.name.to_string()); let column_range_type = name_generator.get_column_range_type_ident(); - let unique_types: std::collections::HashSet = self + let unique_types: IndexSet = self .columns .columns_map .values() diff --git a/codegen/src/lib.rs b/codegen/src/lib.rs index bc7b2ce9..79545d7a 100644 --- a/codegen/src/lib.rs +++ b/codegen/src/lib.rs @@ -1,3 +1,11 @@ +// `common` is now a thin front for `worktable_dsl`, which holds the schema +// model and parser so that anything other than this macro can read a +// declaration. Kept as a module rather than an alias because the name +// generator stays here: generators define inherent `impl`s on it, which the +// orphan rule allows only in the crate that owns the type. +// +// The 127 `crate::common::` paths across this crate are unchanged, so the diff +// is a move rather than a sweep. mod common; mod generators; mod mem_stat; diff --git a/codegen/src/migration_engine/generator.rs b/codegen/src/migration_engine/generator.rs index b81ea25f..77e5e035 100644 --- a/codegen/src/migration_engine/generator.rs +++ b/codegen/src/migration_engine/generator.rs @@ -47,7 +47,7 @@ pub fn generate(input: MigrationEngineInput) -> TokenStream { let rows = source.select_all().execute()?; for row in rows { #chain_steps - target.insert(current_row)?; + target.insert(current_row).await?; } Ok(()) } diff --git a/codegen/src/worktable/mod.rs b/codegen/src/worktable/mod.rs index c4ddfac2..7064af61 100644 --- a/codegen/src/worktable/mod.rs +++ b/codegen/src/worktable/mod.rs @@ -1,9 +1,17 @@ use proc_macro2::TokenStream; use crate::common::Parser; -use crate::common::model::{Columns, IndexBackend, Persistence}; +use crate::common::name_generator::WorktableNameGenerator; pub fn expand(input: TokenStream) -> syn::Result { + // Keep the tokens. The declaration is read a second time at the end, as + // data, so the generated table can carry its own schema. It happens at the + // end rather than here so that this function's diagnostics are the ones a + // bad declaration produces: both parses reject the same inputs, but only + // one of them knows to say that a separate `attributes` section is not + // part of the 1.0 grammar. + let declaration = input.clone(); + let mut parser = Parser::new(input); let mut columns = None; let mut queries = None; @@ -59,7 +67,12 @@ pub fn expand(input: TokenStream) -> syn::Result { "a separate `attributes` section is not part of the 1.0 grammar; keep `primary_key`, `autoincrement`, `custom`, `optional`, and `using` inline on their column or index declarations", )); } - _ => return Err(syn::Error::new(ident.span(), "Unexpected identifier")), + other => { + return Err(syn::Error::new( + ident.span(), + format!("Unexpected token `{other}`; expected one of `columns`, `indexes`, `queries`, `config`"), + )); + } } } @@ -68,10 +81,10 @@ pub fn expand(input: TokenStream) -> syn::Result { columns.indexes = i } - validate_index_backends(&columns, persistence)?; - validate_page_size(config.as_ref(), persistence)?; + worktable_dsl::validate::validate_index_backends(&columns, persistence)?; + worktable_dsl::validate::validate_page_size(config.as_ref(), persistence)?; if let Some(q) = &queries { - validate_in_place_queries(&columns, q)?; + worktable_dsl::validate::validate_in_place_queries(&columns, q)?; } let mut generated = if persistence.is_persisted() { @@ -84,138 +97,40 @@ pub fn expand(input: TokenStream) -> syn::Result { generated.extend(crate::generators::partitions::expand(&name, &key, persistence)); } - Ok(generated) -} + generated.extend(gen_schema_const(&worktable_dsl::Schema::from_tokens(declaration)?)); -/// data_bucket's on-disk layer seeks with its own hardcoded `PAGE_SIZE` of -/// 16384 bytes (`seek_to_page_start`, `seek_by_link`, `persist_page`), while -/// the generated table threads the user's `page_size` through its page-id and -/// length arithmetic. Any other value therefore reads and writes the wrong -/// file offsets as soon as the table persists, silently corrupting it. -/// In-memory tables never seek a file: for them `page_size` only sizes index -/// nodes and stays configurable. -const DATA_BUCKET_PAGE_SIZE: u32 = 16384; - -fn validate_page_size(config: Option<&crate::common::model::Config>, persistence: Persistence) -> syn::Result<()> { - let Some(config) = config else { return Ok(()) }; - let Some(page_size) = config.page_size else { - return Ok(()); - }; - if persistence.is_persisted() && page_size != DATA_BUCKET_PAGE_SIZE { - let span = config.page_size_span.unwrap_or_else(proc_macro2::Span::call_site); - return Err(syn::Error::new( - span, - format!( - "`page_size: {page_size}` cannot be combined with `persist: true`: the on-disk \ - layer (data_bucket) hardcodes {DATA_BUCKET_PAGE_SIZE}-byte pages in every file \ - seek, so a persisted table with any other page size reads and writes the wrong \ - pages and corrupts its files. Remove `page_size` (or set it to \ - {DATA_BUCKET_PAGE_SIZE}); custom page sizes remain available for in-memory \ - tables, where they only size index nodes" - ), - )); - } - Ok(()) -} - -/// `in_place` queries hand the caller a mutable reference to the archived -/// column bytes and bypass all index maintenance, so a column that any index -/// is built over cannot be mutated in place: the index would keep resolving -/// the old value. -fn validate_in_place_queries(columns: &Columns, queries: &crate::common::model::Queries) -> syn::Result<()> { - for (name, op) in &queries.in_place { - for column in &op.columns { - if columns.indexes.values().any(|index| &index.field == column) { - return Err(syn::Error::new( - column.span(), - format!( - "in_place query `{name}` mutates column `{column}`, which is covered by an index; \ - indexed columns cannot be updated in place because secondary indexes are not \ - maintained on this path. Use an `update` query instead" - ), - )); - } - } - } - Ok(()) + Ok(generated) } -fn validate_index_backends(columns: &Columns, persistence: Persistence) -> syn::Result<()> { - let explicit_backend = if columns.primary_index_backend.requires_explicit_persistence() { - Some(( - columns.primary_index_backend, - columns.primary_keys.first().expect("primary key exists"), - true, - )) - } else { - columns - .indexes - .values() - .find(|index| index.backend.requires_explicit_persistence()) - .map(|index| (index.backend, &index.name, false)) - }; - - if let Some((backend, ident, is_primary)) = explicit_backend { - let kind = if is_primary { "primary index" } else { "index" }; - match persistence { - Persistence::MemoryOnly => {} - Persistence::Omitted => { - return Err(syn::Error::new( - ident.span(), - format!( - "{kind} `{ident}` uses `{}`, which requires an explicit `persist: true` or `persist: false`", - backend.name() - ), - )); - } - Persistence::Persisted => {} - } - } - - for index in columns.indexes.values().filter(|index| !index.is_unique) { - match index.backend { - IndexBackend::WorktablesIndex | IndexBackend::Arctic => {} - IndexBackend::Indexset | IndexBackend::Congee => { - return Err(syn::Error::new( - index.name.span(), - format!( - "non-unique index `{}` cannot use `{}`; non-unique indexes currently require \ - `worktables_index` or `arctic`", - index.name, - index.backend.name() - ), - )); - } - } +/// Bake the declaration into the generated code, as the text it was written in. +/// +/// The point is that a compiled binary should be able to say what schema it was +/// built against, without the source. A migration planner needs it as the +/// "declared" side of a comparison against what is on disk; a designer needs it +/// to draw a diagram of an application it did not build. +/// +/// The stored form is the DSL text rather than a serialised structure. It needs +/// no format decision, no serde in the dependency graph of every user's build, +/// and it is legible in a hex dump; `worktable_dsl` reads it back with the same +/// parser that read the original, and `dsl/tests/round_trip.rs` holds that +/// property against all 116 declarations in this repository. +/// +/// `allow(dead_code)` because a `worktable!` inside a function body puts this +/// const inside that body, where nothing refers to it and `-D warnings` would +/// otherwise fail a user's build over a const they never asked for. +fn gen_schema_const(schema: &worktable_dsl::Schema) -> TokenStream { + let ident = WorktableNameGenerator::from_table_name(schema.name.clone()).get_schema_const_ident(); + let text = schema.to_dsl(); + let doc = format!( + "The `worktable!` declaration `{}` was generated from, as text. Read it with `worktable_dsl::Schema::parse`.", + schema.name + ); + + quote::quote! { + #[doc = #doc] + #[allow(dead_code)] + pub const #ident: &str = #text; } - - for (column, index) in &columns.indexes { - let key_type = columns - .columns_map - .get(column) - .expect("an index always references a validated column") - .to_string(); - let supported = match index.backend { - IndexBackend::Congee => Some(&["u8", "u16", "u32", "u64", "usize"][..]), - IndexBackend::Arctic => Some(&["u16", "u32", "u64", "u128"][..]), - IndexBackend::WorktablesIndex | IndexBackend::Indexset => None, - }; - if let Some(supported) = supported - && !supported.contains(&key_type.as_str()) - { - return Err(syn::Error::new( - index.name.span(), - format!( - "index `{}` uses `{}`, which does not support key type `{key_type}`; supported types: {}", - index.name, - index.backend.name(), - supported.join(", ") - ), - )); - } - } - - Ok(()) } #[cfg(test)] @@ -497,7 +412,11 @@ mod tests { }) .unwrap_err(); - assert!(error.to_string().contains("supported types: u16, u32, u64, u128")); + assert!( + error + .to_string() + .contains("supported types: u16, u32, u64, u128, i16, i32, i64, i128") + ); } #[test] @@ -558,7 +477,11 @@ mod tests { }) .unwrap_err(); - assert!(error.to_string().contains("supported types: u16, u32, u64, u128")); + assert!( + error + .to_string() + .contains("supported types: u16, u32, u64, u128, i16, i32, i64, i128") + ); } #[test] @@ -704,3 +627,265 @@ mod position_tests { ); } } + +/// What a designer needs from the schema IR: a declaration that has been read +/// into [`worktable_dsl::Schema`] and written back out is a declaration this +/// macro accepts. +/// +/// `worktable_dsl` can test its own round trip, which shows nothing was lost +/// between its parser and its emitter. It cannot show that the text it emits +/// is a declaration *this* macro accepts, because it cannot call this macro: +/// that check has to live on the near side of the proc-macro boundary. +/// +/// The stronger claim — that repeated expansion generates the *same code* — +/// is asserted separately by `the_same_declaration_expands_the_same_way_twice`. +#[cfg(test)] +mod emitted_declarations { + use quote::quote; + use worktable_dsl::Schema; + + use super::expand; + + fn survives_the_round_trip(declaration: proc_macro2::TokenStream) { + expand(declaration.clone()).expect("the original expands"); + + let schema = Schema::from_tokens(declaration).expect("the IR reads it"); + let emitted = schema.to_dsl(); + let reparsed: proc_macro2::TokenStream = syn::parse_str(&emitted) + .unwrap_or_else(|error| panic!("emitted text does not tokenise: {error}\n{emitted}")); + + assert_eq!( + Schema::from_tokens(reparsed.clone()).expect("the emitted text reads back"), + schema, + "the emitted declaration describes a different schema\n{emitted}" + ); + expand(reparsed).unwrap_or_else(|error| panic!("the emitted declaration does not expand: {error}\n{emitted}")); + } + + #[test] + fn a_minimal_declaration() { + survives_the_round_trip(quote! { + name: Minimal, + columns: { id: u64 primary_key }, + }); + } + + #[test] + fn a_persisted_declaration_with_indexes_and_queries() { + survives_the_round_trip(quote! { + name: Account, + version: 3, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + tenant: u64, + nickname: String optional, + balance: f64, + }, + indexes: { + email_idx: email unique, + tenant_idx: tenant, + }, + queries: { + update: { + Nickname(nickname) by id, + Email(email) by tenant, + } + delete: { + ById() by id, + } + in_place: { + Balance(balance) by id, + } + } + }); + } + + #[test] + fn a_partitioned_declaration() { + survives_the_round_trip(quote! { + name: Price, + partition_by: symbol_id: u16, + columns: { + exchange_id: u8 primary_key, + bid: f64, + }, + }); + } + + #[test] + fn a_composite_key_keeps_its_column_order() { + // The order of a composite key decides the field order of the + // generated `get_primary_key`, and so the layout of the key type. An + // emitter that wrote the columns back in a `HashMap`'s order would + // change it. + survives_the_round_trip(quote! { + name: CompositeKey, + persist: true, + columns: { + tenant_id: u64 primary_key, + record_id: u64 primary_key, + value: i64, + }, + }); + } + + #[test] + fn an_explicit_backend_and_a_custom_page_size() { + survives_the_round_trip(quote! { + name: Tuned, + persist: false, + columns: { + id: u64 primary_key using congee, + value: u64, + }, + indexes: { + value_idx: value unique using arctic, + }, + config: { + page_size: 1024, + row_derives: Clone, Debug, + } + }); + } +} + +#[cfg(test)] +mod generator_determinism { + use quote::quote; + + use super::expand; + + /// Expansion order is part of the generated program. This fixture includes + /// columns, indexes and multiple query blocks so a randomized collection in + /// any of those paths changes the output and fails the test. + #[test] + fn the_same_declaration_expands_the_same_way_twice() { + let declaration = quote! { + name: Twice, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + tenant: u64, + balance: f64, + }, + indexes: { + email_idx: email unique, + tenant_idx: tenant, + }, + queries: { + update: { + SetBalance(balance) by id, + MoveTenant(tenant) by email, + }, + delete: { + ByEmail() by email, + ByTenant() by tenant, + }, + }, + }; + + let first = expand(declaration.clone()).expect("expands").to_string(); + let second = expand(declaration).expect("expands").to_string(); + if first != second { + let at = first + .bytes() + .zip(second.bytes()) + .position(|(left, right)| left != right) + .unwrap_or_else(|| first.len().min(second.len())); + let start = at.saturating_sub(120); + let first_end = (at + 240).min(first.len()); + let second_end = (at + 240).min(second.len()); + panic!( + "expansions first differ at byte {at}\nfirst: {}\nsecond: {}", + &first[start..first_end], + &second[start..second_end], + ); + } + } +} + +/// The generated table carries its own declaration. +#[cfg(test)] +mod schema_const { + use proc_macro2::{TokenStream, TokenTree}; + use quote::quote; + use worktable_dsl::Schema; + + use super::expand; + + /// Pull the string out of `pub const : &str = "..";` in generated code. + fn baked_schema(generated: TokenStream, const_name: &str) -> String { + let mut trees = generated + .into_iter() + .skip_while(|tree| !matches!(tree, TokenTree::Ident(ident) if ident == const_name)); + assert!(trees.next().is_some(), "no `{const_name}` const in the generated code"); + for tree in trees { + if let TokenTree::Literal(literal) = tree { + let text = literal.to_string(); + return syn::parse_str::(&text).expect("a string literal").value(); + } + } + panic!("`{const_name}` has no value"); + } + + #[test] + fn a_persisted_table_carries_the_declaration_it_was_built_from() { + let declaration = quote! { + name: Account, + version: 3, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + nickname: String optional, + }, + indexes: { email_idx: email unique }, + queries: { update: { Nickname(nickname) by id } } + }; + + let baked = baked_schema(expand(declaration.clone()).expect("expands"), "ACCOUNT_SCHEMA"); + + assert_eq!( + Schema::parse(&baked).expect("the baked text parses"), + Schema::from_tokens(declaration).expect("the declaration parses"), + "the baked declaration is not the one the table was generated from" + ); + } + + #[test] + fn an_in_memory_table_carries_it_too() { + // A designer reading a crate wants every table, not only the persisted + // ones, and the const costs a string either way. + let declaration = quote! { + name: Price, + partition_by: symbol_id: u16, + columns: { exchange_id: u8 primary_key, bid: f64 }, + }; + + let baked = baked_schema(expand(declaration.clone()).expect("expands"), "PRICE_SCHEMA"); + + assert_eq!( + Schema::parse(&baked).expect("the baked text parses"), + Schema::from_tokens(declaration).expect("the declaration parses"), + ); + } + + #[test] + fn the_baked_text_is_a_declaration_the_macro_accepts() { + // Which is what makes it usable as the old table definition a + // migration would otherwise need kept by hand. + let declaration = quote! { + name: Regenerated, + persist: true, + columns: { id: u64 primary_key autoincrement, payload: String }, + indexes: { payload_idx: payload unique } + }; + + let baked = baked_schema(expand(declaration).expect("expands"), "REGENERATED_SCHEMA"); + let reparsed: TokenStream = syn::parse_str(&baked).expect("tokenises"); + expand(reparsed).expect("the baked declaration expands"); + } +} diff --git a/docs/TODO.md b/docs/TODO.md index fa6493b5..8fb6bb13 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -3,58 +3,105 @@ What is known to be unfinished, and enough context to act on it without the conversation it came from. Ordered by whether it blocks a release. -Last reviewed 2026-09-01, against `feat/ps-reclaim-beta16`. +Last reviewed 2026-09-04, against `release/beta17`. -## Blocking beta.16 +## Closed, and how -### Publish `ps-reclaim` 0.1.1 and bump the pin +### `ps-reclaim` 0.1.1 is published and pinned -`Cargo.toml` pins `ps-reclaim = "0.1.0"`, and 0.1.0 has two defects, both fixed -on `pathscale/ps-reclaim` master at `d207d06` and **not yet published**: +Was blocking beta.16 and did not stop it. 0.1.0 had two defects, both fixed on +`pathscale/ps-reclaim` master at `d207d06` and published as 0.1.1: -- **`Guard` is `Send`** in 0.1.0, and its own documentation says it is not. - Nothing enforced it: every field was `Send`, so the auto trait applied. - Dropping a sent guard stores `NO_DOMAIN` into the *originating* thread's pin - slot while that thread may still be reading, and decrements the wrong - thread's `DEPTH`. Both are use-after-free windows. 0.1.1 makes it `!Send` by - construction (the packed field is a raw pointer) with a `compile_fail` - doctest holding it. +- **`Guard` was `Send`**, and its own documentation said it was not. Nothing + enforced it: every field was `Send`, so the auto trait applied. Dropping a + sent guard stores `NO_DOMAIN` into the *originating* thread's pin slot while + that thread may still be reading, and decrements the wrong thread's `DEPTH`. + Both are use-after-free windows. 0.1.1 makes it `!Send` by construction (the + packed field is a raw pointer) with a `compile_fail` doctest holding it. - **`Guard` was three words** (`&Domain`, `&'static Participant`, `usize`) against `crossbeam-epoch`'s one pointer. `partition_ref` returns a `PartRef { guard, &T }` per call, so it paid that size on every lookup: 16 bytes became 32. 0.1.1 packs the entry into the participant pointer's spare alignment bits (`Participant` is `#[repr(align(128))]`) and is one word. -0.1.1 removes `Guard::domain` and `Guard::retire`. That is a breaking change -and would normally take a minor bump, but `worktable` is the only consumer and -it already calls `Domain::retire` directly, which exists in both. Both call -sites here already moved to `self.epoch.retire(...)`, which -works against 0.1.0 and 0.1.1 alike, so this repo is ready for the bump. +`pathscale/ps-reclaim` also had no workflows at all, which is why both defects +were found by hand from a downstream measurement rather than by a check. It now +runs build, test, doctests, fmt, clippy and Miri under strict provenance, and +publishes from master, on Ubicloud runners. -Publishing is irreversible and needs a human. After it lands: -`ps-reclaim = "0.1.1"` in `Cargo.toml`, then re-run the benchmarks below. - -### Re-measure the partition regression - -The claim in `82bfdf6` that `crossbeam-epoch` and `ps-reclaim` are "within -noise of each other (3.37 against 3.42)" is disputed by an interleaved A/B run: -`partition_ref` measured 3.16-3.35 ns on beta.15 and 3.60-3.68 ns here, in both -passes, with the two cleanest samples of the run showing the widest gap. The -guard size above is the likely cause and the reason 0.1.1 exists. - -Not yet confirmed. Every attempt so far ran on a machine at load 4 to 24, where -the control (`partition_lookup/cached_handle`, a pure dereference that cannot -differ between versions) varied 3.6x. Re-run on a quiet box, alternate the tree -order between passes, and reject the run if the control moves more than a few -percent. Full brief, including exact commits and setup, at -`~/code/wt-beta16-perf-brief.md`. - -### CI has never run on this branch +### CI has run on this branch `.github/workflows/rust.yml` triggers on push to master and on pull requests -targeting master. There is no PR, so every green result is somebody's laptop. -`./scripts/ci-local.sh` passes all five jobs (2816 test results, 0 failures) as -of `8699b07`. +targeting master, so a branch with no PR is only ever checked on somebody's +laptop. PR #82 opened, all six jobs passed, and master has been green since. + +### The `partition_ref` regression is measured and accepted for beta.17 + +The beta.17 validation grid confirmed a small microbenchmark regression, and +it is explicitly not a beta.17 release blocker. Three rotated-order passes, +using local WorkTable trees for beta.13 (`48f250f`), beta.15 (`e4dcfdf`) and +the beta.17 candidate, measured the median `partition_ref` cost as 0.69 ns, +3.09 ns and 3.35 ns respectively. Beta.17 is 8.3% slower than beta.15, and its +clean range (3.24-3.38 ns) did not overlap beta.15's (3.01-3.18 ns). + +This is isolated to a nanosecond-scale primitive benchmark. Regressions of +roughly 1 ns to 3 ns can be stable without producing an application-level +regression once the primitive is composed into work several times larger. The +routed read `partition_ref_then_select` improved from 30.07 ns in beta.15 to +27.67 ns in beta.17 (-8.0%), and the unchanged `cached_handle` and `contains` +controls moved about 2%. CRUD was otherwise flat or faster. Do not describe +the bare `partition_ref` result as noise, but treat it as implementation +telemetry rather than a release gate: application-level impact was better. + +The older beta.16 measurements below remain invalid and must not be used: one +set ran under severe host load, and another predates the fix for a benchmark +arm labelled `pinned_get` that was actually calling `partition_ref`. + +The beta.17 grid also found and fixed a benchmark defect: insertion arms called +the now-async `insert` without awaiting the returned future. Corrected insert +results were rerun across all three local trees before drawing conclusions. + +### WT DSL expansion and trailing commas are deterministic + +The trailing-comma parser fix was already present on the beta.17 branch and is +covered for `config`, `delete`, `in_place`, block order, and the no-comma form. + +Expansion is now deterministic too. `columns_map`, query maps, and generated +unique-type sets preserve declaration order with `IndexMap`/`IndexSet`. The +previously ignored repro is a normal release-gating test and includes multiple +columns, indexes, update queries, and delete queries. This matters beyond +cosmetics because generated enum variant order can determine discriminants. + +### The local beta.17 release-delta gate is green + +The complete evidence and beta.13/beta.15/beta.17 performance grids are in +`docs/beta17-validation.md`. The exact local CI matrix passes in default, +`versioned-row-publication`, and all-feature configurations. The independent +benchmark workspace also passes its all-target test-mode gate against the +local WorkTable/WTI/DataBucket/ps-reclaim stack. + +The placeholder ignored S3 probe still rejects its literal `test` endpoint +before I/O, but configured runtime coverage is now complete through the local- +source support.cafe consumer. Beta.17 downloaded the live Tigris dataset, +recovered three legacy tables with missing secondary entries, rebuilt them into +a rollback-safe prefix, strict-loaded all six tables, performed an S3-backed +mutation and reloaded it after restart. ACME, HTTPS and WebSocket startup also +passed on Fly. Full evidence is in `docs/beta17-validation.md`. + +## Blocking beta.17 + +There is no unresolved locally testable correctness or application-level +performance blocker. Branch/PR CI must still pass on the exact release commit +before publishing. + +### Decide what happens to beta.16 on crates.io + +1.0.0-beta.16 is published and resolves `ps-reclaim ^0.1.0`, so a lockfile +written before 0.1.1 landed keeps the `Send` guard. A fresh resolve now picks +0.1.1 on its own, since the requirement was always a caret and never an exact +pin. The open question is whether to yank ps-reclaim 0.1.0, which is what makes +the unsound version unreachable rather than merely unpreferred, and whether to +yank beta.16 once beta.17 supersedes it. ## Not blocking, but wrong today @@ -76,42 +123,6 @@ to: a trie with short reads reaches quiescence constantly, which is the exact property that makes `seize` wrong for this crate, where `select` holds a read guard. -### Expansion is not deterministic - -Expanding one `worktable!` declaration twice in one process produces different -code. Several generators iterate `Columns::columns_map`, a `std::collections:: -HashMap`, to emit ordered constructs: the `RowFields` and `AvaiableTypes` enums -among them. `RandomState` seeds each map instance differently, so the variant -order differs between two expansions and can differ between two compilations of -the same source. - -Recorded as an ignored test, `codegen/src/worktable/mod.rs`, module -`generator_determinism`. Run it with `cargo test -p worktable_codegen -- ---ignored`; it fails on unmodified code. - -The fix is to make `columns_map` an `IndexMap` built in declaration order, -which `field_positions` already records. A trial produced 13 mechanical compile -errors (`&Ident` not satisfying `Equivalent`, and explicit `HashMap` -annotations). It changes the generated code of every table, so it wants -reviewing on its own. - -**Open question worth answering first:** both enums derive `rkyv::Archive` and -`Serialize` with `#[repr(C)]`. If either discriminant reaches disk, this is a -persistence hazard rather than a cosmetic one. - -### Trailing commas are accepted inconsistently - -`parse_updates`, `parse_indexes` and `parse_queries` consume a comma after -their block; `parse_deletes`, `parse_in_place` and `parse_configs` do not. So -`config: { .. },` reaches the top-level dispatch as a `,` token and dies as -"Unexpected identifier", and the same for a `delete` block followed by another. -`config` happens to be written last everywhere in this repo, which is why -nobody has hit it. - -Three `try_parse_comma()` calls, strictly more permissive. While there, make -the "Unexpected identifier" arms name the token they actually saw: a `,` -reported as an unexpected identifier is what makes this cost an afternoon. - ### Persistence stalls on a primary index event gap, rarely One run of `cargo test --workspace --all-targets --all-features` failed with diff --git a/docs/beta17-validation.md b/docs/beta17-validation.md new file mode 100644 index 00000000..231f9101 --- /dev/null +++ b/docs/beta17-validation.md @@ -0,0 +1,275 @@ +# Beta.17 release validation + +Validated 2026-09-04. The candidate is the local `release/beta17` working +tree based on `decdd9f`, including the uncommitted validation fixes described +below. No beta.17 WorkTable crate was resolved from crates.io. + +## Local provenance + +Both the WorkTable workspace and the independent `wt-benchmarks` workspace +were inspected with `cargo tree --offline`. They resolve this stack: + +| crate | local source | revision | +| --- | --- | --- | +| worktable 1.0.0-beta.17 | this working tree | `decdd9f` plus validation changes | +| worktable_codegen 1.0.0-beta.17 | `codegen/` in this working tree | same tree | +| worktable_dsl 1.0.0-beta.18 | `dsl/` in this working tree | same tree | +| WorkTablesIndex 0.0.10 | `../WorkTablesIndex` | `c10c82a` | +| data_bucket 0.5.6 | `../DataBucket` | `fa0f8a9` | +| ps-reclaim 0.1.2 | `../ps-reclaim` | `5ee0523` | + +The local patches are deliberately marked temporary. They are validation +plumbing, not publishable dependency configuration. + +## Release-delta coverage + +Beta.17 is not a small changelog. Relative to the local beta.15 point +`e4dcfdf`, it contains about 70 non-merge commits touching 177 files +(11,100 insertions and 1,461 deletions). The release claims were grouped and +checked as follows. + +| release area | evidence exercised | result | +| --- | --- | --- | +| Async `insert`, guarded upsert, `insert` versus `insert_many` | normal/all-feature integration suites; insert-many persistence/reload and rejection tests; same-key churn, insert-publication/delete race, and cancellation tests; corrected CRUD and AgentCode benchmarks | pass | +| Batched async deletes and operation-wide activity | delete-many correctness, lock ordering, deadlock, persistence durability and generated-query tests; bulk-lease microbenchmark; reactive-vacuum stress | pass | +| Three interchangeable index backends | backend contract, signed Arctic key/range, native range, iterator, unique/non-unique and key-width tests; WTI/Arctic/Congee PGO, YCSB, concurrency and vacuum grids | pass | +| Persistence ordering, recovery and failure handling | logical-event ordering, reversed delivery, batch collection, torn WAL, checkpoint, corrupt-index, operation failure, migration, reload and bulk-delete suites | pass | +| Reclamation, partition pinning and page/link reuse | default and versioned-row-publication suites; ABA/read-grace, retirement backlog, partition create/remove/read races; CRUD/partition grid | pass | +| Reactive vacuum and exact memory return | deterministic first-batch exclusion; epoch/check-recheck tests; all-backend foreground stress, invariants, no-row-loss tests and three 10-second soaks | pass | +| Extracted WT DSL, schema IR, scanner, CLI, check, diff, emitter and UML | every DSL target with all features; malformed/adversarial corpus; repository round trip; CLI byte stability; doctests | pass | +| Macro/schema compatibility and deterministic generation | schema-const and emitted-declaration tests; backend/generator validation; composite-key ordering; strengthened normal (not ignored) expansion determinism test | pass after fix | +| Release graph and workflows | Cargo tree proves one local WorkTablesIndex/DataBucket identity; exact local CI script passed every feature/build/test/clippy leg | pass locally | +| S3 runtime integration | local-source support.cafe musl build; live Tigris download, recovery/rebuild, strict reload, S3 write/reload, ACME startup and Fly runtime smoke | pass | +| Registry publication/install | intentionally not exercised because beta.17 is not published and this validation must use local crates | post-publish/admin only | + +## WT DSL findings and fixes + +The trailing-comma fix already on the branch passes five focused cases: +`config`, `delete`, `in_place`, reordered blocks, and the existing no-comma +form. + +Validation found a separate real beta.17 defect: macro expansion could vary +because columns, query maps, and generated type sets used randomized +collections while emitting ordered Rust constructs. They now preserve +declaration order with `IndexMap`/`IndexSet`. The old ignored reproduction is +now an ordinary release-gating test and covers multiple columns, two indexes, +two updates and two deletes. The schema model's public ordering tests were +updated to assert the same declaration-order contract. + +All DSL targets and features pass: parser/check diagnostics, scanner, schema +round trip, diff planning, DSL emission, CLI, JSON, spans, UML, malformed-input +no-panic corpus and macro re-expansion. + +## Downstream async compatibility fix + +Compiling support.cafe against the local beta.17 train exposed a real generated +API defect: unique and brute-force delete futures retained a non-`Send` row +read guard across an `await`, so they could not be called from the service's +`async_trait` handlers. Generated deletes now extract or collect primary keys +before awaiting. WTI, Arctic and Congee regression tests prove the generated +unique and non-unique delete futures are `Send`, and the full WorkTable and +support.cafe all-target/all-feature gates pass with warnings denied. + +## Configured S3 and support.cafe runtime + +The production support.cafe dataset was tested on Fly with an image built from +the local WorkTable, WorkTablesIndex, DataBucket, ps-reclaim and honey-id-types +checkouts. No beta.17 release artifact was used. + +Strict beta.17 load correctly rejected latent missing secondary-index entries +for primary key zero in `app_member`, `chat_session` and `support_message`. +Recovery mode loaded and validated all existing entries and authoritative rows, +showing this was old index divergence rather than a byte-format incompatibility. +The support.cafe migration utility rebuilt every table into the separate +`db-beta17-20260904-0216` prefix while leaving the original `db` prefix intact. +Strict beta.17 reload then passed with row counts 4, 4, 9, 19, 0 and 7. + +The deployed service subsequently downloaded all six rebuilt tables, performed +its normal persisted admin-role update, restarted, and strict-loaded the result +again. ACME completed, all four configured bots were restored, HTTPS returned +200, and a public HTTP/1.1 WebSocket upgrade returned 101. This covers actual +S3 download, mutation/upload, and restart/reload on the production target. + +## Vacuum result + +The stress workload starts with 200,000 rows at 50% fragmentation, then keeps +continuous upsert, missing-key reinsert and delete pressure for three seconds. +Each backend/mode cell ran six times in rotated order. `off`, normal +`reactive`, and deliberately `unpaced` vacuum modes were compared. + +| backend | off operations, median | reactive operations, median | reactive delete p99 | unpaced delete p99 | unpaced sweeps during load | +| --- | ---: | ---: | ---: | ---: | ---: | +| WTI | 692,198 | 675,781 | 1,541 ns | 4,417 ns | 59 | +| Arctic | 696,110 | 699,891 | 1,334 ns | 2,625 ns | 70 | +| Congee | 695,082 | 696,615 | 1,375 ns | 2,625 ns | 20 | + +The small WTI operations-count difference is non-actionable on this host: the +repeated ranges overlap and the other two backends are flat. The unpaced +positive control repeatedly raises foreground tail latency, proving that this +load is strong enough to expose vacuum interference. + +Zero completed sweeps during load is not used as proof of zero vacuum work. +A deterministic test holds a mutation open and asserts that vacuum cannot +process its first batch. The reactive path checks both live mutations and an +epoch derived from completed mutation tickets. The check/recheck quiet buffer +therefore observes work that begins and ends between polls. Bulk delete/update +operations hold one operation-wide activity lease across chunk gaps without +holding a row or stripe lock. + +The bulk lease costs about 3.57 ns per whole operation. Against the existing +one-row `delete_many` measurement of about 7.28 us, that is roughly 0.05%. + +After foreground load, reactive vacuum reaches the independently packed +control exactly: 196 in-use pages and 100,000 live rows in all 18 of 18 +backend/repetition cells. `off` retains 392 pages. This satisfies the 100% +reclamation requirement, not merely an approximate percentage. + +## Beta.13 / beta.15 / beta.17 performance grid + +Every version is a local WorkTable tree: beta.13 `48f250f`, beta.15 `e4dcfdf`, +and the beta.17 candidate. Three passes rotated the version order +(`13/15/17`, `15/17/13`, `17/13/15`). Values below are medians of those three +passes; ranges are retained in the Rust-generated raw summaries. PGO, +concurrency and YCSB exercise all three backends. AgentCode currently models +the default WTI backend; its catalog entry has been corrected to stop claiming +otherwise. + +The PGO, concurrency and YCSB benchmark sources are byte-identical across the +old/new benchmark snapshots. The only compatibility adjustment elsewhere was +awaiting beta.17's newly async writes. An earlier CRUD run that dropped the +insert future was discarded and rerun correctly. + +### Concurrent mixed workload + +Elapsed milliseconds for 128,000 operations; lower is better. + +| backend | writes | beta.13 | beta.15 | beta.17 | beta.17 vs beta.15 | +| --- | ---: | ---: | ---: | ---: | ---: | +| WTI | 0% | 68.027 | 63.573 | 65.405 | +2.9% | +| WTI | 10% | 81.787 | 81.270 | 84.145 | +3.5% | +| WTI | 50% | 159.120 | 155.230 | 163.190 | +5.1% | +| Arctic | 0% | 64.932 | 60.098 | 61.243 | +1.9% | +| Arctic | 10% | 76.313 | 78.206 | 81.476 | +4.2% | +| Arctic | 50% | 149.500 | 146.500 | 150.150 | +2.5% | +| Congee | 0% | 68.173 | 61.102 | 61.114 | +0.0% | +| Congee | 10% | 78.071 | 77.822 | 80.148 | +3.0% | +| Congee | 50% | 164.030 | 154.720 | 152.170 | -1.6% | + +Most repeated ranges overlap. The largest median movement is WTI at 50% +writes (+5.1%, with non-overlapping ranges), small enough to document rather +than chase in isolation given the composed application results below. + +### PGO publish and retire + +The complete publish-plus-retire phase is the application operation. Elapsed +milliseconds at width 12,288; lower is better. + +| backend | beta.13 | beta.15 | beta.17 | beta.17 vs beta.15 | +| --- | ---: | ---: | ---: | ---: | +| WTI | 49.535 | 52.398 | 47.935 | -8.5% | +| Arctic | 27.963 | 30.377 | 29.457 | -3.0% | +| Congee | 34.246 | 36.489 | 35.624 | -2.4% | + +The fixed-work control is stable (1.410/1.429/1.426 ms). Isolated `retire` +is slower in beta.17 than beta.15 (WTI +25.6%, Arctic +95.6%, Congee +114.5%), +but isolating it omits the publish work it completes. The paired application +phase is faster on every backend, so the isolated result is retained as +implementation telemetry rather than a release blocker. + +The PGO accumulate phase is WTI -9.2%, Arctic +11.8% and Congee -9.6% against +beta.15. Arctic and Congee ranges are noisy/overlapping; WTI's improvement is +clean. The array control is -1.0%. + +### YCSB A/B/C/F + +Elapsed milliseconds for the fixed 50,000-operation sample; lower is better. +Criterion calls this group `throughput`, but its time estimate is duration, +not operations/second. + +| workload | backend | beta.13 | beta.15 | beta.17 | beta.17 vs beta.15 | +| --- | --- | ---: | ---: | ---: | ---: | +| A | WTI | 94.355 | 72.066 | 75.473 | +4.7% | +| A | Arctic | 88.969 | 67.412 | 70.165 | +4.1% | +| A | Congee | 90.785 | 67.647 | 70.741 | +4.6% | +| B | WTI | 19.350 | 20.295 | 19.859 | -2.1% | +| B | Arctic | 17.759 | 16.840 | 17.125 | +1.7% | +| B | Congee | 17.829 | 18.007 | 17.306 | -3.9% | +| C | WTI | 7.029 | 5.704 | 7.386 | +29.5% | +| C | Arctic | 6.056 | 5.206 | 4.893 | -6.0% | +| C | Congee | 6.960 | 5.114 | 5.356 | +4.7% | +| F | WTI | 85.543 | 74.406 | 78.410 | +5.4% | +| F | Arctic | 83.210 | 71.450 | 69.031 | -3.4% | +| F | Congee | 84.658 | 71.905 | 70.378 | -2.1% | + +Workload A's first three passes pointed consistently 9.7-13% slower despite +overlapping ranges, so it received three additional balanced-order passes. +Across all six its gap contracts to 4.1-4.7%, and every backend range still +overlaps beta.15. WTI/C is also noisy and overlapping. Read p99 ranges are +broader still and do not support a magnitude claim. No YCSB cell is treated as +proof of an improvement or regression without the repeated range. + +### AgentCode generation + +The full 14,400-row fixture ran three times in rotated version order. Complete +persisted cost includes caller acceptance plus the required durability drain. +Lower is better. + +| operation | beta.13 ns/row | beta.15 ns/row | beta.17 ns/row | beta.17 vs beta.15 | +| --- | ---: | ---: | ---: | ---: | +| persisted one-at-a-time, complete | 5,853.75 | 5,878.55 | 4,985.97 | -15.2% | +| persisted `insert_many`, complete | 6,876.02 | 5,867.25 | 5,041.09 | -14.1% | +| in-memory one-at-a-time | 1,101.44 | 1,080.08 | 1,112.13 | +3.0% | +| in-memory `insert_many` | 890.60 | 892.88 | 890.25 | -0.3% | +| persisted generation readback | 125.32 | 120.31 | 120.99 | +0.6% | + +The async-insert path is flat in memory and materially better once the real +durability drain is included. + +### CRUD and partition checks + +Seventeen common WorkTable CRUD/partition cells ran in three rotated passes. +Corrected simple insert is 689.76 ns in beta.17 versus 696.32 ns in beta.15. +Full-featured insert is 2,810.5 ns versus 2,637.0 ns, with overlapping ranges. +Simple and full-featured delete improve 35.3% and 18.6% respectively. + +The bare `partition_ref` primitive remains an accepted non-blocker: 3.35 ns +in beta.17 versus 3.09 ns in beta.15, with clean non-overlapping ranges. The +composed `partition_ref_then_select` improves 8.0% (27.67 ns versus 30.07 ns). +This is documented as nanosecond-scale implementation telemetry, not dismissed +as noise and not allowed to override the better application-level result. + +## Gates run + +- `scripts/ci-local.sh`: all jobs passed on Rust 1.97.1. This includes default, + `versioned-row-publication`, and all-feature workspace/all-target builds and + tests, plus default and all-feature clippy with warnings denied. +- Stronger direct all-feature/all-target run: the main integration target + passed with zero failures; main library 284 passed; codegen 57 passed; every + DSL target passed. +- Explicit ignored run: all three 10-second backend vacuum soaks and the + persistence concurrency test passed. The fifth test is the unconfigured S3 + probe described above. +- All-feature doctests: four passed and one example intentionally ignored. +- WorkTable format check passed. The benchmark workspace's complete all-target + clippy pass and the touched summarizer tests pass with warnings denied. +- The independent `wt-benchmarks` all-target test-mode gate passed against the + local stack: 29 unit/invariant tests plus smoke execution of every Criterion + target, including all-backend KV/JSON, deletes, concurrency, PGO and YCSB. + +## Remaining release TODO + +1. Review and commit the WorkTable and `wt-benchmarks` changes, including the + WT DSL determinism fix, async benchmark correction, vacuum fix/stress and + validation documentation. +2. Write the beta.17 release notes from the coverage matrix above. The root + `CHANGELOG.md` still stops at 0.4.1 and does not describe the 1.0 beta line. +3. Run normal branch/PR CI on that committed candidate. Local CI is green, but + it is not a substitute for the repository's clean checkout and runner. +4. Decide the release-administration question in `docs/TODO.md`: whether to + yank ps-reclaim 0.1.0 and beta.16 after beta.17 supersedes them. +5. Publish the local dependency train in the required order, then perform the + post-publication consumer-resolution smoke test. Do not replace any of the + pre-release validation above with registry packages. + +No unresolved beta.17 correctness or application-level performance blocker +was found in the locally testable release surface. diff --git a/docs/crate.md b/docs/crate.md index f697626e..993dc840 100644 --- a/docs/crate.md +++ b/docs/crate.md @@ -8,6 +8,7 @@ does not provide multi-table transactions or multi-process access. ## In-memory quick start ```rust +# fn main() { futures::executor::block_on(async { use worktable::prelude::*; use worktable::worktable; @@ -27,15 +28,17 @@ let row = UserRow { id: 1, email: "person@example.com".to_owned(), }; -table.insert(row.clone()).unwrap(); +table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(1), Some(row.clone())); assert_eq!(table.select_by_email("person@example.com".to_owned()), Some(row)); +# }); } ``` String and tuple primary keys accept borrowed forms, so callers do not need to write an explicit clone merely to perform a lookup or delete. ```rust +# fn main() { futures::executor::block_on(async { use worktable::prelude::*; use worktable::worktable; @@ -55,8 +58,9 @@ let row = AccountRow { account: key.1.clone(), enabled: true, }; -table.insert(row.clone()).unwrap(); +table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(&key), Some(row)); +# }); } ``` ## Persistence contract diff --git a/docs/durability-visibility-proposal.md b/docs/durability-visibility-proposal.md new file mode 100644 index 00000000..0a6fcce7 --- /dev/null +++ b/docs/durability-visibility-proposal.md @@ -0,0 +1,203 @@ +# Knowing when a write is actually durable + +**Status:** proposal. Nothing here is implemented. + +Today a mutation returns `Ok` when the in-memory change is done and its +persistence operation was accepted by the queue. That is the top row of the +guarantee matrix in [`persistence-durability.md`](persistence-durability.md), +and it is deliberately weak: **it explicitly does not mean the bytes reached the +OS, the disk, or S3.** + +The gap is not that the boundary is undocumented. It is that a caller who needs +a stronger guarantee has no way to ask for one, and no way to observe when it +arrives. The only signal available is `wait_for_ops()`, which is whole-queue +rather than per-operation, polls on a one second timer, and answers a question +almost nobody asked ("is the worker idle right now?") rather than the one they +did ("is *my* write safe yet?"). + +This proposes the ladder, the API to observe it, and the background worker that +advances it. + +## 1. The ladder + +Four stages. A write climbs them in order, and each one survives strictly more +than the last. + +| Stage | Reached when | Survives | Today | +| --- | --- | --- | --- | +| **Accepted** | `apply_operation` returned `Ok`; the op is in the in-process queue | nothing; a `SIGKILL` here loses the write | observable, it is the return value | +| **Written** | the engine issued this op's writes and `flush()` returned | process crash, `SIGKILL`, panic | **reached but not observable per operation** | +| **Synced** | `sync_data` (or `F_FULLFSYNC`) returned for every file the op touched | power loss, OS crash | **does not exist for the data path** | +| **Uploaded** | the configured S3 path reported success | loss of the machine | partially, and not per operation | + +Two things in that table are worth saying plainly. + +**"Written" is already reached, just invisible.** `save_data`, `save_batch_data` +and `save_info` each end in `self.data_file.flush().await?`. For +`tokio::fs::File` that pushes the userspace buffer into the OS page cache +through the blocking pool. So the bytes genuinely do survive a process crash +once the engine has drained that operation. Nobody can currently find out when +that happened for a given write. + +**"Synced" does not exist.** `sync_data()` appears exactly once in the entire +crate, in `art_index.rs`, on an index file *creation* path. No data write, no +batch, and no info-page write ever syncs. Flushing to the page cache is not +durability against power loss, and the guarantee matrix already says so. So this +feature cannot honestly report "on disk" until the sync is added. Reporting is +the smaller half of the work. + +## 2. Watermarks, not per-operation futures + +Every operation already carries an `OperationId`, which is a UUID v7 and +therefore time-ordered, and the engine drains the queue in that order. So the +state of the whole system at any moment is two monotonically increasing ids: +the highest operation that has been written, and the highest that has been +synced. Anything at or below a watermark has reached that stage. + +That matters because it makes the mechanism O(1) rather than O(pending). No +per-operation registry, no map of waiting futures keyed by id, no bookkeeping +that grows with queue depth. Two atomics and a `Notify`. + +```rust +/// How far a write has got. Ordered: each stage implies the ones before it. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum DurabilityStage { + Accepted, + Written, + Synced, + Uploaded, +} + +/// The highest operation id to have reached each stage. +#[derive(Copy, Clone, Debug)] +pub struct Watermarks { + pub written: Option, + pub synced: Option, + pub uploaded: Option, +} +``` + +## 3. The API + +The important design decision is that **no existing signature changes**. + +Because ids are time-ordered, a caller does not need to know their own operation +id. They need a mark taken *after* their write, and to wait for that mark. Waiting +for a slightly later mark than strictly necessary is always safe: it can wait a +little longer, never report durability too early. + +```rust +let pk = table.insert(row)?; // unchanged, still returns immediately +let mark = table.durability_mark(); // "everything queued up to now" + +// Block until it is on the device, or the worker fails. +mark.wait(DurabilityStage::Synced).await?; +``` + +For callers that want to react rather than await: + +```rust +let mut watch = table.durability_watch(); +while watch.changed().await? { + let marks = watch.watermarks(); + metrics.gauge("wt.unsynced_ops", pending_below(marks.synced)); +} +``` + +And the cheap non-blocking question, for a status endpoint or a health check: + +```rust +match table.stage_of(mark) { + DurabilityStage::Accepted => /* still only in the queue */, + DurabilityStage::Written => /* safe against a process crash */, + DurabilityStage::Synced => /* safe against power loss */, + DurabilityStage::Uploaded => /* safe against losing the machine */, +} +``` + +A `Mark` is a plain `OperationId` plus a handle to the watermarks. It is `Copy`, +cheap to take, and can be stored, sent across threads, or persisted in an +application's own record of what it has acknowledged to *its* callers. + +For the batch case, one mark after a batch covers the whole batch, which is the +natural granularity anyway. + +## 4. The background worker + +`Written` needs no new thread. The engine task already knows when it finished an +operation; it publishes the id and notifies. That is a store and a `notify_waiters` +per drained batch, not per operation. + +`Synced` needs the new worker, and its whole job is **group commit**. + +An `fsync` costs on the order of a millisecond, against a 761 ns insert. Syncing +per operation would make writes three orders of magnitude slower, so the syncer +must batch: one `fsync` covers every write issued before it, so a hundred waiters +arriving during one sync are all satisfied by that single sync. + +```rust +pub enum SyncPolicy { + /// Today's behaviour, and the default. Never syncs; `Synced` never advances + /// and waiting on it returns an error rather than hanging. + Never, + /// Sync when someone is waiting, coalescing all current waiters into one. + OnDemand, + /// Sync at most this often, whether or not anyone is waiting. Bounds the + /// window of writes that a power loss can take. + Interval(Duration), + /// Sync after every drained batch. Slowest and strongest. + EveryBatch, +} +``` + +The loop is small: + +1. wait for either a sync request or the interval tick; +2. read the current `written` watermark, call it `w`; +3. `sync_data()` every open file (data, info, primary index, secondary indexes); +4. publish `synced = w` and notify. + +Step 2 before step 3 is the part to get right: the watermark captured is the one +from *before* the sync started, because writes landing during the sync are not +covered by it. Publishing the later value would report durability that was not +achieved, which is the only genuinely dangerous bug this feature can have. + +**On macOS, `fsync` is not enough.** It returns once the data reaches the drive, +without waiting for the drive to flush its own write cache. Only +`fcntl(F_FULLFSYNC)` does that, and it is considerably slower. If `Synced` is to +mean what it says on a developer's laptop, the syncer needs `F_FULLFSYNC` there, +and the policy should probably let a caller choose the weaker one knowingly. + +## 5. What this does not do + +It does not make WorkTable crash-atomic, and it must not be described as if it +did. A batch still spans data, primary-index and secondary-index files with no +journal, so a power loss mid-batch can still tear across them. `Synced` +answers "are these bytes on the device", not "is this table a consistent +generation". The load-time audit in `persistence-durability.md` remains the +thing that catches a torn batch, and full-directory restore remains the +recovery path. + +Being able to observe durability makes the existing boundary usable. It does not +move it. + +## 6. Order of work + +1. **Publish the `written` watermark.** No new thread, no format change, no cost + on the write path. Delivers the process-crash guarantee that is already being + met but cannot be observed. This is most of the value for the least risk. +2. **`Mark`, `stage_of`, `wait`, and the watch.** Pure API over step 1. +3. **Add `sync_data` to the space layer** and the syncer task with `SyncPolicy`, + defaulting to `Never` so nothing existing changes speed. +4. **Measure it.** Insert throughput under each policy, and the latency of + `wait(Synced)` under concurrent load, on the wt-benchmarks harness. `EveryBatch` + is expected to be dramatically slower; the number should be published rather + than guessed at, because it is the number that tells someone which policy to + pick. +5. **`F_FULLFSYNC` on macOS**, and a documented statement of what `Synced` means + per platform. +6. **`Uploaded`**, last, and only if S3 users ask. It is a different failure + domain and a much weaker ordering story. + +Steps 1 and 2 are additive and could ship in a patch release. Step 3 changes +what the crate does to your disk and wants its own release and its own tests. diff --git a/docs/vacuum-design-directions.md b/docs/vacuum-design-directions.md new file mode 100644 index 00000000..1daed673 --- /dev/null +++ b/docs/vacuum-design-directions.md @@ -0,0 +1,333 @@ +# Vacuum: what is wrong, and what the literature offers + +Written 2026-09-03, after a session spent fixing four bugs in this area. It is +a design note, not a plan: nothing here is decided. + +## The observation that matters + +Four defects were fixed in reclamation this week. Written out, they are one +defect: + +| what was reported | what it was | +| --- | --- | +| a page and its inner links handed to two allocators | an index entry resolving to reused storage | +| `value_idx[792]` returning a row holding `2703` | an index entry resolving to reused storage | +| `upsert` returning `PrimaryUpdateTry` | a primary entry resolving to reused storage | +| a page reclaimed with a live row on it | an index entry resolving to reused storage | + +They are not four bugs that happened to cluster. They are one property of the +design, surfacing wherever storage moves or is reused. + +## The property + +**Indexes store physical addresses.** `pk_map` maps a primary key to an +`OffsetEqLink`; `reverse_pk_map` maps back; every secondary index maps a value +to a `Link`, which is `(page_id, offset, length)`. + +So a row's identity *is* its location. Two consequences follow, and everything +above is one of them: + +1. **Relocation is O(indexes).** Moving one row rewrites the primary entry, the + reverse entry, and one entry per secondary index, each under that row's + lock. `move_candidate_if_current` does exactly this, once per row. +2. **Reuse is a race with every reader.** A freed link is a name that can be + handed to a different row. Any entry still holding that name is now wrong, + and correctness depends on every removal path having run first. + +Vacuum is where both bite at once, because it relocates *and* frees. + +## What vacuum costs today + +Measured 2026-09-03 with `wt-benchmarks`' `vacuum-stress-worktable`: three +index backends, two fragmentation levels, each run twice — once with vacuum +stopped and once with it running — with interleaved inserts and selects for two +seconds per arm. The delta between the two vacuum arms is the measurement; a +single arm says nothing, which is why every cell is run twice. + +**Superseded by the real-stack run below.** Every number in this section was +measured against *published* WorkTablesIndex 0.0.9, data_bucket 0.5.5 and +ps-reclaim 0.1.1, because the local `[patch.crates-io]` was silently not +applying: a patch does not force re-resolution when the lockfile already pins +the published versions. + +On the release stack (WorkTablesIndex 0.0.10, data_bucket 0.5.6, ps-reclaim +0.1.2) the picture at 25% fragmentation changes completely: + +| backend | frag | vacuum off | vacuum on | penalty | +| --- | --- | --- | --- | --- | +| wti | 25% | 1,363,533 | 1,347,376 | -1.2% | +| arctic | 25% | 1,552,868 | 1,569,468 | +1.1% | +| congee | 25% | 1,531,567 | 1,541,972 | +0.7% | +| wti | 60% | 1,560,569 | 820,215 | **-47.4%** | +| arctic | 60% | 1,860,372 | 914,282 | **-50.9%** | +| congee | 60% | 1,811,127 | 898,042 | **-50.4%** | + +At 25% the penalty is **within noise on all three backends**, straddling zero: +two arms report vacuum-on faster than vacuum-off, which is not physically +meaningful and is the signature of an effect below the measurement floor. On +the published stack the same cell measured 2 to 12%. The sweep is effectively +free at low fragmentation on the stack that will actually ship. + +At 60% nothing moves: still about half, on every backend. That is the case +batching did not help and still does not, and it is the one needing a +work-side bound. + +Machine load was 8.1 during this run against 4.4 for the published-stack one, +so the absolute throughputs are not comparable across the two tables. The +penalty is paired within a single run, which is why it is the column to read. + +The older published-stack numbers follow, kept only to show what the wrong +dependency graph and contention did to them. + +| backend | fragmentation | inserts, vacuum off | vacuum on | delta | p50 off | p50 on | max off | max on | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| wti | 25% | 1,572,963 | 1,390,294 | -11.6% | 1000 ns | 1083 ns | 0.16 ms | 0.15 ms | +| arctic | 25% | 1,766,008 | 1,582,590 | -10.4% | 875 ns | 917 ns | 0.20 ms | 0.28 ms | +| congee | 25% | 1,589,229 | 1,555,932 | -2.1% | 958 ns | 958 ns | 0.17 ms | 0.27 ms | +| wti | 60% | 1,606,719 | 827,927 | **-48.5%** | 1083 ns | 2208 ns | 0.13 ms | 0.12 ms | +| arctic | 60% | 1,824,133 | 897,328 | **-50.8%** | 917 ns | 2041 ns | 0.24 ms | 6.92 ms | +| congee | 60% | 1,818,984 | 869,964 | **-52.2%** | 958 ns | 2084 ns | 0.16 ms | 0.44 ms | + +The penalty at 60% is about 50% on all three backends. An earlier reading that +arctic was cheaper there (-24.7%) came from a depressed baseline: its +vacuum-off arm measured 1.23M under contention and 1.82M idle. No backend +handles vacuum better than another. + +**Batching the exclusion did not reduce the penalty**, which is the other thing +the clean run settles. At 25% the sweep still costs 2 to 12%, at 60% still +about half. Releasing the lock more often does not reduce the work of moving +rows, and at high fragmentation the work is the cost. That case needs a +work-side bound -- how much one sweep moves, or how much is moved per page +reclaimed -- not more yielding. + +Three things fall out of this, and they set the whole design. + +**The cost is not a constant.** At 25% fragmentation a sweep is between free +and 10%. At 60% it costs a quarter to half of insert throughput and doubles +median insert latency. Vacuuming early is not merely nicer, it is *cheaper by a +factor of five*, which is an argument for reacting to fragmentation rather than +waiting out an interval that lets it accumulate. + +**Retracted: "not vacuuming has a tail".** The first run showed multi- +millisecond worst-case inserts in every vacuum-off arm at 60%, 32 ms on wti and +41 ms on arctic, and this document argued from it. A clean re-run on an idle +machine does not reproduce it: every vacuum-off arm is 0.13 to 0.24 ms. Those +maxima were machine contention. The only multi-millisecond tail in the clean +run is on the vacuum-*on* side, 6.92 ms for arctic at 60%. + +The cost curve below still stands and is still the argument for reacting early. +The tail benefit was not real, and nothing should be built on it. + +**The mechanism is the exclusion, not the work.** `pop_max` takes the registry +read side with `try_read_owned().ok()?`, so while a sweep holds the write side +every insert wanting reclaimable space is turned away *immediately* and +allocates a fresh page instead. Inserts do not block on vacuum; they lose +free-space reuse for the sweep's entire duration. That is why the p50 doubles +and stays doubled, and it is what the batching below addresses. + +The rest of this section is from reading the code. + +- `defragment` holds `lock_vacuum()`'s write side for the **whole pass**. + `pop_max` takes the read side, so for the duration no insert can reuse a + freed link; inserts append instead. Not a stall, but vacuum and space reuse + are mutually exclusive. +- Each row move takes that row's full lock and awaits every predecessor + operation on the key. O(rows moved) lock acquisitions and awaits. +- Each row move swings every secondary index individually. +- Planning re-reads `get_per_page_info()` from scratch each pass and sorts it. +- A pass is not resumable and carries no work budget. + +## The shape that fits: non-blocking, reactive, range-detecting + +Before reaching for the literature, note that most of this design's machinery +already exists in `EmptyLinkRegistry` and planning does not use it. + +### Range detection: the structure is already built and ignored + +`index_ord_links` is a `BTreeSet` of free links **ordered by absolute position +and coalesced on insert**: pushing a run of adjacent freed links merges them +into one entry. `length_ord_links` orders the same runs by size. + +`get_per_page_info` uses neither. It takes the `op_lock`, iterates every entry +in `page_links_map`, rebuilds a per-page `HashMap` from scratch, and produces +a fragmentation ratio per page. Planning is therefore O(all free links) per +pass and its output is page granular. + +Planning from runs instead changes what vacuum does, not just how it decides: + +- **Today**: to reclaim a page, move *every* live row on it. The work is + proportional to how full the page is, and a mostly-full page is skipped as + poor value even if one row is stranding a large gap. +- **From runs**: find the rows standing *between* two adjacent runs and move + only those. One move merges two runs into one. The work is proportional to + the number of stranding rows, which is usually small, and the value is the + size of the run it creates. + +That is a different cost curve, not a tuning change. + +### Reactive: the signal is already maintained + +The trigger today is a 60 second timer plus a fragmentation ratio. The registry +already keeps `sum_links_len` (free bytes) and `item_count` (free runs), both +updated on every registration and removal and both free to read. + +After coalescing, **the ratio between them is the fragmentation measure**. The +same free bytes in one run is a healthy table; in five hundred runs it is a +fragmented one. No scan is needed to know which. + +So vacuum can wake when that ratio crosses a threshold rather than on a clock, +and can decline to run at all when `length_ord_links` already holds a run big +enough for the allocations being made. Today it wakes every 60 seconds and +scans regardless. + +### Non-blocking: the lock is the whole pass + +`defragment` holds `lock_vacuum()`'s write side for its entire duration, and +`pop_max` takes the read side. So for the length of a pass no insert can reuse +a freed link; every insert appends. Vacuum and space reuse are mutually +exclusive, which is close to self-defeating for a pass whose purpose is to make +space reusable. + +Working a bounded range at a time and taking the lock per range rather than per +pass removes that. It is the same fix shape as review finding WT-6, which is +about bulk delete holding every stripe it touches for the whole batch. + +### What this does not fix + +It reduces how much relocation happens and how long anything is held, which is +worth doing on its own. It does not remove the class of bug that has been +recurring, because a relocation still rewrites index entries that hold physical +addresses. Fewer relocations means fewer chances to get it wrong, not a +guarantee. That distinction is what the rest of this note is about. + +## The literature, and what each idea would actually change + +### Indirection: the one that removes the class + +Give a row a stable identity and resolve location through one mapping, so +indexes never hold an address. + +- **Bw-tree and LLAMA** (Levandoski, Lomet, Sengupta) use a mapping table from + logical page id to physical address. Relocation updates one entry; nothing + that points at the logical id needs touching. +- **Forwarding pointers** in copying garbage collection are the same idea at + object granularity. **Brooks** (1984) gives every object an indirection word; + **Shenandoah** used exactly that before moving to load reference barriers. + +Applied here: relocation becomes copy bytes, update one mapping entry. Index +swings disappear, per-row locking during vacuum disappears, and the entire +"entry resolving to reused storage" class becomes unrepresentable, because no +entry holds a location to go stale. + +The cost is the **RUM tradeoff** (Athanassoulis et al., EDBT 2016): one extra +dereference on every read, on a read path this project has spent real effort +making fast. That is the honest objection and it is why the design is physical +today. It is also the decision worth actually measuring rather than assuming. + +### Concurrent evacuation: keep physical addresses, stop stopping + +If indirection is too expensive, the collector literature still applies. + +- **Baker** (1978) is incremental copying with a read barrier: evacuate a bit + at a time, and readers that touch a not-yet-moved object do the work. +- **Shenandoah** and **ZGC** relocate concurrently with mutators and bound the + pause to work proportional to roots rather than to the heap. ZGC's coloured + pointers and load barriers are the mechanism. + +Applied here: a tombstone at the old link pointing to the new one lets readers +that land on it follow, which means index swings no longer have to happen under +each row's lock and can be batched or done lazily. That is a smaller change +than a mapping table and removes the per-row lock acquisition, which is the +cost driver. + +### Skipping work: the cheapest win available + +- **PostgreSQL's visibility map** keeps a bit per page meaning "nothing here + needs collecting", so vacuum skips those pages without reading them. +- **HOT updates** avoid touching indexes when no indexed column changed. + +Applied here: a per-page "no dead rows since last pass" bit turns planning from +O(all pages) into O(pages that changed). No addressing change, no read cost, +and it composes with everything else. The HOT idea is separately interesting +because this codebase's reinsert path swings *every* secondary index even when +only one column changed. + +### Scheduling: what a pass should cost + +The LSM compaction literature is the closest analogue for the policy question, +since vacuum here is compaction. **Dayan and Idreos** (Monkey, Dostoevsky) frame +the tuning as an explicit tradeoff between write amplification, space +amplification and read cost rather than a fixed policy. The relevant transfer is +not a specific algorithm but the discipline: pick a target for space +amplification, derive the work rate from it, and give the pass a budget it +cannot exceed. Today the policy is a 60 second timer and a fragmentation ratio. + +## Three backends, not one + +Everything above says "the index" as though there were one. There are three, +they do not have the same shape, and any change here has to hold for all of +them. + +| backend | keys | non-unique | value storage | +| --- | --- | --- | --- | +| `worktables_index` (WTI) | any | yes | the fork of `indexset` | +| `arctic` | u16, u32, u64, u128 | yes, via `ArcticMultiIndex` | `ConcurrentMap>`, and `Box>>` for the multi variant | +| `congee` | u8, u16, u32, u64, usize | **no** | `CongeeIndex` | + +Two things follow, and one of them cuts in favour of indirection. + +**Indirection is a value-type change, not a key-type change.** All three store +the `Link` as an opaque `V`. Replacing it with a stable row id touches nothing +about congee's and arctic's key constraints, which is where their restrictions +live. That is cheaper than the note above implied. + +**The per-write cost differs and is not small.** Arctic boxes every value and +its multi variant puts each behind an `RwLock`, so an index write is an +allocation and a lock rather than a slot store. A scheme that reduces the +*number* of index writes per relocation therefore pays off more on arctic than +on WTI, and a scheme that adds a read dereference pays differently again. + +**Measured this session, so the ranking is not a guess:** + +| workload | result | +| --- | --- | +| mixed read/write, 8 threads, all ratios | arctic fastest, congee close, WTI 13 to 15% behind | +| delete grid, all APIs and distributions | congee fastest, WTI 20 to 48% behind | +| AgentCode generation write, in memory | arctic and congee about 20% ahead of WTI | + +WTI is last on every axis measured, and it is the default. That is worth +knowing before optimising the layer above it. + +**Congee cannot hold a non-unique index at all.** Any design that wants a +secondary mapping from row id to location has to say what happens on a table +whose only backend is congee. + +## What I would do, in order + +1. **Benchmark it.** There is no vacuum benchmark, so every claim above about + cost is inference. Foreground insert and select p99.9 *during* a pass, over + fragmentation levels, is the measurement that decides everything else. This + is also what the review asked for and what `wt-benchmarks` does not cover. +2. **Plan from runs, not pages.** `index_ord_links` is already a coalesced, + position-ordered set of free runs and planning ignores it. This is the + change with the best ratio of value to risk, because the structure exists + and the current planner is the thing being replaced rather than extended. +3. **Make the trigger reactive.** `item_count` against `sum_links_len` is a + fragmentation measure that costs nothing to read, so vacuum can wake on + fragmentation and decline when a large enough run already exists. +4. **Take the lock per range, not per pass**, so vacuum stops excluding the + space reuse it exists to enable. Same fix shape as WT-6. +5. **Add the page-level skip bit**, or a generational split, so planning is + proportional to what changed rather than to the table. +6. **Then decide on indirection**, with the numbers from step 1 in hand. It is + the only option that removes the bug class rather than narrowing it, and it + is the only one that costs something on every read. That tradeoff should be + made against a measurement, not against an intuition about which is faster. + +## What not to conclude from this + +The four bugs are fixed. The design is not unsound; it is exacting, and it has +been getting exacted correctly at some cost in vigilance. The argument for +changing it is that the same class keeps recurring, which is evidence about +where the next one will come from, not evidence that the current one is +broken. diff --git a/dsl/Cargo.toml b/dsl/Cargo.toml new file mode 100644 index 00000000..264414fa --- /dev/null +++ b/dsl/Cargo.toml @@ -0,0 +1,74 @@ +[package] +name = "worktable_dsl" +version = "1.0.0-beta.18" +edition = "2024" +license = "MIT" +description = "The worktable! schema language: its model and parser, readable outside the proc macro" +repository = "https://github.com/pathscale/WorkTable" + +[dependencies] +# Exactly what `codegen/src/common` already used. The move adds no dependency +# and drops none; anything else would have made it a rewrite rather than a lift. +# `serde` came later, with the IR, and is optional for the reason given below. +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" +convert_case = "0.6" +indexmap = "2" +serde = { version = "1", features = ["derive"], optional = true } +# Only ever reached through the `json` feature, which only `worktable-schemas` +# needs. Kept out of `serde` so that turning the IR into data does not also +# pull a JSON writer in. +serde_json = { version = "1", optional = true } + +[dev-dependencies] +# The integration test builds as its own crate, which is what makes it evidence +# that this one is consumable from outside. +proc-macro2 = "1" +# Already in the workspace lock; used only by the `serde` feature test. +serde_json = "1" + +# The schema language from a shell. +# +# Deliberately needs nothing beyond the default build. It is the binary a +# cross-implementation test runs on every case, so anything it required would +# be a thing that test could quietly be missing. Byte ranges on diagnostics +# appear when built with `spans` and are absent otherwise, which degrades the +# message rather than the check. +[[bin]] +name = "wt-dsl" +path = "src/bin/wt-dsl.rs" + +# Every declaration under a directory, as JSON plus the canonical text this +# crate emits for it, so a cross-implementation check runs against the tables +# people actually wrote rather than a hand-written corpus that only covers what +# its author already understood. +[[bin]] +name = "worktable-schemas" +path = "src/bin/worktable-schemas.rs" +required-features = ["json"] + +[features] +# The IR derives serde only on request. `worktable_codegen` depends on this +# crate and is a proc macro, so it is compiled for the host before anything +# else in a dependent's build; adding serde unconditionally would put a derive +# macro in front of every WorkTable user's first compile to serve consumers +# who are not the compiler. The designer and the migration planner turn it on. +serde = ["dep:serde"] +# `worktable-schemas` dumps declarations as JSON. Separate from `serde` because +# serialising the IR and shipping a JSON writer are different costs to ask of a +# dependent. +json = ["serde", "dep:serde_json"] +# Byte ranges on diagnostics, for an editor that wants to underline the +# offending token. Off by default for the same reason `serde` is: this crate is +# compiled for the host as part of `worktable_codegen` before anything else in +# a dependent's build, and span tracking is a cost the compiler does not need. +spans = ["proc-macro2/span-locations"] +# Mermaid class diagrams, and the relation guessing they rest on. Off by +# default because it is the one part of this crate that is not about the +# `worktable!` grammar: the language has no foreign keys, so `infer_relations` +# guesses links from a naming convention that WorkTable does not enforce and +# has no opinion about. That mapping belongs to the application that draws the +# diagram, next to whatever it does enforce. Kept here because it is written +# and tested, behind a flag so that turning it on is a decision. +uml = [] diff --git a/dsl/src/bin/worktable-schemas.rs b/dsl/src/bin/worktable-schemas.rs new file mode 100644 index 00000000..32ed1680 --- /dev/null +++ b/dsl/src/bin/worktable-schemas.rs @@ -0,0 +1,76 @@ +//! `worktable-schemas` — every `worktable!` declaration under a directory, as JSON. +//! +//! Each entry carries the schema and the canonical text this crate emits for it, so a consumer +//! written in another language can emit from the same model and compare bytes without needing +//! to reproduce the scan or the parse. +//! +//! It exists to make the cross-implementation check run against *real* declarations rather than +//! against the handful anybody thinks to invent. A hand-written corpus tests the cases its +//! author already understood; this repository's own tables are the ones people actually wrote, +//! and they are where an emitter's unexamined assumption shows up. +//! +//! Requires the `serde` feature, which is off by default because `worktable_dsl` is compiled +//! for the host as part of `worktable_codegen` before anything else in a dependent's build. + +use std::io::Write; +use std::path::Path; + +fn main() { + let root = std::env::args().nth(1).unwrap_or_else(|| ".".to_string()); + + let mut entries: Vec = Vec::new(); + let mut templates = 0usize; + let mut rejected = 0usize; + walk(Path::new(&root), &mut entries, &mut templates, &mut rejected); + + // Sorted, so the output is a function of the tree's contents rather than of the order a + // directory happened to be read in. A consumer diffing two runs should see only real change. + entries.sort(); + + let body = entries.join(",\n "); + let out = format!( + "{{\n \"templates\": {templates},\n \"rejected\": {rejected},\n \"schemas\": [\n {body}\n ]\n}}\n" + ); + if std::io::stdout().write_all(out.as_bytes()).is_err() { + std::process::exit(2); + } +} + +fn walk(dir: &Path, entries: &mut Vec, templates: &mut usize, rejected: &mut usize) { + let Ok(read) = std::fs::read_dir(dir) else { return }; + for entry in read.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if path.is_dir() { + // `target` is build output and would multiply the scan by every vendored crate. + if name == "target" || name == ".git" || name == "node_modules" { + continue; + } + walk(&path, entries, templates, rejected); + continue; + } + if path.extension().is_none_or(|e| e != "rs") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + if !source.contains("worktable!") { + continue; + } + let Ok(found) = worktable_dsl::declarations_in_source(&source) else { + continue; + }; + *templates += found.templates.len(); + *rejected += found.rejected.len(); + for schema in found.schemas { + let dsl = serde_json::to_string(&schema.to_dsl()).expect("a string serialises"); + let model = serde_json::to_string(&schema).expect("the schema serialises"); + entries.push(format!( + "{{ \"file\": {}, \"dsl\": {dsl}, \"schema\": {model} }}", + serde_json::to_string(&path.display().to_string()).expect("a path serialises") + )); + } + } +} diff --git a/dsl/src/bin/wt-dsl.rs b/dsl/src/bin/wt-dsl.rs new file mode 100644 index 00000000..dd44e979 --- /dev/null +++ b/dsl/src/bin/wt-dsl.rs @@ -0,0 +1,138 @@ +//! `wt-dsl`: the schema language from a command line. +//! +//! Everything here already existed as a library function with no way to reach +//! it from a shell. That gap is what blocks a second implementation: a +//! TypeScript emitter cannot be compared against the Rust one without a +//! process to pipe text through, so the two would drift with nothing to say so. +//! +//! Reads stdin, writes stdout, exits non-zero on refusal. +//! +//! ```sh +//! wt-dsl parse < schema.wt # canonical text: the byte-exact target +//! wt-dsl check < schema.wt # diagnostics with byte ranges +//! wt-dsl scan < lib.rs # find worktable! declarations in Rust source +//! wt-dsl diff old.wt new.wt # what changed, and what it costs +//! ``` + +use std::io::{Read, Write}; +use std::process::ExitCode; + +fn read_stdin() -> String { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf).expect("stdin is readable"); + buf +} + +fn fail(message: &str) -> ExitCode { + let _ = writeln!(std::io::stderr(), "{message}"); + ExitCode::FAILURE +} + +/// Check, then emit the canonical text. +/// +/// `check` rather than `Schema::parse`, which is the whole point and is easy +/// to get wrong: `parse` answers "is this a declaration", `check` answers +/// "would the macro accept it". `page_size: 4096` beside `persist: true` +/// parses perfectly and the macro refuses it, so a parse-only round trip +/// reports success for output that does not compile, which is exactly the +/// mistake a second implementation would then ship. +/// +/// The round trip rather than a bare check: a second implementation needs +/// bytes to compare against, and `to_dsl` is that target. +fn parse() -> ExitCode { + let checked = worktable_dsl::check(&read_stdin()); + for diagnostic in &checked.diagnostics { + let _ = writeln!(std::io::stderr(), "{:?}: {}", diagnostic.stage, diagnostic.message); + } + match (&checked.schema, checked.is_acceptable()) { + (Some(schema), true) => { + print!("{}", schema.to_dsl()); + ExitCode::SUCCESS + } + _ => ExitCode::FAILURE, + } +} + +/// Every rule violation, each with the byte range it applies to. +/// +/// Exits zero only when the macro would accept the declaration, so this works +/// as a lint in CI and not only inside an editor. +fn check() -> ExitCode { + let checked = worktable_dsl::check(&read_stdin()); + for diagnostic in &checked.diagnostics { + let at = match &diagnostic.span { + Some(span) => format!("{}..{}", span.start, span.end), + None => "-".to_string(), + }; + let _ = writeln!(std::io::stderr(), "{:?} {at}: {}", diagnostic.stage, diagnostic.message); + } + if checked.is_acceptable() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +/// Find `worktable!` declarations in Rust source, including inside function +/// bodies, which an item walk would miss. +/// +/// A declaration that does not parse is reported rather than dropped: silently +/// losing a table the compiler accepts is worse than saying it could not be +/// read. +fn scan() -> ExitCode { + match worktable_dsl::declarations_in_source(&read_stdin()) { + Ok(found) => { + for schema in &found.schemas { + println!("{}", schema.name); + } + for template in &found.templates { + let _ = writeln!(std::io::stderr(), "note: skipped a macro_rules! template: {template}"); + } + for (text, error) in &found.rejected { + let _ = writeln!(std::io::stderr(), "warning: unreadable declaration: {error}\n{text}"); + } + if found.is_complete() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } + Err(error) => fail(&format!("scan failed: {error}")), + } +} + +/// What changed between two schemas, and what applying it costs. +fn diff(old_path: &str, new_path: &str) -> ExitCode { + let read = |path: &str| -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; + worktable_dsl::Schema::parse(&text).map_err(|e| format!("{path}: {e}")) + }; + let (old, new) = match (read(old_path), read(new_path)) { + (Ok(a), Ok(b)) => (a, b), + (Err(error), _) | (_, Err(error)) => return fail(&error), + }; + let changes = worktable_dsl::plan(std::slice::from_ref(&old), std::slice::from_ref(&new)); + if changes.is_empty() { + println!("no change"); + return ExitCode::SUCCESS; + } + for change in &changes { + println!("{change:?}"); + } + ExitCode::SUCCESS +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let borrowed: Vec<&str> = args.iter().map(String::as_str).collect(); + match borrowed.as_slice() { + ["parse"] => parse(), + ["check"] => check(), + ["scan"] => scan(), + ["diff", old, new] => diff(old, new), + _ => fail( + "usage:\n wt-dsl parse < schema.wt\n wt-dsl check < schema.wt\n \ + wt-dsl scan < source.rs\n wt-dsl diff ", + ), + } +} diff --git a/dsl/src/check.rs b/dsl/src/check.rs new file mode 100644 index 00000000..c6df529f --- /dev/null +++ b/dsl/src/check.rs @@ -0,0 +1,254 @@ +//! Reading a declaration the way an editor has to read one. +//! +//! [`crate::Schema::parse`] is the right entry point for a tool that has a +//! finished declaration and wants it as data. It is the wrong one for a live +//! editor, for two reasons this module exists to fix. +//! +//! **It reports one problem.** `syn::Result` carries a single error, because +//! the macro stops at the first one: it cannot generate code either way, so +//! finding the rest costs a compile it is not going to do. An editor has the +//! opposite economics. Fixing one error, recompiling, and finding the next is +//! the loop a live checker exists to remove, so [`check`] runs every rule and +//! returns all of them. +//! +//! **It reports no location.** A [`crate::Schema`] deliberately has no spans: +//! an `Ident` cannot be serialised, compared across processes, or sent to a +//! designer over a socket, which is the whole reason the IR is plain data. But +//! an editor that cannot underline the offending token is showing a message +//! about a file rather than about a place in it. +//! +//! [`Diagnostic`] resolves that by keeping the location *outside* the IR, as a +//! byte range into the source text that was parsed. The `Schema` stays plain +//! data; the ranges live next to it, in the result of the call that produced +//! it. A consumer that wants neither pays for neither. +//! +//! # The `spans` feature +//! +//! Byte ranges need `proc-macro2/span-locations`, and `worktable_codegen` +//! depends on this crate. A proc macro is compiled for the host before +//! anything else in a dependent's build, so anything added here is added to +//! every WorkTable user's first compile. Span tracking is therefore behind an +//! off-by-default `spans` feature, for the same reason `serde` is: a designer +//! turns both on, and the compiler pays for neither. +//! +//! Without the feature, [`Diagnostic::span`] is `None`. The messages are +//! identical either way, so a consumer degrades to file-level diagnostics +//! rather than losing them. + +use crate::schema::Schema; + +/// Where a diagnostic points, as a half-open byte range into the source that +/// was handed to [`check`]. +/// +/// Byte offsets rather than line and column on purpose: an editor converts to +/// whichever it needs, and a byte range survives being sent to one that +/// disagrees about what a column is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SourceSpan { + /// First byte of the offending text. + pub start: usize, + /// One past the last byte. + pub end: usize, +} + +/// Why a declaration was rejected, and where. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Diagnostic { + /// The message, identical to the one the macro would print. + pub message: String, + /// The offending text, when this crate was built with the `spans` feature + /// and the error carried a span. `None` otherwise: absence of a location + /// is never absence of a problem. + pub span: Option, + /// Whether the declaration was still readable despite this. + pub stage: Stage, +} + +/// Which half of reading a declaration produced a diagnostic. +/// +/// The distinction is the one an editor acts on. A [`Stage::Grammar`] failure +/// means there is no schema to draw; a [`Stage::Rules`] failure means there is +/// one, it can be rendered, and the macro would refuse to expand it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Stage { + /// The text is not a declaration. Nothing was produced. + Grammar, + /// The text is a declaration, and it breaks a rule the macro enforces. + Rules, +} + +/// A declaration, everything wrong with it, or both. +#[derive(Debug, Clone, PartialEq)] +pub struct Checked { + /// The declaration, when the grammar accepted it. Present even when + /// `diagnostics` is not empty: a schema that breaks a rule is still a + /// schema, and an editor has to draw it in order to let anyone fix it. + pub schema: Option, + /// Everything wrong, in the order a reader would work through it. + pub diagnostics: Vec, +} + +impl Checked { + /// Whether the macro would accept this declaration. + /// + /// The question `worktable_dsl` could not answer before this module, and + /// the one a designer has to answer on every keystroke to say whether the + /// thing on screen would compile. + pub fn is_acceptable(&self) -> bool { + self.schema.is_some() && self.diagnostics.is_empty() + } +} + +#[cfg(feature = "spans")] +fn span_of(error: &syn::Error) -> Option { + let range = error.span().byte_range(); + // A synthesised span (`Span::call_site` in a non-macro context) reports an + // empty range at zero, which would point an editor at the first character + // for an error that is not there. Reporting no location beats a wrong one. + if range.is_empty() { + None + } else { + Some(SourceSpan { + start: range.start, + end: range.end, + }) + } +} + +#[cfg(not(feature = "spans"))] +fn span_of(_error: &syn::Error) -> Option { + None +} + +/// Read a declaration, and report everything wrong with it. +/// +/// The input is the macro body, `name: Foo, columns: { .. }`, without the +/// macro name or the surrounding braces, exactly as [`Schema::parse`] takes it. +/// +/// ``` +/// use worktable_dsl::check; +/// +/// // A declaration the macro would refuse: `congee` cannot index a `String`. +/// let checked = check( +/// "name: Bad, +/// persist: false, +/// columns: { id: u64 primary_key, label: String }, +/// indexes: { label_idx: label unique using congee }", +/// ); +/// +/// // Readable, and drawable, even though it would not compile. +/// assert!(checked.schema.is_some()); +/// assert!(!checked.is_acceptable()); +/// assert!(checked.diagnostics[0].message.contains("does not support key type")); +/// ``` +pub fn check(source: &str) -> Checked { + let tokens: proc_macro2::TokenStream = match syn::parse_str(source) { + Ok(tokens) => tokens, + Err(error) => { + // Tokenisation failed, which in practice means an unbalanced + // delimiter: the state a declaration is in for most of the time + // somebody is typing one. There is nothing to parse and nothing to + // draw, and saying so is more use than a partial tree that claims + // the missing half does not exist. + return Checked { + schema: None, + diagnostics: vec![Diagnostic { + message: error.to_string(), + span: span_of(&error), + stage: Stage::Grammar, + }], + }; + } + }; + + let schema = match Schema::from_tokens(tokens.clone()) { + Ok(schema) => schema, + Err(error) => { + return Checked { + schema: None, + diagnostics: vec![Diagnostic { + message: error.to_string(), + span: span_of(&error), + stage: Stage::Grammar, + }], + }; + } + }; + + // The rules run against the model rather than the IR, because that is + // where the spans are and because it is the same code the macro runs. A + // second parse is cheap next to a compile, and it is what keeps this + // answering "would the macro accept this?" rather than "would a + // reimplementation of the macro accept this?". + let diagnostics = match model_of(tokens) { + Ok((columns, queries, config, persistence)) => { + crate::validate::all(&columns, queries.as_ref(), config.as_ref(), persistence) + .iter() + .map(|error| Diagnostic { + message: error.to_string(), + span: span_of(error), + stage: Stage::Rules, + }) + .collect() + } + // Unreachable in practice: the same tokens parsed a moment ago. If the + // two dispatches ever disagree, report it rather than panicking in an + // editor's keystroke handler. + Err(error) => vec![Diagnostic { + message: error.to_string(), + span: span_of(&error), + stage: Stage::Grammar, + }], + }; + + Checked { + schema: Some(schema), + diagnostics, + } +} + +type Model = ( + crate::model::Columns, + Option, + Option, + crate::model::Persistence, +); + +/// The macro's own top-level dispatch, kept to the parts the rules read. +fn model_of(tokens: proc_macro2::TokenStream) -> syn::Result { + let mut parser = crate::Parser::new(tokens); + parser.parse_name()?; + parser.parse_version()?; + let persistence = parser.parse_persist()?; + parser.parse_partition_by()?; + + let mut columns = None; + let mut indexes = None; + let mut queries = None; + let mut config = None; + while let Some(ident) = parser.peek_next() { + match ident.to_string().as_str() { + "columns" => columns = Some(parser.parse_columns()?), + "indexes" => indexes = Some(parser.parse_indexes()?), + "queries" => queries = Some(parser.parse_queries()?), + "config" => config = Some(parser.parse_configs()?), + other => { + return Err(syn::Error::new(ident.span(), format!("Unexpected token `{other}`"))); + } + } + } + + let mut columns = columns.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "Expected a `columns` block in declaration", + ) + })?; + if let Some(indexes) = indexes { + columns.indexes = indexes; + } + Ok((columns, queries, config, persistence)) +} diff --git a/dsl/src/lib.rs b/dsl/src/lib.rs new file mode 100644 index 00000000..00fa2130 --- /dev/null +++ b/dsl/src/lib.rs @@ -0,0 +1,91 @@ +//! The `worktable!` schema language: its model, and the parser that reads it. +//! +//! # Why this is its own crate +//! +//! This was `codegen/src/common`, inside `worktable_codegen`, which is declared +//! `proc-macro = true`. A proc-macro crate can export nothing but macros, so +//! every type here — the columns, the primary key, the indexes, the queries — +//! was unreachable from any other crate no matter how public it was declared. +//! `mod common` was not even public at that crate's root. +//! +//! The consequence was not theoretical. A schema is written down exactly once, +//! in a `worktable!` invocation, and anything that wants to *read* one — a +//! diagram, a migration tool, a documentation generator, an editor — could not +//! reach the parser that already understood it. The available options were to +//! re-implement the grammar and drift from it, or to do without. +//! +//! Nothing here changed in the move. The model and the parser are the ones the +//! macro has always used, and the macro still uses these: `worktable_codegen` +//! depends on this crate, so there is one grammar rather than a copy that can +//! disagree with the compiler about what a schema means. +//! +//! # Reading a declaration +//! +//! ```ignore +//! use worktable_dsl::Parser; +//! use syn::parse_str; +//! +//! let tokens: proc_macro2::TokenStream = parse_str(source)?; +//! let mut parser = Parser::new(tokens); +//! let name = parser.parse_name()?; +//! let columns = parser.parse_columns()?; +//! ``` +//! +//! The parser is token-based rather than textual, so comments and string +//! literals are handled by `proc_macro2` rather than by hand. The schema files +//! this reads are more comment than code, which makes that difference matter. +//! +//! # What is in scope here, and what is not +//! +//! This crate is the WorkTable universe: one `worktable!` declaration, the +//! grammar it must satisfy, and the rules the macro enforces over it. That is +//! [`Parser`] and [`model`], [`Schema`] and its text emitter, [`validate`] and +//! [`check`], and [`schema::Diff`], which prices a schema change in terms of +//! what the storage engine has to do about it. All of those have a single +//! correct answer that WorkTable owns. +//! +//! The mapping *between* tables is a different layer and it is not in scope. +//! The language has no foreign keys: a column called `project_id` is a `u64` +//! like any other, and nothing in WorkTable enforces, records, or checks a +//! relationship between two tables. `infer_relations` guesses those links from +//! a naming convention, and the guess belongs next to whatever application +//! does enforce the convention. It is behind the off-by-default `uml` feature +//! for that reason: available, tested, and not something this crate asserts. +//! +//! An application building on this should treat a `Relation` as a suggestion +//! to confirm, never as a fact recovered from the schema, because there is no +//! fact there to recover. +//! +//! # Features +//! +//! All off by default. `worktable_codegen` depends on this crate and is a proc +//! macro, so it is compiled for the host before anything else in a dependent's +//! build: anything unconditional here is added to every WorkTable user's first +//! compile, to serve consumers who are not the compiler. +//! +//! - `serde` — `Serialize`/`Deserialize` on the IR, for storing a schema next +//! to the data it describes or sending one over a socket. +//! - `spans` — byte ranges on [`check`] diagnostics, for an editor that wants +//! to underline the offending token. +//! - `uml` — Mermaid class diagrams and the relation guessing above. + +pub mod check; +pub mod model; +pub mod parser; +pub mod schema; +pub mod validate; + +pub use check::{Checked, Diagnostic, SourceSpan, Stage, check}; +#[allow(unused_imports)] +pub use model::*; +pub use parser::Parser; +pub use schema::{ + Change, ColumnSpec, ConfigSpec, Cost, Declarations, Diff, IndexSpec, OperationSpec, PartitionKeySpec, QueriesSpec, + Schema, TableChange, TransformReason, TransformRequest, declarations_in_source, declarations_in_tokens, plan, +}; +#[cfg(feature = "uml")] +pub use schema::{Relation, infer_relations, schemas_to_mermaid}; +/// The key types `autoincrement` can generate, and the key types each index +/// backend can hold. Exported so `worktable_codegen` uses the same lists +/// `check` does, rather than a second copy that can drift. +pub use validate::{AUTOINCREMENT_TYPES, supported_key_types}; diff --git a/codegen/src/common/model/column.rs b/dsl/src/model/column.rs similarity index 86% rename from codegen/src/common/model/column.rs rename to dsl/src/model/column.rs index 611c9d73..26f046ba 100644 --- a/codegen/src/common/model/column.rs +++ b/dsl/src/model/column.rs @@ -1,8 +1,9 @@ -use indexmap::IndexMap; use std::collections::HashMap; -use crate::common::model::index::Index; -use crate::common::model::{GeneratorType, IndexBackend}; +use indexmap::IndexMap; + +use crate::model::index::Index; +use crate::model::{GeneratorType, IndexBackend}; use proc_macro2::{Ident, TokenStream}; use quote::quote; use syn::spanned::Spanned; @@ -14,7 +15,13 @@ fn is_sized(ident: &Ident) -> bool { #[derive(Debug, Clone)] pub struct Columns { pub is_sized: bool, - pub columns_map: HashMap, + /// Column types in declaration order. + /// + /// Code generation iterates this map to emit ordered Rust constructs, + /// including archived enums. A randomized `HashMap` made two expansions + /// of the same declaration produce different variant order and therefore + /// potentially different discriminants. + pub columns_map: IndexMap, pub field_positions: HashMap, pub indexes: IndexMap, pub primary_keys: Vec, @@ -34,7 +41,7 @@ pub struct Row { impl Columns { pub fn try_from_rows(rows: Vec, input: &TokenStream) -> syn::Result { - let mut columns_map = HashMap::new(); + let mut columns_map = IndexMap::new(); let mut field_positions = HashMap::new(); let mut sized = true; let mut pk = vec![]; diff --git a/codegen/src/common/model/config.rs b/dsl/src/model/config.rs similarity index 100% rename from codegen/src/common/model/config.rs rename to dsl/src/model/config.rs diff --git a/codegen/src/common/model/index.rs b/dsl/src/model/index.rs similarity index 92% rename from codegen/src/common/model/index.rs rename to dsl/src/model/index.rs index b133c3b6..53c17be6 100644 --- a/codegen/src/common/model/index.rs +++ b/dsl/src/model/index.rs @@ -6,6 +6,7 @@ use proc_macro2::Ident; /// their current implementation and persistence semantics when `using` is /// absent. Vanilla upstream IndexSet is an explicit, parallel backend. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum IndexBackend { #[default] WorktablesIndex, diff --git a/codegen/src/common/model/mod.rs b/dsl/src/model/mod.rs similarity index 100% rename from codegen/src/common/model/mod.rs rename to dsl/src/model/mod.rs diff --git a/codegen/src/common/model/operation.rs b/dsl/src/model/operation.rs similarity index 100% rename from codegen/src/common/model/operation.rs rename to dsl/src/model/operation.rs diff --git a/codegen/src/common/model/partition.rs b/dsl/src/model/partition.rs similarity index 100% rename from codegen/src/common/model/partition.rs rename to dsl/src/model/partition.rs diff --git a/codegen/src/common/model/persistence.rs b/dsl/src/model/persistence.rs similarity index 87% rename from codegen/src/common/model/persistence.rs rename to dsl/src/model/persistence.rs index bef59fa5..ade72936 100644 --- a/codegen/src/common/model/persistence.rs +++ b/dsl/src/model/persistence.rs @@ -4,6 +4,7 @@ /// explicit `persist: false` acknowledgement before selecting an index backend /// that cannot participate in disk or S3 persistence. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum Persistence { #[default] Omitted, diff --git a/codegen/src/common/model/primary_key.rs b/dsl/src/model/primary_key.rs similarity index 79% rename from codegen/src/common/model/primary_key.rs rename to dsl/src/model/primary_key.rs index bbcb6441..d6d3bcb1 100644 --- a/codegen/src/common/model/primary_key.rs +++ b/dsl/src/model/primary_key.rs @@ -8,6 +8,7 @@ pub struct PrimaryKey { } #[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum GeneratorType { None, Autoincrement, diff --git a/dsl/src/model/queries.rs b/dsl/src/model/queries.rs new file mode 100644 index 00000000..69425b66 --- /dev/null +++ b/dsl/src/model/queries.rs @@ -0,0 +1,11 @@ +use indexmap::IndexMap; +use proc_macro2::Ident; + +use crate::model::Operation; + +#[derive(Debug, Default)] +pub struct Queries { + pub updates: IndexMap, + pub deletes: IndexMap, + pub in_place: IndexMap, +} diff --git a/codegen/src/common/parser/attribute.rs b/dsl/src/parser/attribute.rs similarity index 97% rename from codegen/src/common/parser/attribute.rs rename to dsl/src/parser/attribute.rs index 21a1a0b9..a4c6afb4 100644 --- a/codegen/src/common/parser/attribute.rs +++ b/dsl/src/parser/attribute.rs @@ -1,8 +1,8 @@ use proc_macro2::TokenTree; use syn::spanned::Spanned as _; -use crate::common::model::{PARTITION_KEY_TYPES, PartitionKey, Persistence}; -use crate::common::parser::Parser; +use crate::model::{PARTITION_KEY_TYPES, PartitionKey, Persistence}; +use crate::parser::Parser; // TODO: Move this to separate attributes section because now it only parses persist. impl Parser { @@ -97,8 +97,8 @@ impl Parser { mod tests { use quote::quote; - use crate::common::Parser; - use crate::common::model::{PARTITION_KEY_TYPES, PartitionKey, Persistence}; + use crate::Parser; + use crate::model::{PARTITION_KEY_TYPES, PartitionKey, Persistence}; #[test] fn test_empty() { diff --git a/codegen/src/common/parser/columns.rs b/dsl/src/parser/columns.rs similarity index 97% rename from codegen/src/common/parser/columns.rs rename to dsl/src/parser/columns.rs index 65126f64..e9554b41 100644 --- a/codegen/src/common/parser/columns.rs +++ b/dsl/src/parser/columns.rs @@ -1,8 +1,8 @@ use proc_macro2::{Delimiter, TokenTree}; use syn::spanned::Spanned as _; -use crate::common::Parser; -use crate::common::model::{Columns, GeneratorType, Row}; +use crate::Parser; +use crate::model::{Columns, GeneratorType, Row}; impl Parser { pub fn parse_columns(&mut self) -> syn::Result { @@ -129,7 +129,7 @@ mod tests { use quote::quote; - use crate::common::Parser; + use crate::Parser; #[test] fn test_columns_parse() { @@ -321,7 +321,7 @@ mod tests { let mut parser = Parser::new(row_tokens); let row = parser.parse_row().unwrap(); - assert_eq!(row.index_backend, Some(crate::common::model::IndexBackend::Congee)); + assert_eq!(row.index_backend, Some(crate::model::IndexBackend::Congee)); } #[test] diff --git a/codegen/src/common/parser/config.rs b/dsl/src/parser/config.rs similarity index 74% rename from codegen/src/common/parser/config.rs rename to dsl/src/parser/config.rs index 4bf2c95c..a3f62adc 100644 --- a/codegen/src/common/parser/config.rs +++ b/dsl/src/parser/config.rs @@ -3,8 +3,8 @@ use std::str::FromStr; use proc_macro2::{Delimiter, TokenTree}; use syn::spanned::Spanned; -use crate::common::Parser; -use crate::common::model::Config; +use crate::Parser; +use crate::model::Config; const CONFIG_FIELD_NAME: &str = "config"; @@ -50,6 +50,14 @@ impl Parser { let mut config = Config::default(); parser.parse_config(&mut config)?; + // `parse_updates`, `parse_indexes` and `parse_queries` have always + // consumed the comma that may follow their block. This one did not, so + // `config: { .. },` left the comma for the top-level dispatch, which + // reported it as an unexpected identifier. `config` happens to be + // written last in every declaration in this repository, which is the + // only reason nobody hit it. + self.try_parse_comma()?; + Ok(config) } @@ -85,7 +93,19 @@ impl Parser { let value = value.to_string(); let value = value.replace("_", ""); - config.page_size = Some(u32::from_str(value.as_str()).unwrap()) + // A literal token is not a `u32`. `16384u32`, `0x4000`, + // `4294967296` and `1.5` are all things a person can type + // that `Literal` accepts and `u32::from_str` rejects. This + // used to unwrap, which inside a proc macro was a bad + // error message and in the public `check` API is a crash: + // an editor calling it on every keystroke would take the + // panic, not a squiggle. + config.page_size = Some(u32::from_str(value.as_str()).map_err(|_| { + syn::Error::new( + config.page_size_span.unwrap_or_else(|| self.input.span()), + format!("`page_size` must be a plain unsuffixed integer that fits in a u32, not `{value}`"), + ) + })?) } "row_derives" => { const CONFIG_VARIANTS: [&str; 2] = ["page_size", "row_derives"]; @@ -120,7 +140,12 @@ impl Parser { config.row_derives = derives; } - _ => return Err(syn::Error::new(name.span(), "Unexpected identifier")), + other => { + return Err(syn::Error::new( + name.span(), + format!("Unexpected token `{other}` in `config`"), + )); + } } } diff --git a/codegen/src/common/parser/index.rs b/dsl/src/parser/index.rs similarity index 97% rename from codegen/src/common/parser/index.rs rename to dsl/src/parser/index.rs index 1f41bd0f..7dd4adcd 100644 --- a/codegen/src/common/parser/index.rs +++ b/dsl/src/parser/index.rs @@ -1,5 +1,5 @@ -use crate::common::Parser; -use crate::common::model::{Index, IndexBackend}; +use crate::Parser; +use crate::model::{Index, IndexBackend}; use indexmap::IndexMap; use proc_macro2::{Delimiter, Ident, TokenTree}; use syn::spanned::Spanned; @@ -141,8 +141,8 @@ impl Parser { mod tests { use quote::quote; - use crate::common::Parser; - use crate::common::model::IndexBackend; + use crate::Parser; + use crate::model::IndexBackend; #[test] fn absent_using_defaults_to_worktables_index() { diff --git a/codegen/src/common/parser/mod.rs b/dsl/src/parser/mod.rs similarity index 100% rename from codegen/src/common/parser/mod.rs rename to dsl/src/parser/mod.rs diff --git a/codegen/src/common/parser/name.rs b/dsl/src/parser/name.rs similarity index 98% rename from codegen/src/common/parser/name.rs rename to dsl/src/parser/name.rs index 783e853d..b33eb333 100644 --- a/codegen/src/common/parser/name.rs +++ b/dsl/src/parser/name.rs @@ -2,7 +2,7 @@ use proc_macro2::Ident; use proc_macro2::TokenTree; use syn::spanned::Spanned as _; -use crate::common::parser::Parser; +use crate::parser::Parser; impl Parser { pub fn parse_name(&mut self) -> syn::Result { @@ -73,7 +73,7 @@ impl Parser { mod tests { use quote::quote; - use crate::common::Parser; + use crate::Parser; #[test] fn test_name_parse() { diff --git a/codegen/src/common/parser/punct.rs b/dsl/src/parser/punct.rs similarity index 98% rename from codegen/src/common/parser/punct.rs rename to dsl/src/parser/punct.rs index 160956f1..a2ce294b 100644 --- a/codegen/src/common/parser/punct.rs +++ b/dsl/src/parser/punct.rs @@ -1,7 +1,7 @@ use proc_macro2::TokenTree; use syn::spanned::Spanned; -use crate::common::parser::Parser; +use crate::parser::Parser; impl Parser { /// Parses ':' from [`proc_macro2::TokenStream`]. diff --git a/codegen/src/common/parser/queries/delete.rs b/dsl/src/parser/queries/delete.rs similarity index 79% rename from codegen/src/common/parser/queries/delete.rs rename to dsl/src/parser/queries/delete.rs index ffe5aee9..3ea75d0c 100644 --- a/codegen/src/common/parser/queries/delete.rs +++ b/dsl/src/parser/queries/delete.rs @@ -1,13 +1,12 @@ -use std::collections::HashMap; - +use indexmap::IndexMap; use proc_macro2::{Ident, TokenTree}; use syn::spanned::Spanned; -use crate::common::Parser; -use crate::common::model::Operation; +use crate::Parser; +use crate::model::Operation; impl Parser { - pub fn parse_deletes(&mut self) -> syn::Result> { + pub fn parse_deletes(&mut self) -> syn::Result> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), "Expected `delete` field in declaration", @@ -28,7 +27,11 @@ impl Parser { .ok_or(syn::Error::new(self.input.span(), "Expected operation declarations"))?; if let TokenTree::Group(ops) = ops { let mut parser = Parser::new(ops.stream()); - parser.parse_operations() + let operations = parser.parse_operations()?; + // Symmetry with `parse_updates`: consume a comma after the block, + // so a `delete` block is not required to be written last. + self.try_parse_comma()?; + Ok(operations) } else { Err(syn::Error::new(ops.span(), "Expected operation declarations")) } @@ -40,7 +43,7 @@ mod tests { use proc_macro2::{Ident, Span}; use quote::quote; - use crate::common::Parser; + use crate::Parser; #[test] fn test_update() { diff --git a/codegen/src/common/parser/queries/in_place.rs b/dsl/src/parser/queries/in_place.rs similarity index 78% rename from codegen/src/common/parser/queries/in_place.rs rename to dsl/src/parser/queries/in_place.rs index 47fc83f8..2ba2a94b 100644 --- a/codegen/src/common/parser/queries/in_place.rs +++ b/dsl/src/parser/queries/in_place.rs @@ -1,13 +1,12 @@ -use std::collections::HashMap; - +use indexmap::IndexMap; use proc_macro2::{Ident, TokenTree}; use syn::spanned::Spanned; -use crate::common::Parser; -use crate::common::model::Operation; +use crate::Parser; +use crate::model::Operation; impl Parser { - pub fn parse_in_place(&mut self) -> syn::Result> { + pub fn parse_in_place(&mut self) -> syn::Result> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), "Expected `in_place` field in declaration", @@ -28,7 +27,11 @@ impl Parser { .ok_or(syn::Error::new(self.input.span(), "Expected operation declarations"))?; if let TokenTree::Group(ops) = ops { let mut parser = Parser::new(ops.stream()); - parser.parse_operations() + let operations = parser.parse_operations()?; + // Symmetry with `parse_updates`: consume a comma after the block, + // so a `in_place` block is not required to be written last. + self.try_parse_comma()?; + Ok(operations) } else { Err(syn::Error::new(ops.span(), "Expected operation declarations")) } @@ -40,7 +43,7 @@ mod tests { use proc_macro2::{Ident, Span}; use quote::quote; - use crate::common::Parser; + use crate::Parser; #[test] fn test_update() { diff --git a/codegen/src/common/parser/queries/mod.rs b/dsl/src/parser/queries/mod.rs similarity index 85% rename from codegen/src/common/parser/queries/mod.rs rename to dsl/src/parser/queries/mod.rs index 75d0f3a8..a74140c0 100644 --- a/codegen/src/common/parser/queries/mod.rs +++ b/dsl/src/parser/queries/mod.rs @@ -7,8 +7,8 @@ mod update; use proc_macro2::TokenTree; use syn::spanned::Spanned; -use crate::common::Parser; -use crate::common::model::Queries; +use crate::Parser; +use crate::model::Queries; impl Parser { pub fn parse_queries(&mut self) -> syn::Result { @@ -50,7 +50,12 @@ impl Parser { let in_place = parser.parse_in_place()?; queries.in_place = in_place; } - _ => return Err(syn::Error::new(ident.span(), "Unexpected identifier")), + other => { + return Err(syn::Error::new( + ident.span(), + format!("Unexpected token `{other}`; expected one of `update`, `delete`, `in_place`"), + )); + } } } } else { diff --git a/codegen/src/common/parser/queries/operation.rs b/dsl/src/parser/queries/operation.rs similarity index 92% rename from codegen/src/common/parser/queries/operation.rs rename to dsl/src/parser/queries/operation.rs index a3a0b1e1..1f648347 100644 --- a/codegen/src/common/parser/queries/operation.rs +++ b/dsl/src/parser/queries/operation.rs @@ -1,13 +1,13 @@ +use indexmap::IndexMap; use proc_macro2::{Ident, TokenTree}; -use std::collections::HashMap; use syn::spanned::Spanned; -use crate::common::model::Operation; -use crate::common::parser::Parser; +use crate::model::Operation; +use crate::parser::Parser; impl Parser { - pub fn parse_operations(&mut self) -> syn::Result> { - let mut ops = HashMap::new(); + pub fn parse_operations(&mut self) -> syn::Result> { + let mut ops = IndexMap::new(); while self.has_next() { let row = self.parse_operation()?; if ops.contains_key(&row.name) { @@ -96,7 +96,7 @@ impl Parser { mod tests { use quote::quote; - use crate::common::parser::Parser; + use crate::parser::Parser; #[test] fn test_operation() { diff --git a/codegen/src/common/parser/queries/select.rs b/dsl/src/parser/queries/select.rs similarity index 89% rename from codegen/src/common/parser/queries/select.rs rename to dsl/src/parser/queries/select.rs index 4c2aac84..a0bfcbac 100644 --- a/codegen/src/common/parser/queries/select.rs +++ b/dsl/src/parser/queries/select.rs @@ -1,13 +1,12 @@ -use std::collections::HashMap; - +use indexmap::IndexMap; use proc_macro2::{Ident, TokenTree}; use syn::spanned::Spanned; -use crate::common::Parser; -use crate::common::model::Operation; +use crate::Parser; +use crate::model::Operation; impl Parser { - pub fn _parse_selects(&mut self) -> syn::Result> { + pub fn _parse_selects(&mut self) -> syn::Result> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), "Expected `select` field in declaration", @@ -40,7 +39,7 @@ mod tests { use proc_macro2::{Ident, Span}; use quote::quote; - use crate::common::Parser; + use crate::Parser; #[test] fn test_update() { diff --git a/codegen/src/common/parser/queries/update.rs b/dsl/src/parser/queries/update.rs similarity index 89% rename from codegen/src/common/parser/queries/update.rs rename to dsl/src/parser/queries/update.rs index d51fb6b9..3b5fe622 100644 --- a/codegen/src/common/parser/queries/update.rs +++ b/dsl/src/parser/queries/update.rs @@ -1,13 +1,12 @@ -use std::collections::HashMap; - +use indexmap::IndexMap; use proc_macro2::{Ident, TokenTree}; use syn::spanned::Spanned; -use crate::common::Parser; -use crate::common::model::Operation; +use crate::Parser; +use crate::model::Operation; impl Parser { - pub fn parse_updates(&mut self) -> syn::Result> { + pub fn parse_updates(&mut self) -> syn::Result> { let ident = self.input_iter.next().ok_or(syn::Error::new( self.input.span(), "Expected `update` field in declaration", @@ -42,7 +41,7 @@ mod tests { use proc_macro2::{Ident, Span}; use quote::quote; - use crate::common::Parser; + use crate::Parser; #[test] fn test_update() { diff --git a/dsl/src/schema/diff.rs b/dsl/src/schema/diff.rs new file mode 100644 index 00000000..b6722a8e --- /dev/null +++ b/dsl/src/schema/diff.rs @@ -0,0 +1,663 @@ +//! What changed between two schemas, and what that costs. +//! +//! # The question this answers +//! +//! A table opens, and the version on disk does not match the version the +//! binary was compiled with. Something has to decide what to do about it, and +//! today that decision is made by a human who wrote `version_tables: { 1 => +//! v1::UserV1WorkTable }` and kept the old table definition by hand, forever, +//! for every version that ever existed. That hand-maintenance is the whole +//! reason migrations get put off. +//! +//! With both schemas as data, the decision can be computed. [`Diff::between`] +//! says what changed; [`Cost`] says what it costs to apply; and +//! [`Diff::transforms_required`] says which parts a human still has to write, +//! because those are the parts that need intent rather than mechanism. +//! +//! # Cost is about links, not about fields +//! +//! A row is addressed by a `Link { page_id, offset, length }`, and every index +//! holds links. So the question that decides the cost of a change is not "how +//! many columns moved" but "is a row still where it was". A change to the row's +//! archived layout invalidates every link in the table at once, and the only +//! way through is to write every row somewhere else. A change to an index +//! invalidates nothing: the rows have not moved, and the index can be rebuilt +//! from them. That is why [`Cost`] has the shape it does, and why adding a +//! column is expensive while adding an index is not. +//! +//! # What it cannot tell you +//! +//! A rename is a drop and an add. Nothing in a declaration distinguishes +//! `email` becoming `email_address` from `email` being deleted while an +//! unrelated `email_address` appears, and guessing from type equality would be +//! wrong exactly when it mattered. The diff reports both changes and asks for a +//! transform, which is where the intent belongs. + +use std::collections::BTreeSet; +use std::fmt::Write as _; + +use super::{ColumnSpec, IndexSpec, PartitionKeySpec, Schema}; +use crate::model::{IndexBackend, Persistence}; + +/// What applying a change costs. +/// +/// Ordered from cheapest to most expensive, so the cost of a whole diff is the +/// maximum over its changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Cost { + /// Nothing on disk changes. The generated code differs and the data does + /// not: new queries, different row derives, a version bump on its own. + Nothing, + /// Indexes are rebuilt from rows that stay where they are. No link is + /// invalidated, so this can be done in place. + RebuildIndexes, + /// Every row is written somewhere else, because its archived layout + /// changed. Every link in the table is invalidated at once, which is why + /// there is no cheaper version of this: it is a copy-forward into a new + /// space, with the old one left untouched until it succeeds. + RewriteRows, + /// Cannot be planned. A person has to say what they meant before anything + /// can be applied. + NeedsIntent, +} + +impl Cost { + /// A short explanation, for a report or an error message. + pub fn describe(self) -> &'static str { + match self { + Self::Nothing => "no change on disk", + Self::RebuildIndexes => "indexes rebuilt in place; rows are not moved", + Self::RewriteRows => "every row is copied forward into a new space", + Self::NeedsIntent => "cannot be planned automatically", + } + } +} + +/// One difference between two schemas. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Change { + /// The declared version changed. This is the trigger for a migration + /// rather than a cost of one. + Version { + /// The version on disk. + from: u32, + /// The version the binary declares. + to: u32, + }, + /// The table is named differently, which means it is a different space on + /// disk and nothing links the two but a person saying so. + Renamed { + /// The stored name. + from: String, + /// The declared name. + to: String, + }, + /// `persist` changed. + PersistenceChanged { + /// What was stored. + from: Persistence, + /// What is declared. + to: Persistence, + }, + /// The routing key changed. The key is not in the row, so it cannot be + /// recomputed from the data: a row's partition is only knowable from where + /// it already is. + PartitionKeyChanged { + /// What was stored. + from: Option, + /// What is declared. + to: Option, + }, + /// The primary key's columns changed, in membership or in order. Order + /// counts: it decides the layout of the generated key type. + PrimaryKeyChanged { + /// The stored key columns, in order. + from: Vec, + /// The declared key columns, in order. + to: Vec, + }, + /// A column appeared. + ColumnAdded(ColumnSpec), + /// A column is gone, and its values with it. + ColumnDropped(ColumnSpec), + /// A column kept its name and changed its type. + ColumnTypeChanged { + /// Column name. + name: String, + /// The stored type. + from: String, + /// The declared type. + to: String, + }, + /// A column gained or lost `optional`. + /// A primary key's generator changed. + /// + /// Not cosmetic: the generator decides how identities are assigned, so + /// moving between `autoincrement` and a caller-supplied key changes who is + /// responsible for uniqueness and what generator state a persisted table + /// has to carry. `ColumnSpec` has always recorded it; the diff used to + /// compare type, optionality and position only, so this migration planned + /// as no change at all. + PrimaryKeyGeneratorChanged { + /// The column whose generator changed. + name: String, + /// What it was. + from: String, + /// What it is now. + to: String, + }, + ColumnOptionalityChanged { + /// Column name. + name: String, + /// Whether it is optional now. + now_optional: bool, + }, + /// A column moved. Declaration order is the row struct's field order, so + /// moving one changes the archived layout as surely as changing its type. + ColumnMoved { + /// Column name. + name: String, + /// Its stored position. + from: usize, + /// Its declared position. + to: usize, + }, + /// A secondary index appeared. + IndexAdded(IndexSpec), + /// A secondary index is gone. + IndexDropped(IndexSpec), + /// An index of the same name is now built over a different column. + IndexColumnChanged { + /// Index name. + name: String, + /// The stored column. + from: String, + /// The declared column. + to: String, + }, + /// An index gained or lost `unique`. + IndexUniquenessChanged { + /// Index name. + name: String, + /// Whether it is unique now. + now_unique: bool, + }, + /// An index kept its shape and changed its implementation. + IndexBackendChanged { + /// Index name. + name: String, + /// The stored backend. + from: IndexBackend, + /// The declared backend. + to: IndexBackend, + }, + /// The primary index's implementation changed. + PrimaryIndexBackendChanged { + /// The stored backend. + from: IndexBackend, + /// The declared backend. + to: IndexBackend, + }, + /// The generated queries differ. Nothing on disk depends on them. + QueriesChanged, + /// The `config` block differs. `page_size` is pinned to the on-disk page + /// size for persisted tables, so what is left here cannot reach the data. + ConfigChanged, +} + +impl Change { + /// What applying this change costs. + pub fn cost(&self) -> Cost { + match self { + Self::Version { .. } | Self::QueriesChanged | Self::ConfigChanged => Cost::Nothing, + + Self::IndexAdded(_) + | Self::IndexDropped(_) + | Self::IndexColumnChanged { .. } + | Self::IndexUniquenessChanged { .. } + | Self::IndexBackendChanged { .. } + | Self::PrimaryIndexBackendChanged { .. } => Cost::RebuildIndexes, + + Self::ColumnAdded(_) + | Self::ColumnDropped(_) + | Self::ColumnTypeChanged { .. } + | Self::ColumnOptionalityChanged { .. } + | Self::ColumnMoved { .. } => Cost::RewriteRows, + + Self::Renamed { .. } + | Self::PersistenceChanged { .. } + | Self::PartitionKeyChanged { .. } + | Self::PrimaryKeyChanged { .. } + | Self::PrimaryKeyGeneratorChanged { .. } => Cost::NeedsIntent, + } + } + + /// What a person has to supply before this change can be applied, if + /// anything. + /// + /// The rule is that the planner can invent a value only when there is + /// exactly one it could be. Widening a column to `optional` has one answer, + /// `Some(old)`. Narrowing it does not: what a `None` should become is a + /// question about the data, not about the schema. + pub fn transform_required(&self) -> Option { + match self { + Self::ColumnAdded(column) if !column.optional => Some(TransformRequest { + column: column.name.clone(), + reason: TransformReason::NoValueToFillItWith { ty: column.ty.clone() }, + }), + Self::ColumnTypeChanged { name, from, to } => Some(TransformRequest { + column: name.clone(), + reason: TransformReason::NoConversionExists { + from: from.clone(), + to: to.clone(), + }, + }), + Self::ColumnOptionalityChanged { + name, + now_optional: false, + } => Some(TransformRequest { + column: name.clone(), + reason: TransformReason::NothingToPutWhereNoneWas, + }), + _ => None, + } + } + + /// Something true about this change that its cost does not say. + pub fn warning(&self) -> Option { + match self { + Self::IndexUniquenessChanged { name, now_unique: true } => Some(format!( + "index `{name}` becomes unique: rebuilding it fails if the existing rows already \ + hold a duplicate, and that is only knowable by reading them" + )), + Self::ColumnDropped(column) => Some(format!( + "column `{}` is dropped: its values are not carried anywhere and are gone once the \ + old space is removed", + column.name + )), + _ => None, + } + } +} + +/// Something a person has to write before a plan can run. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TransformRequest { + /// The column it concerns. + pub column: String, + /// Why the planner cannot decide it. + pub reason: TransformReason, +} + +/// Why a change needs a human. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum TransformReason { + /// A new column that is not `optional` has no value in any existing row, + /// and nothing in the schema says what it should be. + NoValueToFillItWith { + /// The new column's type. + ty: String, + }, + /// The column's type changed and the schema does not say how one becomes + /// the other. + NoConversionExists { + /// The stored type. + from: String, + /// The declared type. + to: String, + }, + /// A column stopped being `optional`, so every stored `None` needs a value + /// or the row needs dropping. + NothingToPutWhereNoneWas, +} + +impl TransformReason { + /// A one-line explanation, for a report. + pub fn describe(&self) -> String { + match self { + Self::NoValueToFillItWith { ty } => { + format!("new non-optional column of type `{ty}` has no value in existing rows") + } + Self::NoConversionExists { from, to } => { + format!("no conversion from `{from}` to `{to}` is implied by the declaration") + } + Self::NothingToPutWhereNoneWas => { + "stored `None` values need a replacement or the rows need dropping".to_string() + } + } + } +} + +/// Everything that differs between two schemas for one table. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Diff { + /// The table's stored name. + pub table: String, + /// The differences, in a fixed order: identity, then columns, then + /// indexes, then the parts that cannot reach the data. + pub changes: Vec, +} + +impl Diff { + /// Compare a stored schema against a declared one. + /// + /// `stored` is what is on disk and `declared` is what the binary was + /// compiled with, and the direction matters: "a column was added" means + /// added by the binary, and so absent from every row on disk. + pub fn between(stored: &Schema, declared: &Schema) -> Self { + let mut changes = Vec::new(); + + if stored.name != declared.name { + changes.push(Change::Renamed { + from: stored.name.clone(), + to: declared.name.clone(), + }); + } + if stored.version != declared.version { + changes.push(Change::Version { + from: stored.version, + to: declared.version, + }); + } + if stored.persist != declared.persist { + changes.push(Change::PersistenceChanged { + from: stored.persist, + to: declared.persist, + }); + } + if stored.partition_by != declared.partition_by { + changes.push(Change::PartitionKeyChanged { + from: stored.partition_by.clone(), + to: declared.partition_by.clone(), + }); + } + + let stored_key: Vec = stored.primary_key().iter().map(|c| c.name.clone()).collect(); + let declared_key: Vec = declared.primary_key().iter().map(|c| c.name.clone()).collect(); + if stored_key != declared_key { + changes.push(Change::PrimaryKeyChanged { + from: stored_key, + to: declared_key, + }); + } + + diff_columns(stored, declared, &mut changes); + diff_indexes(stored, declared, &mut changes); + + if stored.primary_index_backend() != declared.primary_index_backend() { + changes.push(Change::PrimaryIndexBackendChanged { + from: stored.primary_index_backend(), + to: declared.primary_index_backend(), + }); + } + if stored.queries != declared.queries { + changes.push(Change::QueriesChanged); + } + if stored.config != declared.config { + changes.push(Change::ConfigChanged); + } + + Self { + table: stored.name.clone(), + changes, + } + } + + /// Whether the two schemas are the same. + pub fn is_empty(&self) -> bool { + self.changes.is_empty() + } + + /// Whether the rows on disk can be read by the declared type as they are. + /// + /// This is the question the fast path asks. A version match plus this + /// returning true is an optimistic load with nothing to do; a version match + /// plus this returning false is a schema changed without a version bump, + /// which is a mistake rather than a migration and should be said so. + pub fn rows_are_readable(&self) -> bool { + self.cost() < Cost::RewriteRows + } + + /// The cost of the whole diff, which is the cost of its worst change. + pub fn cost(&self) -> Cost { + self.changes.iter().map(Change::cost).max().unwrap_or(Cost::Nothing) + } + + /// Everything a person has to write before this can be applied. + pub fn transforms_required(&self) -> Vec { + self.changes.iter().filter_map(Change::transform_required).collect() + } + + /// Everything true about this diff that its cost does not say. + pub fn warnings(&self) -> Vec { + self.changes.iter().filter_map(Change::warning).collect() + } + + /// A report, for an error message or a designer's migration pane. + pub fn describe(&self) -> String { + if self.is_empty() { + return format!("`{}` is unchanged", self.table); + } + + let mut out = format!("`{}`: {}\n", self.table, self.cost().describe()); + for change in &self.changes { + let _ = writeln!(out, " {}", describe_change(change)); + } + let transforms = self.transforms_required(); + if !transforms.is_empty() { + out.push_str(" needs a transform written for:\n"); + for transform in transforms { + let _ = writeln!(out, " {}: {}", transform.column, transform.reason.describe()); + } + } + for warning in self.warnings() { + let _ = writeln!(out, " note: {warning}"); + } + out + } +} + +fn describe_change(change: &Change) -> String { + match change { + Change::Version { from, to } => format!("version {from} -> {to}"), + Change::Renamed { from, to } => format!("table renamed {from} -> {to}"), + Change::PersistenceChanged { from, to } => format!("persistence {from:?} -> {to:?}"), + Change::PartitionKeyChanged { from, to } => { + let name = |key: &Option| match key { + Some(key) => format!("{}: {}", key.name, key.ty), + None => "none".to_string(), + }; + format!("partition key {} -> {}", name(from), name(to)) + } + Change::PrimaryKeyChanged { from, to } => { + format!("primary key ({}) -> ({})", from.join(", "), to.join(", ")) + } + Change::PrimaryKeyGeneratorChanged { name, from, to } => { + format!("primary key `{name}` generator {from} -> {to}") + } + Change::ColumnAdded(column) => format!( + "column added: {}: {}{}", + column.name, + column.ty, + if column.optional { " optional" } else { "" } + ), + Change::ColumnDropped(column) => format!("column dropped: {}: {}", column.name, column.ty), + Change::ColumnTypeChanged { name, from, to } => format!("column {name}: {from} -> {to}"), + Change::ColumnOptionalityChanged { name, now_optional } => { + if *now_optional { + format!("column {name} became optional") + } else { + format!("column {name} stopped being optional") + } + } + Change::ColumnMoved { name, from, to } => format!("column {name} moved from position {from} to {to}"), + Change::IndexAdded(index) => format!("index added: {} over {}", index.name, index.column), + Change::IndexDropped(index) => format!("index dropped: {} over {}", index.name, index.column), + Change::IndexColumnChanged { name, from, to } => format!("index {name}: {from} -> {to}"), + Change::IndexUniquenessChanged { name, now_unique } => { + if *now_unique { + format!("index {name} became unique") + } else { + format!("index {name} stopped being unique") + } + } + Change::IndexBackendChanged { name, from, to } => { + format!("index {name}: {} -> {}", from.name(), to.name()) + } + Change::PrimaryIndexBackendChanged { from, to } => { + format!("primary index: {} -> {}", from.name(), to.name()) + } + Change::QueriesChanged => "queries changed".to_string(), + Change::ConfigChanged => "config changed".to_string(), + } +} + +fn diff_columns(stored: &Schema, declared: &Schema, changes: &mut Vec) { + for (position, column) in declared.columns.iter().enumerate() { + match stored.column(&column.name) { + None => changes.push(Change::ColumnAdded(column.clone())), + Some(before) => { + if before.ty != column.ty { + changes.push(Change::ColumnTypeChanged { + name: column.name.clone(), + from: before.ty.clone(), + to: column.ty.clone(), + }); + } + if before.generator != column.generator { + changes.push(Change::PrimaryKeyGeneratorChanged { + name: column.name.clone(), + from: format!("{:?}", before.generator), + to: format!("{:?}", column.generator), + }); + } + if before.optional != column.optional { + changes.push(Change::ColumnOptionalityChanged { + name: column.name.clone(), + now_optional: column.optional, + }); + } + let was_at = stored + .columns + .iter() + .position(|c| c.name == column.name) + .expect("the column was just found by name"); + if was_at != position { + changes.push(Change::ColumnMoved { + name: column.name.clone(), + from: was_at, + to: position, + }); + } + } + } + } + for column in &stored.columns { + if declared.column(&column.name).is_none() { + changes.push(Change::ColumnDropped(column.clone())); + } + } +} + +fn diff_indexes(stored: &Schema, declared: &Schema, changes: &mut Vec) { + let find = |schema: &Schema, name: &str| schema.indexes.iter().find(|index| index.name == name).cloned(); + + let names: BTreeSet<&str> = stored + .indexes + .iter() + .chain(declared.indexes.iter()) + .map(|index| index.name.as_str()) + .collect(); + + for name in names { + match (find(stored, name), find(declared, name)) { + (None, Some(added)) => changes.push(Change::IndexAdded(added)), + (Some(dropped), None) => changes.push(Change::IndexDropped(dropped)), + (Some(before), Some(after)) => { + if before.column != after.column { + changes.push(Change::IndexColumnChanged { + name: name.to_string(), + from: before.column, + to: after.column, + }); + } + if before.unique != after.unique { + changes.push(Change::IndexUniquenessChanged { + name: name.to_string(), + now_unique: after.unique, + }); + } + if before.backend != after.backend { + changes.push(Change::IndexBackendChanged { + name: name.to_string(), + from: before.backend, + to: after.backend, + }); + } + } + (None, None) => unreachable!("the name came from one of the two"), + } + } +} + +/// What happened to one table between two sets of schemas. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum TableChange { + /// A table the binary declares that is not on disk. There is nothing to + /// migrate: it is created empty. + Created(String), + /// A table on disk that the binary no longer declares. Nothing reads it, + /// and nothing here deletes it either: that is a decision, not a + /// consequence. + Dropped(String), + /// A table that exists on both sides and differs. + Changed(Diff), +} + +impl TableChange { + /// What applying this costs. + pub fn cost(&self) -> Cost { + match self { + // A new table has no rows to move. + Self::Created(_) => Cost::Nothing, + // Whether to delete a table's data is not something a diff can + // decide, however obvious the answer looks from the declaration. + Self::Dropped(_) => Cost::NeedsIntent, + Self::Changed(diff) => diff.cost(), + } + } +} + +/// Compare a stored set of schemas against a declared one. +/// +/// Tables are matched by name, which is also how they are matched on disk: +/// a space's name is its identity. A renamed table therefore reads as one +/// dropped and one created, and saying it was a rename is a person's job. +pub fn plan(stored: &[Schema], declared: &[Schema]) -> Vec { + let mut changes = Vec::new(); + + for schema in declared { + match stored.iter().find(|other| other.name == schema.name) { + None => changes.push(TableChange::Created(schema.name.clone())), + Some(before) => { + let diff = Diff::between(before, schema); + if !diff.is_empty() { + changes.push(TableChange::Changed(diff)); + } + } + } + } + for schema in stored { + if !declared.iter().any(|other| other.name == schema.name) { + changes.push(TableChange::Dropped(schema.name.clone())); + } + } + + changes +} diff --git a/dsl/src/schema/emit_dsl.rs b/dsl/src/schema/emit_dsl.rs new file mode 100644 index 00000000..e9c3d2b4 --- /dev/null +++ b/dsl/src/schema/emit_dsl.rs @@ -0,0 +1,167 @@ +//! Rendering a [`Schema`] back into the declaration text it came from. +//! +//! This is the half of the round trip that makes a designer possible. Reading +//! a schema is enough to draw it; writing one back is what lets the drawing be +//! edited and the result be a file the compiler accepts. +//! +//! The output is the macro body, not the invocation: the caller decides whether +//! it is going inside `worktable! { .. }`, into a `.wt` file, or into a diff. +//! [`Schema::to_macro_invocation`] wraps it when the invocation is what is +//! wanted. +//! +//! Nothing here tries to preserve the input's formatting or its comments. It +//! cannot: the parser is token-based, so comments never reach the model, and a +//! `Schema` is a description of a schema rather than of a file. What is +//! preserved is meaning, and the round-trip test asserts exactly that and +//! nothing more. + +use std::fmt::Write as _; + +use super::{ColumnSpec, IndexSpec, OperationSpec, Schema}; +use crate::model::{GeneratorType, IndexBackend, Persistence}; + +const INDENT: &str = " "; + +impl Schema { + /// Render the declaration body. + pub fn to_dsl(&self) -> String { + let mut out = String::new(); + + let _ = writeln!(out, "name: {},", self.name); + let _ = writeln!(out, "version: {},", self.version); + + match self.persist { + // An omitted `persist` is not the same as `persist: false`: the + // macro requires the acknowledgement before it will accept an + // index backend that cannot be persisted, so writing one in would + // silently answer a question the author left open. + Persistence::Omitted => {} + Persistence::MemoryOnly => { + let _ = writeln!(out, "persist: false,"); + } + Persistence::Persisted => { + let _ = writeln!(out, "persist: true,"); + } + } + + if let Some(key) = &self.partition_by { + let _ = writeln!(out, "partition_by: {}: {},", key.name, key.ty); + } + + let _ = writeln!(out, "columns: {{"); + for column in &self.columns { + let _ = writeln!(out, "{INDENT}{},", column_to_dsl(column)); + } + let _ = writeln!(out, "}},"); + + if !self.indexes.is_empty() { + let _ = writeln!(out, "indexes: {{"); + for index in &self.indexes { + let _ = writeln!(out, "{INDENT}{},", index_to_dsl(index)); + } + let _ = writeln!(out, "}},"); + } + + if !self.queries.is_empty() { + let _ = writeln!(out, "queries: {{"); + write_query_block(&mut out, "update", &self.queries.updates); + write_query_block(&mut out, "delete", &self.queries.deletes); + write_query_block(&mut out, "in_place", &self.queries.in_place); + let _ = writeln!(out, "}},"); + } + + if !self.config.is_empty() { + let _ = writeln!(out, "config: {{"); + if let Some(page_size) = self.config.page_size { + let _ = writeln!(out, "{INDENT}page_size: {page_size},"); + } + if !self.config.row_derives.is_empty() { + // `row_derives` reads identifiers until it meets another config + // key, so it has to be written last of the two. + let _ = writeln!(out, "{INDENT}row_derives: {},", self.config.row_derives.join(", ")); + } + // No comma. `parse_configs` ends with `try_parse_comma`, so one here + // would be accepted too; it is omitted because `config` is emitted + // last and nothing follows it. + let _ = writeln!(out, "}}"); + } + + out + } + + /// Render the declaration as a complete `worktable!` invocation, ready to + /// be written into a Rust file. + pub fn to_macro_invocation(&self) -> String { + let mut out = String::from("worktable! {\n"); + for line in self.to_dsl().lines() { + if line.is_empty() { + out.push('\n'); + } else { + let _ = writeln!(out, "{INDENT}{line}"); + } + } + out.push_str("}\n"); + out + } +} + +fn column_to_dsl(column: &ColumnSpec) -> String { + let mut out = format!("{}: {}", column.name, column.ty); + + if column.primary_key { + out.push_str(" primary_key"); + match column.generator { + GeneratorType::None => {} + GeneratorType::Autoincrement => out.push_str(" autoincrement"), + GeneratorType::Custom => out.push_str(" custom"), + } + } + + if column.optional { + out.push_str(" optional"); + } + + // A primary-key column always carries a backend once parsed, because the + // model fills the default in. Writing the default back out would be + // correct but noisy, and the point of this emitter is text a person will + // read, so only a deliberate choice is written. + if let Some(backend) = column.index_backend + && backend != IndexBackend::default() + { + let _ = write!(out, " using {}", backend.name()); + } + + out +} + +fn index_to_dsl(index: &IndexSpec) -> String { + let mut out = format!("{}: {}", index.name, index.column); + if index.unique { + out.push_str(" unique"); + } + if index.backend != IndexBackend::default() { + let _ = write!(out, " using {}", index.backend.name()); + } + out +} + +fn write_query_block(out: &mut String, kind: &str, operations: &[OperationSpec]) { + if operations.is_empty() { + return; + } + let _ = writeln!(out, "{INDENT}{kind}: {{"); + for operation in operations { + let _ = writeln!( + out, + "{INDENT}{INDENT}{}({}) by {},", + operation.name, + operation.columns.join(", "), + operation.by + ); + } + // No comma after the closing brace. All six block parsers now consume one + // if it is there, so both forms parse; omitting it is kept because it is + // also what versions before 1.0.0-beta.17 accept, and emitted text is + // routinely fed to a macro older than the emitter that wrote it. + let _ = writeln!(out, "{INDENT}}}"); +} diff --git a/dsl/src/schema/emit_uml.rs b/dsl/src/schema/emit_uml.rs new file mode 100644 index 00000000..25bef40c --- /dev/null +++ b/dsl/src/schema/emit_uml.rs @@ -0,0 +1,216 @@ +//! Rendering schemas as UML, for a designer to draw. +//! +//! The target is Mermaid's `classDiagram`, which is UML class notation and +//! renders anywhere Markdown does: a GitHub comment, a docs page, an editor +//! preview, and the designer itself. Emitting text rather than a picture keeps +//! this crate free of a rendering dependency and keeps the output diffable, +//! which matters when the diagram is generated from a schema in version +//! control. +//! +//! # The mapping +//! +//! A table is a class. Columns are attributes, carrying their markers in +//! brackets: `[PK]`, `[UK ]` for a unique index, `[IX ]` for a +//! non-unique one, and the backend name when a non-default one was selected. +//! Queries are operations, since that is what they are: a named thing the +//! table can be asked to do, with the columns it touches as parameters and the +//! column it selects by as the qualifier. An `optional` column is written +//! `Option~T~`, Mermaid's spelling of a generic. +//! +//! The partition key is not a column and is not drawn as one. It appears in a +//! note, because it describes the table rather than a row: it is stored once +//! per partition and no query can reference it. +//! +//! # Relations +//! +//! The schema language has no foreign keys, so there is nothing to draw an +//! association from. [`infer_relations`] guesses instead, by a single rule +//! stated in its own documentation, and returns what it guessed so a caller +//! can show the user rather than assert it. [`schemas_to_mermaid`] draws the +//! guesses as dependencies (`..>`) rather than associations, because a dashed +//! arrow is the honest notation for a link the declaration does not make. + +use std::fmt::Write as _; + +use convert_case::{Case, Casing as _}; + +use super::{ColumnSpec, Schema}; +use crate::model::{GeneratorType, IndexBackend, Persistence}; + +impl Schema { + /// Render this schema as a Mermaid `classDiagram`. + pub fn to_mermaid(&self) -> String { + let mut out = String::from("classDiagram\n"); + self.write_mermaid_class(&mut out); + out + } + + fn write_mermaid_class(&self, out: &mut String) { + let _ = writeln!(out, " class {} {{", self.name); + let _ = writeln!(out, " <<{}>>", self.stereotype()); + + for column in &self.columns { + let _ = writeln!(out, " +{}", self.column_member(column)); + } + + for (kind, operations) in [ + ("update", &self.queries.updates), + ("delete", &self.queries.deletes), + ("in_place", &self.queries.in_place), + ] { + for operation in operations { + let _ = writeln!( + out, + " +{kind}_{}({}) by_{}", + operation.name, + operation.columns.join(", "), + operation.by + ); + } + } + + let _ = writeln!(out, " }}"); + + if let Some(key) = &self.partition_by { + let _ = writeln!( + out, + " note for {} \"partitioned by {}: {}\"", + self.name, key.name, key.ty + ); + } + } + + fn stereotype(&self) -> String { + let persistence = match self.persist { + Persistence::Persisted => "persisted", + Persistence::MemoryOnly => "in-memory", + Persistence::Omitted => "in-memory by default", + }; + format!("v{} {persistence}", self.version) + } + + fn column_member(&self, column: &ColumnSpec) -> String { + let ty = if column.optional { + format!("Option~{}~", column.ty) + } else { + column.ty.clone() + }; + let mut member = format!("{} : {ty}", column.name); + + let mut markers = Vec::new(); + if column.primary_key { + markers.push("PK".to_string()); + match column.generator { + GeneratorType::None => {} + GeneratorType::Autoincrement => markers.push("autoincrement".to_string()), + GeneratorType::Custom => markers.push("custom".to_string()), + } + if let Some(backend) = column.index_backend + && backend != IndexBackend::default() + { + markers.push(backend.name().to_string()); + } + } + for index in self.indexes.iter().filter(|index| index.column == column.name) { + let kind = if index.unique { "UK" } else { "IX" }; + let mut marker = format!("{kind} {}", index.name); + if index.backend != IndexBackend::default() { + let _ = write!(marker, " {}", index.backend.name()); + } + markers.push(marker); + } + + if !markers.is_empty() { + let _ = write!(member, " [{}]", markers.join(", ")); + } + member + } +} + +/// A link one schema appears to make to another. +/// +/// Appears because nothing in the declaration says so: see [`infer_relations`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Relation { + /// The table holding the referring column. + pub from: String, + /// The referring column. + pub column: String, + /// The table it appears to refer to. + pub to: String, + /// The primary-key column it appears to match. + pub to_column: String, +} + +/// Guess the links between schemas from their column names. +/// +/// The schema language has no foreign keys, so there is no declared answer to +/// recover and this is a heuristic, deliberately a narrow one. A column of +/// table `A` is taken to refer to table `B` when all of the following hold: +/// +/// - `B` has exactly one primary-key column. A composite key has no single +/// column to point at, and guessing which part was meant is worse than +/// drawing nothing. +/// - The column is named `_`, where `` is `B`'s name in snake_case: +/// `project_id` for `Project { id }`. +/// - The two types are identical, ignoring `optional`. A `String project_id` +/// against a `u64 Project::id` is a name collision, not a reference. +/// - The column is not itself part of `A`'s primary key. Those are usually a +/// composite key's own parts rather than a reference outward, and drawing +/// them as references clutters the diagram where it is already busiest. +/// +/// It will miss links written under any other convention, and it can be wrong. +/// Callers showing this to a user should show it as a suggestion. +pub fn infer_relations(schemas: &[Schema]) -> Vec { + let targets: Vec<(&Schema, &ColumnSpec)> = schemas + .iter() + .filter_map(|schema| { + let key = schema.primary_key(); + match key.as_slice() { + [single] => Some((schema, *single)), + _ => None, + } + }) + .collect(); + + let mut relations = Vec::new(); + for schema in schemas { + for column in &schema.columns { + if column.primary_key { + continue; + } + for (target, key) in &targets { + if target.name == schema.name { + continue; + } + let expected = format!("{}_{}", target.name.to_case(Case::Snake), key.name); + if column.name == expected && column.ty == key.ty { + relations.push(Relation { + from: schema.name.clone(), + column: column.name.clone(), + to: target.name.clone(), + to_column: key.name.clone(), + }); + } + } + } + } + relations +} + +/// Render several schemas as one Mermaid `classDiagram`, with the links from +/// [`infer_relations`] drawn as dependencies. +/// +/// The arrow is `..>` rather than an association because the link is inferred. +/// A solid line would claim the declaration says something it does not. +pub fn schemas_to_mermaid(schemas: &[Schema]) -> String { + let mut out = String::from("classDiagram\n"); + for schema in schemas { + schema.write_mermaid_class(&mut out); + } + for relation in infer_relations(schemas) { + let _ = writeln!(out, " {} ..> {} : {}", relation.from, relation.to, relation.column); + } + out +} diff --git a/dsl/src/schema/mod.rs b/dsl/src/schema/mod.rs new file mode 100644 index 00000000..bb33a37a --- /dev/null +++ b/dsl/src/schema/mod.rs @@ -0,0 +1,403 @@ +//! A schema as plain data, and the emitters that render one. +//! +//! # Why a second representation +//! +//! [`crate::model`] is the macro's representation. It is built out of +//! `proc_macro2::Ident` and `TokenStream`, which is exactly right for a thing +//! whose job is to become Rust code: an `Ident` carries a span, and a span is +//! what turns a schema mistake into an error pointing at the offending line. +//! +//! It is the wrong representation for everything else. An `Ident` cannot be +//! serialised, cannot be compared across processes, cannot be sent to a +//! designer over a socket or written into a data file, and cannot be +//! constructed at all outside a proc-macro context without a `Span::call_site` +//! that lies about where it came from. A `TokenStream` is not `PartialEq`, so +//! two schemas cannot even be asked whether they differ, which is the one +//! question a migration planner exists to answer. +//! +//! [`Schema`] is the same declaration with the compiler's concerns removed: +//! `String` where the model has `Ident`, ordered `Vec`s where the model has +//! `HashMap`, and no spans. It derives `PartialEq`, so two of them can be +//! diffed, and (under the `serde` feature) `Serialize`, so one can be stored +//! next to the data it describes and read back by a process that has never +//! seen the Rust type. +//! +//! # What it is not +//! +//! Building a `Schema` runs the *parser*, not the *validator*. The rules that +//! reject, say, a `congee` index over a `String` key live in +//! [`crate::validate`], and `worktable_codegen` reads the same lists from +//! there, so the macro and an editor calling [`crate::check`] cannot disagree +//! about what is legal. A `Schema` can therefore describe a declaration that +//! the macro would refuse to expand. That is deliberate: a designer needs to +//! hold a half-finished schema while the user is still typing it, and a +//! migration planner needs to read an old one whose rules have since changed. +//! +//! # Determinism +//! +//! Every collection here is ordered, and the order is the one written in the +//! declaration. This matters more than it sounds: [`crate::model::Columns`] +//! stores columns in a `HashMap`, whose iteration order Rust randomises per +//! process, so a consumer walking it draws a different diagram on every run. +//! `field_positions` carries the declaration order and is what this sorts by. +//! The query maps have no such field, so those are sorted by name, which is at +//! least stable. + +use proc_macro2::TokenStream; +use syn::spanned::Spanned as _; + +use crate::model::{Columns, GeneratorType, IndexBackend, Persistence, Queries}; +use crate::parser::Parser; + +mod diff; +mod emit_dsl; +#[cfg(feature = "uml")] +mod emit_uml; +mod scan; + +pub use diff::{Change, Cost, Diff, TableChange, TransformReason, TransformRequest, plan}; +#[cfg(feature = "uml")] +pub use emit_uml::{Relation, infer_relations, schemas_to_mermaid}; +pub use scan::{Declarations, declarations_in_source, declarations_in_tokens}; + +/// One `worktable!` declaration, as data. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Schema { + /// The table name, as written. This is the Rust type name, so it is + /// `UpperCamel` by convention but the parser does not enforce that. + pub name: String, + /// Schema version. Absent in the declaration means 1, and this stores the + /// resolved value rather than the absence, because a consumer comparing an + /// on-disk version against a declared one wants a number either way. + pub version: u32, + /// Whether persistence was selected, and whether it was selected at all. + pub persist: Persistence, + /// The routing key of a partitioned table. Not a column: it is stored once + /// per partition rather than once per row, and no query can name it. + pub partition_by: Option, + /// Columns in declaration order. + pub columns: Vec, + /// Secondary indexes in declaration order. + pub indexes: Vec, + /// Generated queries, sorted by name within each kind. + pub queries: QueriesSpec, + /// The `config` block. + pub config: ConfigSpec, +} + +/// A column declaration: `name: Type [primary_key] [autoincrement|custom] [optional] [using backend]`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ColumnSpec { + /// Field name. + pub name: String, + /// The type as written, with any `optional` wrapper removed. The grammar + /// accepts a single identifier here, so this is never a path or a generic. + pub ty: String, + /// Whether `optional` was written, making the field `Option`. + pub optional: bool, + /// Whether this column is part of the primary key. + pub primary_key: bool, + /// The primary-key generator. Only meaningful when `primary_key` is set, + /// and shared by every column of a composite key. + pub generator: GeneratorType, + /// The primary index backend. `Some` on primary-key columns, carrying the + /// declared backend or the default when `using` was omitted; `None` + /// elsewhere, because `using` on a non-key column is a parse error. + pub index_backend: Option, +} + +/// A secondary index declaration: `name: column [unique] [using backend]`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct IndexSpec { + /// Index name. + pub name: String, + /// The column it is built over. + pub column: String, + /// Whether the index rejects duplicate keys. + pub unique: bool, + /// The physical implementation. + pub backend: IndexBackend, +} + +/// The `partition_by` key. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PartitionKeySpec { + /// Key name, used for generated argument names. + pub name: String, + /// Unsigned integer type. See [`crate::model::PARTITION_KEY_TYPES`]. + pub ty: String, +} + +/// The `queries` block. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct QueriesSpec { + /// `update:` operations. + pub updates: Vec, + /// `delete:` operations. + pub deletes: Vec, + /// `in_place:` operations. + pub in_place: Vec, +} + +impl QueriesSpec { + /// Whether any query was declared. An empty block and an absent one are + /// the same thing to the macro, so the emitter writes neither. + pub fn is_empty(&self) -> bool { + self.updates.is_empty() && self.deletes.is_empty() && self.in_place.is_empty() + } +} + +/// One generated query: `Name(columns) by key`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct OperationSpec { + /// Query name, which becomes part of the generated method name. + pub name: String, + /// Columns the query touches. Empty for a delete. + pub columns: Vec, + /// The column the query selects rows by. + pub by: String, +} + +/// The `config` block. +#[derive(Debug, Clone, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct ConfigSpec { + /// `page_size`, in bytes. + pub page_size: Option, + /// Extra derives placed on the generated row type. + pub row_derives: Vec, +} + +impl ConfigSpec { + /// Whether anything was configured. + pub fn is_empty(&self) -> bool { + self.page_size.is_none() && self.row_derives.is_empty() + } +} + +impl Schema { + /// Read a declaration from the text between a `worktable!`'s braces. + /// + /// The input is the body only: `name: Foo, columns: { .. }`, without the + /// macro name or the surrounding braces. + pub fn parse(source: &str) -> syn::Result { + let tokens: TokenStream = syn::parse_str(source)?; + Self::from_tokens(tokens) + } + + /// Read a declaration from tokens. + /// + /// This mirrors the macro's own top-level dispatch, including the + /// diagnostics for keywords written in the wrong position, because those + /// are properties of the grammar rather than of code generation. It stops + /// short of the macro's semantic validation: see the module docs. + pub fn from_tokens(input: TokenStream) -> syn::Result { + let mut parser = Parser::new(input); + + let name = parser.parse_name()?; + let version = parser.parse_version()?.unwrap_or(1); + let persist = parser.parse_persist()?; + let partition_by = parser.parse_partition_by()?.map(|key| PartitionKeySpec { + name: key.name.to_string(), + ty: key.ty.to_string(), + }); + + let mut columns: Option = None; + let mut indexes = None; + let mut queries: Option = None; + let mut config = None; + + while let Some(ident) = parser.peek_next() { + match ident.to_string().as_str() { + "columns" => columns = Some(parser.parse_columns()?), + "indexes" => indexes = Some(parser.parse_indexes()?), + "queries" => queries = Some(parser.parse_queries()?), + "config" => config = Some(parser.parse_configs()?), + "version" => { + return Err(syn::Error::new( + ident.span(), + "version must be specified before columns/indexes/queries/config", + )); + } + "persist" | "partition_by" => { + return Err(syn::Error::new( + ident.span(), + "`persist` and `partition_by` are positional; the required order is: \ + name, version, persist, partition_by, then columns/indexes/queries/config", + )); + } + other => { + return Err(syn::Error::new( + ident.span(), + format!( + "Unexpected token `{other}`; expected one of `columns`, `indexes`, `queries`, `config`" + ), + )); + } + } + } + + let mut model = + columns.ok_or_else(|| syn::Error::new(parser.input.span(), "Expected a `columns` block in declaration"))?; + if let Some(indexes) = indexes { + model.indexes = indexes; + } + + Ok(Self { + name: name.to_string(), + version, + persist, + partition_by, + columns: columns_from_model(&model)?, + indexes: indexes_from_model(&model), + queries: queries.map(queries_from_model).unwrap_or_default(), + config: config + .map(|config| ConfigSpec { + page_size: config.page_size, + row_derives: config.row_derives.iter().map(ToString::to_string).collect(), + }) + .unwrap_or_default(), + }) + } + + /// The columns forming the primary key, in declaration order. + pub fn primary_key(&self) -> Vec<&ColumnSpec> { + self.columns.iter().filter(|column| column.primary_key).collect() + } + + /// Look a column up by name. + pub fn column(&self, name: &str) -> Option<&ColumnSpec> { + self.columns.iter().find(|column| column.name == name) + } +} + +fn columns_from_model(model: &Columns) -> syn::Result> { + let mut ordered: Vec<_> = model.field_positions.iter().collect(); + ordered.sort_by_key(|(_, position)| **position); + + ordered + .into_iter() + .map(|(name, _)| { + let ty = model.columns_map.get(name).expect("every positioned column has a type"); + let (ty, optional) = split_optional(ty)?; + let primary_key = model.primary_keys.contains(name); + Ok(ColumnSpec { + name: name.to_string(), + ty, + optional, + primary_key, + generator: if primary_key { + model.generator_type + } else { + GeneratorType::None + }, + index_backend: primary_key.then_some(model.primary_index_backend), + }) + }) + .collect() +} + +/// Recover `optional` from the type the model stores. +/// +/// `Columns::try_from_rows` folds the `optional` keyword into the type, so by +/// the time a column reaches the model there is no flag left to read: the type +/// is literally `core::option::Option`. Going back out means undoing that, +/// and it has to be done on the parsed type rather than on the token text, +/// because `TokenStream::to_string` spaces punctuation in a way that makes +/// string matching a guess. +fn split_optional(ty: &TokenStream) -> syn::Result<(String, bool)> { + let parsed: syn::Type = syn::parse2(ty.clone())?; + let syn::Type::Path(path) = &parsed else { + return Err(syn::Error::new(ty.span(), "Expected a named column type")); + }; + let last = path + .path + .segments + .last() + .ok_or_else(|| syn::Error::new(ty.span(), "Expected a named column type"))?; + + if last.ident == "Option" + && let syn::PathArguments::AngleBracketed(args) = &last.arguments + && let Some(syn::GenericArgument::Type(inner)) = args.args.first() + { + return Ok((type_name(inner)?, true)); + } + + Ok((last.ident.to_string(), false)) +} + +fn type_name(ty: &syn::Type) -> syn::Result { + let syn::Type::Path(path) = ty else { + return Err(syn::Error::new(ty.span(), "Expected a named column type")); + }; + path.path + .segments + .last() + .map(|segment| segment.ident.to_string()) + .ok_or_else(|| syn::Error::new(ty.span(), "Expected a named column type")) +} + +fn indexes_from_model(model: &Columns) -> Vec { + model + .indexes + .values() + .map(|index| IndexSpec { + name: index.name.to_string(), + column: index.field.to_string(), + unique: index.is_unique, + backend: index.backend, + }) + .collect() +} + +fn queries_from_model(queries: Queries) -> QueriesSpec { + fn convert(operations: indexmap::IndexMap) -> Vec { + operations + .into_values() + .map(|operation| OperationSpec { + name: operation.name.to_string(), + columns: operation.columns.iter().map(ToString::to_string).collect(), + by: operation.by.to_string(), + }) + .collect() + } + + QueriesSpec { + updates: convert(queries.updates), + deletes: convert(queries.deletes), + in_place: convert(queries.in_place), + } +} + +/// Whether the declaration selected persistence, for callers that only care +/// about the answer rather than about whether it was written down. +impl Schema { + /// Whether the table persists to disk. + pub fn is_persisted(&self) -> bool { + self.persist.is_persisted() + } +} + +impl Schema { + /// The implementation backing the primary index. + /// + /// Every primary-key column carries the same one: the parser rejects a + /// composite key whose parts disagree. A table with no primary key cannot + /// be declared, so the fallback is unreachable through the parser and is + /// here for a `Schema` built by hand. + pub fn primary_index_backend(&self) -> IndexBackend { + self.columns + .iter() + .find(|column| column.primary_key) + .and_then(|column| column.index_backend) + .unwrap_or_default() + } +} diff --git a/dsl/src/schema/scan.rs b/dsl/src/schema/scan.rs new file mode 100644 index 00000000..4b3546f6 --- /dev/null +++ b/dsl/src/schema/scan.rs @@ -0,0 +1,114 @@ +//! Finding the declarations in Rust source. +//! +//! A designer opening a project, a documentation generator, and a migration +//! tool comparing a checkout against a running database all start from the same +//! problem: a schema is written inside a `worktable!` invocation somewhere in a +//! crate, and there is no index of where. +//! +//! This walks tokens rather than `syn`'s item tree. An invocation inside a +//! function body is not an item, and real code puts them there, so an item walk +//! would quietly miss a table and the caller would never know a table existed to +//! be missed. +//! +//! # What gets set aside +//! +//! Some invocations are not declarations. A `macro_rules!` body writing +//! `name: $name, ... using $backend` is a template: the metavariables stand for +//! text that only exists once the outer macro expands, and no parser for this +//! grammar can accept them. Those are counted rather than reported as errors, +//! because they are not mistakes. +//! +//! Everything else that fails to parse *is* reported, with the text that failed, +//! because a designer that silently drops a table the compiler accepts is worse +//! than one that says it could not read it. + +use proc_macro2::{Delimiter, TokenStream, TokenTree}; + +use super::Schema; + +/// What was found in one piece of source. +#[derive(Debug, Default)] +pub struct Declarations { + /// The schemas, in the order they appear. + pub schemas: Vec, + /// Invocations inside a `macro_rules!` body, which are templates rather + /// than declarations. The text of each, for a caller that wants to say so. + pub templates: Vec, + /// Invocations that did not parse: the text, and why. + pub rejected: Vec<(String, syn::Error)>, +} + +impl Declarations { + /// Whether every invocation found was read as a schema. + pub fn is_complete(&self) -> bool { + self.rejected.is_empty() + } + + /// How many invocations were found, read or not. + pub fn found(&self) -> usize { + self.schemas.len() + self.templates.len() + self.rejected.len() + } +} + +/// Read every `worktable!` declaration in a Rust source file. +/// +/// The error case is the file not tokenising at all, which is a broken file +/// rather than a broken declaration. A declaration that does not parse lands in +/// [`Declarations::rejected`] and does not stop the rest. +pub fn declarations_in_source(source: &str) -> syn::Result { + let tokens: TokenStream = syn::parse_str(source)?; + Ok(declarations_in_tokens(tokens)) +} + +/// Read every `worktable!` declaration in a token stream. +pub fn declarations_in_tokens(tokens: TokenStream) -> Declarations { + let mut bodies = Vec::new(); + collect(tokens, &mut bodies); + + let mut found = Declarations::default(); + for body in bodies { + let text = body.to_string(); + if is_macro_template(&body) { + found.templates.push(text); + continue; + } + match Schema::from_tokens(body) { + Ok(schema) => found.schemas.push(schema), + Err(error) => found.rejected.push((text, error)), + } + } + found +} + +/// Both delimiter forms appear in real code: `worktable! { .. }` and +/// `worktable!( .. )`. Either is accepted, and so is `[ .. ]`, because rustc +/// accepts it and a reader that did not would be wrong about the language. +fn collect(tokens: TokenStream, found: &mut Vec) { + let trees: Vec = tokens.into_iter().collect(); + let mut index = 0; + while index < trees.len() { + if let TokenTree::Ident(ident) = &trees[index] + && ident == "worktable" + && let Some(TokenTree::Punct(bang)) = trees.get(index + 1) + && bang.as_char() == '!' + && let Some(TokenTree::Group(body)) = trees.get(index + 2) + && body.delimiter() != Delimiter::None + { + found.push(body.stream()); + index += 3; + continue; + } + if let TokenTree::Group(group) = &trees[index] { + collect(group.stream(), found); + } + index += 1; + } +} + +fn is_macro_template(tokens: &TokenStream) -> bool { + tokens.clone().into_iter().any(|tree| match tree { + TokenTree::Punct(punct) => punct.as_char() == '$', + TokenTree::Group(group) => is_macro_template(&group.stream()), + _ => false, + }) +} diff --git a/dsl/src/validate.rs b/dsl/src/validate.rs new file mode 100644 index 00000000..a6e32b69 --- /dev/null +++ b/dsl/src/validate.rs @@ -0,0 +1,274 @@ +//! The rules a declaration must satisfy beyond being grammatical. +//! +//! These lived in `worktable_codegen`, next to the code they would have +//! generated. That is the right home for the *explanation* and the wrong home +//! for the *check*: a proc-macro crate exports nothing but macros, so the only +//! way to ask "would the macro accept this?" was to expand it. A designer +//! cannot expand a proc macro, and an editor that finds out by compiling is not +//! an editor. +//! +//! They are moved here unchanged, still operating on [`crate::model`] types, +//! which carry `proc_macro2` spans. `worktable_codegen` calls these functions +//! and its diagnostics are identical, down to the span each error points at. +//! Same rule the parser follows: one implementation, two callers, no second +//! copy to drift. +//! +//! [`all`] is the addition. The macro stops at the first error because it will +//! not generate code either way; an editor has the opposite economics, where +//! fix-recompile-find-the-next is the loop a live checker exists to remove. + +use crate::model::{Columns, IndexBackend, Persistence}; + +/// data_bucket's on-disk layer seeks with its own hardcoded `PAGE_SIZE` of +/// 16384 bytes (`seek_to_page_start`, `seek_by_link`, `persist_page`), while +/// the generated table threads the user's `page_size` through its page-id and +/// length arithmetic. Any other value therefore reads and writes the wrong +/// file offsets as soon as the table persists, silently corrupting it. +/// In-memory tables never seek a file: for them `page_size` only sizes index +/// nodes and stays configurable. +const DATA_BUCKET_PAGE_SIZE: u32 = 16384; + +pub fn validate_page_size(config: Option<&crate::model::Config>, persistence: Persistence) -> syn::Result<()> { + let Some(config) = config else { return Ok(()) }; + let Some(page_size) = config.page_size else { + return Ok(()); + }; + if persistence.is_persisted() && page_size != DATA_BUCKET_PAGE_SIZE { + let span = config.page_size_span.unwrap_or_else(proc_macro2::Span::call_site); + return Err(syn::Error::new( + span, + format!( + "`page_size: {page_size}` cannot be combined with `persist: true`: the on-disk \ + layer (data_bucket) hardcodes {DATA_BUCKET_PAGE_SIZE}-byte pages in every file \ + seek, so a persisted table with any other page size reads and writes the wrong \ + pages and corrupts its files. Remove `page_size` (or set it to \ + {DATA_BUCKET_PAGE_SIZE}); custom page sizes remain available for in-memory \ + tables, where they only size index nodes" + ), + )); + } + Ok(()) +} + +/// `in_place` queries hand the caller a mutable reference to the archived +/// column bytes and bypass all index maintenance, so a column that any index +/// is built over cannot be mutated in place: the index would keep resolving +/// the old value. +pub fn validate_in_place_queries(columns: &Columns, queries: &crate::model::Queries) -> syn::Result<()> { + for (name, op) in &queries.in_place { + for column in &op.columns { + if columns.indexes.values().any(|index| &index.field == column) { + return Err(syn::Error::new( + column.span(), + format!( + "in_place query `{name}` mutates column `{column}`, which is covered by an index; \ + indexed columns cannot be updated in place because secondary indexes are not \ + maintained on this path. Use an `update` query instead" + ), + )); + } + } + } + Ok(()) +} + +/// Every backend rule. The `syn::Result` form below is what the macro calls. +/// +/// The three checks here are independent, so a declaration with an unsupported +/// key type *and* a non-unique congee index has two things wrong with it, not +/// one thing and a surprise after the fix. +fn index_backends_into(columns: &Columns, persistence: Persistence, errors: &mut Vec) { + // Every offending index, in declaration order, not the first one found. + // + // This used to pick one: the primary if it qualified, and otherwise the + // first secondary that did. A declaration with three such indexes reported + // one, and if the primary qualified no secondary was examined at all. That + // contradicts `all`'s contract of collecting every failure, and recreates + // the fix-one-recompile-find-the-next loop the collecting form exists to + // remove. + let mut explicit_backends = Vec::new(); + if columns.primary_index_backend.requires_explicit_persistence() { + // A schema with no primary key is malformed, but `check` runs these + // rules over half-typed input where that is an ordinary state, so it + // is skipped rather than unwrapped. + if let Some(ident) = columns.primary_keys.first() { + explicit_backends.push((columns.primary_index_backend, ident, true)); + } + } + for index in columns.indexes.values() { + if index.backend.requires_explicit_persistence() { + explicit_backends.push((index.backend, &index.name, false)); + } + } + + if persistence == Persistence::Omitted { + for (backend, ident, is_primary) in explicit_backends { + let kind = if is_primary { "primary index" } else { "index" }; + errors.push(syn::Error::new( + ident.span(), + format!( + "{kind} `{ident}` uses `{}`, which requires an explicit `persist: true` or `persist: false`", + backend.name() + ), + )); + } + } + + for index in columns.indexes.values().filter(|index| !index.is_unique) { + match index.backend { + IndexBackend::WorktablesIndex | IndexBackend::Arctic => {} + IndexBackend::Indexset | IndexBackend::Congee => { + errors.push(syn::Error::new( + index.name.span(), + format!( + "non-unique index `{}` cannot use `{}`; non-unique indexes currently require \ + `worktables_index` or `arctic`", + index.name, + index.backend.name() + ), + )); + } + } + } + + // The primary key's own backend and generator. + // + // Both used to be skipped entirely: the loop below walks `columns.indexes`, + // which holds the *secondary* indexes, and the primary backend was pushed + // into `explicit_backends` above for the persistence rule only. So + // `id: String primary_key using congee` and `id: usize primary_key + // autoincrement` both passed `check` and then failed to build, which is the + // exact failure this module exists to prevent: an editor shows nothing and + // the compiler refuses. + if let Some(primary) = columns.primary_keys.first() { + let key_type = columns.columns_map.get(primary).map(ToString::to_string); + if let Some(key_type) = key_type { + if let Some(supported) = supported_key_types(columns.primary_index_backend) + && !supported.contains(&key_type.as_str()) + { + errors.push(syn::Error::new( + primary.span(), + // Word for word what the macro says when it reaches the + // same conclusion at expansion. `check` exists to answer + // "would the macro accept this", so a caller reading the + // two side by side should not have to work out that they + // are the same refusal. The alias note matters: a key + // declared through a type alias reads as unsupported here + // because neither this nor the macro can resolve it. + format!( + "`using {}` requires a directly named primitive primary-key type; found `{key_type}`; \ + supported types: {} (type aliases cannot be resolved by the macro)", + columns.primary_index_backend.name(), + supported.join(", ") + ), + )); + } + + if columns.generator_type == crate::model::GeneratorType::Autoincrement + && !AUTOINCREMENT_TYPES.contains(&key_type.as_str()) + { + errors.push(syn::Error::new( + primary.span(), + format!( + "primary key `{primary}` is `autoincrement` over key type `{key_type}`, which cannot \ + be generated; supported types: {}", + AUTOINCREMENT_TYPES.join(", ") + ), + )); + } + } + } + + for (column, index) in &columns.indexes { + // An index over a column that does not exist. The macro never reaches + // this: its own parse fails first, which is why this used to be an + // `expect`. `check` does reach it, because it runs the same rules over + // whatever a person has typed so far, and an index naming a column + // that is not there yet is what half-finished input looks like. It + // panicked, which for an editor calling `check` on every keystroke is + // a crash rather than a squiggle. + // + // Reported rather than skipped: nothing else validates this, so + // without it a schema with a dangling index was accepted in silence. + let Some(key_type) = columns.columns_map.get(column) else { + errors.push(syn::Error::new( + index.name.span(), + format!( + "index `{}` is declared over `{column}`, which is not a column", + index.name + ), + )); + continue; + }; + let key_type = key_type.to_string(); + let supported = supported_key_types(index.backend); + if let Some(supported) = supported + && !supported.contains(&key_type.as_str()) + { + errors.push(syn::Error::new( + index.name.span(), + format!( + "index `{}` uses `{}`, which does not support key type `{key_type}`; supported types: {}", + index.name, + index.backend.name(), + supported.join(", ") + ), + )); + } + } +} + +/// The key types `autoincrement` can generate. +/// +/// One list, exported, because `worktable_codegen` maps exactly these to +/// atomics and errors on anything else. Two copies of this would drift, and +/// the way it would show is `check` accepting a declaration that then fails to +/// build, which is the whole failure this module exists to prevent. +/// +/// `usize` is the case worth knowing: it reads like one of the accepted set +/// and there is no `AtomicUsize` in the mapping. +pub const AUTOINCREMENT_TYPES: &[&str] = &["u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64"]; + +/// The key types an index backend can hold. +/// +/// `None` means the backend takes any key type. +pub fn supported_key_types(backend: IndexBackend) -> Option<&'static [&'static str]> { + match backend { + IndexBackend::Congee => Some(&["u8", "u16", "u32", "u64", "usize"]), + IndexBackend::Arctic => Some(&["u16", "u32", "u64", "u128", "i16", "i32", "i64", "i128"]), + IndexBackend::WorktablesIndex | IndexBackend::Indexset => None, + } +} + +/// The first backend rule that fails, which is all the macro can act on. +pub fn validate_index_backends(columns: &Columns, persistence: Persistence) -> syn::Result<()> { + let mut errors = Vec::new(); + index_backends_into(columns, persistence, &mut errors); + match errors.into_iter().next() { + Some(error) => Err(error), + None => Ok(()), + } +} + +/// Every rule, collecting all failures rather than stopping at the first. +/// +/// Ordered backends, page size, then in-place queries: the order a reader +/// would work through them. +pub fn all( + columns: &Columns, + queries: Option<&crate::model::Queries>, + config: Option<&crate::model::Config>, + persistence: Persistence, +) -> Vec { + let mut errors = Vec::new(); + index_backends_into(columns, persistence, &mut errors); + if let Err(error) = validate_page_size(config, persistence) { + errors.push(error); + } + if let Some(queries) = queries + && let Err(error) = validate_in_place_queries(columns, queries) + { + errors.push(error); + } + errors +} diff --git a/dsl/tests/check.rs b/dsl/tests/check.rs new file mode 100644 index 00000000..593c44e6 --- /dev/null +++ b/dsl/tests/check.rs @@ -0,0 +1,301 @@ +//! The question `worktable_dsl` could not answer before `check`: would the +//! macro accept this? +//! +//! Every case here was run against the parser before `check` existed. The +//! rule failures came back as a `Schema` that parsed cleanly, because +//! `Schema::parse` runs the parser and not the validator, so a designer had no +//! way to know the declaration would not compile short of compiling it. + +use worktable_dsl::{Stage, check}; + +/// A declaration that is both grammatical and acceptable. +#[test] +fn a_good_declaration_has_nothing_to_report() { + let checked = check("name: Good, columns: { id: u64 primary_key, label: String }"); + + assert!(checked.is_acceptable(), "unexpected: {:?}", checked.diagnostics); + assert_eq!(checked.schema.expect("parsed").name, "Good"); +} + +/// The case the module exists for: parses, would not compile. +/// +/// `Schema::parse` returns `Ok` here, which is correct and is the documented +/// contract: the IR holds declarations the macro refuses, so an editor can +/// render what somebody is halfway through typing. It is also useless on its +/// own, because nothing then says the thing on screen will not build. +#[test] +fn a_rule_failure_still_yields_a_drawable_schema() { + // `persist: false` is required, and stated first: `congee` refuses to be + // used at all until the declaration commits either way, and that rule + // fires before the key type is looked at. Leaving it out tests the + // persistence rule while claiming to test the key-type one. + let checked = check( + "name: Bad, + persist: false, + columns: { id: u64 primary_key, label: String }, + indexes: { label_idx: label unique using congee }", + ); + + assert!(checked.schema.is_some(), "a rule failure must still be drawable"); + assert!(!checked.is_acceptable()); + assert_eq!(checked.diagnostics.len(), 1); + assert_eq!(checked.diagnostics[0].stage, Stage::Rules); + assert!( + checked.diagnostics[0].message.contains("does not support key type"), + "got: {}", + checked.diagnostics[0].message + ); +} + +/// Every problem at once, not the first one. +/// +/// The macro stops at the first because it cannot generate code either way. +/// An editor has the opposite economics: fix, recompile, find the next is the +/// loop a live checker removes. +#[test] +fn every_broken_rule_is_reported() { + let checked = check( + "name: Several, + persist: true, + columns: { id: u64 primary_key, label: String }, + indexes: { label_idx: label unique using congee }, + config: { page_size: 4096 }", + ); + + assert!(checked.schema.is_some()); + assert!( + checked.diagnostics.len() >= 2, + "expected the backend and the page size, got: {:?}", + checked.diagnostics.iter().map(|d| &d.message).collect::>() + ); + assert!( + checked + .diagnostics + .iter() + .any(|d| d.message.contains("does not support key type")) + ); + assert!(checked.diagnostics.iter().any(|d| d.message.contains("page_size"))); +} + +/// A grammar failure says so, and produces nothing. +#[test] +fn a_grammar_failure_is_distinguished_from_a_rule_failure() { + let checked = check("name: Half, columns: { id: u64 primary_key, x: }"); + + assert!(checked.schema.is_none(), "there is no schema to draw"); + assert_eq!(checked.diagnostics.len(), 1); + assert_eq!(checked.diagnostics[0].stage, Stage::Grammar); +} + +/// An unbalanced brace, which is the state a declaration is in for most of the +/// time somebody is typing one, is reported rather than panicking. +#[test] +fn an_unclosed_brace_is_a_diagnostic() { + let checked = check("name: Typing, columns: { id: u64 primary_key"); + + assert!(checked.schema.is_none()); + assert_eq!(checked.diagnostics[0].stage, Stage::Grammar); +} + +/// Without the `spans` feature the location is absent, never wrong. +#[cfg(not(feature = "spans"))] +#[test] +fn a_diagnostic_without_the_spans_feature_carries_no_location() { + let checked = check("name: Bad, columns: { id: u64 primary_key, x: }"); + assert!(checked.diagnostics[0].span.is_none()); +} + +/// With `spans`, the range points at the offending text in the input. +/// +/// Asserted by slicing the source with the range rather than by comparing +/// offsets: an off-by-one in either direction produces a plausible-looking +/// number and a wrong underline, and only the slice catches that. +#[cfg(feature = "spans")] +#[test] +fn a_diagnostic_points_at_the_offending_text() { + let source = "name: Bad, + persist: false, + columns: { id: u64 primary_key, label: String }, + indexes: { label_idx: label unique using congee }"; + let checked = check(source); + + let span = checked.diagnostics[0].span.expect("the spans feature is on"); + assert_eq!(&source[span.start..span.end], "label_idx"); +} + +/// An index over a column that does not exist. +/// +/// The macro never reaches this rule: its own parse fails first, so the +/// validator was written with an `expect` on the column being present. +/// `check` does reach it, because it runs the same rules over whatever a +/// person has typed, and an index naming a column that is not there yet is +/// exactly what half-finished input looks like. It panicked, which for an +/// editor calling `check` on every keystroke is a crash rather than a squiggle. +#[test] +fn an_index_over_a_missing_column_is_reported_rather_than_panicking() { + let checked = worktable_dsl::check( + r#" + name: Broken, + columns: { id: u64 primary_key, email: String }, + indexes: { nope_idx: does_not_exist unique }, + "#, + ); + + assert!(!checked.is_acceptable(), "a dangling index must not be accepted"); + assert!( + checked + .diagnostics + .iter() + .any(|d| d.message.contains("nope_idx") && d.message.contains("not a column")), + "the diagnostic must name the index and say what is wrong: {:?}", + checked.diagnostics + ); +} + +/// The same schema with the column present is accepted, so the rule above is +/// rejecting the dangling reference and not the shape of the declaration. +#[test] +fn the_same_index_is_accepted_once_its_column_exists() { + let checked = worktable_dsl::check( + r#" + name: Fixed, + columns: { id: u64 primary_key, does_not_exist: String }, + indexes: { nope_idx: does_not_exist unique }, + "#, + ); + assert!(checked.is_acceptable(), "unexpected: {:?}", checked.diagnostics); +} + +/// `page_size` values a person can actually type that are not a `u32`. +/// +/// `Literal` accepts all of these; `u32::from_str` accepts none of them. The +/// parser used to unwrap, which inside the macro was a poor error and in the +/// public `check` API is a crash: an editor calling it per keystroke takes the +/// panic rather than showing a squiggle. +#[test] +fn a_page_size_that_is_not_a_u32_is_a_diagnostic_not_a_panic() { + for bad in ["16384u32", "4294967296", "1.5", "0x4000"] { + let checked = worktable_dsl::check(&format!( + "name: T, columns: {{ id: u64 primary_key }}, config: {{ page_size: {bad} }}," + )); + assert!(!checked.is_acceptable(), "`{bad}` must be rejected"); + assert!( + !checked.diagnostics.is_empty(), + "`{bad}` must produce a diagnostic rather than nothing" + ); + } +} + +/// The ordinary value still works, so the rule above rejects the literal form +/// and not the option. +#[test] +fn a_plain_page_size_is_still_accepted() { + let checked = worktable_dsl::check("name: T, columns: { id: u64 primary_key }, config: { page_size: 16384 },"); + assert!(checked.is_acceptable(), "unexpected: {:?}", checked.diagnostics); +} + +/// Several indexes breaking the same rule must all be reported. +/// +/// `index_backends_into` used to pick one offender: the primary if it +/// qualified, otherwise the first secondary found. A declaration with three +/// was reported as having one problem, which is the edit-check-fix-one loop +/// that collecting every failure exists to remove. +#[test] +fn every_index_that_needs_explicit_persistence_is_reported() { + let checked = worktable_dsl::check( + r#" + name: Several, + columns: { + id: u64 primary_key, + a: u64, + b: u64, + c: u64, + }, + indexes: { + a_idx: a unique using congee, + b_idx: b unique using congee, + c_idx: c unique using congee, + }, + "#, + ); + + assert!(!checked.is_acceptable()); + let reported = checked + .diagnostics + .iter() + .filter(|d| d.message.contains("persist")) + .count(); + assert_eq!( + reported, 3, + "all three indexes break the same rule; got {reported}: {:?}", + checked.diagnostics + ); +} + +/// `check` must refuse a primary key its backend cannot hold. +/// +/// It used to accept `id: String primary_key using congee` with no diagnostic +/// at all, and the macro then refused it at expansion. The key-type rule was +/// applied in a loop over `columns.indexes`, which holds the *secondary* +/// indexes; the primary backend was only ever checked against the persistence +/// rule. So an editor showed nothing and the build failed. +#[test] +fn a_primary_key_backend_is_checked_against_its_key_type() { + let refused = worktable_dsl::check( + "name: Probe, persist: false, columns: { id: String primary_key using congee, label: String },", + ); + assert!(!refused.is_acceptable(), "congee cannot hold a String primary key"); + assert!( + refused + .diagnostics + .iter() + .any(|d| d.message.contains("congee") && d.message.contains("String")), + "the diagnostic must name the backend and the key type: {:?}", + refused.diagnostics + ); + + // arctic has the same shape with a different list. + assert!( + !worktable_dsl::check("name: P, persist: false, columns: { id: u8 primary_key using arctic },").is_acceptable(), + "arctic does not hold u8" + ); + + // And a key type the backend does hold is still accepted. + assert!( + worktable_dsl::check("name: P, persist: false, columns: { id: u32 primary_key using congee },").is_acceptable() + ); +} + +/// `check` must refuse an `autoincrement` key type that cannot be generated. +/// +/// `usize` is the case worth naming: it is an integer, it reads like one of +/// the accepted set, and there is no `AtomicUsize` in codegen's mapping. This +/// also passed `check` and failed to build. +#[test] +fn autoincrement_is_checked_against_its_key_type() { + for bad in ["usize", "String", "u128", "i128", "isize", "bool"] { + let checked = worktable_dsl::check(&format!( + "name: Probe, persist: false, columns: {{ id: {bad} primary_key autoincrement }}," + )); + assert!(!checked.is_acceptable(), "`{bad}` cannot be autoincremented"); + } +} + +/// The two lists are one list. +/// +/// `worktable_codegen` maps exactly these types to atomics. If the set ever +/// grows on one side only, `check` starts accepting declarations that do not +/// build, which is the failure this module exists to prevent. +#[test] +fn every_autoincrement_type_is_accepted_by_check() { + for good in worktable_dsl::AUTOINCREMENT_TYPES { + let checked = worktable_dsl::check(&format!( + "name: Probe, persist: false, columns: {{ id: {good} primary_key autoincrement }}," + )); + assert!( + checked.is_acceptable(), + "`{good}` is in AUTOINCREMENT_TYPES but check refuses it: {:?}", + checked.diagnostics + ); + } +} diff --git a/dsl/tests/cli.rs b/dsl/tests/cli.rs new file mode 100644 index 00000000..bfcfe6b6 --- /dev/null +++ b/dsl/tests/cli.rs @@ -0,0 +1,104 @@ +//! The `wt-dsl` binary's contract. +//! +//! Exercised through the library rather than by spawning the binary, because +//! the binary is a thin shell over these calls and a test that spawns it +//! measures cargo more than it measures the language. What matters is the +//! contract the shell depends on: `parse` round-trips byte-exactly, `check` +//! separates a grammar failure from a rule failure, and a scan reports what it +//! could not read rather than dropping it. + +/// `wt-dsl parse` is a round trip, not a bare parse. +/// +/// A second implementation compares its output against this text, so the +/// property is byte equality after a second pass, not "it parsed". +#[test] +fn parse_output_is_byte_stable() { + let source = "name: Account, columns: { id: u64 primary_key autoincrement, email: String }, indexes: { email_idx: email unique },"; + let once = worktable_dsl::Schema::parse(source).expect("parse").to_dsl(); + let twice = worktable_dsl::Schema::parse(&once).expect("re-parse").to_dsl(); + assert_eq!( + once, twice, + "emitting is not a fixed point, so no byte comparison is possible" + ); +} + +/// `wt-dsl check` exits non-zero exactly when the macro would refuse. +#[test] +fn check_accepts_what_the_macro_accepts_and_refuses_what_it_refuses() { + assert!(worktable_dsl::check("name: T, columns: { id: u64 primary_key },").is_acceptable()); + + let bad = worktable_dsl::check("name: T, columns: { id: u64 primary_key }, indexes: { nope: missing unique },"); + assert!(!bad.is_acceptable(), "a dangling index must be refused"); + assert_eq!( + bad.diagnostics.len(), + 1, + "one problem, one diagnostic: {:?}", + bad.diagnostics + ); +} + +/// A grammar failure and a rule failure are different things to an editor: one +/// has nothing to draw, the other has a schema that can be rendered. +#[test] +fn check_separates_a_grammar_failure_from_a_rule_failure() { + let broken = worktable_dsl::check("name: Oops, columns: { id: u64 primary_key "); + assert!(broken.schema.is_none(), "a grammar failure produces no tree"); + assert_eq!(broken.diagnostics[0].stage, worktable_dsl::Stage::Grammar); + + let rule = worktable_dsl::check("name: T, columns: { id: u64 primary_key }, indexes: { nope: missing unique },"); + assert!(rule.schema.is_some(), "a rule failure still has a schema to draw"); + assert_eq!(rule.diagnostics[0].stage, worktable_dsl::Stage::Rules); +} + +/// `wt-dsl scan` finds declarations inside function bodies, which an item walk +/// would miss, and reports what it could not read instead of dropping it. +#[test] +fn scan_finds_declarations_in_a_function_body() { + let found = + worktable_dsl::declarations_in_source("fn f() { worktable!(name: Inner, columns: { id: u64 primary_key },); }") + .expect("the file tokenises"); + + assert_eq!( + found.schemas.len(), + 1, + "a declaration inside a function body was missed" + ); + assert_eq!(found.schemas[0].name, "Inner"); + assert!(found.is_complete()); +} + +/// `wt-dsl parse` must answer "would the macro accept this", not "is this a +/// declaration". +/// +/// The difference is not academic and this test exists because the first +/// version of the binary got it wrong. `page_size: 4096` beside +/// `persist: true` parses perfectly: it is a well-formed declaration. The +/// macro refuses it, because the on-disk layer hardcodes 16384-byte pages and +/// any other value reads and writes the wrong file offsets. +/// +/// A round trip built on `Schema::parse` therefore reports success for output +/// that does not compile, which is precisely the mistake a second +/// implementation would then ship, and the cross-implementation check exists +/// to stop. +#[test] +fn a_declaration_the_macro_refuses_is_not_a_successful_round_trip() { + let refused = "name: T, persist: true, columns: { id: u64 primary_key }, config: { page_size: 4096 },"; + + // It parses. That is the trap. + assert!( + worktable_dsl::Schema::parse(refused).is_ok(), + "the fixture must be a well-formed declaration, or it tests nothing" + ); + + // And the macro would refuse it, which is what the binary reports. + let checked = worktable_dsl::check(refused); + assert!( + !checked.is_acceptable(), + "the macro refuses this and so must the binary" + ); + assert!( + checked.diagnostics.iter().any(|d| d.message.contains("page_size")), + "the diagnostic must name the offending option: {:?}", + checked.diagnostics + ); +} diff --git a/dsl/tests/diff.rs b/dsl/tests/diff.rs new file mode 100644 index 00000000..812acba8 --- /dev/null +++ b/dsl/tests/diff.rs @@ -0,0 +1,387 @@ +//! What the migration planner promises, one claim per test. + +use worktable_dsl::{Change, Cost, Diff, Schema, TableChange, TransformReason, plan}; + +fn parse(source: &str) -> Schema { + Schema::parse(source).unwrap_or_else(|error| panic!("{error}\n{source}")) +} + +fn base() -> Schema { + parse( + " + name: User, + version: 1, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + age: u8, + }, + indexes: { email_idx: email unique } + ", + ) +} + +#[test] +fn a_schema_does_not_differ_from_itself() { + let diff = Diff::between(&base(), &base()); + assert!(diff.is_empty()); + assert_eq!(diff.cost(), Cost::Nothing); + assert!(diff.rows_are_readable()); + assert_eq!(diff.describe(), "`User` is unchanged"); +} + +#[test] +fn a_version_bump_on_its_own_costs_nothing() { + // The version is what triggers a migration, not what it costs. A binary + // that bumped the version and changed nothing else has nothing to do, and + // saying so is what keeps the bump cheap enough to be habitual. + let declared = parse( + " + name: User, + version: 2, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + age: u8, + }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.changes, vec![Change::Version { from: 1, to: 2 }]); + assert_eq!(diff.cost(), Cost::Nothing); + assert!(diff.rows_are_readable()); +} + +#[test] +fn an_added_index_leaves_the_rows_where_they_are() { + // Every index holds links, and rebuilding one reads rows that have not + // moved. Nothing is invalidated, so this is the cheap kind of change. + let declared = parse( + " + name: User, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + age: u8, + }, + indexes: { email_idx: email unique, age_idx: age } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.cost(), Cost::RebuildIndexes); + assert!(diff.rows_are_readable()); + assert!(diff.transforms_required().is_empty()); +} + +#[test] +fn an_added_optional_column_rewrites_rows_but_needs_no_decision() { + // The archived layout changes, so every link is invalidated and every row + // is copied forward. There is only one value the new column could hold in + // an existing row, so nobody has to be asked. + let declared = parse( + " + name: User, + version: 2, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + age: u8, + nickname: String optional, + }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.cost(), Cost::RewriteRows); + assert!(!diff.rows_are_readable()); + assert!(diff.transforms_required().is_empty()); +} + +#[test] +fn an_added_required_column_has_to_be_asked_about() { + let declared = parse( + " + name: User, + version: 2, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + age: u8, + nickname: String, + }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.cost(), Cost::RewriteRows); + let transforms = diff.transforms_required(); + assert_eq!(transforms.len(), 1); + assert_eq!(transforms[0].column, "nickname"); + assert_eq!( + transforms[0].reason, + TransformReason::NoValueToFillItWith { + ty: "String".to_string() + } + ); +} + +#[test] +fn a_type_change_has_to_be_asked_about() { + let declared = parse( + " + name: User, + version: 2, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + age: u32, + }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + let transforms = diff.transforms_required(); + assert_eq!(transforms.len(), 1); + assert_eq!( + transforms[0].reason, + TransformReason::NoConversionExists { + from: "u8".to_string(), + to: "u32".to_string(), + } + ); +} + +#[test] +fn widening_to_optional_is_decided_but_narrowing_is_not() { + // There is exactly one thing an existing value becomes when a column gains + // `optional`. There is no one thing a stored `None` becomes when it loses + // it, and that is a question about the data rather than about the schema. + let widened = parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key autoincrement, email: String, age: u8 optional }, + indexes: { email_idx: email unique } + ", + ); + assert!(Diff::between(&base(), &widened).transforms_required().is_empty()); + + let narrowed = Diff::between(&widened, &base()); + assert_eq!(narrowed.transforms_required().len(), 1); + assert_eq!( + narrowed.transforms_required()[0].reason, + TransformReason::NothingToPutWhereNoneWas + ); +} + +#[test] +fn reordering_columns_is_a_layout_change() { + // Declaration order is the generated row struct's field order, so moving a + // column changes the archived layout exactly as changing its type does. + // It is the change most likely to be made by accident and least likely to + // look like one. + let declared = parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key autoincrement, age: u8, email: String }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.cost(), Cost::RewriteRows); + assert!(diff.changes.iter().any(|change| matches!( + change, + Change::ColumnMoved { name, from: 1, to: 2 } if name == "email" + ))); +} + +#[test] +fn a_dropped_column_says_the_data_goes_with_it() { + let declared = parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key autoincrement, email: String }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.cost(), Cost::RewriteRows); + assert!( + diff.warnings() + .iter() + .any(|warning| warning.contains("`age` is dropped")) + ); +} + +#[test] +fn a_renamed_column_reads_as_a_drop_and_an_add() { + // Nothing in a declaration distinguishes a rename from a deletion next to + // an unrelated addition, and guessing by type would be wrong exactly when + // it mattered. The transform is where the intent goes. + let declared = parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key autoincrement, email_address: String, age: u8 }, + indexes: { email_idx: email_address unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert!( + diff.changes + .iter() + .any(|c| matches!(c, Change::ColumnAdded(column) if column.name == "email_address")) + ); + assert!( + diff.changes + .iter() + .any(|c| matches!(c, Change::ColumnDropped(column) if column.name == "email")) + ); + assert_eq!(diff.transforms_required().len(), 1); +} + +#[test] +fn a_changed_primary_key_needs_a_person() { + let declared = parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key, email: String primary_key, age: u8 }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.cost(), Cost::NeedsIntent); + assert!(!diff.rows_are_readable()); +} + +#[test] +fn a_changed_partition_key_needs_a_person() { + // The routing key is not in the row, so which partition a row belongs to + // cannot be recomputed from the row: it is only knowable from where the + // row already is. + let stored = parse("name: Price, columns: { id: u64 primary_key, bid: f64 }"); + let declared = + parse("name: Price, version: 2, partition_by: shard: u32, columns: { id: u64 primary_key, bid: f64 }"); + assert_eq!(Diff::between(&stored, &declared).cost(), Cost::NeedsIntent); +} + +#[test] +fn making_an_index_unique_says_it_can_still_fail() { + let stored = parse( + "name: User, persist: true, columns: { id: u64 primary_key, email: String }, indexes: { email_idx: email }", + ); + let declared = parse( + "name: User, version: 2, persist: true, columns: { id: u64 primary_key, email: String }, indexes: { email_idx: email unique }", + ); + let diff = Diff::between(&stored, &declared); + assert_eq!(diff.cost(), Cost::RebuildIndexes); + assert!(diff.warnings().iter().any(|warning| warning.contains("duplicate"))); +} + +#[test] +fn a_schema_change_without_a_version_bump_is_still_visible() { + // This is the middle branch of the load state machine: the versions agree + // and the schemas do not, which is a forgotten bump rather than a + // migration. Catching it costs one comparison of two small structs and no + // row access at all. + let declared = parse( + " + name: User, version: 1, persist: true, + columns: { id: u64 primary_key autoincrement, email: String, age: u32 }, + indexes: { email_idx: email unique } + ", + ); + let diff = Diff::between(&base(), &declared); + assert!( + !diff + .changes + .iter() + .any(|change| matches!(change, Change::Version { .. })) + ); + assert!(!diff.is_empty()); + assert!(!diff.rows_are_readable()); +} + +#[test] +fn queries_and_config_never_reach_the_data() { + let declared = parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key autoincrement, email: String, age: u8 }, + indexes: { email_idx: email unique }, + queries: { update: { Age(age) by id } }, + config: { row_derives: Clone } + ", + ); + let diff = Diff::between(&base(), &declared); + assert_eq!(diff.cost(), Cost::Nothing); + assert!(diff.rows_are_readable()); + assert!(diff.changes.contains(&Change::QueriesChanged)); + assert!(diff.changes.contains(&Change::ConfigChanged)); +} + +#[test] +fn a_plan_sorts_tables_into_created_changed_and_dropped() { + let stored = vec![ + base(), + parse("name: Legacy, persist: true, columns: { id: u64 primary_key }"), + ]; + let declared = vec![ + parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key autoincrement, email: String, age: u8, nickname: String optional }, + indexes: { email_idx: email unique } + ", + ), + parse("name: Session, persist: true, columns: { id: u64 primary_key }"), + ]; + + let plan = plan(&stored, &declared); + assert_eq!(plan.len(), 3); + assert!(plan.contains(&TableChange::Created("Session".to_string()))); + assert!(plan.contains(&TableChange::Dropped("Legacy".to_string()))); + assert!(plan.iter().any(|change| matches!( + change, + TableChange::Changed(diff) if diff.table == "User" && diff.cost() == Cost::RewriteRows + ))); +} + +#[test] +fn a_new_table_costs_nothing_and_a_dropped_one_is_never_assumed() { + // A table the binary declares and disk does not is created empty: there is + // nothing to move. A table on disk the binary no longer declares is a + // different matter, because deleting data is a decision rather than a + // consequence of a declaration. + assert_eq!(TableChange::Created("New".to_string()).cost(), Cost::Nothing); + assert_eq!(TableChange::Dropped("Old".to_string()).cost(), Cost::NeedsIntent); +} + +#[test] +fn an_unchanged_table_is_absent_from_the_plan() { + assert!(plan(&[base()], &[base()]).is_empty()); +} + +#[test] +fn the_report_names_the_cost_the_changes_and_what_is_still_needed() { + let declared = parse( + " + name: User, version: 2, persist: true, + columns: { id: u64 primary_key autoincrement, email: String, nickname: String }, + indexes: { email_idx: email unique } + ", + ); + let report = Diff::between(&base(), &declared).describe(); + assert!(report.contains("every row is copied forward")); + assert!(report.contains("version 1 -> 2")); + assert!(report.contains("column added: nickname: String")); + assert!(report.contains("column dropped: age: u8")); + assert!(report.contains("needs a transform written for:")); + assert!(report.contains("nickname: new non-optional column")); + assert!(report.contains("note: column `age` is dropped")); +} diff --git a/dsl/tests/never_panics.rs b/dsl/tests/never_panics.rs new file mode 100644 index 00000000..caa2b166 --- /dev/null +++ b/dsl/tests/never_panics.rs @@ -0,0 +1,193 @@ +//! `check` is the oracle every tool trusts: an editor calls it on each +//! keystroke, and a generator validates against it before emitting. Both feed +//! it text that is wrong in ways nobody enumerated, so the contract it has to +//! keep is that a bad declaration comes back as a diagnostic and never as a +//! panic. A proc macro that panics reports a compiler ICE-shaped error with no +//! span, which is the worst possible way to tell someone they typed a comma. +//! +//! This is a mutation sweep rather than a fuzzer: a fixed seed, valid +//! declarations as seeds, and the edits that actually happen while typing. +//! Deterministic, so a failure here is reproducible from the printed input. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +const SEEDS: &[&str] = &[ + "worktable!(name: T, columns: { id: u64 primary_key });", + "worktable!(name: T, persist: true, columns: { id: u64 primary_key autoincrement, v: String }, indexes: { v_idx: v unique });", + "worktable!(name: T, columns: { id: u32 primary_key using arctic, v: i64 }, indexes: { v_idx: v });", + "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update: { ById(v) by id } });", + "worktable!(name: T, persist: false, columns: { id: u8 primary_key using congee }, config: { page_size: 4096 });", +]; + +/// A tiny deterministic PRNG, so this needs no dependency and a failure is +/// reproducible from the seed alone. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } +} + +fn mutate(rng: &mut Rng, source: &str) -> String { + let mut bytes: Vec = source.chars().collect(); + if bytes.is_empty() { + return String::new(); + } + match rng.below(6) { + // Truncate: what every declaration looks like while it is typed. + 0 => bytes.truncate(rng.below(bytes.len())), + // Drop a character. + 1 => { + let at = rng.below(bytes.len()); + bytes.remove(at); + } + // Duplicate one, which is how doubled commas and braces arrive. + 2 => { + let at = rng.below(bytes.len()); + bytes.insert(at, bytes[at]); + } + // Swap two neighbours. + 3 => { + let at = rng.below(bytes.len().saturating_sub(1).max(1)); + if at + 1 < bytes.len() { + bytes.swap(at, at + 1); + } + } + // Insert a delimiter or separator somewhere it does not belong. + 4 => { + let at = rng.below(bytes.len()); + let c = [',', ':', '{', '}', '(', ')', ';'][rng.below(7)]; + bytes.insert(at, c); + } + // Replace a character with another from the source alphabet. + _ => { + let at = rng.below(bytes.len()); + let from = rng.below(bytes.len()); + bytes[at] = bytes[from]; + } + } + bytes.into_iter().collect() +} + +#[test] +fn check_never_panics_on_malformed_input() { + let mut rng = Rng(0x5EED_1234_ABCD_0001); + let mut failures = Vec::new(); + + for seed in SEEDS { + // Compounding edits, so the sweep reaches inputs several mistakes deep + // rather than only one edit from valid. + let mut current = seed.to_string(); + for round in 0..2_000 { + current = mutate(&mut rng, ¤t); + if catch_unwind(AssertUnwindSafe(|| { + let _ = worktable_dsl::check(¤t); + })) + .is_err() + { + failures.push(format!("round {round}: {current:?}")); + break; + } + // Restart from the seed periodically so one degenerate string does + // not swallow the rest of the budget. + if round % 50 == 49 { + current = seed.to_string(); + } + } + } + + assert!( + failures.is_empty(), + "`check` panicked on {} input(s) instead of returning diagnostics:\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// The mutation sweep above rarely produces input that *parses* and is then +/// semantically wrong, which is exactly where the known panics lived: a +/// `.expect` reached after the grammar was satisfied. These are written by +/// hand because a character-level mutator will not find them. +#[test] +fn check_reports_semantic_errors_rather_than_panicking() { + let cases: &[(&str, &str)] = &[ + ( + "autoincrement with no primary key at all", + "worktable!(name: T, columns: { id: u64 autoincrement, v: String });", + ), + ( + "autoincrement on a type that has no atomic", + "worktable!(name: T, columns: { id: usize primary_key autoincrement });", + ), + ( + "autoincrement on a string", + "worktable!(name: T, columns: { id: String primary_key autoincrement });", + ), + ( + "congee over a string key", + "worktable!(name: T, persist: false, columns: { id: String primary_key using congee });", + ), + ( + "arctic over a signed type it does not take", + "worktable!(name: T, persist: false, columns: { id: i8 primary_key using arctic });", + ), + ( + "congee on a non-unique secondary", + "worktable!(name: T, persist: false, columns: { id: u64 primary_key, v: u64 }, indexes: { v_idx: v using congee });", + ), + ( + "an ART backend with no persistence decision", + "worktable!(name: T, columns: { id: u64 primary_key using arctic });", + ), + ("no columns at all", "worktable!(name: T, columns: { });"), + ("no primary key", "worktable!(name: T, columns: { v: u64 });"), + ( + "two primary keys", + "worktable!(name: T, columns: { a: u64 primary_key, b: u64 primary_key });", + ), + ( + "an index over a column that does not exist", + "worktable!(name: T, columns: { id: u64 primary_key }, indexes: { ghost_idx: nope });", + ), + ( + "a query over a column that does not exist", + "worktable!(name: T, columns: { id: u64 primary_key }, queries: { update: { ById(nope) by id } });", + ), + ( + "a page size that is not a number", + "worktable!(name: T, persist: true, columns: { id: u64 primary_key }, config: { page_size: huge });", + ), + ]; + + let mut panicked = Vec::new(); + let mut accepted = Vec::new(); + for (what, source) in cases { + match catch_unwind(AssertUnwindSafe(|| worktable_dsl::check(source))) { + Err(_) => panicked.push(*what), + // Not panicking is the floor. The point of `check` is to answer + // "would the macro accept this", so silently accepting one of + // these is the same class of bug wearing a quieter face: a + // generator validating against it emits a declaration that then + // fails to compile. + Ok(checked) if checked.diagnostics.is_empty() => accepted.push(*what), + Ok(_) => {} + } + } + + assert!( + panicked.is_empty(), + "`check` panicked instead of reporting a diagnostic for: {panicked:?}" + ); + assert!( + accepted.is_empty(), + "`check` accepted declarations the macro refuses: {accepted:?}" + ); +} diff --git a/dsl/tests/readable_from_outside.rs b/dsl/tests/readable_from_outside.rs new file mode 100644 index 00000000..40c441e8 --- /dev/null +++ b/dsl/tests/readable_from_outside.rs @@ -0,0 +1,88 @@ +//! A schema can be read by a crate that is not the macro. +//! +//! This is the whole point of the extraction, so it gets an explicit test +//! rather than trusting that the code moved. Before it, every type here lived +//! in a `proc-macro = true` crate, which can export nothing but macros: the +//! parser existed, understood the grammar exactly as the compiler does, and was +//! unreachable. Anything wanting to read a declaration — a diagram, a migration +//! tool, a documentation generator — had to re-implement the grammar and drift +//! from it. +//! +//! An integration test is the right shape for that claim, because it compiles +//! as a separate crate. If `worktable_dsl` ever became a proc-macro crate +//! again, or stopped exporting these types, this file would fail to build, +//! which is a louder failure than an assertion. + +use worktable_dsl::Parser; + +/// Parse the shape of a real declaration, from outside. +#[test] +fn a_declaration_parses_into_a_model() { + let tokens: proc_macro2::TokenStream = r#" + name: Question, + columns: { + id: String primary_key, + project_id: String, + answered: bool, + } + "# + .parse() + .expect("the fixture is valid tokens"); + + let mut parser = Parser::new(tokens); + + let name = parser.parse_name().expect("a name is declared"); + assert_eq!(name.to_string(), "Question"); + + let columns = parser.parse_columns().expect("columns are declared"); + + let mut declared: Vec = columns.columns_map.keys().map(ToString::to_string).collect(); + declared.sort(); + assert_eq!(declared, ["answered", "id", "project_id"]); + + // The primary key is recognised as one rather than read as part of the + // type, which is the parse most likely to be silently wrong. + assert_eq!( + columns.primary_keys.first().map(ToString::to_string), + Some("id".to_owned()), + "the primary key should be identified: {:?}", + columns.primary_keys + ); +} + +/// Both public views retain declaration order. `columns_map` is directly +/// iterable by consumers; `field_positions` remains the explicit numeric view. +#[test] +fn declaration_order_is_recovered_from_field_positions() { + let tokens: proc_macro2::TokenStream = r#" + name: Question, + columns: { + id: String primary_key, + project_id: String, + answered: bool, + } + "# + .parse() + .expect("the fixture is valid tokens"); + + let mut parser = Parser::new(tokens); + parser.parse_name().expect("a name is declared"); + let columns = parser.parse_columns().expect("columns are declared"); + + let map_names: Vec = columns.columns_map.keys().map(ToString::to_string).collect(); + assert_eq!(map_names, ["id", "project_id", "answered"]); + + let mut ordered: Vec<(usize, String)> = columns + .field_positions + .iter() + .map(|(name, position)| (*position, name.to_string())) + .collect(); + ordered.sort(); + + let names: Vec = ordered.into_iter().map(|(_, name)| name).collect(); + assert_eq!( + names, + ["id", "project_id", "answered"], + "field_positions should recover the order the columns were declared in" + ); +} diff --git a/dsl/tests/round_trip.rs b/dsl/tests/round_trip.rs new file mode 100644 index 00000000..78fffdaa --- /dev/null +++ b/dsl/tests/round_trip.rs @@ -0,0 +1,115 @@ +//! The round trip, checked against every schema the project actually declares. +//! +//! A hand-written fixture proves the emitter handles the cases its author +//! thought of. The repository already contains 128 `worktable!` invocations +//! written by people who were not thinking about this crate at all, which is a +//! better corpus than anything written here would be: they use the grammar the +//! way it is really used, including the corners. +//! +//! The property is `parse(emit(parse(source))) == parse(source)`. It is stated +//! on the parsed form rather than the text because the emitter does not +//! reproduce formatting or comments and is not trying to: what has to survive +//! is the meaning. Comparing text would fail on whitespace and would say +//! nothing about whether anything was lost. +//! +//! This runs through `declarations_in_source`, so it is also the evidence that +//! the scanner finds what is there. + +use std::fs; +use std::path::{Path, PathBuf}; + +use worktable_dsl::{Schema, declarations_in_source}; + +fn rust_files(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + rust_files(&path, out); + } else if path.extension().is_some_and(|extension| extension == "rs") { + out.push(path); + } + } +} + +#[test] +fn every_declaration_in_the_repository_survives_a_round_trip() { + let repository = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crate is in the workspace"); + + let mut files = Vec::new(); + for directory in ["src", "tests", "benches", "examples", "codegen"] { + rust_files(&repository.join(directory), &mut files); + } + files.sort(); + assert!(!files.is_empty(), "found no sources to read"); + + let mut checked = 0; + let mut templates = 0; + let mut rejected = Vec::new(); + + for file in &files { + let Ok(contents) = fs::read_to_string(file) else { + continue; + }; + if !contents.contains("worktable!") { + continue; + } + let Ok(found) = declarations_in_source(&contents) else { + continue; + }; + + templates += found.templates.len(); + for (source, error) in found.rejected { + rejected.push(format!(" {}: {error}\n {source}", file.display())); + } + + for schema in found.schemas { + let emitted = schema.to_dsl(); + let reparsed = Schema::parse(&emitted).unwrap_or_else(|error| { + panic!( + "emitted declaration for `{}` from {} does not parse: {error}\n{emitted}", + schema.name, + file.display() + ) + }); + assert_eq!( + schema, + reparsed, + "round trip changed `{}` from {}\n{emitted}", + schema.name, + file.display() + ); + checked += 1; + } + } + + assert!( + rejected.is_empty(), + "{} declaration(s) the parser rejected:\n{}", + rejected.len(), + rejected.join("\n") + ); + assert!(checked >= 100, "only {checked} declarations were checked"); + assert!( + templates >= 12, + "the `macro_rules!` templates stopped being found, so the filter is now hiding \ + something else: {templates}" + ); +} + +#[test] +fn reading_the_same_declaration_twice_gives_the_same_schema() { + // Columns and query maps retain declaration order, so independently parsed + // schemas and their emitted text must agree exactly. + let source = " + name: Repeatable, + columns: { id: u64 primary_key, a: u64, b: u64, c: String }, + queries: { update: { A(a) by id, B(b) by id, C(c) by id } } + "; + let first = Schema::parse(source).expect("parses"); + let second = Schema::parse(source).expect("parses"); + assert_eq!(first, second); + assert_eq!(first.to_dsl(), second.to_dsl()); +} diff --git a/dsl/tests/schema.rs b/dsl/tests/schema.rs new file mode 100644 index 00000000..c3f36d7f --- /dev/null +++ b/dsl/tests/schema.rs @@ -0,0 +1,237 @@ +//! What the IR and the emitters promise, stated one claim per test. + +use worktable_dsl::{Schema, declarations_in_source}; + +fn parse(source: &str) -> Schema { + Schema::parse(source).unwrap_or_else(|error| panic!("{error}\n{source}")) +} + +#[test] +fn columns_are_in_declaration_order() { + // Declaration order is part of the public model contract. A consumer + // walking the model or schema must see the same layout the author wrote. + let schema = parse( + " + name: Answer, + columns: { + id: u64 primary_key autoincrement, + project_id: u64, + answered: bool, + } + ", + ); + let names: Vec<&str> = schema.columns.iter().map(|column| column.name.as_str()).collect(); + assert_eq!(names, ["id", "project_id", "answered"]); +} + +#[test] +fn queries_are_in_declaration_order() { + // Query order is preserved as well, so schema tools and generated macro + // output are both deterministic without rewriting the author's order. + let schema = parse( + " + name: Sorted, + columns: { id: u64 primary_key, a: u64, b: u64, c: u64 }, + queries: { update: { Charlie(c) by id, Alpha(a) by id, Bravo(b) by id } } + ", + ); + let names: Vec<&str> = schema.queries.updates.iter().map(|q| q.name.as_str()).collect(); + assert_eq!(names, ["Charlie", "Alpha", "Bravo"]); +} + +#[test] +fn optional_is_recovered_from_the_type_the_model_stores() { + // `optional` is not kept as a flag past the parser: `try_from_rows` folds + // it into the type, which becomes `core::option::Option`. Reading it + // back out is the only way the emitter can write the keyword again. + let schema = parse( + " + name: Optionals, + columns: { + id: u64 primary_key, + nickname: String optional, + age: u8, + } + ", + ); + let nickname = schema.column("nickname").expect("declared"); + assert_eq!(nickname.ty, "String"); + assert!(nickname.optional); + assert!(!schema.column("age").expect("declared").optional); + assert!(schema.to_dsl().contains("nickname: String optional,")); +} + +#[test] +fn an_omitted_persist_is_not_written_back() { + // `Omitted` and `MemoryOnly` are different answers. The macro requires an + // explicit `persist: false` before it will accept an index backend that + // cannot persist, so writing one in would answer a question the author + // deliberately left open. + let omitted = parse("name: Omitted, columns: { id: u64 primary_key }"); + assert!(!omitted.to_dsl().contains("persist")); + + let explicit = parse("name: Explicit, persist: false, columns: { id: u64 primary_key }"); + assert!(explicit.to_dsl().contains("persist: false,")); +} + +#[test] +fn only_a_deliberate_backend_choice_is_written_back() { + // A primary key always carries a backend once parsed, because the model + // fills the default in. Emitting `using worktables_index` everywhere would + // round-trip correctly and read like noise. + let default = parse("name: Default, columns: { id: u64 primary_key }"); + assert!(!default.to_dsl().contains("using")); + + let chosen = parse("name: Chosen, persist: false, columns: { id: u64 primary_key using congee }"); + assert!(chosen.to_dsl().contains("id: u64 primary_key using congee,")); +} + +#[test] +fn the_emitted_body_wraps_into_an_invocation() { + let schema = parse("name: Wrapped, columns: { id: u64 primary_key }"); + let invocation = schema.to_macro_invocation(); + assert!(invocation.starts_with("worktable! {\n")); + assert!(invocation.ends_with("}\n")); + assert!(invocation.contains(" name: Wrapped,")); +} + +#[cfg(feature = "serde")] +#[test] +fn a_schema_survives_a_trip_through_serde() { + // This is the property the migration planner rests on: a schema written + // beside the data it describes has to come back the same, in a process + // that never saw the Rust type it was generated from. + let schema = parse( + " + name: Stored, + version: 4, + persist: true, + partition_by: shard: u32, + columns: { + id: u64 primary_key autoincrement using congee, + payload: String optional, + }, + indexes: { payload_idx: payload unique }, + queries: { update: { Payload(payload) by id } }, + config: { page_size: 16384, row_derives: Clone, Debug } + ", + ); + let encoded = serde_json::to_string(&schema).expect("serialises"); + let decoded: Schema = serde_json::from_str(&encoded).expect("deserialises"); + assert_eq!(schema, decoded); + assert_eq!(schema.to_dsl(), decoded.to_dsl()); +} + +#[test] +fn the_scanner_reads_both_delimiter_forms_and_nested_invocations() { + // An invocation inside a function body is not an item, and real code puts + // them there, so an item walk would quietly miss a table. + let source = r#" + worktable!( + name: Braced, + columns: { id: u64 primary_key }, + ); + + mod inner { + worktable! { + name: Nested, + columns: { id: u64 primary_key }, + } + } + + fn in_a_body() { + worktable! { + name: InABody, + columns: { id: u64 primary_key }, + } + } + "#; + + let found = declarations_in_source(source).expect("the source tokenises"); + let names: Vec<&str> = found.schemas.iter().map(|schema| schema.name.as_str()).collect(); + assert_eq!(names, ["Braced", "Nested", "InABody"]); + assert!(found.is_complete()); + assert_eq!(found.found(), 3); +} + +#[test] +fn the_scanner_sets_templates_aside_and_reports_real_failures() { + // A `macro_rules!` body is not a declaration and its metavariables are not + // mistakes. A declaration the compiler would accept but this cannot read is + // a different matter, and dropping it silently would be worse than saying + // so. + let source = r#" + macro_rules! table_for { + ($name:ident, $backend:ident) => { + worktable! { + name: $name, + columns: { id: u64 primary_key using $backend }, + } + }; + } + + worktable! { + name: Broken, + columns: { id: u64 primary_key }, + nonsense: { whatever: 1 }, + } + "#; + + let found = declarations_in_source(source).expect("the source tokenises"); + assert!(found.schemas.is_empty()); + assert_eq!(found.templates.len(), 1); + assert_eq!(found.rejected.len(), 1); + assert!(!found.is_complete()); + // The rejection names the token it tripped on. The old text said + // "Unexpected identifier" for anything at all, including a `,`, which + // named the wrong category of token and sent the reader hunting for a + // misspelled keyword. + let reason = found.rejected[0].1.to_string(); + assert!( + reason.contains("nonsense"), + "the rejection should name the block it did not recognise, got: {reason}" + ); +} + +/// A primary key's generator changing is a migration, not a no-op. +/// +/// `ColumnSpec` has always recorded the generator, but `diff_columns` compared +/// type, optionality and position only. Moving a key between `autoincrement` +/// and caller-supplied therefore planned as no change at all, while it changes +/// who is responsible for uniqueness and what generator state a persisted +/// table has to carry. A migration tool would have reported nothing to do. +#[test] +fn a_primary_key_generator_change_is_planned() { + let with_gen = worktable_dsl::Schema::parse("name: T, columns: { id: u64 primary_key autoincrement },") + .expect("schema with a generator"); + let without = + worktable_dsl::Schema::parse("name: T, columns: { id: u64 primary_key },").expect("schema without one"); + + let changes = worktable_dsl::plan(std::slice::from_ref(&with_gen), std::slice::from_ref(&without)); + assert!( + !changes.is_empty(), + "dropping the generator planned as no change at all" + ); + + let described = format!("{changes:?}"); + assert!( + described.contains("PrimaryKeyGeneratorChanged"), + "the change must name the generator: {described}" + ); + + // And it needs a human decision rather than being applied silently. + let worktable_dsl::TableChange::Changed(diff) = &changes[0] else { + panic!("expected a changed table, got {:?}", changes[0]); + }; + assert_eq!( + diff.cost(), + worktable_dsl::Cost::NeedsIntent, + "changing how identities are assigned cannot be a mechanical migration" + ); + + // Symmetric: adding one is equally a change. + assert!( + !worktable_dsl::plan(std::slice::from_ref(&without), std::slice::from_ref(&with_gen)).is_empty(), + "adding a generator planned as no change either" + ); +} diff --git a/dsl/tests/trailing_commas.rs b/dsl/tests/trailing_commas.rs new file mode 100644 index 00000000..e6f27674 --- /dev/null +++ b/dsl/tests/trailing_commas.rs @@ -0,0 +1,95 @@ +//! Every block accepts a trailing comma, and every block accepts its absence. +//! +//! Three of the six block parsers consumed the comma that may follow their +//! block and three did not, so `config: { .. },` reached the top-level +//! dispatch as a `,` token and died as "Unexpected identifier". `config`, +//! `delete` and `in_place` happen to be written last in every declaration in +//! this repository, which is the only reason it had not been hit. +//! +//! It matters for the designer specifically: the emitter writes a declaration +//! and the parser reads it back, so an asymmetry here is a round trip that +//! fails on the tool's own output the moment block order changes. +//! +//! Each case below fails on the pre-fix parser. That was checked by reverting +//! the three `try_parse_comma()` calls and watching this file go red, rather +//! than by assuming a new test tests something. + +use worktable_dsl::Schema; + +/// The block that must not be last: everything after `config` was unreachable. +#[test] +fn a_comma_after_config_is_accepted() { + let schema = Schema::parse( + "name: Trailing, + columns: { id: u64 primary_key, payload: String }, + config: { page_size: 4096 },", + ) + .expect("a comma after the `config` block is a comma, not an identifier"); + + assert_eq!(schema.name, "Trailing"); + assert_eq!(schema.config.page_size, Some(4096)); +} + +/// `config` written before `queries`, which the comma asymmetry forbade. +#[test] +fn config_does_not_have_to_be_written_last() { + let schema = Schema::parse( + "name: Ordered, + columns: { id: u64 primary_key, name: String }, + config: { page_size: 8192 }, + queries: { update: { Renamed(name) by id, } }", + ) + .expect("block order should not depend on which parser eats a comma"); + + assert_eq!(schema.config.page_size, Some(8192)); + assert_eq!(schema.queries.updates.len(), 1); +} + +/// The same asymmetry inside `queries`, where `delete` and `in_place` sat. +#[test] +fn a_comma_after_delete_or_in_place_is_accepted() { + let schema = Schema::parse( + "name: Inner, + columns: { id: u64 primary_key, name: String }, + queries: { + delete: { ByName() by name, }, + in_place: { SetName(name) by id, }, + update: { Renamed(name) by id, } + }", + ) + .expect("`delete` and `in_place` should not have to be written last either"); + + assert_eq!(schema.queries.deletes.len(), 1); + assert_eq!(schema.queries.in_place.len(), 1); + assert_eq!(schema.queries.updates.len(), 1); +} + +/// Omitting the comma stays valid. The fix is permissive, not a new rule. +#[test] +fn omitting_the_comma_is_still_accepted() { + let schema = Schema::parse( + "name: NoComma, + columns: { id: u64 primary_key }, + config: { page_size: 4096 }", + ) + .expect("the form the emitter writes must keep parsing"); + + assert_eq!(schema.config.page_size, Some(4096)); +} + +/// A genuinely unexpected token names itself now. +/// +/// The old message said "Unexpected identifier" for a `,`, which is what made +/// this class of bug cost an afternoon: the text names the wrong category of +/// token and sends you looking for a misspelled keyword. +#[test] +fn an_unexpected_token_is_named() { + let error = + Schema::parse("name: Bad, columns: { id: u64 primary_key }, wat: { x: 1 }").expect_err("`wat` is not a block"); + + let message = error.to_string(); + assert!( + message.contains("wat"), + "the error should name the token it saw, got: {message}" + ); +} diff --git a/dsl/tests/uml.rs b/dsl/tests/uml.rs new file mode 100644 index 00000000..b584e3ad --- /dev/null +++ b/dsl/tests/uml.rs @@ -0,0 +1,144 @@ +//! The Mermaid emitter and the relation guessing it rests on. +//! +//! Separate from `schema.rs` because this is the one part of `worktable_dsl` +//! that is not about the `worktable!` grammar. The language has no foreign +//! keys, so `infer_relations` guesses links from a naming convention that +//! WorkTable does not enforce and has no opinion about; that mapping belongs +//! to whatever application draws the diagram. Hence the `uml` feature, and +//! hence these tests living behind it rather than beside the ones that test +//! what the compiler actually enforces. +#![cfg(feature = "uml")] + +use worktable_dsl::{Schema, infer_relations, schemas_to_mermaid}; + +fn parse(source: &str) -> Schema { + Schema::parse(source).unwrap_or_else(|error| panic!("{error}\n{source}")) +} +#[test] +fn mermaid_marks_the_key_the_generator_and_the_indexes() { + let schema = parse( + " + name: Account, + version: 3, + persist: true, + columns: { + id: u64 primary_key autoincrement, + email: String, + tenant: u64, + nickname: String optional, + }, + indexes: { + email_idx: email unique, + tenant_idx: tenant, + } + ", + ); + let diagram = schema.to_mermaid(); + + assert!(diagram.starts_with("classDiagram\n")); + assert!(diagram.contains("class Account {")); + assert!(diagram.contains("<>")); + assert!(diagram.contains("+id : u64 [PK, autoincrement]")); + assert!(diagram.contains("+email : String [UK email_idx]")); + assert!(diagram.contains("+tenant : u64 [IX tenant_idx]")); + // Mermaid spells a generic with tildes. + assert!(diagram.contains("+nickname : Option~String~")); +} + +#[test] +fn mermaid_draws_queries_as_operations() { + let schema = parse( + " + name: Ledger, + columns: { id: u64 primary_key, balance: f64, note: String }, + queries: { + update: { Balance(balance) by id } + delete: { ById() by id } + } + ", + ); + let diagram = schema.to_mermaid(); + assert!(diagram.contains("+update_Balance(balance) by_id")); + assert!(diagram.contains("+delete_ById() by_id")); +} + +#[test] +fn mermaid_puts_the_partition_key_in_a_note_not_a_column() { + // The routing key is stored once per partition rather than once per row, + // and no query can name it, so drawing it as an attribute would be a lie + // about where the data lives. + let schema = parse( + " + name: Price, + partition_by: symbol_id: u16, + columns: { exchange_id: u8 primary_key, bid: f64 } + ", + ); + let diagram = schema.to_mermaid(); + assert!(diagram.contains("note for Price \"partitioned by symbol_id: u16\"")); + assert!(!diagram.contains("symbol_id : u16")); +} + +fn related() -> Vec { + vec![ + parse("name: Project, columns: { id: u64 primary_key autoincrement, title: String }"), + parse( + " + name: Answer, + columns: { + id: u64 primary_key autoincrement, + project_id: u64, + body: String, + } + ", + ), + ] +} + +#[test] +fn a_reference_is_inferred_from_the_naming_convention() { + let relations = infer_relations(&related()); + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].from, "Answer"); + assert_eq!(relations[0].column, "project_id"); + assert_eq!(relations[0].to, "Project"); + assert_eq!(relations[0].to_column, "id"); +} + +#[test] +fn an_inferred_reference_is_drawn_as_a_dependency() { + // Dashed, because the declaration does not say this. A solid association + // would claim the schema language has foreign keys, and it does not. + let diagram = schemas_to_mermaid(&related()); + assert!(diagram.contains("Answer ..> Project : project_id")); +} + +#[test] +fn a_name_collision_on_a_different_type_is_not_a_reference() { + let schemas = vec![ + parse("name: Project, columns: { id: u64 primary_key, title: String }"), + parse("name: Answer, columns: { id: u64 primary_key, project_id: String }"), + ]; + assert!(infer_relations(&schemas).is_empty()); +} + +#[test] +fn a_composite_key_is_not_guessed_at() { + // There is no single column to point the arrow at, and picking one part of + // the key would be worse than drawing nothing. + let schemas = vec![ + parse("name: Project, columns: { tenant_id: u64 primary_key, id: u64 primary_key }"), + parse("name: Answer, columns: { id: u64 primary_key, project_id: u64 }"), + ]; + assert!(infer_relations(&schemas).is_empty()); +} + +#[test] +fn a_key_column_is_not_read_as_a_reference() { + // `project_id` here is half of this table's own identity, not a link out. + let schemas = vec![ + parse("name: Project, columns: { id: u64 primary_key, title: String }"), + parse("name: Answer, columns: { project_id: u64 primary_key, seq: u64 primary_key }"), + ]; + assert!(infer_relations(&schemas).is_empty()); +} diff --git a/examples/read_throughput.rs b/examples/read_throughput.rs index 1a63e11c..2640105b 100644 --- a/examples/read_throughput.rs +++ b/examples/read_throughput.rs @@ -91,10 +91,11 @@ fn write_pass(table: &Arc, links: &Arc>, threads: u64) (threads * WRITES_PER_THREAD) as f64 / elapsed.as_secs_f64() } -fn main() { +#[tokio::main] +async fn main() { let table = Arc::new(BenchWorkTable::default()); for id in 0..ROWS { - table.insert(BenchRow { id, value: id * 3 }).unwrap(); + table.insert(BenchRow { id, value: id * 3 }).await.unwrap(); } let links: Arc> = Arc::new( @@ -153,7 +154,7 @@ fn main() { rt.block_on(async { for i in 0..CHURN { let id = ROWS + (i % 64); - table.insert(BenchRow { id, value: i }).unwrap(); + table.insert(BenchRow { id, value: i }).await.unwrap(); table.delete(id).await.unwrap(); } }); diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index bb73cadc..a667366b 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -10,6 +10,25 @@ # than looped so that each job's arguments are the literal ones CI passes. set -u +# CI installs `dtolnay/rust-toolchain@stable`, which is whatever the newest +# stable release is on the day the job runs. This script uses whatever is +# default here. When those differ, every lint clippy added in between is +# invisible locally and fires in CI, and the failure looks like it came from +# nowhere: `clippy::for_unbounded_range` arrived in 1.98 and took a green PR +# red on code nobody had touched. +# +# So say which toolchain is being used, and say what CI will use, rather than +# letting a pass here be read as a pass there. +echo "=== toolchain ===" +rustc --version +if rustup check >/dev/null 2>&1; then + rustup check | grep -i "stable" || true +fi +echo "CI runs dtolnay/rust-toolchain@stable, i.e. the newest stable at run time." +echo "If the line above says a newer stable is available, this run cannot see" +echo "the lints that came with it." +echo + fail_count=0 failed="" diff --git a/src/in_memory/empty_link_registry.rs b/src/in_memory/empty_link_registry.rs index 8dbcb1ac..825c7ccc 100644 --- a/src/in_memory/empty_link_registry.rs +++ b/src/in_memory/empty_link_registry.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use data_bucket::Link; use data_bucket::page::PageId; @@ -7,7 +7,7 @@ use derive_more::Into; use indexset::concurrent::multimap::BTreeMultiMap; use indexset::concurrent::set::BTreeSet; use parking_lot::FairMutex; -use tokio::sync::OwnedRwLockReadGuard; +use tokio::sync::{Notify, OwnedRwLockReadGuard}; use crate::in_memory::DATA_INNER_LENGTH; @@ -85,8 +85,23 @@ pub struct EmptyLinkRegistry { /// Aggregate bytes across all registered empty links. u64: a u32 wraps /// once the aggregate passes 4 GiB of reclaimable space. + /// + /// Accounting only. It is a sum of lengths, so it cannot answer "is + /// anything registered" without assuming every mutation was real, and a + /// mutation that changed nothing used to move it anyway. sum_links_len: AtomicU64, + /// How many links are registered in the authoritative container. + /// + /// Separate from `sum_links_len` because the `pop_max` fast path needs + /// membership, not bytes, and membership is the thing that can be kept + /// exact: both counters now move only when `index_ord_links` actually + /// reports a change. Before that, a double remove drove the byte total to + /// zero while links were still registered, and the fast path then returned + /// `None` before taking the lock, so those links could never be reused and + /// inserts appended forever. + item_count: AtomicUsize, + pub(crate) op_lock: FairMutex<()>, /// Reader/writer exclusion between link consumers and vacuum. An insert @@ -95,6 +110,35 @@ pub struct EmptyLinkRegistry { /// while any popped link is still being written through, and no new link /// can be popped while vacuum runs. vacuum_lock: Arc>, + + /// How many times a caller has asked this registry for reclaimable space. + /// + /// This is what vacuum samples to answer "is now a good time". While + /// vacuum holds the exclusion every one of these attempts fails and the + /// caller allocates a fresh page instead, so the *rate* of attempts is + /// precisely the rate at which vacuum is making foreground inserts more + /// expensive. Counting demand rather than instrumenting the insert path + /// costs nothing extra: `pop_max` already loads an atomic here. + pop_attempts: AtomicU64, + + /// Reclaimable bytes at which a parked vacuum is woken, or `0` when + /// nothing is waiting. + /// + /// A timer cannot know when a table became fragmented; the registry can, + /// because it is the thing that grew. + vacuum_wake_threshold: AtomicU64, + + /// Wakes a vacuum parked in [`Self::wait_for_fragmentation`]. + vacuum_wake: Arc, + + /// Pages named by a *batched* delete, for the next sweep to look at first. + /// + /// A ranged or batched delete concentrates its freed space, so its pages + /// are the ones most likely to empty out entirely — the cheapest possible + /// reclamation, and the most valuable. A scattered single-row delete says + /// nothing about where to look, so [`Self::push`] does not record + /// anything and only [`Self::push_many`] does. + targeted_pages: FairMutex>, } /// A [`Link`] popped from the registry, together with the read guard that @@ -109,8 +153,13 @@ impl Default for EmptyLinkRegistry { length_ord_links: BTreeMultiMap::new(), page_links_map: BTreeMultiMap::new(), sum_links_len: Default::default(), + item_count: Default::default(), op_lock: Default::default(), vacuum_lock: Default::default(), + pop_attempts: Default::default(), + vacuum_wake_threshold: Default::default(), + vacuum_wake: Default::default(), + targeted_pages: Default::default(), } } } @@ -118,26 +167,41 @@ impl Default for EmptyLinkRegistry { impl EmptyLinkRegistry { pub fn remove_link>(&self, link: L) { let link = link.into(); - self.index_ord_links.remove(&IndexOrdLink(link)); + // `index_ord_links` is the authoritative membership set, and its + // `remove` reports whether anything was there. The counters follow + // that answer rather than the caller's intent: a remove for a link + // that is not registered must leave both untouched, or the aggregate + // drifts down past what is actually free. + let was_present = self.index_ord_links.remove(&IndexOrdLink(link)).is_some(); self.length_ord_links.remove(&link.length, &link); self.page_links_map.remove(&link.page_id, &link); - // Saturating: a remove for a link that is not accounted any more - // (e.g. a double remove) must not underflow the aggregate. - let _ = self - .sum_links_len - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| { - Some(v.saturating_sub(u64::from(link.length))) - }); + if was_present { + self.item_count.fetch_sub(1, Ordering::AcqRel); + // Saturating still, as a belt: the count is what the fast path + // trusts, and this is accounting. + let _ = self + .sum_links_len + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| { + Some(v.saturating_sub(u64::from(link.length))) + }); + } } fn insert_link>(&self, link: L) { let link = link.into(); - self.index_ord_links.insert(IndexOrdLink(link)); + // Symmetrically: `insert` reports false when an equal entry was + // already registered, and counting that would drift the other way, + // leaving the fast path convinced there is space that is not there. + let is_new = self.index_ord_links.insert(IndexOrdLink(link)); self.length_ord_links.insert(link.length, link); self.page_links_map.insert(link.page_id, link); - self.sum_links_len.fetch_add(u64::from(link.length), Ordering::AcqRel); + if is_new { + self.item_count.fetch_add(1, Ordering::AcqRel); + let freed = self.sum_links_len.fetch_add(u64::from(link.length), Ordering::AcqRel) + u64::from(link.length); + self.wake_vacuum_if_fragmented(freed); + } } pub fn remove_link_for_page(&self, page_id: PageId) { @@ -149,9 +213,74 @@ impl EmptyLinkRegistry { } pub fn push(&self, link: Link) { - let mut index_ord_link = IndexOrdLink(link); let _g = self.op_lock.lock(); + self.push_locked(IndexOrdLink(link)); + } + + /// Restores several freed links in one pass. + /// + /// Reclamation frees links in retirement order, and in the common case + /// those links are adjacent: a range delete, or any workload that deletes + /// in primary key order, frees a contiguous run of them. Pushed one at a + /// time each link pays its own coalesce, and a coalesce is up to two + /// removals and one insertion across three ordered containers, so freeing + /// `n` adjacent links costs `n` times that. Merging the batch against + /// itself first turns a whole run into a single insertion, and the lock is + /// taken once rather than `n` times. + pub fn push_many(&self, links: &[Link]) { + match links { + [] => return, + [link] => return self.push(*link), + _ => {} + } + + let runs = Self::merge_runs(links); + + // Recorded before the links are registered, so a sweep woken by the + // registration below already sees where to look. + self.note_coalesced_pages(links, &runs); + + let _g = self.op_lock.lock(); + for run in runs { + self.push_locked(run); + } + } + /// Merges a batch of freed links against itself, so a contiguous run + /// becomes one link before the registry ever sees it. + /// + /// Separate from [`push_many`] and pure, because the saving it represents + /// is invisible in the registry's final state: coalescing per link and + /// coalescing per run leave exactly the same contents, and differ only in + /// how much work they did to get there. A test can only hold onto that + /// difference by looking at this directly. + /// + /// [`push_many`]: Self::push_many + fn merge_runs(links: &[Link]) -> Vec> { + let mut sorted: Vec> = links.iter().copied().map(IndexOrdLink).collect(); + sorted.sort_unstable(); + + // `unite_with_right_neighbor` already encodes both the same-page + // requirement and the adjacency test, so merging the sorted batch is + // the same rule the registry applies against its own contents. + let mut runs: Vec> = Vec::with_capacity(sorted.len()); + for link in sorted { + match runs.last().and_then(|last| last.unite_with_right_neighbor(&link)) { + Some(united) => { + if let Some(last) = runs.last_mut() { + *last = united; + } + } + None => runs.push(link), + } + } + runs + } + + /// The body of [`push`], for callers already holding `op_lock`. + /// + /// [`push`]: Self::push + fn push_locked(&self, mut index_ord_link: IndexOrdLink) { { let mut iter = self.index_ord_links.range(..index_ord_link).rev(); if let Some(possible_left_neighbor) = iter.next() @@ -189,6 +318,31 @@ impl EmptyLinkRegistry { /// still writing through the link. The caller must hold the returned /// guard until that write completes. pub fn pop_max(&self) -> Option { + // Nothing registered means nothing to pop, and the whole body below + // exists only to choose which link to hand back. Checking one relaxed + // atomic first keeps an append-only table off both locks entirely. + // + // This is the hot path, not a corner: `DataPages::insert` calls + // `pop_max` on **every** insert, so before this an append-only workload + // took a global `FairMutex` per row to discover there was nothing to + // reuse. Under eight concurrent writers that mutex shows up in a + // profile as `RawMutex::lock_slow`. + // + // `Relaxed` is enough because the answer is a hint, not an invariant. + // A push landing concurrently can leave this reading zero, and the + // caller then appends a fresh row instead of reusing a link that became + // available a moment ago. That is the same outcome as calling one + // instruction earlier, and the link stays registered for the next + // insert. The reverse cannot happen: the counter is only non-zero when + // a link was registered, and the locked path below re-checks anyway. + // Counted before the early return, because an attempt that finds the + // registry empty is still a caller that wanted space. + self.pop_attempts.fetch_add(1, Ordering::Relaxed); + + if self.item_count.load(Ordering::Relaxed) == 0 { + return None; + } + let guard = self.vacuum_lock.clone().try_read_owned().ok()?; let _g = self.op_lock.lock(); @@ -224,12 +378,99 @@ impl EmptyLinkRegistry { pub async fn lock_vacuum(&self) -> tokio::sync::RwLockWriteGuard<'_, ()> { self.vacuum_lock.write().await } + + /// How many times a caller has asked for reclaimable space since the + /// registry was created. Monotonic; callers compare two samples. + pub fn pop_attempts(&self) -> u64 { + self.pop_attempts.load(Ordering::Relaxed) + } + + /// Reclaimable bytes currently registered. + pub fn reclaimable_bytes(&self) -> u64 { + self.sum_links_len.load(Ordering::Relaxed) + } + + /// Wake a vacuum parked in [`Self::wait_for_fragmentation`] once + /// reclaimable space reaches `bytes`. Passing `0` disables the wake. + pub fn set_vacuum_wake_threshold(&self, bytes: u64) { + self.vacuum_wake_threshold.store(bytes, Ordering::Release); + } + + /// Park until enough space has been freed to be worth reclaiming. + /// + /// Returns immediately when the threshold is already met, so a caller that + /// checks and then waits cannot miss the crossing that happened in + /// between. + pub async fn wait_for_fragmentation(&self) { + let threshold = self.vacuum_wake_threshold.load(Ordering::Acquire); + if threshold == 0 || self.sum_links_len.load(Ordering::Acquire) < threshold { + self.vacuum_wake.notified().await; + } + } + + /// Records the pages a batch freed *contiguously*. + /// + /// Every deferred reclamation arrives through [`Self::push_many`], so the + /// batch alone does not say a delete was ranged — a scattered delete is + /// batched too. Coalescing does say it: a run only forms when freed links + /// were adjacent on the same page, which is what a ranged delete produces + /// and a scattered one does not. So a page whose links merged is a page a + /// ranged delete emptied part of, and it is where a sweep should look + /// first. + fn note_coalesced_pages(&self, links: &[Link], runs: &[IndexOrdLink]) { + let mut links_per_page: std::collections::BTreeMap = Default::default(); + for link in links { + *links_per_page.entry(link.page_id).or_default() += 1; + } + let mut runs_per_page: std::collections::BTreeMap = Default::default(); + for run in runs { + *runs_per_page.entry(run.0.page_id).or_default() += 1; + } + + let coalesced: Vec = links_per_page + .into_iter() + .filter(|(page, count)| runs_per_page.get(page).copied().unwrap_or(0) < *count) + .map(|(page, _)| page) + .collect(); + if coalesced.is_empty() { + return; + } + self.targeted_pages.lock().extend(coalesced); + } + + /// Takes the pages named by batched deletes since the last call. + /// + /// Draining rather than reading: a sweep that has taken them is + /// responsible for them, and leaving them would make every later sweep + /// re-prioritise pages that are already compact. + pub fn take_targeted_pages(&self) -> std::collections::BTreeSet { + std::mem::take(&mut *self.targeted_pages.lock()) + } + + /// Wakes a parked vacuum when freeing crossed the configured threshold. + /// + /// Called on the delete path, so it must stay to a relaxed load in the + /// common case where nothing is waiting. + fn wake_vacuum_if_fragmented(&self, reclaimable: u64) { + let threshold = self.vacuum_wake_threshold.load(Ordering::Relaxed); + if threshold != 0 && reclaimable >= threshold { + self.vacuum_wake.notify_one(); + } + } } #[cfg(test)] mod tests { use super::*; + fn link(page_id: u32, offset: u32, length: u32) -> Link { + Link { + page_id: page_id.into(), + offset, + length, + } + } + #[test] fn test_unite_with_right_neighbor() { let left = IndexOrdLink::(Link { @@ -391,6 +632,167 @@ mod tests { assert_eq!(pop2.length, 50); } + /// The batch path must land the registry in exactly the state the + /// one-at-a-time path would, for every shape of input: adjacent, gapped, + /// out of order, spread across pages, and duplicated. Comparing the two + /// registries rather than asserting a hand-written expectation is what + /// gives this teeth: `push_many` merging a run that `push` would not, or + /// dropping a link at a page boundary, shows up as a difference here + /// without anyone having to predict it. + #[test] + fn push_many_agrees_with_pushing_one_at_a_time() { + let cases: Vec> = vec![ + // A contiguous run in order: the case reclamation actually hits. + (0..8).map(|i| link(1, i * 32, 32)).collect(), + // The same run shuffled, because retirement order is not + // allocation order once inserts reuse links. + vec![ + link(1, 96, 32), + link(1, 0, 32), + link(1, 224, 32), + link(1, 32, 32), + link(1, 160, 32), + ], + // Two runs with a live row between them. + vec![link(1, 0, 32), link(1, 32, 32), link(1, 128, 32), link(1, 160, 32)], + // Adjacent offsets on different pages must not merge. + vec![link(1, 0, 32), link(2, 32, 32), link(2, 0, 32), link(3, 0, 32)], + // Uneven lengths, so a wrong merge changes a length rather than + // just a count. + vec![link(4, 0, 16), link(4, 16, 48), link(4, 64, 8)], + // A repeated link. Neither path deduplicates, so neither may + // start. + vec![link(5, 0, 32), link(5, 0, 32)], + ]; + + for (case, links) in cases.iter().enumerate() { + let one_at_a_time = EmptyLinkRegistry::::default(); + for l in links { + one_at_a_time.push(*l); + } + + let batched = EmptyLinkRegistry::::default(); + batched.push_many(links); + + let expected: Vec = one_at_a_time.iter().collect(); + let actual: Vec = batched.iter().collect(); + assert_eq!(actual, expected, "case {case}: registries diverged"); + assert_eq!( + batched.get_empty_links_size_bytes(), + one_at_a_time.get_empty_links_size_bytes(), + "case {case}: byte totals diverged" + ); + } + } + + /// The reason the batch path exists, asserted where it is observable. + /// + /// It cannot be asserted through the registry: pushing eight adjacent + /// links one at a time and pushing them as one run both leave a registry + /// holding a single 256-byte link. The saving is the work done to get + /// there, so the merge is tested as the pure function it is. Deleting the + /// self-merge leaves every registry-level test green and fails this one. + #[test] + fn merging_a_batch_collapses_runs_before_the_registry_sees_them() { + // A contiguous run in retirement order. + let run: Vec = (0..8).map(|i| link(7, i * 32, 32)).collect(); + let merged = EmptyLinkRegistry::::merge_runs(&run); + assert_eq!(merged.len(), 1, "eight adjacent links are one insertion, not eight"); + assert_eq!(merged[0].0.offset, 0); + assert_eq!(merged[0].0.length, 8 * 32); + + // Out of order, because retirement order is not allocation order. + let shuffled = vec![link(7, 96, 32), link(7, 0, 32), link(7, 64, 32), link(7, 32, 32)]; + let merged = EmptyLinkRegistry::::merge_runs(&shuffled); + assert_eq!(merged.len(), 1, "sorting is what makes an out-of-order run mergeable"); + assert_eq!(merged[0].0.length, 128); + + // A gap splits the batch, and nothing merges across pages however + // adjacent the offsets look. + let split = vec![link(7, 0, 32), link(7, 32, 32), link(7, 128, 32)]; + assert_eq!(EmptyLinkRegistry::::merge_runs(&split).len(), 2); + let across_pages = vec![link(7, 0, 32), link(8, 32, 32)]; + assert_eq!( + EmptyLinkRegistry::::merge_runs(&across_pages).len(), + 2, + "a page boundary is not an adjacency" + ); + + // Nothing is invented or dropped: the merged bytes equal the input. + let total: u64 = run.iter().map(|l| u64::from(l.length)).sum(); + let merged_total: u64 = EmptyLinkRegistry::::merge_runs(&run) + .iter() + .map(|l| u64::from(l.0.length)) + .sum(); + assert_eq!(merged_total, total); + } + + /// A batch merged against itself still has to coalesce with what the + /// registry already holds on both sides. + #[test] + fn push_many_coalesces_with_links_already_registered() { + let registry = EmptyLinkRegistry::::default(); + registry.push(link(1, 0, 32)); + registry.push(link(1, 160, 32)); + + registry.push_many(&[link(1, 64, 32), link(1, 32, 32), link(1, 96, 32), link(1, 128, 32)]); + + assert_eq!(registry.len(), 1, "the batch should bridge the two registered links"); + let (popped, _guard) = registry.pop_max().unwrap(); + assert_eq!(popped.offset, 0); + assert_eq!(popped.length, 192); + } + + /// A remove for a link that is not registered must not move the counters. + /// + /// It used to. `remove_link` subtracted the length whether or not anything + /// was there, so removing the same link twice drove the aggregate to zero + /// while another link was still registered. The `pop_max` fast path then + /// returned `None` before taking the lock, and that surviving link could + /// never be reused: every insert appended instead, forever. + #[test] + fn a_double_remove_does_not_hide_a_surviving_link() { + let registry = EmptyLinkRegistry::::default(); + let a = link(1, 0, 100); + let b = link(1, 500, 100); + registry.push(a); + registry.push(b); + + let _g = registry.op_lock.lock(); + registry.remove_link(a); + registry.remove_link(a); + drop(_g); + + assert_eq!(registry.len(), 1, "b must still be registered"); + let (popped, _guard) = registry + .pop_max() + .expect("the surviving link must still be reachable through the fast path"); + assert_eq!(popped, b); + } + + /// And pushing the same link twice must not invent space that is not + /// there, which is the same defect with the sign flipped. + #[test] + fn a_duplicate_push_does_not_invent_space() { + let registry = EmptyLinkRegistry::::default(); + let a = link(2, 0, 100); + registry.push(a); + registry.push(a); + + assert_eq!(registry.len(), 1, "the same link is one entry"); + assert_eq!( + registry.get_empty_links_size_bytes(), + 100, + "byte total must match the one entry actually registered" + ); + let (popped, _guard) = registry.pop_max().expect("the entry is there"); + assert_eq!(popped, a); + assert!( + registry.pop_max().is_none(), + "one push and one duplicate is one link, not two" + ); + } + #[test] fn test_pop_max_returns_largest() { let registry = EmptyLinkRegistry::::default(); @@ -468,6 +870,52 @@ mod tests { assert_eq!(links.len(), 3); } + /// The fast path in `pop_max` must agree with the slow path. + /// + /// `pop_max` returns early when `sum_links_len` is zero, so a counter that + /// drifts above zero while the registry is empty costs a pointless lock, + /// and one that drifts to zero while links remain makes reuse stop + /// silently: inserts would append forever and the free list would never + /// drain. Neither shows up as a failing assertion anywhere else, because + /// appending is a correct way to insert. + /// + /// This pins the two together across a push/pop cycle. + #[test] + fn the_pop_fast_path_agrees_with_the_registry() { + let registry = EmptyLinkRegistry::::default(); + + // Empty: the counter says so, and popping takes the early return. + assert_eq!(registry.get_empty_links_size_bytes(), 0); + assert!(registry.is_empty()); + assert!(registry.pop_max().is_none(), "an empty registry has nothing to pop"); + + let link = Link { + page_id: 1.into(), + offset: 0, + length: 64, + }; + registry.push(link); + + // Non-empty: the counter must be non-zero, or `pop_max` would return + // early and this link would never be reused. + assert_ne!( + registry.get_empty_links_size_bytes(), + 0, + "a registered link left the counter at zero, so pop_max would skip it" + ); + let popped = registry.pop_max().expect("a registered link is poppable"); + assert_eq!(popped.0, link); + drop(popped); + + // Drained: back to agreeing. + assert_eq!( + registry.get_empty_links_size_bytes(), + 0, + "the counter did not return to zero after the only link was popped" + ); + assert!(registry.pop_max().is_none(), "a drained registry has nothing to pop"); + } + #[test] fn test_empty_registry() { let registry = EmptyLinkRegistry::::default(); @@ -521,6 +969,34 @@ mod tests { assert_eq!(registry.get_empty_links_size_bytes(), 0); } + /// A ranged delete frees adjacent links, which coalesce; a scattered one + /// frees links that do not. Only the first says anything about where the + /// next sweep should look, and every deferred reclamation arrives batched + /// either way — so the batch alone cannot be the signal. + #[test] + fn only_a_contiguous_batch_marks_its_pages_for_the_next_sweep() { + let registry = EmptyLinkRegistry::::default(); + + registry.push_many(&[link(1, 0, 10), link(1, 50, 10), link(1, 100, 10)]); + assert!( + registry.take_targeted_pages().is_empty(), + "a scattered batch names no page: nothing about it says where space is concentrated" + ); + + registry.push_many(&[link(2, 0, 10), link(2, 10, 10), link(2, 20, 10)]); + assert_eq!( + registry.take_targeted_pages().into_iter().collect::>(), + vec![PageId::from(2)], + "adjacent links coalesce, which is what a ranged delete leaves behind" + ); + + assert!( + registry.take_targeted_pages().is_empty(), + "taking must drain: a sweep that took a page owns it, and leaving it would \ + make every later sweep re-prioritise a page that is already compact" + ); + } + #[tokio::test] async fn test_lock_vacuum_prevents_pop() { let registry = EmptyLinkRegistry::::default(); diff --git a/src/in_memory/pages.rs b/src/in_memory/pages.rs index 80c36805..f714eefa 100644 --- a/src/in_memory/pages.rs +++ b/src/in_memory/pages.rs @@ -46,6 +46,25 @@ const RETIREMENT_BACKLOG_WARN_AT: usize = 1_024; /// for the next caller. const RECLAIM_BATCH_LIMIT: usize = 256; +/// How many retirements may wait before a *producer* of them absorbs a sweep. +/// +/// Reclamation exists to hand storage back for reuse, and the consumer of that +/// storage is `insert`, which already sweeps unconditionally before it looks +/// for a free link. A delete that also sweeps is doing the consumer's work on +/// the producer's thread, and doing it one item at a time, which is the +/// expensive way: freeing links in a batch lets the registry merge a +/// contiguous run into a single insertion instead of coalescing once per link. +/// +/// So the delete paths sweep only to keep the queue bounded, not to make space +/// available. This cap is what a delete-only workload accumulates before it +/// pays, and a sweep drains up to [`RECLAIM_BATCH_LIMIT`] at once, so matching +/// them means a triggered sweep clears the backlog it was triggered by. +/// +/// Deliberately not applied to `mark_page_empty` or `retire_published_link`. +/// Neither is a hot path, so deferring them would change when pages become +/// allocatable for no measurable gain. +const RECLAIM_BACKLOG_TRIGGER: usize = RECLAIM_BATCH_LIMIT; + fn mix_publication_offset(mut value: u64) -> u64 { value ^= value >> 30; value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9); @@ -206,6 +225,14 @@ where /// when there is no work pending. pending_retirements: AtomicUsize, + /// How many queued retirements are whole pages. A reclaim sweep has to + /// know whether any page retirement is queued behind the links it is + /// about to free, and the only way to answer that from the queue itself + /// is to scan all of it, on every sweep, which is quadratic in the + /// backlog. Pages are retired rarely, so counting them makes the answer + /// one relaxed load in the case that matters. + queued_page_retirements: AtomicUsize, + /// Pages vector. Currently, not lock free. pages: RwLock::WrappedRow, DATA_LENGTH>>>>, @@ -315,19 +342,67 @@ where /// thread can later collect it; it executes only after every reader /// pinned right now has unpinned. fn retire(&self, item: Retired) { + self.retire_many(std::iter::once(item)); + } + + /// Queue several retired items behind one grace marker. + /// + /// Retiring `n` items one at a time takes `n` domain advances, and an + /// advance is the expensive half: it is the only operation here that has to + /// decide what every reader can still reach. Retiring a batch behind a + /// single marker is the same guarantee, because the marker is stamped after + /// the whole batch is queued and therefore covers all of it. + /// + /// The lock is taken once for the batch rather than once per item, which + /// also stops a bulk delete interleaving its queue pushes with concurrent + /// mutations for no reason. + fn retire_many(&self, items: impl IntoIterator>) { + let mut queued = 0usize; + let mut queued_pages = 0usize; let len = { let mut retired = self.retired.lock(); - retired.push_back(item); + for item in items { + if matches!(item, Retired::Page(_)) { + queued_pages += 1; + } + retired.push_back(item); + queued += 1; + } + // Both counters are published while the queue lock is still held, + // because they are not statistics: `reclaim_retired` reads + // `queued_page_retirements` to decide whether it must look for + // page retirements queued behind the links it is about to free. + // + // Incrementing after the lock was released opened exactly the race + // the counter exists to close. A reclaimer with already-expired + // claims could take the queue in the gap, see the page retirement + // in it, read the counter as zero, skip building `queued_pages`, + // and hand an older link from that same page back to the + // allocator. A row written through that link is then destroyed + // when the page retirement matures and the page is reset. + // + // The queue and its two mirrors are one state transition. Anything + // that observes the queue must observe the counters that describe + // it. + if queued_pages > 0 { + self.queued_page_retirements.fetch_add(queued_pages, Ordering::Release); + } + self.pending_retirements.fetch_add(queued, Ordering::Release); retired.len() }; - self.pending_retirements.fetch_add(1, Ordering::Release); + if queued == 0 { + return; + } if len >= RETIREMENT_BACKLOG_WARN_AT && len.is_power_of_two() { tracing::warn!(len, "versioned publication retirement backlog is growing"); } let reclaimable = Arc::clone(&self.reclaimable); let guard = self.epoch.pin(); + // One marker for the whole batch. It is stamped now, so it expires only + // after every reader pinned now has unpinned, which is exactly the + // condition each item would have waited for individually. self.epoch.retire(move || { - reclaimable.fetch_add(1, Ordering::Release); + reclaimable.fetch_add(queued, Ordering::Release); }); drop(guard); self.epoch.advance(); @@ -339,6 +414,54 @@ where /// still active, that item (and everything after it) simply stays queued. /// Each call drains at most [`RECLAIM_BATCH_LIMIT`] items, so a mutation /// never absorbs an unbounded backlog inline. + /// Drains whatever retirements have expired, now. + /// + /// For consumers of freed storage that plan from the empty-link registry + /// rather than allocating through it. Vacuum is the one that matters: + /// it chooses which pages to compact from `get_per_page_info`, so with + /// reclamation deferred it would plan against a stale picture and skip + /// pages whose rows were deleted but not yet reclaimed. + /// + /// `insert` and `allocate_new_or_pop_free` do not need this. They already + /// sweep on their own path, because they are about to ask for storage + /// rather than to reason about it. + pub fn reclaim_pending(&self) { + // Drain everything currently reclaimable, not one batch of it. + // + // `reclaim_retired` deliberately stops at `RECLAIM_BATCH_LIMIT`, which + // is right on a mutation path: a delete must not absorb an unbounded + // backlog. It is wrong here. Vacuum plans from the empty-link registry + // immediately after this call, so with a backlog larger than one batch + // it would choose pages from a picture missing everything in the later + // batches, and a successful vacuum would leave reclaimable space + // untouched. + // + // Bounded by progress rather than by a count: each pass either shrinks + // the queue or is blocked by a live reader, and the second case stops + // the loop rather than spinning against a pin that is not going + // anywhere. + loop { + let before = self.pending_retirements.load(Ordering::Acquire); + if before == 0 { + return; + } + self.reclaim_retired(); + if self.pending_retirements.load(Ordering::Acquire) == before { + return; + } + } + } + + /// Reclaims only once the queue has grown past [`RECLAIM_BACKLOG_TRIGGER`]. + /// + /// For paths that retire storage rather than consume it. See that constant + /// for why they should not be sweeping on every call. + fn reclaim_if_backlogged(&self) { + if self.pending_retirements.load(Ordering::Acquire) >= RECLAIM_BACKLOG_TRIGGER { + self.reclaim_retired(); + } + } + fn reclaim_retired(&self) { if self.pending_retirements.load(Ordering::Acquire) == 0 { return; @@ -371,13 +494,29 @@ where // link is skipped when its page's retirement is queued anywhere // behind it, and a page purges its stale links on processing, so the // invariant holds across batch boundaries in both directions. - let queued_pages: HashSet = retired - .iter() - .filter_map(|item| match item { - Retired::Page(page_id) => Some(*page_id), - _ => None, - }) - .collect(); + // + // Building that set means reading the whole queue, so it is built + // only when a page retirement is actually queued. Otherwise every + // sweep would scan the entire backlog to learn there is nothing to + // skip, which is quadratic in the backlog and shows up the moment + // reclamation runs behind the mutations feeding it. + let queued_pages: HashSet = if self.queued_page_retirements.load(Ordering::Acquire) == 0 { + HashSet::new() + } else { + retired + .iter() + .filter_map(|item| match item { + Retired::Page(page_id) => Some(*page_id), + _ => None, + }) + .collect() + }; + + // Freed links are collected and restored together. Reclamation runs + // in retirement order, so a workload deleting in key order frees a + // contiguous run, and the registry can merge such a run into a single + // insertion instead of coalescing once per link. + let mut freed: Vec = Vec::new(); for _ in 0..take { let Some(item) = retired.pop_front() else { @@ -388,21 +527,33 @@ where let key = OffsetEqLink(link); self.published_rows[publication_shard(&key)].write().remove(&key); if !queued_pages.contains(&link.page_id) { - self.empty_links.push(link); + freed.push(link); } } Retired::Publication(key) => { self.published_rows[publication_shard(&key)].write().remove(&key); } Retired::Page(page_id) => { + // `queued_pages` has already kept this page's links out of + // the buffer, so flushing here is not what upholds the + // invariant. It keeps the buffer from outliving a purge + // regardless: batching is a change to *when* links are + // restored, and the one ordering that must not drift is + // restoring them after a page they belong to was reset. + self.empty_links.push_many(&freed); + freed.clear(); + // Purge stale fragments of this page from the link // allocator before exposing the whole page for reuse. self.empty_links.remove_link_for_page(page_id); self.empty_pages.write().push_back(page_id); + self.queued_page_retirements.fetch_sub(1, Ordering::Release); } } self.pending_retirements.fetch_sub(1, Ordering::Release); } + + self.empty_links.push_many(&freed); } pub fn new() -> Self { @@ -412,6 +563,7 @@ where retired: Mutex::new(VecDeque::new()), reclaimable: Arc::new(AtomicUsize::new(0)), pending_retirements: AtomicUsize::new(0), + queued_page_retirements: AtomicUsize::new(0), // We are starting ID's from `1` because `0`'s page in file is info page. pages: RwLock::new(vec![Arc::new(Data::new(1.into()))]), empty_links: EmptyLinkRegistry::::default(), @@ -434,6 +586,7 @@ where retired: Mutex::new(VecDeque::new()), reclaimable: Arc::new(AtomicUsize::new(0)), pending_retirements: AtomicUsize::new(0), + queued_page_retirements: AtomicUsize::new(0), pages: RwLock::new(vec), empty_links: EmptyLinkRegistry::default(), empty_pages: Default::default(), @@ -845,10 +998,64 @@ where self.row_count.fetch_sub(1, Ordering::Relaxed); self.retire(Retired::Link(link)); - self.reclaim_retired(); + self.reclaim_if_backlogged(); Ok(()) } + /// Ghost every link in `links`, behind one grace marker. + /// + /// Same per-row effect as calling [`Self::delete`] in a loop: each row is + /// marked deleted in place and its link is queued for reuse once no reader + /// can still reach it. The difference is that the batch takes one domain + /// advance and one reclaim pass instead of one of each per row, and an + /// advance is the expensive half of a retirement. + /// + /// Ghosting is done first, for all links, and the batch is retired only + /// after. A link must not become reusable while a later row in the same + /// batch is still being marked, or a concurrent insert could claim it and + /// be ghosted by this call. + /// + /// On error the links ghosted so far are still retired: they are genuinely + /// deleted, and dropping them from the queue would leak their storage for + /// the life of the table. The caller learns which link failed and how many + /// preceded it. + pub fn delete_many(&self, links: &[Link]) -> Result<(), ExecutionError> + where + Row: Archive + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + ::WrappedRow: + Archive + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper + + Portable + + Deserialize<::WrappedRow, HighDeserializer>, + { + if links.is_empty() { + return Ok(()); + } + + let mut ghosted = 0usize; + let mut failure = None; + for link in links { + match unsafe { self.with_mut_ref(*link, |r| r.delete()) } { + Ok(()) => ghosted += 1, + Err(error) => { + failure = Some(error); + break; + } + } + } + + if ghosted > 0 { + self.row_count.fetch_sub(ghosted as u64, Ordering::Relaxed); + self.retire_many(links[..ghosted].iter().map(|link| Retired::Link(*link))); + self.reclaim_if_backlogged(); + } + + match failure { + Some(error) => Err(error), + None => Ok(()), + } + } + pub fn select_raw(&self, link: Link) -> Result, ExecutionError> { let pages = self.pages.read(); let page = pages @@ -1014,6 +1221,33 @@ where self.empty_links.len() } + /// Data pages allocated, including any currently on the empty list. + /// + /// The table's memory is its pages, so this times `DATA_LENGTH` is what it + /// is holding from the allocator. It is the measure that verifies a + /// vacuum: reclaiming memory is the whole point of a sweep, and a cost + /// figure without it cannot be checked, because a sweep that never runs + /// looks exactly like a sweep that is free. + pub fn allocated_pages(&self) -> usize { + self.pages.read().len() + } + + /// Pages allocated but currently on the empty list, so reusable without + /// asking the allocator for more. + pub fn reusable_pages(&self) -> usize { + self.empty_pages.read().len() + } + + /// Retirements queued but not yet swept into the registry. + /// + /// This moves on every delete, where the registry only moves when the + /// backlog flushes, so it is the signal for "is a delete burst still + /// running" and the registry's byte total is not: between flushes the + /// bytes sit still while deletes are streaming. + pub fn pending_retirements(&self) -> usize { + self.pending_retirements.load(Ordering::Acquire) + } + pub fn empty_links_registry(&self) -> &EmptyLinkRegistry { &self.empty_links } @@ -1031,6 +1265,21 @@ where pub fn current_page_id(&self) -> PageId { self.current_page_id.load(Ordering::Acquire).into() } + + /// Makes an already allocated page the append target for a vacuum pass. + /// + /// Vacuum uses this only when the old current page is itself fragmented. + /// Rotating first lets that old page become a normal source while the new + /// current page serves as the sweep's first destination. Concurrent + /// inserts are safe: the insert path rechecks `current_page_id` under the + /// page barrier before writing and retries if the target changed. + pub(crate) fn rotate_current_for_vacuum(&self, page_id: PageId) { + debug_assert!( + self.get_page(page_id).is_some(), + "vacuum current page must be allocated" + ); + self.current_page_id.store(page_id.into(), Ordering::Release); + } } #[derive(Debug, Display, Error, From, PartialEq)] @@ -1503,6 +1752,106 @@ mod tests { ); } + /// The case a single-sweep test cannot reach: the link's grace period + /// expires before the page's, so one sweep frees the link while the whole + /// page retirement is still queued behind it. + /// + /// If the link were republished here, an insert could pop it and start + /// writing through it before the later sweep hands the same page to the + /// page allocator, which is two allocators owning overlapping storage. + /// The existing whole-page test processes both retirements in one sweep + /// and stays green even with the queued-page check disabled entirely; + /// this one does not. + #[test] + fn a_link_is_not_reused_while_its_page_retirement_is_still_queued() { + let pages = DataPages::::from_data(vec![ + Arc::new(Data::new(1.into())), + Arc::new(Data::new(2.into())), + Arc::new(Data::new(3.into())), + ]); + pages.current_page_id.store(2, Ordering::Release); + let old_link = pages.insert(TestRow { a: 1, b: 1 }).unwrap(); + unsafe { + pages.with_mut_ref(old_link, |row| row.unghost()).unwrap(); + } + pages.current_page_id.store(3, Ordering::Release); + + // Queue the row's retirement and then the page's, with a reader + // pinned throughout so neither is reclaimed inline. + let read_guard = pages.read_guard(); + pages.delete(old_link).unwrap(); + pages.mark_page_empty(old_link.page_id); + drop(read_guard); + + // Let exactly one retirement through. A sweep is capped at + // `RECLAIM_BATCH_LIMIT` and only drains what has actually expired, so + // a partial sweep is the ordinary case rather than a contrived one. + pages.reclaimable.store(1, Ordering::Release); + pages.reclaim_retired(); + + assert!( + pages + .get_empty_links() + .iter() + .all(|link| link.page_id != old_link.page_id), + "a link must not be handed back for reuse while its page is queued for whole-page reclamation" + ); + + // And once the page retirement is processed, the page itself is the + // thing that becomes reusable. + pages.reclaimable.store(1, Ordering::Release); + pages.reclaim_retired(); + assert!(pages.get_empty_pages().contains(&old_link.page_id)); + assert!( + pages + .get_empty_links() + .iter() + .all(|link| link.page_id != old_link.page_id), + "processing the page must not resurrect its inner links either" + ); + } + + /// A backlog larger than one reclaim batch must still be fully visible to + /// whoever asks for a drain. + /// + /// `reclaim_retired` stops at `RECLAIM_BATCH_LIMIT`, which is correct on a + /// mutation path and wrong for vacuum: it plans from the empty-link + /// registry immediately after asking, so one bounded pass left it choosing + /// pages from a picture missing everything past the first 256 entries. + #[test] + fn reclaim_pending_drains_a_backlog_larger_than_one_batch() { + let pages = DataPages::::new(); + + let mut links = Vec::new(); + for i in 0..(super::RECLAIM_BATCH_LIMIT * 3) { + let link = pages.insert(TestRow { a: i as u64, b: 0 }).unwrap(); + unsafe { + pages.with_mut_ref(link, |r| r.unghost()).unwrap(); + } + links.push(link); + } + // Ghost and retire them all without letting any mutation reclaim. + let guard = pages.read_guard(); + for link in &links { + pages.delete(*link).unwrap(); + } + drop(guard); + + assert!( + pages.pending_retirements.load(Ordering::Acquire) > super::RECLAIM_BATCH_LIMIT, + "the fixture must build a backlog bigger than one batch" + ); + + pages.reclaim_pending(); + + assert_eq!( + pages.pending_retirements.load(Ordering::Acquire), + 0, + "reclaim_pending left {} retirements queued, so vacuum would plan against a stale registry", + pages.pending_retirements.load(Ordering::Acquire) + ); + } + #[test] fn page_is_full_switch_does_not_orphan_empty_pages() { use data_bucket::page::PageId; @@ -1695,12 +2044,15 @@ mod tests { "delete must decrement row_count" ); - assert_eq!(pages.empty_links.pop_max().map(|(l, _)| l), Some(link)); - pages.empty_links.push(link); - + // The freed link is deliberately not registered for reuse yet: + // reclamation is driven by the consumer of the storage rather than the + // producer of it, so a delete queues the retirement and the next + // insert is what turns it back into free space. Asserting the + // registry's contents here would be asserting that timing rather than + // the guarantee, and the guarantee is the line below. let row = TestRow { a: 20, b: 20 }; let new_link = pages.insert(row).unwrap(); - assert_eq!(new_link, link); + assert_eq!(new_link, link, "the next insert must reuse the deleted row's storage"); assert_eq!( pages.row_count.load(Ordering::Relaxed), 1, diff --git a/src/index/arctic.rs b/src/index/arctic.rs index aa5f0827..329a74dd 100644 --- a/src/index/arctic.rs +++ b/src/index/arctic.rs @@ -87,6 +87,42 @@ macro_rules! impl_arctic_key { impl_arctic_key!(u16, u32, u64, u128); +/// Signed keys, mapped onto the unsigned key space by flipping the sign bit. +/// +/// An ART orders keys by the bytes of the key, and two's complement puts that +/// ordering the wrong way round: a negative has its high bit set, so it sorts +/// *after* every positive. XOR with the sign bit corrects exactly that. It is a +/// bijection over the full width and strictly monotonic, which is the only +/// property the tree needs, so `i64::MIN` lands at `0`, `-1` at `0x7fff_..._ffff`, +/// `0` at `0x8000_..._0000` and `i64::MAX` at `u64::MAX`. +/// +/// Being a bijection over the *whole* width is also what makes the exclusive +/// bounds in `raw_inclusive_bounds` correct: `next`/`previous` run in the raw +/// space, and adjacency is preserved because no raw value is unreachable. +/// +/// There is no `i8`, because Arctic's narrowest raw key is `u16`. +macro_rules! impl_arctic_signed_key { + ($($ty:ty => $raw:ty),* $(,)?) => { + $( + impl ArcticKey for $ty { + type Raw = $raw; + + #[inline] + fn to_arctic(&self) -> Self::Raw { + (*self as $raw) ^ ((1 as $raw) << (<$raw>::BITS - 1)) + } + + #[inline] + fn from_arctic(value: Self::Raw) -> Self { + (value ^ ((1 as $raw) << (<$raw>::BITS - 1))) as Self + } + } + )* + }; +} + +impl_arctic_signed_key!(i16 => u16, i32 => u32, i64 => u64, i128 => u128); + /// Arctic's lock-free adaptive radix tree with WorkTable's unique-index /// contract. /// @@ -125,6 +161,22 @@ where K::Raw: arctic::topology::Key, V: Clone + Debug + Send + Sync + 'static, { + /// Every entry, ascending. + /// + /// An inherent alias for [`UniqueIndex::iter_values`], so this reads the + /// same as the general-purpose backend does. Without it, code written + /// against that backend's inherent `iter` does not compile against this + /// one: a difference in spelling rather than in capability, which costs + /// the reader a detour and quietly pushes tests towards the one backend + /// whose name they already know. It caught out the vacuum invariant tests + /// twice. + /// + /// Materialises, exactly as `iter_values` does: the underlying map hands + /// out entries borrowed from a guard, so they are cloned rather than lent. + pub fn iter(&self) -> impl DoubleEndedIterator + '_ { + >::iter_values(self) + } + pub(crate) fn export_topology( &mut self, mut encode: impl FnMut(&V) -> T, @@ -269,6 +321,75 @@ mod tests { use super::{ArcticIndex, UniqueIndex}; + /// Two's complement puts negatives after positives when ordered by their + /// bytes, which is how an ART orders. If the sign-bit flip were missing or + /// applied on one side only, this is what would break, and it would break + /// silently: point lookups would still work. + #[test] + fn signed_keys_iterate_in_signed_order() { + let index = ArcticIndex::::default(); + let keys = [i64::MIN, -1_000, -1, 0, 1, 1_000, i64::MAX]; + // Inserted out of order, so passing cannot come from insertion order. + for key in [0, i64::MAX, -1, 1_000, i64::MIN, 1, -1_000] { + index.insert_value(key, key as u64); + } + + let seen: Vec = index.iter_values().map(|(key, _)| key).collect(); + assert_eq!( + seen, keys, + "an ART orders by key bytes, so signed order has to be built in" + ); + } + + /// The case a half-applied transform passes: a range that stays on one side + /// of zero is fine even when the mapping is wrong, so the range has to + /// cross the sign boundary to be evidence. + #[test] + fn signed_ranges_cross_the_sign_boundary() { + let index = ArcticIndex::::default(); + for key in -5..=5 { + index.insert_value(key, key as u64); + } + + let spanning: Vec = index.range_values(-2..=2).map(|(key, _)| key).collect(); + assert_eq!(spanning, vec![-2, -1, 0, 1, 2]); + + let below: Vec = index.range_values(..0).map(|(key, _)| key).collect(); + assert_eq!( + below, + vec![-5, -4, -3, -2, -1], + "zero must not sort below the negatives" + ); + + // Exclusive bounds are resolved by `next`/`previous` in the raw space. + // They are only correct because the mapping is onto the whole width. + let exclusive: Vec = index + .range_values((Bound::Excluded(-1), Bound::Excluded(1))) + .map(|(key, _)| key) + .collect(); + assert_eq!(exclusive, vec![0], "stepping across the sign boundary must land on 0"); + } + + /// The extremes are where an off-by-one in the flip shows up, and where a + /// `next`/`previous` that ran outside the mapping's image would. + #[test] + fn signed_keys_round_trip_at_the_extremes() { + let index = ArcticIndex::::default(); + for key in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] { + index.insert_value(key, 7); + assert_eq!(index.get_value(&key), Some(7), "{key} did not survive the mapping"); + } + + let from_min: Vec = index + .range_values(i64::MIN..=i64::MIN + 1) + .map(|(key, _)| key) + .collect(); + assert_eq!(from_min, vec![i64::MIN, i64::MIN + 1]); + + let to_max: Vec = index.range_values(i64::MAX - 1..).map(|(key, _)| key).collect(); + assert_eq!(to_max, vec![i64::MAX - 1, i64::MAX]); + } + #[test] fn implements_unique_index_contract() { let index = ArcticIndex::::default(); diff --git a/src/index/congee.rs b/src/index/congee.rs index 06e8a4d5..4d18ac67 100644 --- a/src/index/congee.rs +++ b/src/index/congee.rs @@ -89,6 +89,21 @@ where K: CongeeKey, V: Clone + Debug + Send + Sync + 'static, { + /// Every entry, ascending. + /// + /// An inherent alias for [`UniqueIndex::iter_values`], so this reads the + /// same as the general-purpose backend does. Without it, code written + /// against that backend's inherent `iter` does not compile against this + /// one: a difference in spelling rather than in capability, which costs + /// the reader a detour and quietly pushes tests towards the one backend + /// whose name they already know. It caught out the vacuum invariant tests + /// twice. + /// + /// Materialises, exactly as `iter_values` does. + pub fn iter(&self) -> impl DoubleEndedIterator + '_ { + >::iter_values(self) + } + #[inline] unsafe fn arc_from_pointer(pointer: usize) -> Arc { // SAFETY: callers guarantee that `pointer` was produced by diff --git a/src/lib.rs b/src/lib.rs index 021fa5d2..6e1c06de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,9 @@ pub use data_bucket; pub use worktable_codegen::migration_engine; pub use worktable_codegen::worktable; pub use worktable_codegen::worktable_version; +/// The schema language, so the declaration each table embeds can be read +/// without taking a second dependency and matching its version by hand. +pub use worktable_dsl; #[cfg(feature = "s3-support")] pub use worktable_codegen::s3_sync_persistence; @@ -52,12 +55,12 @@ pub mod prelude { pub use crate::table::system_info::{IndexInfo, IndexKind, SystemInfo}; pub use crate::util::{OffsetEqLink, OrderedF32Def, OrderedF64Def}; pub use crate::{ - ArcticIndex, ArcticKey, ArcticMultiIndex, AvailableIndex, BatchInsertError, CongeeIndex, CongeeKey, Difference, - IndexError, IndexMap, IndexMultiMap, MultiPairRecreate, PersistentArcticIndex, PersistentArcticMultiIndex, - PersistentArtIndex, PersistentCongeeIndex, PersistentWtiIndex, PrimaryIndex, TableIndex, TableIndexCdc, - TableRow, TableSecondaryIndex, TableSecondaryIndexCdc, TableSecondaryIndexEventsOps, TableSecondaryIndexInfo, - UniqueIndex, UnsizedNode, UpstreamIndexMap, UpstreamIndexPair, WorkTable, WorkTableError, - vacuum::EmptyDataVacuum, vacuum::VacuumPersistence, vacuum::WorkTableVacuum, + ArcticIndex, ArcticKey, ArcticMultiIndex, AvailableIndex, BatchDeleteError, BatchInsertError, CongeeIndex, + CongeeKey, Difference, IndexError, IndexMap, IndexMultiMap, MultiPairRecreate, PersistentArcticIndex, + PersistentArcticMultiIndex, PersistentArtIndex, PersistentCongeeIndex, PersistentWtiIndex, PrimaryIndex, + TableIndex, TableIndexCdc, TableRow, TableSecondaryIndex, TableSecondaryIndexCdc, TableSecondaryIndexEventsOps, + TableSecondaryIndexInfo, UniqueIndex, UnsizedNode, UpstreamIndexMap, UpstreamIndexPair, WorkTable, + WorkTableError, vacuum::EmptyDataVacuum, vacuum::VacuumPersistence, vacuum::WorkTableVacuum, }; pub use data_bucket::{ DATA_VERSION, DataPage, GENERAL_HEADER_SIZE, GeneralHeader, GeneralPage, INNER_PAGE_SIZE, IndexPage, Interval, diff --git a/src/lock/map.rs b/src/lock/map.rs index 4b424a68..ea79742e 100644 --- a/src/lock/map.rs +++ b/src/lock/map.rs @@ -29,6 +29,17 @@ pub struct MutationGuard { stripe: usize, } +/// Operation-wide activity signal for a chunked bulk mutation. +/// +/// It does not hold a row or stripe lock. Its only job is to keep background +/// vacuum out while a bulk operation deliberately releases locks between +/// chunks, so those gaps are not mistaken for the table becoming idle. +#[doc(hidden)] +#[derive(Debug)] +pub struct BulkMutationGuard { + active: Arc, +} + #[derive(Debug)] struct LockEntry { lock: Arc>, @@ -98,6 +109,12 @@ impl Drop for MutationGuard { } } +impl Drop for BulkMutationGuard { + fn drop(&mut self) { + self.active.fetch_sub(1, Ordering::Release); + } +} + /// Registry for per-row async locks and synchronous mutation stripes. /// /// # Sync/async lock boundary @@ -113,6 +130,7 @@ pub struct LockMap { map: RwLock>>, next_id: AtomicU16, mutation_stripes: Arc<[MutationStripe; MUTATION_STRIPE_COUNT]>, + bulk_mutations: Arc, } impl Default for LockMap { @@ -121,6 +139,7 @@ impl Default for LockMap { map: RwLock::new(HashMap::new()), next_id: AtomicU16::default(), mutation_stripes: Arc::new(std::array::from_fn(|_| MutationStripe::default())), + bulk_mutations: Arc::default(), } } } @@ -269,6 +288,53 @@ where (hasher.finish() as usize) % MUTATION_STRIPE_COUNT } + /// Mutation stripes currently held or being waited on. + /// + /// A live read of "is anything writing to this table right now", which is + /// what a background job needs before it takes an exclusion. Every insert, + /// delete and upsert passes through one of these gates, so unlike counting + /// requests for reclaimable space it cannot miss a workload: deletes never + /// ask for space at all, and an upsert that fits in place does not either, + /// so a sweep watching that signal saw an idle table under a load of + /// exactly those and walked straight in. + /// + /// Each stripe is a ticket lock, so a handed-out ticket that is not yet + /// being served is a writer either inside the gate or queued for it. + pub fn mutations_in_flight(&self) -> usize { + let striped = self + .mutation_stripes + .iter() + .filter(|stripe| stripe.next_ticket.load(Ordering::Acquire) != stripe.serving.load(Ordering::Acquire)) + .count(); + striped + usize::from(self.bulk_mutations.load(Ordering::Acquire) > 0) + } + + /// Monotonic-with-wrap count of completed mutation-stripe entries. + /// + /// Vacuum samples this between polls so a continuous mutation stream + /// cannot look idle merely because both polls landed between operations. + /// It is derived from the ticket locks' existing counters and adds no + /// atomic operation to the foreground path. + #[doc(hidden)] + pub fn mutation_epoch(&self) -> u64 { + self.mutation_stripes.iter().fold(0u64, |epoch, stripe| { + epoch.wrapping_add(stripe.serving.load(Ordering::Acquire)) + }) + } + + /// Keeps vacuum out for the duration of a chunked bulk mutation without + /// holding any row or mutation-stripe lock. + /// + /// One increment and one decrement are paid per whole operation, not per + /// row or chunk. + #[doc(hidden)] + pub fn bulk_mutation_guard(&self) -> BulkMutationGuard { + self.bulk_mutations.fetch_add(1, Ordering::AcqRel); + BulkMutationGuard { + active: Arc::clone(&self.bulk_mutations), + } + } + fn mutation_guard_for_stripe(&self, stripe: usize) -> MutationGuard { let gate = &self.mutation_stripes[stripe]; let ticket = gate.next_ticket.fetch_add(1, Ordering::Relaxed); @@ -313,6 +379,37 @@ mod tests { } } + #[test] + fn bulk_mutation_guard_spans_chunk_gaps_without_holding_a_stripe() { + let lock_map: LockMap = LockMap::default(); + assert_eq!(lock_map.mutations_in_flight(), 0); + + let first = lock_map.bulk_mutation_guard(); + assert_eq!(lock_map.mutations_in_flight(), 1); + { + let second = lock_map.bulk_mutation_guard(); + assert_eq!(lock_map.mutations_in_flight(), 1); + drop(second); + } + assert_eq!(lock_map.mutations_in_flight(), 1); + + drop(first); + assert_eq!(lock_map.mutations_in_flight(), 0); + } + + #[test] + fn mutation_epoch_detects_work_that_finished_between_checks() { + let lock_map: LockMap = LockMap::default(); + let before = lock_map.mutation_epoch(); + + let guard = lock_map.mutation_guard(&17); + assert_eq!(lock_map.mutations_in_flight(), 1); + drop(guard); + + assert_eq!(lock_map.mutations_in_flight(), 0); + assert_ne!(lock_map.mutation_epoch(), before); + } + /// Two threads acquiring overlapping key sets in opposite caller order /// must not deadlock: stripe ordering, not caller ordering, decides /// acquisition order. diff --git a/src/partition/mod.rs b/src/partition/mod.rs index 7f52c705..fdc2779b 100644 --- a/src/partition/mod.rs +++ b/src/partition/mod.rs @@ -292,13 +292,40 @@ impl PartitionSet { /// /// So a tick loop should not pin per lookup. Pin once, read many: /// - /// ```ignore + /// ``` + /// # fn main() { futures::executor::block_on(async { + /// use worktable::prelude::*; + /// use worktable::worktable; + /// + /// worktable!( + /// name: Price, + /// partition_by: symbol_id: u16, + /// columns: { + /// exchange_id: u8 primary_key, + /// bid: f64 + /// } + /// ); + /// + /// let prices = PricePartitions::new(); + /// for symbol in [7u16, 9, 11] { + /// prices + /// .partition_or_create(symbol) + /// .unwrap() + /// .insert(PriceRow { exchange_id: 1, bid: symbol as f64 }) + /// .await + /// .unwrap(); + /// } + /// + /// // One pin for the whole batch, then three dependent loads per lookup. /// let pinned = prices.pinned(); - /// for tick in batch { - /// if let Some(book) = pinned.get(tick.symbol_id) { - /// book.insert(tick.into())?; + /// let mut total = 0.0; + /// for symbol in [7u16, 9, 11] { + /// if let Some(book) = pinned.get(symbol) { + /// total += book.select(1).unwrap().bid; /// } /// } + /// assert_eq!(total, 27.0); + /// # }); } /// ``` /// /// The pin is held for the whole scope, so nothing retired during it is diff --git a/src/persistence/operation/batch.rs b/src/persistence/operation/batch.rs index f2c35912..9b376766 100644 --- a/src/persistence/operation/batch.rs +++ b/src/persistence/operation/batch.rs @@ -15,6 +15,21 @@ use crate::persistence::task::{LastEventIds, QueueInnerRow}; use crate::prelude::*; use crate::prelude::{From, Order, SelectQueryExecutor}; +/// Cycles of a persistently gapped event stream before the engine gives up and +/// fails the table. +/// +/// A gap is usually transient: the operation carrying the missing id has been +/// pushed but not yet batched, or its producer has not reached its push. Each +/// deferral sleeps 500ms in the worker loop, so this is about a minute of +/// waiting. The previous value of eight was about four seconds, which a +/// producer descheduled under load can lose, and the engine then blamed a +/// permanent bug for what was a slow thread. +/// +/// Widening the collection is a *separate* decision, taken far sooner and +/// tracked by the analyzer's own no-progress counter. This one only decides +/// when to stop hoping. +const GIVE_UP_AFTER_ATTEMPTS: usize = 120; + // Ephemeral metadata rebuilt for every persistence batch, not a persisted // schema. One Multi operation deliberately owns several rows, so operation_id // is non-unique while pos is the unique association back to the ops vector. @@ -342,11 +357,12 @@ where // assigned — only non-CDC index mutations do that — so a gap // that persists is a bug upstream of the analyzer; report it // loudly instead of force-applying and corrupting the file. - if attempts > 8 { + if attempts > GIVE_UP_AFTER_ATTEMPTS { return Err(eyre::eyre!( - "persistence stalled on primary index event gap: last applied {:?}, next available {:?} (attempt {attempts}); an event id was likely consumed without its event being queued", + "persistence stalled on primary index event gap: last applied {:?}, next available {:?} after {attempts} attempts, with {} operations queued. Every one of them was collected and the stream is still gapped, so the operation carrying the missing id never reached the queue: an event id was consumed without its event being pushed. The producer is upstream of the analyzer, not here.", last_ids.primary_id, - id + id, + self.ops.len() )); } self.ops.extend(ops_to_remove); @@ -364,9 +380,10 @@ where // Same rule as the primary index above: never apply a gapped // stream, defer until the missing event arrives, and report // a persistent gap as the bug it is. - if attempts > 8 { + if attempts > GIVE_UP_AFTER_ATTEMPTS { return Err(eyre::eyre!( - "persistence stalled on secondary index {index:?} event gap: last applied {last:?}, next available {id:?} (attempt {attempts}); an event id was likely consumed without its event being queued" + "persistence stalled on secondary index {index:?} event gap: last applied {last:?}, next available {id:?} after {attempts} attempts, with {} operations queued. All of them were collected and the stream is still gapped, so the operation carrying the missing id never reached the queue.", + self.ops.len() )); } self.ops.extend(ops_to_remove); @@ -699,6 +716,7 @@ mod tests { op_type: OperationType::Insert, pos, }) + .await .unwrap(); } diff --git a/src/persistence/space/art_index.rs b/src/persistence/space/art_index.rs index d8766fc7..15bfd276 100644 --- a/src/persistence/space/art_index.rs +++ b/src/persistence/space/art_index.rs @@ -91,6 +91,44 @@ macro_rules! impl_art_persistence_key { impl_art_persistence_key!(u8, u16, u32, u64, u128, usize); +/// Signed keys, encoded through the same sign-bit flip the in-memory ART uses. +/// +/// Signed types have `to_be_bytes` of their own, so a plain round trip would +/// work and the flip is **not load-bearing today**: every use of this trait is +/// a WAL record encoded and decoded whole, and nothing sorts or ranges over the +/// encoded bytes. +/// +/// It is here for consistency with why the unsigned encoding is big-endian at +/// all. Endianness is irrelevant to a pure round trip; big-endian was chosen so +/// byte order is numeric order, and two's complement breaks that for signed +/// keys because a negative has its high bit set. Flipping the sign bit keeps +/// the stated property true for signed keys too, so a future reader who relies +/// on it is not caught out by a gap that only exists for half the key types. +macro_rules! impl_art_persistence_key_signed { + ($($type:ty => $raw:ty),+ $(,)?) => { + $( + impl ArtPersistenceKey for $type { + const WIDTH: u8 = std::mem::size_of::() as u8; + + fn encode_art_key(&self, output: &mut Vec) { + let raw = (*self as $raw) ^ ((1 as $raw) << (<$raw>::BITS - 1)); + output.extend_from_slice(&raw.to_be_bytes()); + } + + fn decode_art_key(bytes: &[u8]) -> eyre::Result { + let bytes: [u8; std::mem::size_of::()] = bytes + .try_into() + .map_err(|_| eyre!("invalid {}-byte ART key", Self::WIDTH))?; + let raw = <$raw>::from_be_bytes(bytes) ^ ((1 as $raw) << (<$raw>::BITS - 1)); + Ok(raw as Self) + } + } + )+ + }; +} + +impl_art_persistence_key_signed!(i8 => u8, i16 => u16, i32 => u32, i64 => u64, i128 => u128, isize => usize); + #[derive(Clone, Debug, Eq, PartialEq)] enum WalOp { /// Unique files: associate the key with this link. Multi files: add one diff --git a/src/persistence/task.rs b/src/persistence/task.rs index a8707677..be444ba7 100644 --- a/src/persistence/task.rs +++ b/src/persistence/task.rs @@ -38,6 +38,24 @@ worktable! ( const MAX_PAGE_AMOUNT: usize = 16; +/// Attempts after which batch collection stops grouping by data page and takes +/// the whole queue. +/// +/// Grouping by page is a write-batching optimisation, and it cannot always +/// assemble a gapless event stream. Event ids are allocated during the index +/// mutations, while an operation id is minted later, at the push site, so two +/// concurrent writers can invert the two orders. Vacuum makes that systematic: +/// its update lands on the destination page while inserts append to the current +/// page, so the operation holding the missing id sits on a page this collection +/// never visits. Every retry then rebuilds the same gapped batch, and the +/// attempt budget eventually fails the engine for that table permanently. +/// +/// Taking the whole queue breaks the loop. It gives up the grouping and keeps +/// the guarantee that matters: `validate_events` still refuses to apply a +/// stream with a hole, so a complete collection can only ever apply more of the +/// contiguous prefix than a partial one, never something unsafe. +const COLLECT_WHOLE_QUEUE_AFTER_ATTEMPTS: usize = 4; + #[derive(Debug)] struct PersistenceLifecycle { state: ParkingMutex, @@ -177,7 +195,18 @@ pub struct QueueAnalyzer, last_invalid_batch_size: usize, page_limit: usize, + /// Cycles since the engine last declared a batch failed. Drives only the + /// give-up condition. attempts: usize, + /// Cycles since the applied watermark last moved. + /// + /// Separate from `attempts` because the two questions are different. A + /// batch can be valid and still apply nothing: everything in it sat behind + /// a gap and was trimmed, and validation returns an empty batch rather than + /// a deferral. That is a success, so it resets `attempts`, and widening the + /// collection off `attempts` therefore never happened in exactly the case + /// that needed it. Progress is what should widen the search. + no_progress: usize, } #[derive(Debug)] @@ -230,6 +259,7 @@ where last_invalid_batch_size: 0, page_limit: MAX_PAGE_AMOUNT, attempts: 0, + no_progress: 0, } } @@ -244,7 +274,10 @@ where }; let pos = self.operations.push(value); row.pos = pos; - self.queue_inner_wt.insert(row)?; + // The generated wrapper is async now; this is a synchronous internal + // queue push, so it uses the inner table's insert directly. The inner + // one is the same write without the async signature. + self.queue_inner_wt.0.insert(row)?; Ok(()) } @@ -280,8 +313,18 @@ where let mut ops_set = HashSet::new(); let mut used_page_ids = HashSet::new(); + // See `COLLECT_WHOLE_QUEUE_AFTER_ATTEMPTS`: page grouping can wedge on a + // stream whose event order and operation order disagree, so after a few + // failed attempts the batch is assembled from everything queued. + let took_whole_queue = self.no_progress >= COLLECT_WHOLE_QUEUE_AFTER_ATTEMPTS; + if took_whole_queue { + for (queued_op_id, _) in self.queue_inner_wt.0.indexes.operation_id_idx.iter() { + ops_set.insert(queued_op_id); + } + } + let mut next_op_id = op_id; - let mut no_more_ops = false; + let mut no_more_ops = took_whole_queue; while used_page_ids.len() < self.page_limit && !no_more_ops { let ops_rows = self.queue_inner_wt.select_by_operation_id(next_op_id).execute()?; match next_op_id { @@ -392,7 +435,8 @@ where for (pos, (op, mut row)) in queued_ops.into_iter().enumerate() { row.pos = pos; row.op_type = op.operation_type(); - info_wt.insert(row)?; + // Inner insert: see the note in `push`. + info_wt.0.insert(row)?; ops.push(op); } @@ -400,17 +444,25 @@ where let invalid_for_this_batch_ops = op.validate(&self.last_events_ids, self.attempts).await?; if let Some(invalid_for_this_batch_ops) = invalid_for_this_batch_ops { self.extend_from_iter(invalid_for_this_batch_ops.into_iter())?; + let previous_primary = self.last_events_ids.primary_id; let last_ids = op.get_last_event_ids(); + let advanced = last_ids.primary_id > previous_primary; self.last_events_ids.merge(last_ids); self.last_invalid_batch_size = 0; self.page_limit = MAX_PAGE_AMOUNT; self.attempts = 0; + if advanced { + self.no_progress = 0; + } else { + self.no_progress += 1; + } Ok(Some(op)) } else { // can't collect batch for now let ops = op.ops(); self.attempts += 1; + self.no_progress += 1; if self.last_invalid_batch_size == ops.len() { self.page_limit += 8; } else { @@ -559,6 +611,87 @@ mod lifecycle_tests { }) } + /// A single-row insert on `page`, carrying one primary event with + /// `event_id`. The two ids are independent on purpose: that is the whole + /// point of the regression below. + fn insert_operation_with_event(id: u128, page: u32, event_id: u64) -> Operation<(), u64, TestEvents> { + let link = Link { + page_id: page.into(), + offset: 0, + length: 1, + }; + Operation::Insert(InsertOperation { + id: OperationId::Single(uuid::Uuid::from_u128(id)), + pk_gen_state: (), + primary_key_events: vec![indexset::cdc::change::ChangeEvent::InsertAt { + event_id: event_id.into(), + max_value: indexset::core::pair::Pair { + key: event_id, + value: link, + }, + value: indexset::core::pair::Pair { + key: event_id, + value: link, + }, + index: 0, + }], + secondary_keys_events: TestEvents, + bytes: vec![id as u8], + link, + }) + } + + /// Regression: collection could not assemble a gapless event stream when + /// event order and operation order disagree, and never recovered. + /// + /// Event ids are allocated during the index mutations; an operation id is + /// minted later, at the push site. Two concurrent writers can therefore + /// invert the two orders, and vacuum does it systematically, because its + /// update lands on the destination page while inserts append to the + /// current page. Observed in the field as 110 inversions in one run, in a + /// regular alternating pattern. + /// + /// Page-grouped collection then never visits the page holding the missing + /// id, so every retry rebuilt the same gapped batch until the attempt + /// budget failed the engine and persistence for that table stopped for + /// good. Before the whole-queue fallback this panics on the ninth attempt + /// with "persistence stalled on primary index event gap". + #[tokio::test] + async fn collection_recovers_when_event_order_and_operation_order_disagree() { + let queue_inner_wt = Arc::new(QueueInnerWorkTable::default()); + let mut analyzer: QueueAnalyzer<(), u64, TestEvents, TestIndex> = QueueAnalyzer::new(queue_inner_wt); + analyzer.last_events_ids.primary_id = 1.into(); + + // Collecting page 5 from operation 1 also takes operation 3, and + // advances past it. Operation 2 sits between them in operation order, + // on another page, and carries the event the stream needs next, so it + // is skipped and then the walk runs out of operations entirely. The + // page-limit growth that normally widens a stuck collection cannot + // help here: the loop ended because it ran out, not because it was + // full. + analyzer.push(insert_operation_with_event(1, 5, 3)).unwrap(); + analyzer.push(insert_operation_with_event(2, 9, 2)).unwrap(); + analyzer.push(insert_operation_with_event(3, 5, 4)).unwrap(); + + let start = OperationId::Single(uuid::Uuid::from_u128(1)); + for attempt in 0..12 { + if analyzer + .collect_batch_from_op_id(start) + .await + .expect("collection must not fail the engine over an ordering it can recover from") + .is_some() + { + assert!( + attempt >= 1, + "the first attempt is expected to defer; progress on attempt 0 would mean \ + the inversion was not reproduced" + ); + return; + } + } + panic!("collection never made progress: the gapped batch was rebuilt every time"); + } + fn multi_insert_operation(id: u128, offset: u32, byte: u8) -> Operation<(), u64, TestEvents> { multi_insert_operation_on(1, id, offset, byte) } diff --git a/src/table/mod.rs b/src/table/mod.rs index 7f290da9..80439b16 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -30,6 +30,14 @@ use std::marker::PhantomData; use std::path::Path; use std::sync::Arc; use uuid::Uuid; +/// Keys per chunk when a bulk delete takes its mutation guards. +/// +/// Guards are striped 64 ways, so any batch wider than that holds every stripe +/// and no other write on the table can proceed until it finishes. A range +/// delete dropping a generation is exactly that shape. Taking the guards a +/// chunk at a time bounds the exclusion to one chunk's work, which is the +/// difference between a pause and a stall. +const DELETE_CHUNK_KEYS: usize = 256; #[derive(Debug)] pub struct WorkTable< @@ -252,6 +260,47 @@ where { let pk = row.get_primary_key().clone(); let _mutation_guard = self.lock_manager.mutation_guard(&pk); + self.insert_locked(row) + } + + /// `insert`, for a caller already holding this key's mutation gate. + /// + /// `upsert` needs this. It takes the full row lock, finds the key absent, + /// and wants to insert without letting go: releasing first is what opens a + /// window another writer can win, which is why that path carries a retry + /// loop and a backoff. It could not hold on, because `insert` re-acquires + /// the same per-key gate the guard already holds and would deadlock + /// against itself. + /// + /// Splitting the acquisition off is the whole fix, and it is the shape + /// `update_with_guard` already uses. Making `insert` async does not help + /// here and makes it worse: the drop-to-insert window would then contain + /// an await, so a task can be descheduled inside it and the race widens + /// from a few hundred nanoseconds of straight-line code to a scheduling + /// quantum. + /// + /// # Correctness + /// + /// The caller must already hold the mutation gate for this row's primary + /// key. Calling it without one drops the serialisation every other write + /// on this table has. + pub fn insert_locked(&self, row: Row) -> Result + where + Row: Archive + + Clone + + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + ::WrappedRow: + Archive + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper + + Portable + + Deserialize<::WrappedRow, HighDeserializer>, + PrimaryKey: Clone, + AvailableTypes: 'static, + AvailableIndexes: AvailableIndex, + SecondaryIndexes: TableSecondaryIndex, + LockType: 'static, + { + let pk = row.get_primary_key().clone(); let link = self.data.insert(row.clone()).map_err(WorkTableError::PagesError)?; if self.primary_index.insert_checked(pk.clone(), link).is_none() { self.data.delete(link).map_err(WorkTableError::PagesError)?; @@ -290,6 +339,246 @@ where Ok(pk) } + /// Deletes every row named by `pks`, ghosting them behind one grace marker. + /// + /// Deleting is already a bit flip rather than a move: the row is marked + /// deleted in place, its index entries are removed, and its storage becomes + /// reusable once no reader can still reach it. Vacuum is what later + /// compacts the pages and hands whole ones back. This batches that, and the + /// batching is worth having for one specific reason: the per-row cost is + /// dominated by the domain advance each retirement takes, not by the bit + /// flip, so `n` deletes cost `n` advances where a batch costs one. + /// + /// Unlike [`Self::insert_many`] this is **not** all-or-nothing, and the + /// difference is deliberate. A rejected insert has published nothing, so + /// unwinding restores a state that was real. A delete that fails partway + /// has already removed index entries and ghosted rows, and those rows are + /// genuinely gone; resurrecting them would mean re-publishing index entries + /// for storage that is queued for reuse. So the batch reports what it + /// deleted and stops at the first failure, rather than pretending it can + /// rewind. + /// + /// A primary key that is not present is skipped rather than failing the + /// batch. Callers evicting a generation do not generally know which of its + /// keys a concurrent writer has already removed, and making them find out + /// first would be a race they cannot win. + /// + /// Returns the keys actually deleted, in the order given. + pub fn delete_many(&self, pks: Vec) -> Result, BatchDeleteError> + where + Row: Archive + + Clone + + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + ::WrappedRow: + Archive + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper + + Portable + + Deserialize<::WrappedRow, HighDeserializer>, + PrimaryKey: Clone, + AvailableTypes: 'static, + AvailableIndexes: AvailableIndex, + SecondaryIndexes: TableSecondaryIndex, + LockType: 'static, + { + if pks.is_empty() { + return Ok(Vec::new()); + } + // The stripe guards below are intentionally chunked so foreground + // writers get a turn. Keep only the cheap operation-wide activity bit + // across those gaps, so vacuum does not mistake one for quiescence. + let _bulk_mutation = self.lock_manager.bulk_mutation_guard(); + let mut deleted: Vec = Vec::with_capacity(pks.len()); + + // Guards are taken a chunk at a time rather than over the whole batch. + // See `DELETE_CHUNK_KEYS`. Each chunk is self-contained: its rows leave + // every index, are ghosted, and are queued for reclaim before its + // guards drop, so no row is ever left out of the indexes and un-ghosted + // while a guard it needed has been released. + for chunk in pks.chunks(DELETE_CHUNK_KEYS) { + // Stripe-ordered, exactly as `insert_many` takes them, so a batch + // delete and a batch insert cannot deadlock against each other. Chunks + // release before the next is taken, so that ordering holds across them. + let _mutation_guards = self.lock_manager.mutation_guards(chunk.iter()); + + let mut links: Vec = Vec::with_capacity(chunk.len()); + + for pk in chunk { + let Some(link) = self.primary_index.pk_map.get_value(pk).map(Into::into) else { + // Already gone. Not an error: see above. + continue; + }; + // Read at the link rather than by key: the link is already in + // hand, and `select` would spend a second primary-key lookup to + // find it again. `select_non_ghosted` keeps the check that matters, + // which is that low-level staged or hydrated state can publish + // index reachability before clearing a row's ghost bit. + let Ok(row) = self.data.select_non_ghosted(link) else { + continue; + }; + + // Index removals run BEFORE the rows are ghosted, and for the same + // reason the single-row path gives: insert publishes data first and + // indexes second, so tearing down in the reverse order guarantees + // no index entry ever resolves to storage that has been freed or + // reused. + if let Err(source) = self.indexes.delete_row(row, link) { + // Finish the rows that already succeeded before leaving. + // + // Each of them is out of every index but not yet ghosted, + // because ghosting is deferred to one pass below. Returning + // here without it would leave them allocated, live in the page + // layer, and unreachable through any index: leaked for the + // lifetime of the table, and contradicting the documented + // promise that a failed batch's earlier rows are genuinely + // gone and queued for reuse. + // + // A failure here is reported in preference to a failure in the + // cleanup: the caller's key is the more useful diagnosis, and + // the cleanup failing means the pages were already unusable. + let _ = self.data.delete_many(&links); + return Err(BatchDeleteError::Key { + key: pk.clone(), + deleted: deleted.len(), + source: WorkTableError::from(source), + }); + } + self.primary_index.remove(pk, link); + links.push(link); + deleted.push(pk.clone()); + } + + // One ghosting pass, one grace marker, one reclaim, per chunk. + if let Err(source) = self.data.delete_many(&links) { + return Err(BatchDeleteError::Table(WorkTableError::PagesError(source))); + } + } + + Ok(deleted) + } + + /// Deletes every row whose primary key falls in `range`. + /// + /// The shape bulk eviction actually has. A caller dropping a generation + /// knows the span it wants gone, not the individual keys, and making them + /// enumerate the span first means walking the primary index by hand and + /// then handing the result straight back. + /// + /// The span is collected from the primary index in one ordered walk, and + /// the delete then runs exactly as [`Self::delete_many`]: keys are still + /// resolved under their mutation guards, because the set can change between + /// the walk and the delete and a key that has since gone is skipped rather + /// than failing the batch. So this is one walk instead of the caller's, not + /// a way to skip the per-key work that keeps the delete correct. + /// + /// Returns the keys actually deleted, in key order. + pub fn delete_range(&self, range: R) -> Result, BatchDeleteError> + where + R: std::ops::RangeBounds, + Row: Archive + + Clone + + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + ::WrappedRow: + Archive + for<'a> Serialize, Share>, rkyv::rancor::Error>>, + <::WrappedRow as Archive>::Archived: ArchivedRowWrapper + + Portable + + Deserialize<::WrappedRow, HighDeserializer>, + PrimaryKey: Clone, + AvailableTypes: 'static, + AvailableIndexes: AvailableIndex, + SecondaryIndexes: TableSecondaryIndex, + LockType: 'static, + { + // Covers both index walks and every chunk without holding a row or + // stripe lock across the operation. + let _bulk_mutation = self.lock_manager.bulk_mutation_guard(); + + // Owned bounds, because the span is walked twice and `R` is consumed. + let start = range.start_bound().cloned(); + let end = range.end_bound().cloned(); + + // First walk: learn which keys the span holds. Nothing is trusted from + // this pass except the key set, which is what the guards are for. + let keys: Vec = self + .primary_index + .pk_map + .range_values((start.clone(), end.clone())) + .map(|(key, _)| key) + .collect(); + if keys.is_empty() { + return Ok(Vec::new()); + } + let mut deleted: Vec = Vec::with_capacity(keys.len()); + + // Guards a chunk at a time, as in `delete_many`: see + // `DELETE_CHUNK_KEYS`. A range delete is the widest batch this table + // takes, so it is the one that most needs not to hold every stripe. + for chunk in keys.chunks(DELETE_CHUNK_KEYS) { + let _mutation_guards = self.lock_manager.mutation_guards(chunk.iter()); + + // Second walk, under the guards. Links for guarded keys cannot move + // and are used directly; keys that appeared since the first walk are + // filtered out below, because no guard covers them. This is the whole + // point: `k` individual lookups cost + // `k` times `O(log n)` in table size, one walk costs `O(log n + k)`, so + // the saving grows with both the batch and the table. A first version + // walked and then looked every key up again, which is strictly more + // work than `delete_many` and gave this no reason to exist. + // Narrowed to this chunk's span, so the walk stays proportional to the + // chunk rather than repeating the whole range for each one. + let pinned: Vec<(PrimaryKey, Link)> = self + .primary_index + .pk_map + .range_values(( + std::ops::Bound::Included(chunk[0].clone()), + std::ops::Bound::Included(chunk[chunk.len() - 1].clone()), + )) + .map(|(key, link)| (key, link.into())) + .collect(); + + let mut links: Vec = Vec::with_capacity(chunk.len()); + for (key, link) in pinned { + // Only keys the guards actually cover. + // + // The guards were taken over the first walk's key set. A key + // inserted into the span between the two walks appears in this one + // while hashing to a stripe nobody locked, so deleting it would + // tear down its indexes and storage with none of the mutation + // serialisation every other write on this table has, racing the + // publication that is still in flight. + // + // `keys` came from an ordered walk, so it is sorted and this is a + // binary search rather than a set allocation. + if chunk.binary_search(&key).is_err() { + continue; + } + // Read at the link, as above: the walk already produced it. + let Ok(row) = self.data.select_non_ghosted(link) else { + continue; + }; + if let Err(source) = self.indexes.delete_row(row, link) { + // As in `delete_many`: ghost what already succeeded rather + // than leaving those rows out of every index and still + // allocated. + let _ = self.data.delete_many(&links); + return Err(BatchDeleteError::Key { + key, + deleted: deleted.len(), + source: WorkTableError::from(source), + }); + } + self.primary_index.remove(&key, link); + links.push(link); + deleted.push(key); + } + + if let Err(source) = self.data.delete_many(&links) { + return Err(BatchDeleteError::Table(WorkTableError::PagesError(source))); + } + } + + Ok(deleted) + } + /// Inserts every row of `rows`, all or nothing. /// /// Rows are first staged ghosted (invisible to lock-free readers), every @@ -1051,6 +1340,28 @@ pub enum BatchInsertError { Table(WorkTableError), } +/// Why a [`WorkTable::delete_many`] stopped. +/// +/// It carries how many keys were deleted before the failure, because a bulk +/// delete does not roll back: those rows are gone, and a caller retrying the +/// batch needs to know the prefix already succeeded rather than assume nothing +/// happened. +#[derive(Debug, Display, Error)] +pub enum BatchDeleteError { + /// One key could not be deleted. Everything before it was. + #[display("batch delete stopped at {key:?} after {deleted} deleted: {source}")] + Key { + /// The key that failed. + key: PrimaryKey, + /// How many keys were deleted before this one. + deleted: usize, + source: WorkTableError, + }, + /// The batch failed for a reason not attributable to a single key. + #[display("{_0}")] + Table(WorkTableError), +} + #[derive(Debug, Display, Error, From)] pub enum WorkTableError { NotFound, diff --git a/src/table/vacuum/manager.rs b/src/table/vacuum/manager.rs index f55ac6b3..24bf092c 100644 --- a/src/table/vacuum/manager.rs +++ b/src/table/vacuum/manager.rs @@ -9,11 +9,36 @@ use smart_default::SmartDefault; use crate::vacuum::WorkTableVacuum; +/// How long a sweep waits before checking a table nothing has woken it about. +/// +/// Not configurable, and deliberately so. Sweeps are triggered by tables +/// actually freeing space; this only bounds how long a table whose threshold is +/// never reached goes unlooked-at. Exposing it invited callers to turn it down +/// and get the polling behaviour this design replaced, where the timer wins +/// every wake and neither the threshold nor the settle does anything. +const FALLBACK_INTERVAL: Duration = Duration::from_secs(60); + +/// Consecutive sweeps of one table before the manager moves on. +/// +/// Reclamation continues until the table is clean, but not without bound: on a +/// persisted table each reclaimed page queues a durable-free barrier, and an +/// unbounded run queues them faster than the persistence worker drains them. +const MAX_CONSECUTIVE_PASSES: u32 = 32; + +/// Pause between consecutive sweeps, so the persistence worker gets a turn and +/// the on-disk state does not fall behind the table in memory. +const BETWEEN_PASSES: Duration = Duration::from_millis(5); + /// Configuration for [`VacuumManager`]. #[derive(Debug, Clone, SmartDefault)] pub struct VacuumManagerConfig { - #[default(Duration::from_secs(60))] - pub check_interval: Duration, + /// Reclaimable bytes at which a table wakes the sweep task. + /// + /// Small enough to react while the fragmentation is still cheap to + /// reclaim — measured, a sweep at 25% fragmentation costs between nothing + /// and 10% of insert throughput, and one at 60% costs 25-49%. + #[default(1024 * 1024)] + pub wake_threshold_bytes: u64, #[default(3.0)] pub low_fragmentation_threshold: f64, #[default(1.5)] @@ -24,10 +49,36 @@ pub struct VacuumManagerConfig { pub critical_fragmentation_threshold: f64, } +/// What the manager has actually done, cumulatively. +/// +/// Cost is unreadable without it. A sweep that stands down so hard it never +/// runs reports no overhead at all, which looks like a win and is a +/// regression in the thing vacuum exists for. This is also how you tell a +/// reactive sweep that keeps firing from one that fired once and never +/// again, which a snapshot of the table cannot distinguish. +#[derive(Debug, Default)] +pub struct VacuumManagerStats { + pub sweeps: AtomicU64, + pub pages_freed: AtomicU64, + pub bytes_freed: AtomicU64, +} + +impl VacuumManagerStats { + pub fn snapshot(&self) -> (u64, u64, u64) { + ( + self.sweeps.load(Ordering::Relaxed), + self.pages_freed.load(Ordering::Relaxed), + self.bytes_freed.load(Ordering::Relaxed), + ) + } +} + #[derive(derive_more::Debug, Default)] pub struct VacuumManager { pub config: VacuumManagerConfig, pub id_gen: AtomicU64, + /// Cumulative record of sweeps run. See [`VacuumManagerStats`]. + pub stats: VacuumManagerStats, #[debug(ignore)] pub vacuums: Arc>>>, } @@ -43,12 +94,14 @@ impl VacuumManager { Self { config, id_gen: Default::default(), + stats: Default::default(), vacuums: Arc::default(), } } /// Registers a new vacuum with the manager and returns its unique ID. pub fn register(&self, table: Arc) -> u64 { + table.arm_wake(self.config.wake_threshold_bytes); let id = self.id_gen.fetch_add(1, Ordering::AcqRel); let mut vacuums = self.vacuums.write(); vacuums.insert(id, table); @@ -62,7 +115,7 @@ impl VacuumManager { pub fn run_vacuum_task(self: Arc) -> AbortHandle { let handle = tokio::spawn(async move { loop { - tokio::time::sleep(self.config.check_interval).await; + self.wait_for_work().await; let vacuums_to_check: Vec<_> = { let vacuums_read = self.vacuums.read(); @@ -79,34 +132,96 @@ impl VacuumManager { }; if let Some(vacuum) = vacuum_opt { - let info = vacuum.analyze_fragmentation(); - - log::debug!("vacuum info: {:?}", info); - // println!("vacuum info: {:?}", info); - if info.overall_fragmentation_ratio < self.config.low_fragmentation_threshold - && info.overall_fragmentation_ratio != 0.0 - { - log::debug!("Vacuuming {}", info.table_name); - match vacuum.vacuum().await { - Ok(stats) => { - // println!( - // "Vacuum completed for table '{}': {} pages processed, {} bytes freed in {:.2}ms", - // table_name, - // stats.pages_processed, - // stats.bytes_freed, - // stats.duration_ns as f64 / 1_000_000.0 - // ); - log::debug!( - "Vacuum completed for table '{}': {} pages processed, {} bytes freed in {:.2}ms", - table_name, - stats.pages_processed, - stats.bytes_freed, - stats.duration_ns as f64 / 1_000_000.0 - ); - } - Err(e) => { - // println!("Vacuum failed for table '{}': {}", table_name, e); - log::debug!("Vacuum failed for table '{}': {}", table_name, e); + // Sweep until there is nothing left worth reclaiming, + // rather than once per wake. + // + // The wake fires when a table *frees* space, so a table + // that has gone quiet produces no more of them. Doing a + // single pass and going back to waiting therefore left + // whatever that pass could not finish sitting there + // forever: measured, a table holding 393 pages where 196 + // would do, half its memory never returned, with the + // sweep reporting success. A partial return is not a + // return. + // + // Three exits, so this cannot spin or run away: a final + // consolidation pass after fragmentation crosses the + // threshold, a pass that frees no pages, and a bound on + // consecutive passes. + // + // That bound is not defensive tidiness. On a persisted + // table every reclaimed page queues a durable-free + // barrier, so sweeping back to back queues work faster + // than the persistence worker drains it and the on-disk + // state falls behind the table in memory. The pause + // between passes hands the worker its turn, and the cap + // ends the run rather than letting one table's + // reclamation monopolise the queue. Whatever is left is + // picked up by the next wake or the fallback. + let mut passes = 0u32; + let mut final_consolidation_ran = false; + loop { + let info = vacuum.analyze_fragmentation(); + + log::debug!("vacuum info: {:?}", info); + // println!("vacuum info: {:?}", info); + let above_sweep_threshold = info.overall_fragmentation_ratio + < self.config.low_fragmentation_threshold + && info.overall_fragmentation_ratio != 0.0; + if !above_sweep_threshold && (passes == 0 || final_consolidation_ran) { + break; + } + // A productive pass can cross the manager's + // threshold while still leaving a handful of + // pages that another packing pass can eliminate. + // Give that residual exactly one pass. Rechecking + // only the threshold here left 200 pages where an + // independently packed table needed 196; looping + // without this bit could churn a fully packed + // table forever because it still has tail gaps. + if !above_sweep_threshold { + final_consolidation_ran = true; + } + { + log::debug!("Vacuuming {}", info.table_name); + match vacuum.vacuum().await { + Ok(stats) => { + self.stats.sweeps.fetch_add(1, Ordering::Relaxed); + self.stats + .pages_freed + .fetch_add(stats.pages_freed as u64, Ordering::Relaxed); + self.stats.bytes_freed.fetch_add(stats.bytes_freed, Ordering::Relaxed); + let freed_nothing = stats.pages_freed == 0; + // println!( + // "Vacuum completed for table '{}': {} pages processed, {} bytes freed in {:.2}ms", + // table_name, + // stats.pages_processed, + // stats.bytes_freed, + // stats.duration_ns as f64 / 1_000_000.0 + // ); + log::debug!( + "Vacuum completed for table '{}': {} pages processed, {} bytes freed in {:.2}ms", + table_name, + stats.pages_processed, + stats.bytes_freed, + stats.duration_ns as f64 / 1_000_000.0 + ); + if freed_nothing { + break; + } + passes += 1; + if passes >= MAX_CONSECUTIVE_PASSES { + break; + } + // The persistence worker's turn. See the + // note above the loop. + tokio::time::sleep(BETWEEN_PASSES).await; + } + Err(e) => { + // println!("Vacuum failed for table '{}': {}", table_name, e); + log::debug!("Vacuum failed for table '{}': {}", table_name, e); + break; + } } } } @@ -117,4 +232,23 @@ impl VacuumManager { handle.abort_handle() } + + /// Blocks until some registered table has freed enough space to be worth + /// a sweep, or the fallback interval elapses. + async fn wait_for_work(&self) { + let registered: Vec<_> = { + let vacuums = self.vacuums.read(); + vacuums.values().cloned().collect() + }; + if registered.is_empty() { + tokio::time::sleep(FALLBACK_INTERVAL).await; + return; + } + + let waits: Vec<_> = registered.iter().map(|v| v.wait_until_worth_running()).collect(); + tokio::select! { + _ = futures::future::select_all(waits) => {} + _ = tokio::time::sleep(FALLBACK_INTERVAL) => {} + } + } } diff --git a/src/table/vacuum/mod.rs b/src/table/vacuum/mod.rs index 727ceedd..a7163165 100644 --- a/src/table/vacuum/mod.rs +++ b/src/table/vacuum/mod.rs @@ -10,10 +10,12 @@ use crate::vacuum::fragmentation_info::FragmentationInfo; mod fragmentation_info; mod manager; +mod pacing; #[allow(clippy::module_inception)] mod vacuum; pub use manager::{VacuumManager, VacuumManagerConfig}; +pub use pacing::{VacuumGate, VacuumPacing}; pub use vacuum::EmptyDataVacuum; /// Sink for persisting vacuum row moves. @@ -57,6 +59,18 @@ pub trait WorkTableVacuum { fn analyze_fragmentation(&self) -> FragmentationInfo; /// Run vacuum operation async fn vacuum(&self) -> eyre::Result; + + /// Wake a waiting sweep once this table has freed `bytes` worth of + /// reclaimable space. `0` disables the wake and leaves only the fallback + /// interval. + fn arm_wake(&self, bytes: u64); + + /// Park until this table is fragmented enough to be worth sweeping. + /// + /// A timer cannot know when that happened; the table can, because it is + /// the thing that got fragmented. Callers still want a fallback interval + /// alongside this, for a table whose threshold is never reached. + async fn wait_until_worth_running(&self); } /// Represents vacuum statistics after a vacuum operation diff --git a/src/table/vacuum/pacing.rs b/src/table/vacuum/pacing.rs new file mode 100644 index 00000000..1ff64c9d --- /dev/null +++ b/src/table/vacuum/pacing.rs @@ -0,0 +1,223 @@ +//! Deciding whether now is a good time to vacuum. +//! +//! Vacuum takes the registry's write side for as long as it runs, and while it +//! holds it every insert asking for reclaimable space is turned away and +//! allocates a fresh page instead. Measured on a table at 60% fragmentation +//! that costs 25-49% of insert throughput and doubles median insert latency, +//! for the whole duration of the sweep. At 25% fragmentation the same sweep +//! costs between nothing and 10%. +//! +//! So the cost is not a constant to be scheduled around, it is a function of +//! how much of the table vacuum holds at once and of how busy the table is +//! while it does. Both are addressed here: the sweep is cut into batches that +//! release the exclusion between them, and before every batch vacuum asks the +//! mutation stripes whether foreground work is active or has completed since +//! the preceding check. Every insert, delete and upsert passes through those +//! stripes, including mutations that never ask for reclaimable space. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; + +use smart_default::SmartDefault; + +/// A bit a caller flips to hold vacuum off entirely. +/// +/// Separate from the automatic backoff below: that one reacts to live mutation +/// activity, this one is for a caller who knows something the table cannot see +/// — a bulk load about to start, a latency-sensitive window, a benchmark. +#[derive(Debug, Default)] +pub struct VacuumGate { + paused: AtomicBool, + /// Bumped every time vacuum stood down, so a test can prove the gate is + /// doing something rather than merely being set. + stand_downs: AtomicU64, +} + +impl VacuumGate { + /// Hold vacuum off. Takes effect at the next batch boundary; it does not + /// interrupt a batch in flight, so no move is left half-applied. + pub fn pause(&self) { + self.paused.store(true, Ordering::Release); + } + + /// Let vacuum resume. + pub fn resume(&self) { + self.paused.store(false, Ordering::Release); + } + + pub fn is_paused(&self) -> bool { + self.paused.load(Ordering::Acquire) + } + + /// How many batch boundaries vacuum has stood down at. + pub fn stand_downs(&self) -> u64 { + self.stand_downs.load(Ordering::Relaxed) + } + + pub(crate) fn note_stand_down(&self) { + self.stand_downs.fetch_add(1, Ordering::Relaxed); + } +} + +/// How vacuum paces itself against foreground work. +#[derive(Debug, Clone, SmartDefault)] +pub struct VacuumPacing { + /// Source pages processed before releasing the exclusion. + /// + /// This is the knob that decides how long inserts go without free-space + /// reuse. Small enough that a foreground burst waits microseconds rather + /// than the length of a whole-table sweep, large enough that the + /// re-acquisition is not the dominant cost. + #[default = 8] + pub batch_pages: usize, + + /// How long to stand down for when the table is busy. Doubles on each + /// consecutive stand-down, up to [`Self::max_backoff`], so a table under + /// sustained load is polled cheaply rather than every couple of + /// milliseconds. + #[default(Duration::from_millis(2))] + pub backoff: Duration, + + /// Ceiling for the doubling. + #[default(Duration::from_millis(128))] + pub max_backoff: Duration, + + /// Consecutive samples that must all find the table idle before a sweep + /// takes the exclusion. + /// + /// One sample is not enough: under a heavy write load the stripes are free + /// for most of any given instant, so a single look finds a gap almost + /// immediately and the sweep goes in on top of the workload anyway. A short + /// run of quiet samples distinguishes a gap between two writes from a table + /// that has actually stopped. + #[default = 3] + pub quiet_samples: u32, +} + +/// What a sweep asks before taking the exclusion. +/// +/// Deliberately a live question rather than a budget. A sweep that defers for +/// a fixed span and then forces itself in is not waiting its turn, it is +/// queueing, and it takes its cut from every burst that outlasts the span: +/// measured against a null arm, 3.8 to 12.5% of foreground throughput. Asking +/// the table what it is doing has no such span to run out. +pub trait ForegroundActivity { + /// Writers currently inside a mutation gate or queued for one. + fn mutations_in_flight(&self) -> usize; + + /// Aggregate completion counter for the mutation gates. + /// + /// If this changes between polls, foreground work happened even when both + /// instantaneous reads happened to land between operations. + fn mutation_epoch(&self) -> u64; +} + +impl ForegroundActivity for crate::lock::LockMap +where + PrimaryKey: Clone + std::fmt::Debug + Eq + std::hash::Hash, +{ + fn mutations_in_flight(&self) -> usize { + crate::lock::LockMap::mutations_in_flight(self) + } + + fn mutation_epoch(&self) -> u64 { + crate::lock::LockMap::mutation_epoch(self) + } +} + +impl VacuumPacing { + /// Called at a batch boundary, with the exclusion already released. + /// + /// Returns once vacuum should take the exclusion again. Yields at least + /// once even on an idle table, so a waiting insert gets the registry + /// before vacuum asks for it back. + pub(crate) async fn wait_until_quiet(&self, activity: &impl ForegroundActivity, gate: &VacuumGate) { + tokio::task::yield_now().await; + + let mut backoff = self.backoff; + let mut quiet = 0; + let mut observed_epoch = activity.mutation_epoch(); + loop { + let current_epoch = activity.mutation_epoch(); + if gate.is_paused() || activity.mutations_in_flight() > 0 || current_epoch != observed_epoch { + gate.note_stand_down(); + quiet = 0; + observed_epoch = current_epoch; + tokio::time::sleep(backoff).await; + // Doubling, so a table busy for a long time is asked about + // cheaply rather than every couple of milliseconds. + backoff = backoff.saturating_mul(2).min(self.max_backoff); + continue; + } + + quiet += 1; + if quiet >= self.quiet_samples { + return; + } + // Idle once is a gap between two writes. Look again, close + // together, before believing it. + tokio::time::sleep(self.backoff).await; + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + + use super::*; + + #[derive(Default)] + struct ActivityBetweenChecks { + active: AtomicBool, + epoch: AtomicU64, + } + + impl ForegroundActivity for ActivityBetweenChecks { + fn mutations_in_flight(&self) -> usize { + 0 + } + + fn mutation_epoch(&self) -> u64 { + if self.active.load(Ordering::Acquire) { + self.epoch.fetch_add(1, Ordering::AcqRel) + 1 + } else { + self.epoch.load(Ordering::Acquire) + } + } + } + + #[tokio::test] + async fn completed_work_between_checks_resets_the_quiet_buffer() { + let activity = Arc::new(ActivityBetweenChecks::default()); + activity.active.store(true, Ordering::Release); + let gate = Arc::new(VacuumGate::default()); + let pacing = VacuumPacing { + backoff: Duration::from_millis(1), + max_backoff: Duration::from_millis(2), + quiet_samples: 3, + ..Default::default() + }; + + let waiting = { + let activity = Arc::clone(&activity); + let gate = Arc::clone(&gate); + tokio::spawn(async move { + pacing.wait_until_quiet(activity.as_ref(), gate.as_ref()).await; + }) + }; + + tokio::time::sleep(Duration::from_millis(10)).await; + assert!( + !waiting.is_finished(), + "activity between snapshots must keep vacuum out" + ); + + activity.active.store(false, Ordering::Release); + tokio::time::timeout(Duration::from_millis(20), waiting) + .await + .expect("vacuum should enter after the recheck buffer stays quiet") + .unwrap(); + } +} diff --git a/src/table/vacuum/vacuum.rs b/src/table/vacuum/vacuum.rs index b1191e22..5d16f214 100644 --- a/src/table/vacuum/vacuum.rs +++ b/src/table/vacuum/vacuum.rs @@ -2,7 +2,16 @@ use std::collections::VecDeque; use std::fmt::Debug; use std::marker::PhantomData; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; + +/// How long retirements have to stop arriving for a delete burst to count as +/// over. Short enough that a sweep still follows a delete promptly, long +/// enough that the gaps inside one ranged delete do not read as its end. +const SETTLE_INTERVAL: Duration = Duration::from_millis(25); + +/// The longest a sweep waits for a burst to settle, since under continuous +/// delete load it never does. +const MAX_SETTLE: Duration = Duration::from_millis(500); use data_bucket::Link; use data_bucket::page::PageId; @@ -20,6 +29,7 @@ use crate::vacuum::VacuumPersistence; use crate::vacuum::VacuumStats; use crate::vacuum::WorkTableVacuum; use crate::vacuum::fragmentation_info::FragmentationInfo; +use crate::vacuum::{VacuumGate, VacuumPacing}; use crate::{ AvailableIndex, PrimaryIndex, TableIndex, TableIndexCdc, TableRow, TableSecondaryIndex, TableSecondaryIndexCdc, UniqueIndex, @@ -71,6 +81,13 @@ pub struct EmptyDataVacuum< #[debug(ignore)] persistence: Option>>, + /// How the sweep is cut up and when it stands down. See + /// [`VacuumPacing`]. + pacing: VacuumPacing, + + /// The bit a caller flips to hold the sweep off. + gate: Arc, + phantom_data: PhantomData<(SecondaryEvents, AvailableTypes, AvailableIndexes)>, } @@ -130,10 +147,58 @@ where primary_index, secondary_indexes, persistence: None, + pacing: VacuumPacing::default(), + gate: Arc::new(VacuumGate::default()), phantom_data: PhantomData, } } + /// Replaces the pacing policy. See [`VacuumPacing`] for what each knob + /// trades away. + pub fn with_pacing(mut self, pacing: VacuumPacing) -> Self { + self.pacing = pacing; + self + } + + /// Shares the gate, so a caller holding the same `Arc` can hold vacuum + /// off across a bulk load or a latency-sensitive window. + pub fn with_gate(mut self, gate: Arc) -> Self { + self.gate = gate; + self + } + + /// The gate this vacuum stands down on. + pub fn gate(&self) -> &Arc { + &self.gate + } + + /// Wait for the burst that woke the sweep to finish before running it. + /// + /// The wake fires on the *first* crossing of the threshold, which during a + /// ranged delete is near its start. Sweeping there means competing with the + /// workload producing the garbage, and compacting pages that are still + /// being emptied behind it: a moving target, and the worst moment to take + /// the exclusion. + /// + /// The signal is queued retirements rather than the registry's byte total. + /// Deletes queue their storage and only reach the registry when the backlog + /// flushes, so between flushes the bytes sit still while deletes stream, + /// and a settle watching them would call the burst over in every gap. + /// + /// The cap is what keeps a table under continuous delete load from + /// deferring its sweep forever: there, retirements never stop arriving, and + /// a bounded wait is the right answer rather than an unbounded one. + async fn settle_after_wake(&self) { + let deadline = Instant::now() + MAX_SETTLE; + loop { + let before = self.data_pages.pending_retirements(); + tokio::time::sleep(SETTLE_INTERVAL).await; + if self.data_pages.pending_retirements() == before || Instant::now() >= deadline { + return; + } + } + } + /// Attaches a persistence sink. Index updates for moved rows then use the /// CDC mutation variants and their events are queued as persistence /// operations. Required for persisted tables. @@ -143,16 +208,55 @@ where } async fn defragment(&self) -> eyre::Result { + // The first batch needs the same permission as every later batch. If + // this check lives only at the batch boundary, a sweep woken during a + // sustained mutation stream still moves `batch_pages` sources before + // it notices the table is busy. That is enough to produce a small but + // repeatable foreground latency penalty even though the completed- + // sweep counter stays at zero until the load ends. + // `batch_pages == 0` is the explicit unpaced policy: it holds the + // exclusion for the whole pass and remains useful as a benchmark + // control for proving that a workload can detect interference. + if self.pacing.batch_pages > 0 { + self.pacing.wait_until_quiet(&*self.lock_manager, &self.gate).await; + } + let now = Instant::now(); + // Deletes queue their storage for reclamation and leave the sweep to + // whoever needs the space, so the registry can lag behind what has + // actually been freed. Vacuum plans from that registry rather than + // allocating through it, so it has to ask for the sweep itself or it + // will skip pages whose rows are already gone. + self.data_pages.reclaim_pending(); + let registry = self.data_pages.empty_links_registry(); let mut per_page_info = registry.get_per_page_info(); - let _registry_lock = registry.lock_vacuum().await; - - per_page_info.sort_by_key(|l| OrderedFloat(l.filled_empty_ratio)); + let mut registry_lock = Some(registry.lock_vacuum().await); + + // Pages named by a batched or ranged delete go first. They are where + // the freed space is concentrated, so they are the pages most likely + // to empty out entirely, and a sweep that stands down partway through + // will have done the valuable work before it did. + let targeted = registry.take_targeted_pages(); + per_page_info.sort_by_key(|l| (!targeted.contains(&l.page_id), OrderedFloat(l.filled_empty_ratio))); let initial_bytes_freed: u64 = per_page_info.iter().map(|i| i.empty_bytes as u64).sum(); + let fragmented_current = per_page_info + .iter() + .any(|info| info.page_id == self.data_pages.current_page_id()); let additional_allocated_page = self.data_pages.allocate_new_or_pop_free(); + // The append page is normally excluded from vacuum so foreground + // inserts always have a stable destination. If that page is itself + // fragmented, excluding it permanently leaves one partially occupied + // page after every otherwise complete compaction. Rotate to the fresh + // scratch page first: the old current becomes a normal source and the + // new current is the first destination, so no extra in-use page is + // retained. Inserts racing the rotation retry against the new current. + if fragmented_current { + self.data_pages.rotate_current_for_vacuum(additional_allocated_page.id); + } + let mut free_pages = VecDeque::new(); let mut defragmented_pages = VecDeque::new(); free_pages.push_back(additional_allocated_page.id); @@ -161,7 +265,29 @@ where let pages_processed = per_page_info.len(); let info_iter = per_page_info.into_iter(); + + // The exclusion is released every `batch_pages` sources. It is held + // for the whole sweep otherwise, and while it is held every insert + // asking for reclaimable space is turned away and allocates instead — + // which is why a whole-table sweep shows up as doubled insert latency + // for its entire duration rather than as a brief stall. + let mut pages_since_yield = 0usize; + for info in info_iter { + pages_since_yield += 1; + if self.pacing.batch_pages > 0 && pages_since_yield > self.pacing.batch_pages { + pages_since_yield = 1; + + // Ask the table whether anyone is writing, rather than + // inferring it from demand for reclaimable space. Deletes never + // ask for space and an upsert that fits in place does not + // either, so under a load of exactly those the old signal read + // idle and the sweep went in on top of the workload. + drop(registry_lock.take()); + self.pacing.wait_until_quiet(&*self.lock_manager, &self.gate).await; + registry_lock = Some(registry.lock_vacuum().await); + } + let page_from = info.page_id; if self.data_pages.current_page_id() == page_from { // don't touch current page or else inserts will be broken @@ -263,6 +389,7 @@ where // vacuum itself as scratch destinations; handing them back is not // freeing table pages, so they do not count towards `pages_freed`. self.finalize_staged_pages(free_pages, defragmented_pages)?; + drop(registry_lock); Ok(VacuumStats { pages_processed, @@ -355,6 +482,35 @@ where from_page_will_be_moved = false; } + if from_page_will_be_moved { + // Re-read the page's rows after the moves. + // + // The candidate list above is a snapshot: the range is collected + // and dropped before any row is moved, because each move takes + // that row's lock and holding an index iterator across an await + // would pin the index for the whole drain. So an insert landing on + // this page after the snapshot is not in `links`, is never moved, + // and used to leave the page reported fully moved with a live row + // on it. `defragment` then reclaimed the page, an allocator reused + // it, and index entries pointing into it resolved to whatever row + // took their storage. + // + // That is the defect behind `value_idx[792]` resolving to a row + // holding 2703, and behind `upsert` returning `PrimaryUpdateTry` + // when a primary entry resolved to a row with another key. It + // needed a delete to free the space, an insert to take it, and + // vacuum to reclaim underneath, which is why it only ever appeared + // with all three running. + let occupied = self + .primary_index + .reverse_pk_map + .range(page_start..page_end) + .any(|(link, _)| link.0.page_id == from); + if occupied { + from_page_will_be_moved = false; + } + } + Ok((from_page_will_be_moved, to_page_will_be_filled)) } @@ -517,12 +673,22 @@ where async fn vacuum(&self) -> eyre::Result { self.defragment().await } + + fn arm_wake(&self, bytes: u64) { + self.data_pages.empty_links_registry().set_vacuum_wake_threshold(bytes); + } + + async fn wait_until_worth_running(&self) { + self.data_pages.empty_links_registry().wait_for_fragmentation().await; + self.settle_after_wake().await; + } } #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::collections::{HashMap, VecDeque}; use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use data_bucket::Link; use data_bucket::page::PageId; @@ -530,7 +696,10 @@ mod tests { use crate::in_memory::{ArchivedRowWrapper, RowWrapper, StorableRow}; use crate::prelude::*; + use std::time::Duration; + use crate::vacuum::vacuum::{CandidateMove, EmptyDataVacuum}; + use crate::vacuum::{VacuumGate, VacuumPacing, WorkTableVacuum}; worktable!( name: Test, @@ -547,6 +716,363 @@ mod tests { } ); + /// While a sweep holds the registry's write side, every insert that wants + /// reclaimable space is turned away and allocates a fresh page instead. + /// Cutting the sweep into batches gives that space back between them. + /// + /// Both arms run here because only the pair is evidence: the unbatched arm + /// is what the code did before, and it cannot reuse anything. + #[tokio::test] + async fn vacuum_releases_the_registry_between_batches() { + async fn reuses_during_sweep(batch_pages: usize) -> usize { + let table = TestWorkTable::default(); + let mut ids = Vec::new(); + for i in 0..2_000 { + let row = TestRow { + id: table.get_next_pk().into(), + test: i, + another: i as u64, + exchange: format!("test{}", i), + }; + ids.push(row.id); + table.insert(row).await.unwrap(); + } + // Every other row, so the free space is spread over every page + // rather than concentrated in one. + for id in ids.iter().step_by(2) { + table.delete(*id).await.unwrap(); + } + + let vacuum = create_vacuum(&table).with_pacing(VacuumPacing { + batch_pages, + ..Default::default() + }); + + let pages = Arc::clone(&table.0.data); + let stop = Arc::new(AtomicBool::new(false)); + let reuses = Arc::new(AtomicUsize::new(0)); + let poller = tokio::spawn({ + let (stop, reuses) = (Arc::clone(&stop), Arc::clone(&reuses)); + async move { + while !stop.load(Ordering::Relaxed) { + if pages.empty_links_registry().pop_max().is_some() { + reuses.fetch_add(1, Ordering::Relaxed); + } + tokio::task::yield_now().await; + } + } + }); + + vacuum.defragment().await.unwrap(); + stop.store(true, Ordering::Relaxed); + poller.await.unwrap(); + reuses.load(Ordering::Relaxed) + } + + let unbatched = reuses_during_sweep(0).await; + let batched = reuses_during_sweep(1).await; + assert!( + batched > unbatched, + "a batched sweep must hand the registry back more often than an unbatched sweep; \ + unbatched reused {unbatched} links, batched reused {batched}" + ); + } + + /// The gate holds the sweep off at batch boundaries until it is resumed. + #[tokio::test] + async fn a_paused_gate_holds_vacuum_until_resumed() { + let table = TestWorkTable::default(); + let mut ids = Vec::new(); + for i in 0..1_000 { + let row = TestRow { + id: table.get_next_pk().into(), + test: i, + another: i as u64, + exchange: format!("test{}", i), + }; + ids.push(row.id); + table.insert(row).await.unwrap(); + } + for id in ids.iter().step_by(2) { + table.delete(*id).await.unwrap(); + } + + let gate = Arc::new(VacuumGate::default()); + gate.pause(); + let vacuum = create_vacuum(&table) + .with_gate(Arc::clone(&gate)) + .with_pacing(VacuumPacing { + batch_pages: 1, + backoff: Duration::from_millis(1), + ..Default::default() + }); + + // A paused gate holds the sweep until it is resumed, rather than + // delaying it a fixed number of tries and then going in anyway. That + // was the old behaviour and it is what made "stand down" meaningless + // under sustained pressure: the budget ran out and the sweep took its + // cut regardless. + let sweeping = tokio::spawn(async move { vacuum.defragment().await }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + !sweeping.is_finished(), + "a paused gate must actually hold the sweep, not merely delay it" + ); + assert!( + gate.stand_downs() > 0, + "and it should be standing down while it waits, not spinning" + ); + + gate.resume(); + sweeping.await.unwrap().unwrap(); + + assert!( + !table.0.data.get_empty_pages().is_empty(), + "and once resumed it must finish the work it was holding" + ); + } + + /// A sweep asks before its first batch, not only after it has already + /// moved some pages. Completed-sweep statistics cannot prove this: a + /// sweep that did one batch and then waited would still read as zero. + #[tokio::test] + async fn a_busy_table_holds_vacuum_before_the_first_batch() { + let table = TestWorkTable::default(); + let mut ids = Vec::new(); + for i in 0..1_000 { + let row = TestRow { + id: table.get_next_pk().into(), + test: i, + another: i as u64, + exchange: format!("test{i}"), + }; + ids.push(row.id); + table.insert(row).await.unwrap(); + } + for id in ids.iter().step_by(2) { + table.delete(*id).await.unwrap(); + } + + let mutation = table.0.lock_manager.mutation_guard(&ids[1].into()); + let vacuum = create_vacuum(&table).with_pacing(VacuumPacing { + batch_pages: 1, + backoff: Duration::from_millis(1), + ..Default::default() + }); + let gate = Arc::clone(vacuum.gate()); + let sweeping = tokio::spawn(async move { vacuum.defragment().await }); + + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + !sweeping.is_finished(), + "a live mutation must hold vacuum before it processes its first page" + ); + assert!(gate.stand_downs() > 0, "vacuum should be waiting rather than spinning"); + + drop(mutation); + sweeping.await.unwrap().unwrap(); + assert!( + !table.0.data.get_empty_pages().is_empty(), + "vacuum must proceed after the table becomes quiet" + ); + } + + #[tokio::test] + async fn vacuum_reclaims_to_the_exact_packed_page_count() { + const ROWS: u64 = 4_000; + + fn fixed_row(id: u64) -> TestRow { + TestRow { + id, + test: id as i64, + another: id, + exchange: format!("row-{id:08}"), + } + } + + let packed = TestWorkTable::default(); + for id in (0..ROWS).filter(|id| id % 2 == 1) { + packed.insert(fixed_row(id)).await.unwrap(); + } + let ideal = packed.0.data.allocated_pages() - packed.0.data.reusable_pages(); + + let table = TestWorkTable::default(); + for id in 0..ROWS { + table.insert(fixed_row(id)).await.unwrap(); + } + for id in (0..ROWS).step_by(2) { + table.delete(id).await.unwrap(); + } + + create_vacuum(&table).defragment().await.unwrap(); + let after = table.0.data.allocated_pages() - table.0.data.reusable_pages(); + assert_eq!( + after, ideal, + "vacuum retained pages beyond an independently packed table" + ); + + for id in 0..ROWS { + assert_eq!(table.select(id).is_some(), id % 2 == 1, "row {id} changed visibility"); + } + } + + #[tokio::test] + async fn vacuum_reclaims_exactly_after_reinsert_delete_churn() { + const ROWS: u64 = 4_000; + const TURNS: u64 = 13_597; + + fn fixed_row(id: u64) -> TestRow { + TestRow { + id, + test: id as i64, + another: id, + exchange: format!("row-{id:08}"), + } + } + + let packed = TestWorkTable::default(); + for id in 0..ROWS / 2 { + packed.insert(fixed_row(id)).await.unwrap(); + } + let ideal = packed.0.data.allocated_pages() - packed.0.data.reusable_pages(); + + let table = TestWorkTable::default(); + let mut missing = VecDeque::new(); + let mut live = VecDeque::new(); + for id in 0..ROWS { + table.insert(fixed_row(id)).await.unwrap(); + if id % 2 == 0 { + table.delete(id).await.unwrap(); + missing.push_back(id); + } else { + live.push_back(id); + } + } + + for _ in 0..TURNS { + let live_id = *live.front().unwrap(); + table.upsert(fixed_row(live_id)).await.unwrap(); + let missing_id = missing.pop_front().unwrap(); + table.upsert(fixed_row(missing_id)).await.unwrap(); + live.push_back(missing_id); + let delete_id = live.pop_front().unwrap(); + table.delete(delete_id).await.unwrap(); + missing.push_back(delete_id); + } + + create_vacuum(&table).defragment().await.unwrap(); + let after = table.0.data.allocated_pages() - table.0.data.reusable_pages(); + assert_eq!(after, ideal, "vacuum retained pages after mutation churn"); + assert_eq!(table.count(), ROWS as usize / 2); + for id in live { + assert!(table.select(id).is_some(), "live row {id} disappeared"); + } + for id in missing { + assert!(table.select(id).is_none(), "deleted row {id} reappeared"); + } + } + + /// Deletes wake a parked sweep, so vacuum arrives because the table got + /// fragmented rather than because a minute elapsed. + /// + /// The unreachable-threshold arm is the "before": that is what a sweep + /// parked on a table that never wakes it looks like, and it is what every + /// sweep looked like when the only trigger was the interval. + #[tokio::test] + async fn freeing_space_wakes_a_parked_sweep() { + async fn woke(threshold: u64, within: Duration) -> bool { + let table = TestWorkTable::default(); + let mut ids = Vec::new(); + // Deletes queue their storage for reclamation and only push it to + // the registry once the backlog is worth a sweep, so the wake + // cannot be finer-grained than that batch. Delete well past it. + for i in 0..2_000 { + let row = TestRow { + id: table.get_next_pk().into(), + test: i, + another: i as u64, + exchange: format!("test{}", i), + }; + ids.push(row.id); + table.insert(row).await.unwrap(); + } + + let vacuum = create_vacuum(&table); + vacuum.arm_wake(threshold); + + let (parked, _) = tokio::join!(tokio::time::timeout(within, vacuum.wait_until_worth_running()), async { + // Let the wait park before anything is freed, so a pass + // means the notification arrived and not that the threshold + // was already met when it was called. + tokio::task::yield_now().await; + for id in ids.iter().step_by(2) { + table.delete(*id).await.unwrap(); + } + }); + parked.is_ok() + } + + assert!( + !woke(u64::MAX, Duration::from_millis(300)).await, + "a threshold the table cannot reach must leave the sweep parked" + ); + assert!( + woke(1024, Duration::from_secs(5)).await, + "freeing past the threshold must wake the sweep" + ); + } + + /// The wake fires on the *first* crossing of the threshold, which during a + /// ranged delete is near its start. Reporting work there sends the sweep in + /// while the delete is still streaming, to compete with the workload + /// producing the garbage and compact pages that are still being emptied + /// behind it. + /// + /// So it settles first. This asserts the sweep is not told to run until the + /// burst that woke it has stopped. + #[tokio::test] + async fn a_woken_sweep_waits_for_the_delete_burst_to_settle() { + let table = Arc::new(TestWorkTable::default()); + let mut ids = Vec::new(); + for i in 0..4_000 { + let row = TestRow { + id: table.get_next_pk().into(), + test: i, + another: i as u64, + exchange: format!("test{}", i), + }; + ids.push(row.id); + table.insert(row).await.unwrap(); + } + + let vacuum = create_vacuum(&table); + vacuum.arm_wake(1024); + + let burst_done = Arc::new(AtomicBool::new(false)); + let deleting = tokio::spawn({ + let (table, burst_done) = (Arc::clone(&table), Arc::clone(&burst_done)); + let victims: Vec<_> = ids.iter().step_by(2).copied().collect(); + async move { + // Spread over well past one settle interval, in the chunks a + // ranged delete actually arrives in. + for chunk in victims.chunks(100) { + for id in chunk { + table.delete(*id).await.unwrap(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + burst_done.store(true, Ordering::Release); + } + }); + + vacuum.wait_until_worth_running().await; + assert!( + burst_done.load(Ordering::Acquire), + "the sweep was told to run while deletes were still streaming" + ); + deleting.await.unwrap(); + } + /// Creates an EmptyDataVacuum instance from a WorkTable #[allow(clippy::type_complexity)] fn create_vacuum( @@ -583,7 +1109,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.push((id, row)); } @@ -614,7 +1140,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.insert(id, row); } @@ -648,7 +1174,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.insert(id, row); } @@ -682,7 +1208,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.insert(id, row); } @@ -714,7 +1240,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.push((id, row)); } @@ -744,7 +1270,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.push((id, row)); } @@ -782,7 +1308,7 @@ mod tests { exchange: s.to_string(), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.insert(id, row); } @@ -814,7 +1340,7 @@ mod tests { exchange: format!("original{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); original_ids.insert(id, row); } @@ -835,7 +1361,7 @@ mod tests { exchange: format!("new{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); new_ids.insert(id, row); } @@ -864,7 +1390,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.push((id, row)); } @@ -896,7 +1422,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.push((id, row)); } @@ -928,7 +1454,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.push((id, row)); } @@ -957,7 +1483,7 @@ mod tests { exchange: format!("test{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); ids.push((id, row)); } @@ -995,7 +1521,7 @@ mod tests { exchange: format!("{i:02}-{}", "x".repeat(6_000)), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); let link = table .0 .primary_index @@ -1059,7 +1585,7 @@ mod tests { another: i as u64, exchange: format!("test{}", i), }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } // Nothing was deleted, so there is nothing to free: the page vacuum @@ -1084,7 +1610,7 @@ mod tests { another: i, exchange: format!("{i:02}-{}", "x".repeat(6_000)), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.push(row); } @@ -1136,7 +1662,7 @@ mod tests { another: i, exchange: format!("{i:02}-{}", "x".repeat(6_000)), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.push(row); } @@ -1180,7 +1706,7 @@ mod tests { exchange: "target00".to_string(), }; let target_id = target.id; - table.insert(target).unwrap(); + table.insert(target).await.unwrap(); // Model the reverse-index snapshot taken before vacuum waits for the // row lock. @@ -1219,7 +1745,7 @@ mod tests { exchange: "reused00".to_string(), }; let replacement_id = replacement.id; - table.insert(replacement.clone()).unwrap(); + table.insert(replacement.clone()).await.unwrap(); let replacement_link = table .0 .primary_index diff --git a/tests/migration/mod.rs b/tests/migration/mod.rs index 080f5216..c09b20a6 100644 --- a/tests/migration/mod.rs +++ b/tests/migration/mod.rs @@ -120,12 +120,14 @@ fn test_migrate_v1_to_current() { id: table.get_next_pk().into(), name: "Alice".to_string(), }) + .await .unwrap(); table .insert(v1::UserRow { id: table.get_next_pk().into(), name: "Bob".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -202,6 +204,7 @@ fn test_migrate_v2_to_current() { name: "Charlie".to_string(), email: "charlie@test.com".to_string(), }) + .await .unwrap(); table .insert(v2::UserRow { @@ -209,6 +212,7 @@ fn test_migrate_v2_to_current() { name: "Diana".to_string(), email: "diana@test.com".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -271,6 +275,7 @@ fn test_next_pk_and_indexes_after_migration() { name: "Eve".to_string(), email: "eve@test.com".to_string(), }) + .await .unwrap(); table .insert(v2::UserRow { @@ -278,6 +283,7 @@ fn test_next_pk_and_indexes_after_migration() { name: "Frank".to_string(), email: "frank@test.com".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -307,7 +313,7 @@ fn test_next_pk_and_indexes_after_migration() { created_at: ctx.default_created_at + 1, }; - table.insert(inserted.clone()).unwrap(); + table.insert(inserted.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); assert_eq!(table.count(), 3); diff --git a/tests/persistence/bulk_delete_durability.rs b/tests/persistence/bulk_delete_durability.rs new file mode 100644 index 00000000..17ea58f9 --- /dev/null +++ b/tests/persistence/bulk_delete_durability.rs @@ -0,0 +1,116 @@ +use worktable::prelude::PersistedWorkTable; +use worktable::prelude::*; +use worktable_codegen::worktable; + +use crate::remove_dir_if_exists; + +worktable!( + name: BulkDeleteDurability, + persist: true, + columns: { + id: u64 primary_key, + tag: u64, + note: String, + }, + indexes: { + tag_idx: tag, + } +); + +fn row(id: u64) -> BulkDeleteDurabilityRow { + BulkDeleteDurabilityRow { + id, + tag: id % 4, + note: format!("row-{id}"), + } +} + +/// A bulk delete has to reach the disk, not just memory. +/// +/// The generated persisted `delete_many` and `delete_range` used to call +/// straight through to the in-memory batch. That returned success and the rows +/// vanished from memory, but no persistence operation was ever produced, so +/// the durable table was untouched and every deleted row came back on the next +/// load. Nothing caught it: the bulk-delete tests declared their table without +/// `persist: true`, so they only ever exercised the in-memory generator. +/// +/// This is why it is a restart test rather than an assertion about operations: +/// what a caller cares about is that a row it deleted stays deleted across a +/// reopen, through the primary index *and* the secondary one. +#[test] +fn bulk_deletes_survive_reload() { + let dir = "tests/data/bulk_delete_durability"; + let config = DiskConfig::new_with_table_name( + dir, + BulkDeleteDurabilityWorkTable::name_snake_case(), + BulkDeleteDurabilityWorkTable::version(), + ); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_io() + .enable_time() + .build() + .unwrap(); + + runtime.block_on(async { + remove_dir_if_exists(dir.to_string()).await; + + { + let engine = BulkDeleteDurabilityPersistenceEngine::new(config.clone()) + .await + .unwrap(); + let table = BulkDeleteDurabilityWorkTable::load(engine).await.unwrap(); + for id in 0..20u64 { + table.insert(row(id)).await.unwrap(); + } + table.wait_for_ops().await.unwrap(); + + // A key list, and a span. Both paths, because they generate + // separately. + let by_key = table.delete_many((0..5u64).collect()).await.unwrap(); + assert_eq!(by_key.len(), 5); + let lo: BulkDeleteDurabilityPrimaryKey = 10u64.into(); + let hi: BulkDeleteDurabilityPrimaryKey = 15u64.into(); + let by_range = table.delete_range(lo..hi).await.unwrap(); + assert_eq!(by_range.len(), 5); + + assert_eq!(table.count(), 10); + table.wait_for_ops().await.unwrap(); + } + + { + let engine = BulkDeleteDurabilityPersistenceEngine::new(config.clone()) + .await + .unwrap(); + let table = BulkDeleteDurabilityWorkTable::load(engine).await.unwrap(); + + assert_eq!(table.count(), 10, "deleted rows came back on reload"); + + for id in (0..5u64).chain(10..15u64) { + assert!( + table.select(id).is_none(), + "row {id} was deleted before the reload and is present after it" + ); + } + for id in (5..10u64).chain(15..20u64) { + assert!(table.select(id).is_some(), "row {id} was never deleted but is missing"); + } + + // The secondary index has to agree. A row removed from the primary + // index but left in a secondary one is still reachable, and points + // at storage that is free to be reused. + for tag in 0..4u64 { + for found in table.select_by_tag(tag).execute().unwrap() { + assert!( + !(found.id < 5 || (10..15).contains(&found.id)), + "deleted row {} is still reachable through tag_idx after reload", + found.id + ); + } + } + } + + remove_dir_if_exists(dir.to_string()).await; + }) +} diff --git a/tests/persistence/bulk_load_stall.rs b/tests/persistence/bulk_load_stall.rs index 665ea2ce..949447d1 100644 --- a/tests/persistence/bulk_load_stall.rs +++ b/tests/persistence/bulk_load_stall.rs @@ -75,7 +75,7 @@ fn test_bulk_insert_delete_persistence() { exchange: format!("test{i}"), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.insert(id, row); } diff --git a/tests/persistence/concurrent/mod.rs b/tests/persistence/concurrent/mod.rs index ecec914f..4ea6a997 100644 --- a/tests/persistence/concurrent/mod.rs +++ b/tests/persistence/concurrent/mod.rs @@ -93,6 +93,7 @@ fn test_concurrent() { value, ..Default::default() }) + .await .unwrap(); tokio::time::sleep(Duration::from_millis(50)).await; diff --git a/tests/persistence/duplicate_key_index_reload.rs b/tests/persistence/duplicate_key_index_reload.rs index dfd995fd..d3b41405 100644 --- a/tests/persistence/duplicate_key_index_reload.rs +++ b/tests/persistence/duplicate_key_index_reload.rs @@ -208,6 +208,7 @@ fn test_duplicate_key_secondary_index_survives_reload() { bucket: bucket.clone(), label: format!("row-{i}-{}", "x".repeat((i % 50) as usize)), }) + .await .unwrap(); model.insert(i, i % KEYS, bucket); } @@ -239,6 +240,7 @@ fn test_duplicate_key_secondary_index_survives_reload() { bucket: bucket.clone(), label: format!("late-{j}"), }) + .await .unwrap(); model.insert(id, j % KEYS, bucket); } @@ -324,6 +326,7 @@ fn test_single_key_all_duplicates_survives_reload() { bucket: "the-bucket".to_string(), label: format!("row-{i}"), }) + .await .unwrap(); } assert_eq!(table.select_by_score(42).execute().unwrap().len() as u64, ROWS); @@ -360,6 +363,7 @@ fn test_single_key_all_duplicates_survives_reload() { bucket: "the-bucket".to_string(), label: "post-reload".to_string(), }) + .await .unwrap(); timeout(Duration::from_secs(30), table.wait_for_ops()) .await @@ -409,6 +413,7 @@ fn test_duplicate_key_mutations_without_reload() { bucket: bucket.clone(), label: format!("row-{i}-{}", "x".repeat((i % 50) as usize)), }) + .await .unwrap(); model.insert(i, i % KEYS, bucket); } @@ -427,6 +432,7 @@ fn test_duplicate_key_mutations_without_reload() { bucket: bucket.clone(), label: format!("late-{j}"), }) + .await .unwrap(); model.insert(id, j % KEYS, bucket); } diff --git a/tests/persistence/exact_boundary_load.rs b/tests/persistence/exact_boundary_load.rs index 9e8c7850..d72a9db8 100644 --- a/tests/persistence/exact_boundary_load.rs +++ b/tests/persistence/exact_boundary_load.rs @@ -30,7 +30,7 @@ async fn table_whose_data_file_ends_on_an_exact_page_boundary_loads() { id: table.get_next_pk().into(), another: i, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); expected.push(row); } table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/failure/insert.rs b/tests/persistence/failure/insert.rs index 7273a899..2ae09644 100644 --- a/tests/persistence/failure/insert.rs +++ b/tests/persistence/failure/insert.rs @@ -24,7 +24,7 @@ fn test_insert_two_indexes_first_fail() { unique_a: 100, unique_b: 200, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.unique_a }; @@ -39,14 +39,14 @@ fn test_insert_two_indexes_first_fail() { unique_a: 1000, unique_b: 1001, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 2000, unique_b: 2001, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -56,7 +56,7 @@ fn test_insert_two_indexes_first_fail() { unique_b: 300, }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::AlreadyExists(_))); @@ -65,7 +65,7 @@ fn test_insert_two_indexes_first_fail() { unique_a: 3000, unique_b: 3001, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -87,7 +87,7 @@ fn test_insert_two_indexes_first_fail() { unique_a: 4000, unique_b: 4001, }; - assert!(table.insert(new_row).is_ok()); + assert!(table.insert(new_row).await.is_ok()); table.wait_for_ops().await.unwrap(); } }); @@ -116,7 +116,7 @@ fn test_insert_two_indexes_second_fail() { unique_a: 100, unique_b: 200, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.unique_b }; @@ -131,14 +131,14 @@ fn test_insert_two_indexes_second_fail() { unique_a: 1000, unique_b: 1001, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 2000, unique_b: 2001, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -148,7 +148,7 @@ fn test_insert_two_indexes_second_fail() { unique_b: existing_b, }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::AlreadyExists(_))); @@ -157,7 +157,7 @@ fn test_insert_two_indexes_second_fail() { unique_a: 3000, unique_b: 3001, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -178,7 +178,10 @@ fn test_insert_two_indexes_second_fail() { unique_a: 300, unique_b: 301, }; - assert!(table.insert(new_row).is_ok(), "BUG: orphaned entry in unique_a_idx!"); + assert!( + table.insert(new_row).await.is_ok(), + "BUG: orphaned entry in unique_a_idx!" + ); table.wait_for_ops().await.unwrap(); } }); @@ -208,7 +211,7 @@ fn test_insert_three_indexes_first_fail() { unique_b: 200, unique_c: 300, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.unique_a }; @@ -224,7 +227,7 @@ fn test_insert_three_indexes_first_fail() { unique_b: 1001, unique_c: 1002, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -232,7 +235,7 @@ fn test_insert_three_indexes_first_fail() { unique_b: 2001, unique_c: 2002, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -243,7 +246,7 @@ fn test_insert_three_indexes_first_fail() { unique_c: 500, }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); let valid_row3 = ThreeUniqueIdxRow { @@ -252,7 +255,7 @@ fn test_insert_three_indexes_first_fail() { unique_b: 3001, unique_c: 3002, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -271,7 +274,7 @@ fn test_insert_three_indexes_first_fail() { unique_b: 4001, unique_c: 4002, }; - assert!(table.insert(new_row).is_ok()); + assert!(table.insert(new_row).await.is_ok()); table.wait_for_ops().await.unwrap(); } }); @@ -301,7 +304,7 @@ fn test_insert_three_indexes_middle_fail() { unique_b: 200, unique_c: 300, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.unique_b }; @@ -317,7 +320,7 @@ fn test_insert_three_indexes_middle_fail() { unique_b: 1001, unique_c: 1002, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -325,7 +328,7 @@ fn test_insert_three_indexes_middle_fail() { unique_b: 2001, unique_c: 2002, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -336,7 +339,7 @@ fn test_insert_three_indexes_middle_fail() { unique_c: 500, }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); let valid_row3 = ThreeUniqueIdxRow { @@ -345,7 +348,7 @@ fn test_insert_three_indexes_middle_fail() { unique_b: 3001, unique_c: 3002, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -364,7 +367,10 @@ fn test_insert_three_indexes_middle_fail() { unique_b: 201, unique_c: 500, }; - assert!(table.insert(new_row).is_ok(), "BUG: orphaned entry in unique_a_idx!"); + assert!( + table.insert(new_row).await.is_ok(), + "BUG: orphaned entry in unique_a_idx!" + ); table.wait_for_ops().await.unwrap(); } }); @@ -394,7 +400,7 @@ fn test_insert_three_indexes_last_fail() { unique_b: 200, unique_c: 300, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.unique_c }; @@ -410,7 +416,7 @@ fn test_insert_three_indexes_last_fail() { unique_b: 1001, unique_c: 1002, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -418,7 +424,7 @@ fn test_insert_three_indexes_last_fail() { unique_b: 2001, unique_c: 2002, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -429,7 +435,7 @@ fn test_insert_three_indexes_last_fail() { unique_c: existing_c, }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); let valid_row3 = ThreeUniqueIdxRow { @@ -438,7 +444,7 @@ fn test_insert_three_indexes_last_fail() { unique_b: 3001, unique_c: 3002, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -457,7 +463,7 @@ fn test_insert_three_indexes_last_fail() { unique_b: 500, unique_c: 301, }; - assert!(table.insert(new_row).is_ok(), "BUG: orphaned entries in indexes!"); + assert!(table.insert(new_row).await.is_ok(), "BUG: orphaned entries in indexes!"); table.wait_for_ops().await.unwrap(); } }); @@ -485,7 +491,7 @@ fn test_insert_primary_duplicate() { id: table.get_next_pk().0, data: 100, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -499,13 +505,13 @@ fn test_insert_primary_duplicate() { id: table.get_next_pk().0, data: 1000, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = PrimaryOnlyRow { id: table.get_next_pk().0, data: 2000, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -514,14 +520,14 @@ fn test_insert_primary_duplicate() { data: 200, }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); let valid_row3 = PrimaryOnlyRow { id: table.get_next_pk().0, data: 3000, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await diff --git a/tests/persistence/failure/reinsert.rs b/tests/persistence/failure/reinsert.rs index 0d889167..86f92631 100644 --- a/tests/persistence/failure/reinsert.rs +++ b/tests/persistence/failure/reinsert.rs @@ -24,7 +24,7 @@ fn test_reinsert_pk_mismatch() { unique_a: 100, unique_b: 200, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -39,14 +39,14 @@ fn test_reinsert_pk_mismatch() { unique_a: 1000, unique_b: 1001, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 2000, unique_b: 2001, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -64,7 +64,7 @@ fn test_reinsert_pk_mismatch() { unique_a: 3000, unique_b: 3001, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -108,14 +108,14 @@ fn test_reinsert_two_indexes_first_fail() { unique_a: 100, unique_b: 200, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 300, unique_b: 400, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); (row1.id, row2.id, row1.unique_a) @@ -131,14 +131,14 @@ fn test_reinsert_two_indexes_first_fail() { unique_a: 1000, unique_b: 1001, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 2000, unique_b: 2001, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -156,7 +156,7 @@ fn test_reinsert_two_indexes_first_fail() { unique_a: 3000, unique_b: 3001, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -206,14 +206,14 @@ fn test_reinsert_two_indexes_second_fail() { unique_a: 100, unique_b: 200, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 300, unique_b: 400, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row1.unique_b @@ -229,14 +229,14 @@ fn test_reinsert_two_indexes_second_fail() { unique_a: 1000, unique_b: 1001, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 2000, unique_b: 2001, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -256,7 +256,7 @@ fn test_reinsert_two_indexes_second_fail() { unique_a: 3000, unique_b: 3001, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -274,7 +274,10 @@ fn test_reinsert_two_indexes_second_fail() { unique_a: 500, unique_b: 600, }; - assert!(table.insert(new_row).is_ok(), "BUG: orphaned entry in unique_a_idx!"); + assert!( + table.insert(new_row).await.is_ok(), + "BUG: orphaned entry in unique_a_idx!" + ); table.wait_for_ops().await.unwrap(); } }); @@ -304,7 +307,7 @@ fn test_reinsert_three_indexes_first_fail() { unique_b: 200, unique_c: 300, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -312,7 +315,7 @@ fn test_reinsert_three_indexes_first_fail() { unique_b: 500, unique_c: 600, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row1.unique_a @@ -329,7 +332,7 @@ fn test_reinsert_three_indexes_first_fail() { unique_b: 1001, unique_c: 1002, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -337,7 +340,7 @@ fn test_reinsert_three_indexes_first_fail() { unique_b: 2001, unique_c: 2002, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -359,7 +362,7 @@ fn test_reinsert_three_indexes_first_fail() { unique_b: 3001, unique_c: 3002, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -405,7 +408,7 @@ fn test_reinsert_three_indexes_middle_fail() { unique_b: 200, unique_c: 300, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -413,7 +416,7 @@ fn test_reinsert_three_indexes_middle_fail() { unique_b: 500, unique_c: 600, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row1.unique_b @@ -430,7 +433,7 @@ fn test_reinsert_three_indexes_middle_fail() { unique_b: 1001, unique_c: 1002, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -438,7 +441,7 @@ fn test_reinsert_three_indexes_middle_fail() { unique_b: 2001, unique_c: 2002, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -460,7 +463,7 @@ fn test_reinsert_three_indexes_middle_fail() { unique_b: 3001, unique_c: 3002, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -479,7 +482,7 @@ fn test_reinsert_three_indexes_middle_fail() { unique_b: 900, unique_c: 1000, }; - assert!(table.insert(new_row).is_ok(), "BUG: orphaned entry!"); + assert!(table.insert(new_row).await.is_ok(), "BUG: orphaned entry!"); table.wait_for_ops().await.unwrap(); } }); @@ -509,7 +512,7 @@ fn test_reinsert_three_indexes_last_fail() { unique_b: 200, unique_c: 300, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -517,7 +520,7 @@ fn test_reinsert_three_indexes_last_fail() { unique_b: 500, unique_c: 600, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row1.unique_c @@ -534,7 +537,7 @@ fn test_reinsert_three_indexes_last_fail() { unique_b: 1001, unique_c: 1002, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = ThreeUniqueIdxRow { id: table.get_next_pk().0, @@ -542,7 +545,7 @@ fn test_reinsert_three_indexes_last_fail() { unique_b: 2001, unique_c: 2002, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -564,7 +567,7 @@ fn test_reinsert_three_indexes_last_fail() { unique_b: 3001, unique_c: 3002, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -583,7 +586,7 @@ fn test_reinsert_three_indexes_last_fail() { unique_b: 800, unique_c: 900, }; - assert!(table.insert(new_row).is_ok(), "BUG: orphaned entries!"); + assert!(table.insert(new_row).await.is_ok(), "BUG: orphaned entries!"); table.wait_for_ops().await.unwrap(); } }); diff --git a/tests/persistence/failure/update.rs b/tests/persistence/failure/update.rs index 9e50c0e0..fce70ad6 100644 --- a/tests/persistence/failure/update.rs +++ b/tests/persistence/failure/update.rs @@ -25,14 +25,14 @@ fn test_update_unique_secondary_conflict() { unique_a: 100, unique_b: 200, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 300, unique_b: 400, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row1.id }; @@ -47,14 +47,14 @@ fn test_update_unique_secondary_conflict() { unique_a: 1000, unique_b: 1001, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 2000, unique_b: 2001, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -72,7 +72,7 @@ fn test_update_unique_secondary_conflict() { unique_a: 3000, unique_b: 3001, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -116,7 +116,7 @@ fn test_update_pk_based_success() { unique_a: 100, unique_b: 200, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row1.id }; @@ -131,14 +131,14 @@ fn test_update_pk_based_success() { unique_a: 1000, unique_b: 1001, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = TwoUniqueIdxRow { id: table.get_next_pk().0, unique_a: 2000, unique_b: 2001, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -156,7 +156,7 @@ fn test_update_pk_based_success() { unique_a: 3000, unique_b: 3001, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await diff --git a/tests/persistence/failure/update_non_unique.rs b/tests/persistence/failure/update_non_unique.rs index 36e7b8b5..109daac8 100644 --- a/tests/persistence/failure/update_non_unique.rs +++ b/tests/persistence/failure/update_non_unique.rs @@ -26,7 +26,7 @@ fn test_update_non_unique_middle_fail() { unique_value: 10, data: 100, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = MixedIdxRow { id: table.get_next_pk().0, @@ -34,7 +34,7 @@ fn test_update_non_unique_middle_fail() { unique_value: 20, data: 200, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); let row3 = MixedIdxRow { id: table.get_next_pk().0, @@ -42,7 +42,7 @@ fn test_update_non_unique_middle_fail() { unique_value: 30, data: 300, }; - table.insert(row3.clone()).unwrap(); + table.insert(row3.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); (row1.id, row2.id, row3.id) @@ -59,7 +59,7 @@ fn test_update_non_unique_middle_fail() { unique_value: 1000, data: 1000, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = MixedIdxRow { id: table.get_next_pk().0, @@ -67,7 +67,7 @@ fn test_update_non_unique_middle_fail() { unique_value: 2000, data: 2000, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -81,7 +81,7 @@ fn test_update_non_unique_middle_fail() { unique_value: 3000, data: 3000, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -126,7 +126,7 @@ fn test_update_non_unique_last_fail() { unique_value: 99, data: 0, }; - table.insert(conflict_row.clone()).unwrap(); + table.insert(conflict_row.clone()).await.unwrap(); let row1 = MixedIdxRow { id: table.get_next_pk().0, @@ -134,7 +134,7 @@ fn test_update_non_unique_last_fail() { unique_value: 10, data: 100, }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = MixedIdxRow { id: table.get_next_pk().0, @@ -142,7 +142,7 @@ fn test_update_non_unique_last_fail() { unique_value: 20, data: 200, }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); let row3 = MixedIdxRow { id: table.get_next_pk().0, @@ -150,7 +150,7 @@ fn test_update_non_unique_last_fail() { unique_value: 30, data: 300, }; - table.insert(row3.clone()).unwrap(); + table.insert(row3.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); conflict_row.id @@ -167,7 +167,7 @@ fn test_update_non_unique_last_fail() { unique_value: 1000, data: 1000, }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = MixedIdxRow { id: table.get_next_pk().0, @@ -175,7 +175,7 @@ fn test_update_non_unique_last_fail() { unique_value: 2000, data: 2000, }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -189,7 +189,7 @@ fn test_update_non_unique_last_fail() { unique_value: 3000, data: 3000, }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await diff --git a/tests/persistence/failure/update_unsized.rs b/tests/persistence/failure/update_unsized.rs index 8c860587..da29b215 100644 --- a/tests/persistence/failure/update_unsized.rs +++ b/tests/persistence/failure/update_unsized.rs @@ -26,7 +26,7 @@ fn test_update_unsized_same_size() { unique_value: 10, name: "aaa".to_string(), }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -34,7 +34,7 @@ fn test_update_unsized_same_size() { unique_value: 20, name: "bbb".to_string(), }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); let row3 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -42,7 +42,7 @@ fn test_update_unsized_same_size() { unique_value: 30, name: "ccc".to_string(), }; - table.insert(row3.clone()).unwrap(); + table.insert(row3.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); (row1.id, row2.id, row3.id) @@ -59,7 +59,7 @@ fn test_update_unsized_same_size() { unique_value: 1000, name: "xxx".to_string(), }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -67,7 +67,7 @@ fn test_update_unsized_same_size() { unique_value: 2000, name: "yyy".to_string(), }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -84,7 +84,7 @@ fn test_update_unsized_same_size() { unique_value: 3000, name: "zzz".to_string(), }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -129,7 +129,7 @@ fn test_update_unsized_larger_all_success() { unique_value: 10, name: "a".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -145,7 +145,7 @@ fn test_update_unsized_larger_all_success() { unique_value: 1000, name: "xxx".to_string(), }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -153,7 +153,7 @@ fn test_update_unsized_larger_all_success() { unique_value: 2000, name: "yyy".to_string(), }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -171,7 +171,7 @@ fn test_update_unsized_larger_all_success() { unique_value: 3000, name: "zzz".to_string(), }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -215,7 +215,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 99, name: "x".to_string(), }; - table.insert(conflict.clone()).unwrap(); + table.insert(conflict.clone()).await.unwrap(); let row1 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -223,7 +223,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 10, name: "a".to_string(), }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -231,7 +231,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 20, name: "b".to_string(), }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); let row3 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -239,7 +239,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 30, name: "c".to_string(), }; - table.insert(row3.clone()).unwrap(); + table.insert(row3.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); (conflict.id, row2.id, row3.id) @@ -256,7 +256,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 1000, name: "xxx".to_string(), }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -264,7 +264,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 2000, name: "yyy".to_string(), }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -282,7 +282,7 @@ fn test_update_unsized_larger_middle_fail() { unique_value: 3000, name: "zzz".to_string(), }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await @@ -334,7 +334,7 @@ fn test_update_unsized_larger_last_fail() { unique_value: 10, name: "a".to_string(), }; - table.insert(row1.clone()).unwrap(); + table.insert(row1.clone()).await.unwrap(); let row2 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -342,7 +342,7 @@ fn test_update_unsized_larger_last_fail() { unique_value: 20, name: "b".to_string(), }; - table.insert(row2.clone()).unwrap(); + table.insert(row2.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); (row1.id, row2.id) @@ -359,7 +359,7 @@ fn test_update_unsized_larger_last_fail() { unique_value: 1000, name: "xxx".to_string(), }; - table.insert(valid_row1).unwrap(); + table.insert(valid_row1).await.unwrap(); let valid_row2 = NonUniqueUnsizedRow { id: table.get_next_pk().0, @@ -367,7 +367,7 @@ fn test_update_unsized_larger_last_fail() { unique_value: 2000, name: "yyy".to_string(), }; - table.insert(valid_row2).unwrap(); + table.insert(valid_row2).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -385,7 +385,7 @@ fn test_update_unsized_larger_last_fail() { unique_value: 3000, name: "zzz".to_string(), }; - table.insert(valid_row3).unwrap(); + table.insert(valid_row3).await.unwrap(); let wait_result = timeout(Duration::from_secs(4), table.wait_for_ops()) .await diff --git a/tests/persistence/in_place_durability.rs b/tests/persistence/in_place_durability.rs index 0d960741..154dc6b1 100644 --- a/tests/persistence/in_place_durability.rs +++ b/tests/persistence/in_place_durability.rs @@ -54,6 +54,7 @@ fn in_place_update_survives_reload() { counter: 10, note: "row".to_string(), }) + .await .unwrap(); table .update_counter_by_id_in_place(|counter| *counter = 42u64.into(), 1) diff --git a/tests/persistence/insert_many.rs b/tests/persistence/insert_many.rs index 55919a3c..071ed308 100644 --- a/tests/persistence/insert_many.rs +++ b/tests/persistence/insert_many.rs @@ -59,12 +59,12 @@ fn batch_insert_survives_reload() { // Multi operations. for _ in 0..3 { let id: u64 = table.get_next_pk().into(); - table.insert(row(id, 10_000 + id as u128)).unwrap(); + table.insert(row(id, 10_000 + id as u128)).await.unwrap(); } let range = table.reserve_pks(200); let rows: Vec<_> = range.clone().map(|id| row(id, 20_000 + id as u128)).collect(); - let pks = table.insert_many(rows).unwrap(); + let pks = table.insert_many(rows).await.unwrap(); assert_eq!(pks.len(), 200); // Read-your-writes before any persistence wait. @@ -74,7 +74,7 @@ fn batch_insert_survives_reload() { // Singles after the batch too. let id: u64 = table.get_next_pk().into(); - table.insert(row(id, 30_000 + id as u128)).unwrap(); + table.insert(row(id, 30_000 + id as u128)).await.unwrap(); table.wait_for_ops().await.unwrap(); } @@ -124,13 +124,13 @@ fn rejected_batch_leaves_no_trace_after_reload() { let engine = BatchPersistPersistenceEngine::new(config.clone()).await.unwrap(); let table = BatchPersistWorkTable::load(engine).await.unwrap(); - table.insert(row(0, 999)).unwrap(); + table.insert(row(0, 999)).await.unwrap(); // Last row collides on the unique wallet index. let mut rows: Vec<_> = (1..20u64).map(|id| row(id, 40_000 + id as u128)).collect(); rows.last_mut().unwrap().wallet = 999; - let error = table.insert_many(rows).unwrap_err(); + let error = table.insert_many(rows).await.unwrap_err(); match error { BatchInsertError::Row { row_index, source } => { assert_eq!(row_index, 18); @@ -145,7 +145,9 @@ fn rejected_batch_leaves_no_trace_after_reload() { table.wait_for_ops().await.unwrap(); // The pipeline stays usable for later batches. - let pks = table.insert_many((100..110u64).map(|id| row(id, 50_000 + id as u128)).collect()); + let pks = table + .insert_many((100..110u64).map(|id| row(id, 50_000 + id as u128)).collect()) + .await; assert_eq!(pks.unwrap().len(), 10); table.wait_for_ops().await.unwrap(); } @@ -187,12 +189,12 @@ fn batches_and_singles_interleave_through_the_engine() { let mut expected = 0u64; for round in 0..10u64 { let single: u64 = table.get_next_pk().into(); - table.insert(row(single, 60_000 + single as u128)).unwrap(); + table.insert(row(single, 60_000 + single as u128)).await.unwrap(); expected += 1; let range = table.reserve_pks(25); let rows: Vec<_> = range.map(|id| row(id, 60_000 + id as u128)).collect(); - table.insert_many(rows).unwrap(); + table.insert_many(rows).await.unwrap(); expected += 25; if round % 3 == 0 { diff --git a/tests/persistence/insert_many_bench.rs b/tests/persistence/insert_many_bench.rs index 9bb545e2..07e94e57 100644 --- a/tests/persistence/insert_many_bench.rs +++ b/tests/persistence/insert_many_bench.rs @@ -50,7 +50,7 @@ fn insert_many_vs_loop_insert_at_5000_rows() { let loop_rows = rows_for(loop_table.reserve_pks(ROWS as usize)); let started = Instant::now(); for row in loop_rows { - loop_table.insert(row).unwrap(); + loop_table.insert(row).await.unwrap(); } let loop_visible = started.elapsed(); loop_table.wait_for_ops().await.unwrap(); @@ -61,7 +61,7 @@ fn insert_many_vs_loop_insert_at_5000_rows() { let batch_table = table_at("tests/data/insert_many/bench_batch").await; let batch_rows = rows_for(batch_table.reserve_pks(ROWS as usize)); let started = Instant::now(); - batch_table.insert_many(batch_rows).unwrap(); + batch_table.insert_many(batch_rows).await.unwrap(); let batch_visible = started.elapsed(); batch_table.wait_for_ops().await.unwrap(); let batch_durable = started.elapsed(); diff --git a/tests/persistence/loaded_index_growth.rs b/tests/persistence/loaded_index_growth.rs index db353cea..12ff9b90 100644 --- a/tests/persistence/loaded_index_growth.rs +++ b/tests/persistence/loaded_index_growth.rs @@ -93,7 +93,7 @@ fn test_primary_index_grows_on_a_loaded_table() { let engine = LoadedIndexGrowthPersistenceEngine::new(config.clone()).await.unwrap(); let table = LoadedIndexGrowthWorkTable::load(engine).await.unwrap(); for i in 0..ROWS_BEFORE_RELOAD { - table.insert(row(i)).unwrap(); + table.insert(row(i)).await.unwrap(); } timeout(Duration::from_secs(30), table.wait_for_ops()) .await @@ -112,6 +112,7 @@ fn test_primary_index_grows_on_a_loaded_table() { for i in ROWS_BEFORE_RELOAD..(ROWS_BEFORE_RELOAD + ROWS_AFTER_RELOAD) { table .insert(row(i)) + .await .unwrap_or_else(|error| panic!("insert {i} into the loaded table was refused: {error:?}")); } timeout(Duration::from_secs(30), table.wait_for_ops()) @@ -164,7 +165,7 @@ fn test_primary_index_grows_on_a_loaded_table() { // And the grown, reloaded table must still be writable: the // production stores died on exactly this insert. - table.insert(row(ROWS_BEFORE_RELOAD + ROWS_AFTER_RELOAD)).unwrap(); + table.insert(row(ROWS_BEFORE_RELOAD + ROWS_AFTER_RELOAD)).await.unwrap(); timeout(Duration::from_secs(30), table.wait_for_ops()) .await .expect("persistence stalled on the post-reload insert") diff --git a/tests/persistence/mod.rs b/tests/persistence/mod.rs index 5334176b..e282cc9f 100644 --- a/tests/persistence/mod.rs +++ b/tests/persistence/mod.rs @@ -2,6 +2,7 @@ use worktable::prelude::PersistedWorkTable; use worktable::prelude::*; use worktable::worktable; +mod bulk_delete_durability; mod bulk_load_stall; mod concurrent; mod duplicate_key_index_reload; diff --git a/tests/persistence/multi_row_backend_order.rs b/tests/persistence/multi_row_backend_order.rs index 657404e5..cbd71a8d 100644 --- a/tests/persistence/multi_row_backend_order.rs +++ b/tests/persistence/multi_row_backend_order.rs @@ -49,6 +49,7 @@ macro_rules! persisted_multi_row_backend_case { group_id: 7, payload: "x".repeat(length), }) + .await .unwrap(); } table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/recovery_load.rs b/tests/persistence/recovery_load.rs index d3e06423..3cf8feaf 100644 --- a/tests/persistence/recovery_load.rs +++ b/tests/persistence/recovery_load.rs @@ -45,9 +45,9 @@ async fn recovery_mode_reads_valid_rows_through_a_surviving_secondary_index() { remove_dir_if_exists(DIR.to_owned()).await; let table = RecoveryLoadWorkTable::load(engine(DIR).await).await.unwrap(); - table.insert(row("row-1", "project-a")).unwrap(); - table.insert(row("row-2", "project-a")).unwrap(); - table.insert(row("row-3", "project-b")).unwrap(); + table.insert(row("row-1", "project-a")).await.unwrap(); + table.insert(row("row-2", "project-a")).await.unwrap(); + table.insert(row("row-3", "project-b")).await.unwrap(); table.close().await.unwrap(); let table_dir = format!("{DIR}/{}", RecoveryLoadWorkTable::name_snake_case()); @@ -98,7 +98,7 @@ async fn recovery_mode_rejects_corrupt_rows_reached_through_a_secondary_index() remove_dir_if_exists(CORRUPT_DIR.to_owned()).await; let table = RecoveryLoadWorkTable::load(engine(CORRUPT_DIR).await).await.unwrap(); - let id = table.insert(row("row-corrupt", "project-a")).unwrap(); + let id = table.insert(row("row-corrupt", "project-a")).await.unwrap(); let link = table.0.primary_index.pk_map.get_value(&id).unwrap().0; table.close().await.unwrap(); diff --git a/tests/persistence/s3/mod.rs b/tests/persistence/s3/mod.rs index 9b5a7f3c..92df8ad5 100644 --- a/tests/persistence/s3/mod.rs +++ b/tests/persistence/s3/mod.rs @@ -51,6 +51,7 @@ fn test_s3_engine_compiles() { id: table.get_next_pk().into(), value: 0, }) + .await .unwrap(); assert!(!table.select_all().execute().unwrap().is_empty()); table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/same_size_in_place.rs b/tests/persistence/same_size_in_place.rs index 31d33562..8c36463f 100644 --- a/tests/persistence/same_size_in_place.rs +++ b/tests/persistence/same_size_in_place.rs @@ -52,6 +52,7 @@ fn same_size_updates_keep_the_row_link() { amount: 1, note: "aaaa".to_string(), }) + .await .unwrap(); let pk = SameSizeInPlacePrimaryKey(1); let link_before = table.0.primary_index.pk_map.get_value(&pk).unwrap().0; diff --git a/tests/persistence/sync/failure.rs b/tests/persistence/sync/failure.rs index 4a20f7ee..70d11c88 100644 --- a/tests/persistence/sync/failure.rs +++ b/tests/persistence/sync/failure.rs @@ -32,7 +32,7 @@ fn test_failed_update_by_pk_doesnt_corrupt_persistence() { non_unique: 0, field: i as f64, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); pks.push(row.id); } table.wait_for_ops().await.unwrap(); @@ -105,7 +105,7 @@ fn test_failed_update_by_unique_index_doesnt_corrupt_persistence() { non_unique: 0, field: i as f64, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); pks.push(row.id); } table.wait_for_ops().await.unwrap(); @@ -178,7 +178,7 @@ fn test_failed_delete_by_pk_doesnt_corrupt_persistence() { non_unique: 0, field: i as f64, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); pks.push(row.id); } table.wait_for_ops().await.unwrap(); diff --git a/tests/persistence/sync/failure_multi_index.rs b/tests/persistence/sync/failure_multi_index.rs index 469e87f1..d0181188 100644 --- a/tests/persistence/sync/failure_multi_index.rs +++ b/tests/persistence/sync/failure_multi_index.rs @@ -56,7 +56,7 @@ fn test_multi_index_insert_failure_doesnt_corrupt_persistence() { unique_a: 0, unique_b: 0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -72,14 +72,14 @@ fn test_multi_index_insert_failure_doesnt_corrupt_persistence() { unique_a: 9999, unique_b: 999, }; - table.insert(valid_row).unwrap(); + table.insert(valid_row).await.unwrap(); let valid_row = MultiUniqueIdxRow { id: table.get_next_pk().0, unique_a: 1, unique_b: 1, }; - table.insert(valid_row).unwrap(); + table.insert(valid_row).await.unwrap(); tokio::time::sleep(Duration::from_millis(500)).await; @@ -89,7 +89,7 @@ fn test_multi_index_insert_failure_doesnt_corrupt_persistence() { unique_b: 0, // This already exists }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::AlreadyExists(_))); @@ -99,7 +99,7 @@ fn test_multi_index_insert_failure_doesnt_corrupt_persistence() { unique_b: 0, // This already exists }; - let result = table.insert(failing_row); + let result = table.insert(failing_row).await; assert!(result.is_err()); assert!(matches!(result.unwrap_err(), WorkTableError::AlreadyExists(_))); @@ -109,7 +109,7 @@ fn test_multi_index_insert_failure_doesnt_corrupt_persistence() { unique_b: 99, }; let valid_pk = valid_row.id; - table.insert(valid_row).unwrap(); + table.insert(valid_row).await.unwrap(); // Use timeout to detect if persistence is stuck // If this hangs, the bug exists - CDC queue is blocked @@ -145,7 +145,7 @@ fn test_multi_index_insert_failure_doesnt_corrupt_persistence() { unique_a: 99, unique_b: 100, }; - let result = table.insert(row_with_99.clone()); + let result = table.insert(row_with_99.clone()).await; assert!( result.is_ok(), "BUG DETECTED: unique_a_idx has orphaned entry for unique_a=99 \ diff --git a/tests/persistence/sync/many_strings.rs b/tests/persistence/sync/many_strings.rs index 4f266bb4..d9082675 100644 --- a/tests/persistence/sync/many_strings.rs +++ b/tests/persistence/sync/many_strings.rs @@ -44,13 +44,13 @@ fn test_space_update_query_pk_sync() { field: "".to_string(), id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); let row = TestSyncRow { another: 43, field: "".to_string(), id: "Some string before 2".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -102,13 +102,13 @@ fn test_space_update_query_pk_many_times_sync() { field: "".to_string(), id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); let row = TestSyncRow { another: 43, field: "".to_string(), id: "Some string before 2".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; diff --git a/tests/persistence/sync/mod.rs b/tests/persistence/sync/mod.rs index 23bbb716..38d8ecc5 100644 --- a/tests/persistence/sync/mod.rs +++ b/tests/persistence/sync/mod.rs @@ -93,7 +93,7 @@ fn test_space_insert_sync() { field: 0.234, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -136,7 +136,7 @@ fn test_space_insert_many_sync() { field: i as f64 / 100.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); row.id }; pks.push(pk); @@ -183,7 +183,7 @@ fn test_space_update_full_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update(TestSyncRow { another: 13, @@ -233,7 +233,7 @@ fn test_space_update_query_pk_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_another_by_id(AnotherByIdQuery { another: 13 }, row.id) .await @@ -278,7 +278,7 @@ fn test_space_update_query_unique_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) .await @@ -323,7 +323,7 @@ fn test_space_update_query_non_unique_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) .await @@ -368,7 +368,7 @@ fn test_space_delete_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.delete(row.id).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id @@ -409,7 +409,7 @@ fn test_space_delete_query_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.delete_by_another(row.another).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id diff --git a/tests/persistence/sync/option.rs b/tests/persistence/sync/option.rs index 7c2ac3b4..0a164804 100644 --- a/tests/persistence/sync/option.rs +++ b/tests/persistence/sync/option.rs @@ -53,7 +53,7 @@ fn test_option_insert_none_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -95,7 +95,7 @@ fn test_option_insert_some_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -137,7 +137,7 @@ fn test_option_update_full_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update(TestOptionSyncRow { @@ -189,7 +189,7 @@ fn test_option_update_by_id_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_test_by_id(TestByIdQuery { test: Some(42) }, row.id) @@ -236,7 +236,7 @@ fn test_option_update_none_to_some_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_test_by_id(TestByIdQuery { test: Some(55) }, row.id) @@ -283,7 +283,7 @@ fn test_option_update_some_to_none_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_test_by_id(TestByIdQuery { test: None }, row.id) @@ -330,7 +330,7 @@ fn test_option_update_by_another_sync() { another: 123, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_test_by_another(TestByAnotherQuery { test: Some(77) }, 123) @@ -377,7 +377,7 @@ fn test_option_update_by_exchange_sync() { another: 1, exchange: 456, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_test_by_exchange(TestByExchangeQuery { test: Some(88) }, 456) @@ -425,7 +425,7 @@ fn test_option_multiple_rows_sync() { another: 1, exchange: 1, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let row2 = TestOptionSyncRow { id: table.get_next_pk().0, @@ -433,7 +433,7 @@ fn test_option_multiple_rows_sync() { another: 2, exchange: 2, }; - let pk2 = table.insert(row2).unwrap(); + let pk2 = table.insert(row2).await.unwrap(); table .update_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) @@ -503,7 +503,7 @@ fn test_option_indexed_insert_none_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -545,7 +545,7 @@ fn test_option_indexed_insert_some_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -587,7 +587,7 @@ fn test_option_indexed_update_none_to_some_by_id_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_index_test_by_id(IndexTestByIdQuery { test: Some(55) }, row.id) @@ -634,7 +634,7 @@ fn test_option_indexed_update_some_to_none_by_id_sync() { another: 1, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_index_test_by_id(IndexTestByIdQuery { test: None }, row.id) @@ -681,7 +681,7 @@ fn test_option_indexed_update_by_another_sync() { another: 123, exchange: 1, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_index_test_by_another(IndexTestByAnotherQuery { test: Some(77) }, 123) @@ -729,7 +729,7 @@ fn test_option_indexed_multiple_rows_sync() { another: 1, exchange: 1, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let row2 = TestOptionSyncIndexRow { id: table.get_next_pk().0, @@ -737,7 +737,7 @@ fn test_option_indexed_multiple_rows_sync() { another: 2, exchange: 2, }; - let pk2 = table.insert(row2).unwrap(); + let pk2 = table.insert(row2).await.unwrap(); let row3 = TestOptionSyncIndexRow { id: table.get_next_pk().0, @@ -745,7 +745,7 @@ fn test_option_indexed_multiple_rows_sync() { another: 3, exchange: 3, }; - let pk3 = table.insert(row3).unwrap(); + let pk3 = table.insert(row3).await.unwrap(); table .update_index_test_by_id(IndexTestByIdQuery { test: Some(40) }, pk1.clone()) @@ -798,7 +798,7 @@ fn test_option_indexed_full_row_update_sync() { another: 100, exchange: 200, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update(TestOptionSyncIndexRow { diff --git a/tests/persistence/sync/repeated_string_upsert.rs b/tests/persistence/sync/repeated_string_upsert.rs index 837fc18b..02ae114d 100644 --- a/tests/persistence/sync/repeated_string_upsert.rs +++ b/tests/persistence/sync/repeated_string_upsert.rs @@ -44,6 +44,7 @@ fn repeated_varying_string_upserts_keep_the_worker_healthy() { value: format!("initial-{index}"), updated_at: format!("2026-08-08T00:{:02}:00Z", index % 60), }) + .await .unwrap(); } table @@ -52,6 +53,7 @@ fn repeated_varying_string_upserts_keep_the_worker_healthy() { value: "{}".into(), updated_at: "2026-08-08T00:00:00Z".into(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); table.close().await.unwrap(); diff --git a/tests/persistence/sync/string_primary_index.rs b/tests/persistence/sync/string_primary_index.rs index 0de9853c..58019b28 100644 --- a/tests/persistence/sync/string_primary_index.rs +++ b/tests/persistence/sync/string_primary_index.rs @@ -56,7 +56,7 @@ fn test_space_insert_sync() { field: 0.234, id: "Some string to test".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -98,7 +98,7 @@ fn test_space_insert_many_sync() { field: i as f64 / 100.0, id: format!("Some string to test number {i}"), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); row.id }; pks.push(pk); @@ -143,7 +143,7 @@ fn test_space_update_full_sync() { field: 0.0, id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update(TestSyncRow { another: 13, @@ -192,7 +192,7 @@ fn test_space_update_query_pk_sync() { field: 0.0, id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_another_by_id(AnotherByIdQuery { another: 13 }, row.id.clone()) .await @@ -236,7 +236,7 @@ fn test_space_update_query_unique_sync() { field: 0.0, id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_field_by_another(FieldByAnotherQuery { field: 1.0 }, 42) .await @@ -280,7 +280,7 @@ fn test_space_update_query_non_unique_sync() { field: 0.0, id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_another_by_non_unique(AnotherByNonUniqueQuery { another: 13 }, 10) .await @@ -324,14 +324,14 @@ fn test_space_delete_sync() { field: 0.0, id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); let another_row = TestSyncRow { another: 43, non_unique: 0, field: 0.0, id: "Some string".to_string(), }; - table.insert(another_row.clone()).unwrap(); + table.insert(another_row.clone()).await.unwrap(); table.delete(another_row.id.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); another_row.id @@ -371,7 +371,7 @@ fn test_space_delete_query_sync() { field: 0.0, id: "Some string before".to_string(), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.delete_by_another(row.another).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id diff --git a/tests/persistence/sync/string_re_read.rs b/tests/persistence/sync/string_re_read.rs index ff478294..fd54f2b2 100644 --- a/tests/persistence/sync/string_re_read.rs +++ b/tests/persistence/sync/string_re_read.rs @@ -57,6 +57,7 @@ fn test_key() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table .insert(StringReReadRow { @@ -66,6 +67,7 @@ fn test_key() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() @@ -81,6 +83,7 @@ fn test_key() { second: "second_last".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() } @@ -121,6 +124,7 @@ fn test_key_delete_scenario() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); let pk = table .insert(StringReReadRow { @@ -130,6 +134,7 @@ fn test_key_delete_scenario() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -158,6 +163,7 @@ fn test_key_delete_scenario() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() @@ -217,6 +223,7 @@ fn test_key_delete() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); let pk = table .insert(StringReReadRow { @@ -226,6 +233,7 @@ fn test_key_delete() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -279,6 +287,7 @@ fn test_key_delete_all() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); let pk1 = table .insert(StringReReadRow { @@ -288,6 +297,7 @@ fn test_key_delete_all() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -344,6 +354,7 @@ fn test_key_delete_all_and_insert() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); let pk1 = table .insert(StringReReadRow { @@ -353,6 +364,7 @@ fn test_key_delete_all_and_insert() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -379,6 +391,7 @@ fn test_key_delete_all_and_insert() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -426,6 +439,7 @@ fn test_key_delete_by_unique() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); let pk = table .insert(StringReReadRow { @@ -435,6 +449,7 @@ fn test_key_delete_by_unique() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -488,6 +503,7 @@ fn test_key_delete_by_non_unique() { second: "second".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); let pk1 = table .insert(StringReReadRow { @@ -497,6 +513,7 @@ fn test_key_delete_by_non_unique() { second: "second_again".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -553,6 +570,7 @@ fn test_toc_not_updated_when_index_value_same_but_link_changes() { second: "second_1".to_string(), last: "last_1".to_string(), }) + .await .unwrap(); table @@ -563,6 +581,7 @@ fn test_toc_not_updated_when_index_value_same_but_link_changes() { second: "second_2".to_string(), last: "last_2".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -591,13 +610,15 @@ fn test_toc_not_updated_when_index_value_same_but_link_changes() { let engine = StringReReadPersistenceEngine::new(config.clone()).await.unwrap(); let table = StringReReadWorkTable::load(engine).await.unwrap(); - let result = table.insert(StringReReadRow { - first: "same_first".to_string(), - id: table.get_next_pk().into(), - third: "third_3".to_string(), - second: "second_3".to_string(), - last: "last_3".to_string(), - }); + let result = table + .insert(StringReReadRow { + first: "same_first".to_string(), + id: table.get_next_pk().into(), + third: "third_3".to_string(), + second: "second_3".to_string(), + last: "last_3".to_string(), + }) + .await; assert!(result.is_ok(), "TOC entry is stale after update with same index value"); @@ -652,6 +673,7 @@ fn test_big_amount_reread() { second: format!("second_{i}"), last: format!("_________________________last_____________________{i}"), }) + .await .unwrap(); } @@ -668,6 +690,7 @@ fn test_big_amount_reread() { second: "second_last".to_string(), last: "_________________________last_____________________".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() @@ -712,6 +735,7 @@ fn test_unique_index_same_value_link_changes() { second: "unique_second".to_string(), last: "last_1".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); @@ -736,13 +760,15 @@ fn test_unique_index_same_value_link_changes() { .unwrap(); // Insert new row with different unique value - let result = table.insert(StringReReadRow { - first: "first_2".to_string(), - id: table.get_next_pk().into(), - third: "third_2".to_string(), - second: "unique_second_2".to_string(), - last: "last_2".to_string(), - }); + let result = table + .insert(StringReReadRow { + first: "first_2".to_string(), + id: table.get_next_pk().into(), + third: "third_2".to_string(), + second: "unique_second_2".to_string(), + last: "last_2".to_string(), + }) + .await; assert!( result.is_ok(), diff --git a/tests/persistence/sync/string_secondary_index.rs b/tests/persistence/sync/string_secondary_index.rs index e7266298..93a6fafc 100644 --- a/tests/persistence/sync/string_secondary_index.rs +++ b/tests/persistence/sync/string_secondary_index.rs @@ -72,7 +72,7 @@ fn fragmented_string_index_compacts_after_restart_before_appending() { project_id: project_id.clone(), }; inserted_ids.push(row.id); - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } table.wait_for_ops().await.unwrap(); for id in inserted_ids.iter().take(37).copied() { @@ -91,7 +91,7 @@ fn fragmented_string_index_compacts_after_restart_before_appending() { id: table.get_next_pk().0, project_id: project_id.clone(), }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); } table.wait_for_ops().await.unwrap(); } @@ -151,7 +151,7 @@ fn test_space_insert_sync() { field: 0.234, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id }; @@ -194,7 +194,7 @@ fn test_space_insert_many_sync() { field: i as f64 / 100.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); row.id }; pks.push(pk); @@ -241,7 +241,7 @@ fn test_space_update_full_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update(TestSyncRow { another: "Some string to test updated".to_string(), @@ -298,7 +298,7 @@ fn test_space_update_query_pk_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_another_by_id( AnotherByIdQuery { @@ -351,7 +351,7 @@ fn test_space_update_query_unique_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_field_by_another(FieldByAnotherQuery { field: 1.0 }, "Some string before".to_string()) .await @@ -396,7 +396,7 @@ fn test_space_update_query_non_unique_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table .update_another_by_non_unique( AnotherByNonUniqueQuery { @@ -449,7 +449,7 @@ fn test_space_delete_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.delete(row.id).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id @@ -490,7 +490,7 @@ fn test_space_delete_query_sync() { field: 0.0, id: table.get_next_pk().0, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.delete_by_another(row.another).await.unwrap(); table.wait_for_ops().await.unwrap(); row.id diff --git a/tests/persistence/sync/string_update_timeout.rs b/tests/persistence/sync/string_update_timeout.rs index 4eef4cd3..05bab4c0 100644 --- a/tests/persistence/sync/string_update_timeout.rs +++ b/tests/persistence/sync/string_update_timeout.rs @@ -74,7 +74,7 @@ fn test_string_update_doesnt_block_persistence() { status: 1, honey_app_role: 2, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); table.wait_for_ops().await.unwrap(); row }; diff --git a/tests/persistence/sync/uuid_.rs b/tests/persistence/sync/uuid_.rs index 7725f7ea..2100ae0c 100644 --- a/tests/persistence/sync/uuid_.rs +++ b/tests/persistence/sync/uuid_.rs @@ -46,6 +46,7 @@ fn test_uuid() { id: table.get_next_pk().into(), second: Uuid::now_v7(), }) + .await .unwrap(); table .insert(UuidReReadRow { @@ -53,6 +54,7 @@ fn test_uuid() { id: table.get_next_pk().into(), second: Uuid::now_v7(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() @@ -66,6 +68,7 @@ fn test_uuid() { id: table.get_next_pk().into(), second: Uuid::now_v7(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() } @@ -105,6 +108,7 @@ fn test_big_amount_reread() { id: table.get_next_pk().into(), second: Uuid::now_v7(), }) + .await .unwrap(); } @@ -121,6 +125,7 @@ fn test_big_amount_reread() { id: table.get_next_pk().into(), second: second_last, }) + .await .unwrap(); table.wait_for_ops().await.unwrap() } diff --git a/tests/persistence/torn_shutdown.rs b/tests/persistence/torn_shutdown.rs index f206bdb4..9891e5cc 100644 --- a/tests/persistence/torn_shutdown.rs +++ b/tests/persistence/torn_shutdown.rs @@ -99,11 +99,14 @@ fn torn_shutdown_writer() { .unwrap(); runtime.block_on(async { let table = open_table().await; - for i in start.. { + // Bounded only to satisfy `clippy::for_unbounded_range`, which is right + // in general: an unbounded range wraps. This child is killed by its + // parent within a second and will never approach the bound. + for i in start..=u64::MAX { // Errors are tolerated, aborts are not: the parent only checks // how this process DIES, and it must die by the parent's signal, // not by its own reading of what the last kill left behind. - let _ = table.insert(row(i)); + let _ = table.insert(row(i)).await; if i % 64 == 0 { tokio::time::sleep(Duration::from_millis(1)).await; } @@ -132,7 +135,7 @@ fn tear_the_store_repeatedly() { { let table = open_table().await; for i in 0..200 { - table.insert(row(i)).unwrap(); + table.insert(row(i)).await.unwrap(); } timeout(Duration::from_secs(30), table.wait_for_ops()) .await @@ -259,7 +262,7 @@ fn test_store_survives_torn_shutdowns() { ); } // And the survivor must still accept writes and a drain. - table.insert(row(9_000_000)).unwrap(); + table.insert(row(9_000_000)).await.unwrap(); timeout(Duration::from_secs(30), table.wait_for_ops()) .await .expect("persistence stalled appending to the survivor store") @@ -286,7 +289,7 @@ fn corrupted_row_is_refused_with_typed_load_error() { let link = runtime.block_on(async { remove_dir_if_exists(DIR.to_string()).await; let table = open_table().await; - let primary_key = table.insert(row(7)).unwrap(); + let primary_key = table.insert(row(7)).await.unwrap(); let link = table.0.primary_index.pk_map.get_value(&primary_key).unwrap().0; table.close().await.unwrap(); link @@ -334,7 +337,7 @@ fn incomplete_secondary_index_is_refused_with_typed_load_error() { runtime.block_on(async { remove_dir_if_exists(DIR.to_string()).await; let table = open_table().await; - table.insert(row(11)).unwrap(); + table.insert(row(11)).await.unwrap(); table.close().await.unwrap(); }); @@ -400,6 +403,7 @@ fn test_many_clean_sessions_stay_readable() { for _ in 0..8 { table .insert(row(next_id)) + .await .unwrap_or_else(|error| panic!("session {session}: insert {next_id} refused: {error:?}")); next_id += 1; } diff --git a/tests/persistence/tuple_primary_key.rs b/tests/persistence/tuple_primary_key.rs index e7b07b85..fb7f4944 100644 --- a/tests/persistence/tuple_primary_key.rs +++ b/tests/persistence/tuple_primary_key.rs @@ -47,7 +47,7 @@ async fn composite_primary_key_survives_mutations_and_reload() { .unwrap(); let table = PersistedTuplePrimaryKeyWorkTable::load(engine).await.unwrap(); for row in &rows { - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); } table.wait_for_ops().await.unwrap(); for row in &rows { diff --git a/tests/persistence/vacuum.rs b/tests/persistence/vacuum.rs index 3a9ba923..7048e543 100644 --- a/tests/persistence/vacuum.rs +++ b/tests/persistence/vacuum.rs @@ -62,7 +62,7 @@ fn test_vacuum_on_persisted_table_survives_reload() { exchange: format!("test{i}"), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.insert(id, row); } @@ -103,7 +103,7 @@ fn test_vacuum_on_persisted_table_survives_reload() { exchange: format!("test{i}"), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.insert(id, row); if i % 50 == 49 { timeout(Duration::from_secs(30), table.wait_for_ops()) @@ -155,7 +155,7 @@ fn test_vacuum_on_persisted_table_survives_reload() { }; let reused_id = reused_row.id; reused_after_reload_id = reused_id; - table.insert(reused_row.clone()).unwrap(); + table.insert(reused_row.clone()).await.unwrap(); rows.insert(reused_id, reused_row); timeout(Duration::from_secs(30), table.wait_for_ops()) .await @@ -194,7 +194,7 @@ fn test_vacuum_on_persisted_table_survives_reload() { another: 1_101, exchange: "second-reuse-after-reload".to_string(), }; - table.insert(second_reused_row).unwrap(); + table.insert(second_reused_row).await.unwrap(); timeout(Duration::from_secs(30), table.wait_for_ops()) .await .expect("persistence should catch up after a second durable page reuse") @@ -208,3 +208,168 @@ fn test_vacuum_on_persisted_table_survives_reload() { } }) } + +/// Batching the sweep lets a concurrent insert reuse freed space *while* +/// vacuum runs. On a persisted table that means an insert can claim a link on +/// a page the sweep is still working through, and both the insert and the +/// sweep's row moves go through the same CDC event stream. +/// +/// Before the sweep was batched this could not happen at all: vacuum held the +/// registry for its whole duration, so no insert could reuse anything. Nothing +/// on the persistence path had ever seen this interleaving. +/// +/// The deletes here are one contiguous block, which is also what makes the +/// freed links coalesce, so this exercises the ranged path rather than a +/// scattered one. +/// +/// What this does not pin down: whether a given run hits the window where an +/// insert reuses a link on a page the sweep is mid-way through. That is +/// scheduling-dependent. The test creates the opportunity and asserts the +/// durable outcome; it is not a proof that the window was entered. +/// +/// # This is the test that found the CDC event-id gap +/// +/// It failed about half of runs with: +/// +/// ```text +/// persistence stalled on primary index event gap: +/// last applied Id(2938), next available Id(2940) (attempt 9) +/// ``` +/// +/// Nothing was lost. Event ids are allocated during the index mutations while +/// an operation id is minted later, at the push site, so two concurrent +/// writers can invert the two orders; vacuum did it systematically because its +/// update lands on the destination page while inserts append to the current +/// one. Page-grouped batch collection then never visited the page holding the +/// missing id, rebuilt the same gapped batch every retry, and failed the +/// engine for that table permanently. +/// +/// Fixed by the whole-queue fallback in `collect_batch_from_op_id`. The +/// deterministic version lives in `persistence::task`; this one is the +/// integration case that surfaced it, and it is kept because the race it +/// creates is the one that mattered. +#[test] +fn test_persisted_vacuum_survives_inserts_reusing_space_mid_sweep() { + let config = DiskConfig::new_with_table_name( + "tests/data/vacuum/persisted_reuse", + VacuumPersistWorkTable::name_snake_case(), + VacuumPersistWorkTable::version(), + ); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_io() + .enable_time() + .build() + .unwrap(); + + runtime.block_on(async { + remove_dir_if_exists("tests/data/vacuum/persisted_reuse".to_string()).await; + + let mut rows = HashMap::new(); + let deleted: Vec; + { + let engine = VacuumPersistPersistenceEngine::new(config.clone()).await.unwrap(); + let table = std::sync::Arc::new(VacuumPersistWorkTable::load(engine).await.unwrap()); + + for i in 0..2_000i64 { + let row = VacuumPersistRow { + id: table.get_next_pk().into(), + test: i, + another: i as u64, + exchange: format!("test{i}"), + }; + rows.insert(row.id, row.clone()); + table.insert(row).await.unwrap(); + } + + // One contiguous block, well past the reclamation backlog so the + // freed links actually reach the registry before the sweep. + let mut ids: Vec<_> = rows.keys().copied().collect(); + ids.sort_unstable(); + deleted = ids.into_iter().skip(400).take(800).collect(); + for id in &deleted { + table.delete(*id).await.unwrap(); + } + timeout(Duration::from_secs(30), table.wait_for_ops()) + .await + .expect("persistence should catch up before vacuum") + .expect("persistence engine failed"); + + // Inserts run against the table for the whole sweep, so they land + // in the windows between batches where reuse is possible again. + let inserting = tokio::spawn({ + let table = std::sync::Arc::clone(&table); + async move { + let mut inserted = Vec::new(); + for i in 2_000..2_600i64 { + let row = VacuumPersistRow { + id: table.get_next_pk().into(), + test: i, + another: i as u64, + exchange: format!("test{i}"), + }; + inserted.push(row.clone()); + let pk = row.id; + if let Err(error) = table.insert(row).await { + panic!( + "insert of fresh autoincrement pk {pk:?} failed: {error:?}; \ + already present in table: {}", + table.select(pk).is_some() + ); + } + tokio::task::yield_now().await; + } + inserted + } + }); + + let stats = table.vacuum().vacuum().await.unwrap(); + assert!( + stats.pages_freed > 0, + "the sweep must actually reclaim pages, or this test proves nothing about \ + interleaving with it" + ); + for row in inserting.await.unwrap() { + rows.insert(row.id, row); + } + + // Longer than the engine's own give-up budget, so a stall surfaces + // as its diagnostic naming the missing event id rather than as a + // bare timeout here, which says nothing. + timeout(Duration::from_secs(90), table.wait_for_ops()) + .await + .expect("persistence stalled after a sweep interleaved with inserts") + .expect("persistence engine failed"); + + for id in &deleted { + rows.remove(id); + } + for (id, expected) in &rows { + assert_eq!( + table.select(*id).as_ref(), + Some(expected), + "row {id} lost or corrupted by a sweep running alongside inserts" + ); + } + let table = std::sync::Arc::into_inner(table).expect("the inserting task has finished"); + table.close().await.unwrap(); + } + + // The reload is the part that a mid-sweep reuse would break: an insert + // writing through a link the sweep also relocated leaves the on-disk + // state describing two different rows at one address. + let engine = VacuumPersistPersistenceEngine::new(config.clone()).await.unwrap(); + let table = VacuumPersistWorkTable::load(engine).await.unwrap(); + for (id, expected) in &rows { + assert_eq!( + table.select(*id).as_ref(), + Some(expected), + "row {id} did not survive the reload after a sweep interleaved with inserts" + ); + } + for id in &deleted { + assert!(table.select(*id).is_none(), "deleted row {id} came back after reload"); + } + }); +} diff --git a/tests/worktable/array.rs b/tests/worktable/array.rs index e9a64e32..3927c4ec 100644 --- a/tests/worktable/array.rs +++ b/tests/worktable/array.rs @@ -20,7 +20,7 @@ worktable! ( async fn insert() { let table = TestWorkTable::default(); let row = TestRow { id: 1, test: [1; 20] }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, row); @@ -31,7 +31,7 @@ async fn insert() { async fn update() { let table = TestWorkTable::default(); let row = TestRow { id: 1, test: [1; 20] }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let new_row = TestRow { id: 1, test: [2; 20] }; table.update(new_row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); @@ -45,7 +45,7 @@ async fn update_in_a_middle() { let table = TestWorkTable::default(); for i in 0..10 { let row = TestRow { id: i, test: [1; 20] }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let new_row = TestRow { id: 3, test: [1; 20] }; table.update(new_row.clone()).await.unwrap(); @@ -58,7 +58,7 @@ async fn update_in_a_middle() { async fn update_query() { let table = TestWorkTable::default(); let row = TestRow { id: 1, test: [1; 20] }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let q = TestByIdQuery { test: [2; 20] }; table.update_test_by_id(q.clone(), pk.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); @@ -86,7 +86,7 @@ worktable! ( async fn insert_i() { let table = TestIWorkTable::default(); let row = TestIRow { id: 1, test: [1; 20] }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, row); @@ -97,7 +97,7 @@ async fn insert_i() { async fn update_i() { let table = TestIWorkTable::default(); let row = TestIRow { id: 1, test: [1; 20] }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let new_row = TestIRow { id: 1, test: [2; 20] }; table.update(new_row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); @@ -111,7 +111,7 @@ async fn update_in_a_middle_i() { let table = TestIWorkTable::default(); for i in 0..10 { let row = TestIRow { id: i, test: [1; 20] }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let new_row = TestIRow { id: 3, test: [1; 20] }; table.update(new_row.clone()).await.unwrap(); @@ -124,7 +124,7 @@ async fn update_in_a_middle_i() { async fn update_query_i() { let table = TestIWorkTable::default(); let row = TestIRow { id: 1, test: [1; 20] }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let q = TestIByIdQuery { test: [2; 20] }; table.update_test_i_by_id(q.clone(), pk.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); diff --git a/tests/worktable/base.rs b/tests/worktable/base.rs index 5ca38862..77b14c2e 100644 --- a/tests/worktable/base.rs +++ b/tests/worktable/base.rs @@ -45,7 +45,16 @@ fn table_name() { } #[test] -fn iter_with() { +fn generated_delete_futures_are_send() { + fn assert_send(_: T) {} + + let table = TestWorkTable::default(); + assert_send(table.delete_by_test(1)); + assert_send(table.delete_by_another(1)); +} + +#[tokio::test] +async fn iter_with() { let table = TestWorkTable::default(); let row = TestRow { id: table.get_next_pk().into(), @@ -53,21 +62,21 @@ fn iter_with() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 2, another: 2, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 3, another: 3, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); table.iter_with(|_| Ok(())).unwrap() } @@ -81,21 +90,21 @@ async fn iter_with_async() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 2, another: 2, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 3, another: 3, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); table.iter_with_async(|_| async move { Ok(()) }).await.unwrap() } @@ -109,7 +118,7 @@ async fn update_spawn() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let updated = TestRow { id: pk.clone().into(), test: 2, @@ -137,7 +146,7 @@ async fn upsert_spawn() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let updated = TestRow { id: pk.clone().into(), test: 2, @@ -165,7 +174,7 @@ async fn update() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let updated = TestRow { id: pk.clone().into(), test: 2, @@ -188,7 +197,7 @@ async fn update_string() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let updated = TestRow { id: pk.clone().into(), @@ -200,6 +209,10 @@ async fn update_string() { let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, updated); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); assert_eq!(table.0.data.get_empty_links().first().unwrap(), &first_link); assert!(table.select(2).is_none()) } @@ -215,7 +228,7 @@ async fn update_parallel() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let shared_i_state = i_state.clone(); @@ -268,7 +281,7 @@ async fn delete() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); table.delete(pk.clone()).await.unwrap(); let selected_row = table.select(pk); @@ -284,7 +297,7 @@ async fn delete() { another: 3, exchange: "test".to_string(), }; - let pk = table.insert(updated.clone()).unwrap(); + let pk = table.insert(updated.clone()).await.unwrap(); let new_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); assert_eq!(link, new_link) @@ -299,14 +312,14 @@ async fn delete_by_another() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); table.delete_by_another(1).await.unwrap(); assert_eq!(table.select_all().execute().unwrap().len(), 0) } @@ -320,14 +333,14 @@ async fn delete_by_exchange() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); table.delete_by_exchange("test".to_string()).await.unwrap(); assert_eq!(table.select_all().execute().unwrap().len(), 0) } @@ -341,14 +354,14 @@ async fn delete_by_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); table.delete_by_test(2).await.unwrap(); assert_eq!(table.select_all().execute().unwrap().len(), 1) } @@ -362,14 +375,14 @@ async fn delete_and_insert_less() { another: 0, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 1, another: 1, exchange: "test1234567890".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); table.delete(pk.clone()).await.unwrap(); let selected_row = table.select(pk); @@ -381,7 +394,7 @@ async fn delete_and_insert_less() { another: 3, exchange: "test1".to_string(), }; - let pk = table.insert(updated.clone()).unwrap(); + let pk = table.insert(updated.clone()).await.unwrap(); let new_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); assert_ne!(link.0, new_link.0) @@ -396,14 +409,14 @@ async fn delete_and_replace() { another: 0, exchange: "test1".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 1, another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); table.delete(pk.clone()).await.unwrap(); let selected_row = table.select(pk); @@ -415,7 +428,7 @@ async fn delete_and_replace() { another: 3, exchange: "test".to_string(), }; - let pk = table.insert(updated.clone()).unwrap(); + let pk = table.insert(updated.clone()).await.unwrap(); let new_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); assert_eq!(link, new_link) @@ -444,8 +457,8 @@ async fn upsert() { assert!(table.select(2).is_none()) } -#[test] -fn insert_same() { +#[tokio::test] +async fn insert_same() { let table = TestWorkTable::default(); let row = TestRow { id: table.get_next_pk().into(), @@ -453,13 +466,13 @@ fn insert_same() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); - let res = table.insert(row.clone()); + let _ = table.insert(row.clone()).await.unwrap(); + let res = table.insert(row.clone()).await; assert!(res.is_err()) } -#[test] -fn insert_exchange_same() { +#[tokio::test] +async fn insert_exchange_same() { let table = TestWorkTable::default(); let row = TestRow { id: table.get_next_pk().into(), @@ -467,14 +480,14 @@ fn insert_exchange_same() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = TestRow { id: table.get_next_pk().into(), test: 1, another: 1, exchange: "test".to_string(), }; - let res = table.insert(row.clone()); + let res = table.insert(row.clone()).await; assert!(res.is_err()) } @@ -487,7 +500,7 @@ async fn select_by_exchange() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let selected_rows = table.select_by_exchange("test".to_string()).execute().expect("rows"); assert_eq!(selected_rows.len(), 1); @@ -510,14 +523,14 @@ async fn select_multiple_by_exchange() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row_next = TestRow { id: table.get_next_pk().into(), test: 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row_next.clone()).unwrap(); + let _ = table.insert(row_next.clone()).await.unwrap(); let selected_rows = table.select_by_exchange("test".to_string()).execute().expect("rows"); assert_eq!(selected_rows.len(), 2); @@ -541,7 +554,7 @@ async fn select_by_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let selected_row = table.select_by_test(1).unwrap(); assert_eq!(selected_row, row); @@ -557,14 +570,14 @@ async fn select_all_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); let all = table.select_all().execute().unwrap(); @@ -596,9 +609,9 @@ async fn select_all_range_test() { exchange: "P".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table .select_all() @@ -632,9 +645,9 @@ async fn select_all_range_inclusive_test() { exchange: "P".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table .select_all() @@ -668,9 +681,9 @@ async fn select_all_where_by_eq_string_test() { exchange: "P1".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table.select_all(); @@ -701,9 +714,9 @@ async fn select_all_where_by_contains_string_test() { exchange: "P1".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table.select_all(); let contains = all.where_by(|row| row.exchange.contains("1")).execute().unwrap(); @@ -734,9 +747,9 @@ async fn select_all_where_by_gt_string_number_test() { exchange: "P1".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table.select_all(); @@ -767,9 +780,9 @@ async fn select_all_where_by_eq_string_number_test() { exchange: "P1".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table.select_all(); @@ -800,9 +813,9 @@ async fn select_all_order_multiple_test() { exchange: "P".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table .select_all() @@ -826,14 +839,14 @@ async fn select_all_limit_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 100 - 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); for i in 3..100 { let row = TestRow { id: table.get_next_pk().into(), @@ -841,7 +854,7 @@ async fn select_all_limit_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let all = table.select_all().limit(2).execute().unwrap(); @@ -860,14 +873,14 @@ async fn select_all_offset_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 100 - 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); let all = table.select_all().offset(1).execute().unwrap(); assert_eq!(all.len(), 1); @@ -886,14 +899,14 @@ async fn select_all_order_on_unique_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); for i in 3..100 { let row = TestRow { id: table.get_next_pk().into(), @@ -901,7 +914,7 @@ async fn select_all_order_on_unique_test() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let all = table @@ -925,14 +938,14 @@ async fn select_all_order_on_non_unique_test() { another: 3, exchange: "c_test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 2, another: 2, exchange: "b_test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); for i in 3..100 { let row = TestRow { id: table.get_next_pk().into(), @@ -940,7 +953,7 @@ async fn select_all_order_on_non_unique_test() { another: 1, exchange: "a_test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let all = table @@ -964,14 +977,14 @@ async fn select_all_order_two_test() { another: 3, exchange: "a_test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 2, another: 2, exchange: "b_test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); for i in 3..100 { let row = TestRow { id: table.get_next_pk().into(), @@ -979,7 +992,7 @@ async fn select_all_order_two_test() { another: 1, exchange: "c_test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let all = table @@ -996,8 +1009,8 @@ async fn select_all_order_two_test() { assert_eq!(&all[2].test, &3) } -#[test] -fn select_by_order_on_test() { +#[tokio::test] +async fn select_by_order_on_test() { let table = TestWorkTable::default(); let row1 = TestRow { id: table.get_next_pk().into(), @@ -1005,14 +1018,14 @@ fn select_by_order_on_test() { another: 3, exchange: "a_test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 2, another: 2, exchange: "b_test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); for i in 3..100 { let row = TestRow { id: table.get_next_pk().into(), @@ -1020,7 +1033,7 @@ fn select_by_order_on_test() { another: 1, exchange: "c_test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let all = table @@ -1048,14 +1061,14 @@ async fn select_by_offset_test() { another: 3, exchange: "a_test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 2, another: 2, exchange: "b_test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); for i in 3..100 { let row = TestRow { id: table.get_next_pk().into(), @@ -1063,7 +1076,7 @@ async fn select_by_offset_test() { another: 1, exchange: "c_test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let all = table @@ -1092,14 +1105,14 @@ async fn test_update_by_non_unique() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), test: 2, another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row2.clone()).unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); let row = AnotherByExchangeQuery { another: 3 }; table.update_another_by_exchange(row, "test".to_string()).await.unwrap(); @@ -1136,7 +1149,7 @@ async fn test_update_by_unique() { another: 1, exchange: "test".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row = AnotherByTestQuery { another: 3 }; table.update_another_by_test(row, 1).await.unwrap(); @@ -1163,7 +1176,7 @@ async fn test_update_by_pk() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let row = AnotherByIdQuery { another: 3 }; table.update_another_by_id(row, pk).await.unwrap(); @@ -1195,7 +1208,7 @@ async fn _bench() { exchange: "XD".to_string(), }; - let a = table.insert(row).expect("TODO: panic message"); + let a = table.insert(row).await.expect("TODO: panic message"); v.push(a) } diff --git a/tests/worktable/bench.rs b/tests/worktable/bench.rs index 217af7c2..8c474e07 100644 --- a/tests/worktable/bench.rs +++ b/tests/worktable/bench.rs @@ -38,7 +38,7 @@ async fn _rw_lock_hash_map_vs_wt() { for i in 0..100 { let s: String = Alphanumeric.sample_string(&mut rand::rng(), 8); let row = MapRow { id: i, value: s }; - wt.insert(row).unwrap(); + wt.insert(row).await.unwrap(); } println!("wt insert in {} μs", wt_start.elapsed().as_micros()); diff --git a/tests/worktable/borrowed_primary_key.rs b/tests/worktable/borrowed_primary_key.rs index 36acd65b..7babb52b 100644 --- a/tests/worktable/borrowed_primary_key.rs +++ b/tests/worktable/borrowed_primary_key.rs @@ -34,7 +34,7 @@ async fn string_primary_key_accepts_borrowed_forms() { id: id.clone(), value: 7, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(&id), Some(row.clone())); assert_eq!(table.select(id.as_str()), Some(row.clone())); @@ -73,7 +73,7 @@ async fn tuple_primary_key_accepts_a_borrowed_tuple() { record: key.1.clone(), value: 11, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(&key), Some(row)); table.delete(&key).await.unwrap(); diff --git a/tests/worktable/cancel_safety.rs b/tests/worktable/cancel_safety.rs index 89eecf7a..8b12c433 100644 --- a/tests/worktable/cancel_safety.rs +++ b/tests/worktable/cancel_safety.rs @@ -45,6 +45,7 @@ async fn cancelled_full_row_update_releases_registered_lock() { value: 0, other: 0, }) + .await .unwrap(); let pk = CancelSafetyPrimaryKey(1); @@ -100,6 +101,7 @@ async fn cancelled_custom_update_releases_registered_lock() { value: 0, other: 0, }) + .await .unwrap(); let pk = CancelSafetyPrimaryKey(3); diff --git a/tests/worktable/concurrency.rs b/tests/worktable/concurrency.rs new file mode 100644 index 00000000..51eceb4f --- /dev/null +++ b/tests/worktable/concurrency.rs @@ -0,0 +1,496 @@ +//! Concurrent writes, across every index backend, above the writer count the +//! rest of the suite reaches. +//! +//! Concurrency coverage existed before this file but was scattered and +//! backend-specific: `nonunique_arctic` races four writers against an arctic +//! non-unique index, `index_backends` recovers concurrent same-row updates, +//! `vacuum` runs a vacuum thread beside sequential inserts. Nothing raced +//! writers across all three backends, and **nothing went above four writers**. +//! +//! That mattered. Insert throughput on this engine is flat to four writers and +//! collapses at eight, so four is precisely the last thread count at which +//! everything looks fine. See `insert_throughput_should_scale_past_four_writers` +//! at the end of this file. +//! +//! Congee appears only where a unique index is enough: it has no non-unique +//! backend, and `worktable_codegen` rejects the declaration rather than letting +//! it fail later. + +/// Shape of the concurrent workload, tunable without editing the file. +/// +/// Hardcoding a writer count is what let this whole class of problem hide: the +/// suite stopped at four writers, four is the last count at which this engine +/// still behaves, and nobody could turn the dial without a recompile. Every +/// number below is an environment variable with a default sized for CI. +/// +/// ```sh +/// WT_CONC_WRITERS=32 WT_CONC_PER_WRITER=2000 cargo test --test mod concurrency +/// WT_CONC_SWEEP=1,2,4,8,16,32,64 cargo test --test mod insert_throughput -- --ignored --nocapture +/// ``` +mod params { + /// Reads an integer from the environment, falling back to `default`. + /// + /// A malformed value is a hard error rather than a silent fallback: a typo + /// in `WT_CONC_WRITERS` that quietly runs the default is a run you believe + /// tested something it did not. + pub fn env_u64(name: &str, default: u64) -> u64 { + match std::env::var(name) { + Ok(raw) => raw + .trim() + .parse() + .unwrap_or_else(|_| panic!("{name} must be an integer, got {raw:?}")), + Err(_) => default, + } + } + + /// Concurrent writers. Defaults to eight because four is where the rest of + /// the suite stops and where this engine still looks healthy. + pub fn writers() -> u64 { + env_u64("WT_CONC_WRITERS", 8) + } + + /// Rows each writer inserts. + pub fn per_writer() -> u64 { + env_u64("WT_CONC_PER_WRITER", 500) + } + + /// Rows seeded before a race that also deletes. + pub fn seed_rows() -> u64 { + env_u64("WT_CONC_SEED_ROWS", 2_000) + } + + /// Reader threads running beside the writers. + pub fn readers() -> u64 { + env_u64("WT_CONC_READERS", 4) + } +} + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use worktable::prelude::*; +use worktable::worktable; + +/// One table per backend. Separate modules because the generated idents would +/// otherwise collide, and a macro because three hand-written copies drift. +macro_rules! backend_suite { + ($module:ident, $backend:ident, $label:literal) => { + mod $module { + use super::*; + + worktable!( + name: Conc, + persist: false, + columns: { + id: u64 primary_key, + payload: u64, + bucket: u32, + }, + indexes: { + payload_idx: payload unique using $backend, + bucket_idx: bucket using worktables_index, + }, + ); + + fn row(id: u64) -> ConcRow { + ConcRow { id, payload: 1_000_000 + id, bucket: (id % 16) as u32 } + } + + /// Eight writers on disjoint key ranges: every row lands, exactly + /// once, reachable through both the unique and the non-unique + /// index. + /// + /// Eight rather than four on purpose. Four is where the existing + /// tests stop and where this engine still behaves; the interesting + /// interleavings start above it. + #[tokio::test] + async fn eight_writers_lose_no_rows() { + let (writers, per_writer) = (params::writers(), params::per_writer()); + + let table = Arc::new(ConcWorkTable::default()); + std::thread::scope(|scope| { + for w in 0..writers { + let table = Arc::clone(&table); + scope.spawn(move || { + for n in 0..per_writer { + let id = w * per_writer + n; + futures::executor::block_on(table.insert(row(id))).expect("insert"); + // Read while the others write, so the scan and + // the mutations actually overlap. + let _ = table.select(id); + } + }); + } + }); + + assert_eq!(table.count(), (writers * per_writer) as usize, "{} lost rows", $label); + for id in 0..(writers * per_writer) { + assert!(table.select(id).is_some(), "{}: row {id} missing by primary key", $label); + assert!( + table.select_by_payload(1_000_000 + id).is_some(), + "{}: row {id} missing from the unique index", + $label + ); + } + } + + /// Writers inserting while other threads delete, so inserts race + /// storage reuse rather than only each other. + /// + /// A link freed by a delete becomes reusable once no reader can + /// reach it, so this is the interleaving where an insert can claim + /// a slot another thread is still finishing with. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn inserts_racing_deletes_leave_a_consistent_index() { + let seed = params::seed_rows(); + let (writers, per_writer) = (params::writers(), params::per_writer()); + + let table = Arc::new(ConcWorkTable::default()); + for id in 0..seed { + table.insert(row(id)).await.expect("seed"); + } + + let deleter = { + let table = Arc::clone(&table); + tokio::spawn(async move { + for id in 0..seed { + table.delete(id).await.expect("delete"); + } + }) + }; + + std::thread::scope(|scope| { + for w in 0..writers { + let table = Arc::clone(&table); + scope.spawn(move || { + for n in 0..per_writer { + let id = seed + w * per_writer + n; + futures::executor::block_on(table.insert(row(id))).expect("insert"); + } + }); + } + }); + deleter.await.expect("deleter did not panic"); + + // Every inserted row survived, and nothing the deleter removed + // came back through an index. + for w in 0..writers { + for n in 0..per_writer { + let id = seed + w * per_writer + n; + assert!(table.select(id).is_some(), "{}: inserted row {id} lost", $label); + } + } + for id in 0..seed { + assert!(table.select(id).is_none(), "{}: deleted row {id} still readable", $label); + assert!( + table.select_by_payload(1_000_000 + id).is_none(), + "{}: deleted row {id} still in the unique index", + $label + ); + } + } + + /// `insert` and `insert_many` racing on the same table. + /// + /// They take the same striped mutation gate by different routes: + /// `insert` gates one key, `insert_many` gates its whole key set + /// and sorts the stripes so a batch and a single insert cannot + /// deadlock against each other. That ordering is the claim worth + /// testing, and a deadlock here shows up as a hang rather than a + /// failure, which is why the batches and singles interleave on + /// overlapping stripes rather than staying politely apart. + #[tokio::test] + async fn batches_and_single_inserts_interleave_without_loss() { + let writers = params::writers(); + let per_writer = params::per_writer(); + + let table = Arc::new(ConcWorkTable::default()); + std::thread::scope(|scope| { + for w in 0..writers { + let table = Arc::clone(&table); + scope.spawn(move || { + let base = w * per_writer; + if w % 2 == 0 { + // Batches, in chunks, so several stripes are + // held at once. + for chunk in (0..per_writer).step_by(16) { + let rows: Vec<_> = (chunk..(chunk + 16).min(per_writer)) + .map(|n| row(base + n)) + .collect(); + futures::executor::block_on(table.insert_many(rows)).expect("insert_many"); + } + } else { + for n in 0..per_writer { + futures::executor::block_on(table.insert(row(base + n))).expect("insert"); + } + } + }); + } + }); + + assert_eq!( + table.count(), + (writers * per_writer) as usize, + "{}: rows lost between insert and insert_many", + $label + ); + for id in 0..(writers * per_writer) { + assert!(table.select(id).is_some(), "{}: row {id} missing", $label); + assert!( + table.select_by_payload(1_000_000 + id).is_some(), + "{}: row {id} missing from the unique index", + $label + ); + } + } + + /// A unique collision under contention rejects exactly one writer. + /// + /// Every writer races to claim the same payload. Exactly one must + /// win: two winners is a broken unique index, zero is a broken + /// insert. + #[tokio::test] + async fn a_contended_unique_key_admits_exactly_one_writer() { + let writers = params::writers(); + + let table = Arc::new(ConcWorkTable::default()); + let winners = Arc::new(AtomicU64::new(0)); + std::thread::scope(|scope| { + for w in 0..writers { + let table = Arc::clone(&table); + let winners = Arc::clone(&winners); + scope.spawn(move || { + // Distinct primary keys, one shared payload. + let contended = ConcRow { id: w, payload: 42, bucket: 0 }; + if futures::executor::block_on(table.insert(contended)).is_ok() { + winners.fetch_add(1, Ordering::Release); + } + }); + } + }); + + assert_eq!( + winners.load(Ordering::Acquire), + 1, + "{}: a contended unique key admitted more than one writer", + $label + ); + assert_eq!(table.count(), 1, "{}", $label); + } + + /// Concurrent readers see a consistent non-unique group while it is + /// being written. + /// Readers racing storage *reuse*, which is the case the rest of + /// this suite does not reach. + /// + /// The other tests race readers against inserts, so links are only + /// ever allocated. This one deletes and reinserts continuously, so + /// links are retired, reclaimed, and handed to *different* rows + /// while readers are resolving index entries. That covers the + /// epoch grace period, the retirement queue, the free list, and + /// the deferral that moved reclamation off the deleting thread. + /// + /// Three things here were each necessary to make it test anything, + /// and each was found by instrumenting rather than by reasoning: + /// + /// 1. **The reader uses the raw link path, not `select`.** `select` + /// returns an owned published snapshot rather than page bytes, + /// and that indirection is exactly what makes the published path + /// safe. Written against `select`, this test passes with the + /// grace period deleted outright. + /// 2. **Two id sets share the same storage.** A writer that deletes + /// and reinserts the *same* id would put the same id back in the + /// recycled space, and a stale link would read the row it + /// expected. Set A and set B alternate, so recycled storage + /// holds a different id than the link was resolved for. + /// 3. **A live population is kept at all times.** The first version + /// inserted and immediately deleted, so across a whole run the + /// readers resolved a live link nine times. It asserted almost + /// nothing. + /// + /// The detection is probabilistic, so the round count is + /// load-bearing rather than arbitrary. Measured against a build + /// with the grace period removed outright: 16 rounds catches it in + /// three runs out of four, 128 catches it in five out of five, + /// costing 1.4s. Lowering it trades away the only thing this test + /// does. Raise `WT_CONC_PER_WRITER` to go further. + #[tokio::test] + async fn readers_never_see_a_row_reassembled_from_reused_storage() { + /// Rows live per writer per set. Small enough that readers + /// sweep the live population often, large enough to keep the + /// free list and its coalescing genuinely busy. + const WINDOW: u64 = 32; + + let readers = params::readers(); + let seed = params::seed_rows(); + let writers = params::writers(); + // Each round retires and reallocates `WINDOW` rows per writer. + let rounds = (params::per_writer() / WINDOW).max(128); + + let table = Arc::new(ConcWorkTable::default()); + for id in 0..seed { + table.insert(row(id)).await.expect("seed"); + } + + // Writer `w` owns [base, base + 2 * WINDOW): set A below, set B + // above. Disjoint per writer, so a failure is reuse rather than + // two writers colliding on a key. + let base_of = |w: u64| seed + w * WINDOW * 2; + for w in 0..writers { + for i in 0..WINDOW { + table.insert(row(base_of(w) + i)).await.expect("seed set A"); + } + } + + let churn_end = base_of(writers); + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let finished = Arc::new(AtomicU64::new(0)); + + std::thread::scope(|scope| { + for w in 0..writers { + let table = Arc::clone(&table); + let finished = Arc::clone(&finished); + scope.spawn(move || { + let base = base_of(w); + for round in 0..rounds { + // Alternate which set is live. The set being + // freed this round is the storage the set being + // filled will be given. + let (from, to) = if round % 2 == 0 { + (base, base + WINDOW) + } else { + (base + WINDOW, base) + }; + for i in 0..WINDOW { + futures::executor::block_on(table.delete(from + i)).expect("delete"); + futures::executor::block_on(table.insert(row(to + i))).expect("insert"); + } + } + finished.fetch_add(1, Ordering::Release); + }); + } + + for _ in 0..readers { + let table = Arc::clone(&table); + let stop = Arc::clone(&stop); + scope.spawn(move || { + while !stop.load(Ordering::Relaxed) { + for id in seed..churn_end { + let pk: ConcPrimaryKey = id.into(); + // Pin first, then resolve, then read: the + // order `select` itself uses, and the order + // the grace period is defined against. + let guard = table.0.data.read_guard(); + let link: Option = + table.0.primary_index.pk_map.get_value(&pk).map(Into::into); + // The interval between resolving a link and + // reading through it is the whole hazard. + std::thread::yield_now(); + if let Some(link) = link + && let Ok(r) = table.0.data.select_non_ghosted(link) + { + assert_eq!( + r.id, id, + "{}: the link resolved for {id} produced row {}, so its storage was \ + recycled while a reader was pinned on it", + $label, r.id + ); + assert_eq!(r.payload, 1_000_000 + r.id, "{}: payload disagrees with id", $label); + } + drop(guard); + } + // The published path too, so ordinary reads are + // checked for consistency beside the raw ones. + for bucket in 0..16u32 { + for r in table.select_by_bucket(bucket).execute().unwrap() { + assert_eq!(r.payload, 1_000_000 + r.id, "{}: group read saw a mismatched row", $label); + assert_eq!(r.bucket, bucket, "{}: row in the wrong group", $label); + } + } + } + }); + } + + // Counting finished writers rather than watching the row + // count: the count returns to its starting value between + // every delete and the next insert. + scope.spawn({ + let stop = Arc::clone(&stop); + let finished = Arc::clone(&finished); + move || { + while finished.load(Ordering::Acquire) < writers { + std::hint::spin_loop(); + } + stop.store(true, Ordering::Relaxed); + } + }); + }); + + // The seeded rows are untouched: churn beside them must not + // have taken any with it. + for id in 0..seed { + let r = table.select(id).expect("seeded row present"); + assert_eq!(r.payload, 1_000_000 + id, "{}: seeded row {id} was corrupted", $label); + } + // And each writer's window ends with exactly one set live. + for w in 0..writers { + let base = base_of(w); + let live = (base..base + WINDOW * 2).filter(|id| table.select(*id).is_some()).count(); + assert_eq!(live as u64, WINDOW, "{}: writer {w} left {live} rows, expected {WINDOW}", $label); + } + } + + #[tokio::test] + async fn readers_see_consistent_groups_during_writes() { + let (writers, per_writer) = (params::writers(), params::per_writer()); + let readers = params::readers(); + + let table = Arc::new(ConcWorkTable::default()); + std::thread::scope(|scope| { + for w in 0..writers { + let table = Arc::clone(&table); + scope.spawn(move || { + for n in 0..per_writer { + futures::executor::block_on(table.insert(row(w * per_writer + n))).expect("insert"); + } + }); + } + // Readers running throughout: a group read must never + // return a row that is not in that group. + for _ in 0..readers { + let table = Arc::clone(&table); + scope.spawn(move || { + for _ in 0..2_000 { + for bucket in 0..16u32 { + for row in table.select_by_bucket(bucket).execute().unwrap() { + assert_eq!(row.bucket, bucket, "{}: row in the wrong group", $label); + } + } + } + }); + } + }); + + let mut counts: HashMap = HashMap::new(); + for id in 0..(writers * per_writer) { + let row = table.select(id).expect("row present"); + *counts.entry(row.bucket).or_default() += 1; + } + for bucket in 0..16u32 { + let selected = table.select_by_bucket(bucket).execute().unwrap(); + assert_eq!( + selected.len(), + *counts.get(&bucket).unwrap_or(&0), + "{}: group {bucket} disagrees with the rows", + $label + ); + } + } + } + }; +} + +backend_suite!(wti, worktables_index, "wti"); +backend_suite!(arctic, arctic, "arctic"); +backend_suite!(congee, congee, "congee"); diff --git a/tests/worktable/count.rs b/tests/worktable/count.rs index a6f366a1..7398304b 100644 --- a/tests/worktable/count.rs +++ b/tests/worktable/count.rs @@ -65,10 +65,10 @@ async fn count() { // Count WT with 0 rows assert_eq!(0, test_table.count()); - let _ = test_table.insert(row1); - let _ = test_table.insert(row2); - let _ = test_table.insert(row3); - let _ = test_table.insert(row4); + let _ = test_table.insert(row1).await; + let _ = test_table.insert(row2).await; + let _ = test_table.insert(row3).await; + let _ = test_table.insert(row4).await; // Count by WT assert_eq!(4, test_table.count()); diff --git a/tests/worktable/custom_pk.rs b/tests/worktable/custom_pk.rs index 1d66e2a4..4ebe1348 100644 --- a/tests/worktable/custom_pk.rs +++ b/tests/worktable/custom_pk.rs @@ -71,7 +71,7 @@ async fn borrowed_custom_primary_key_is_accepted() { id: id.clone(), test: 7, }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(&id), Some(row)); table.delete(&id).await.unwrap(); diff --git a/tests/worktable/delete.rs b/tests/worktable/delete.rs index 0da0c41a..fb3aaf4b 100644 --- a/tests/worktable/delete.rs +++ b/tests/worktable/delete.rs @@ -18,6 +18,15 @@ worktable!( }, delete: { ByVal1() by val1, + ByVal2() by val2, } } ); + +#[test] +fn brute_force_delete_future_is_send() { + fn assert_send(_: T) {} + + let table = DeleteTestWorkTable::default(); + assert_send(table.delete_by_val_2(1)); +} diff --git a/tests/worktable/delete_many.rs b/tests/worktable/delete_many.rs new file mode 100644 index 00000000..b1bd37db --- /dev/null +++ b/tests/worktable/delete_many.rs @@ -0,0 +1,311 @@ +//! Bulk delete: what it removes, what it leaves reachable, and what it reuses. +//! +//! The single-row `delete` already ghosts rather than moves: the row is marked +//! deleted in place, its index entries come out, and the storage becomes +//! reusable once no reader can still reach it. `delete_many` is that, batched, +//! and the batching is not cosmetic. The per-row cost is dominated by the +//! reclamation bookkeeping each retirement takes rather than by the bit flip, +//! so a loop of `n` deletes pays `n` domain advances where a batch pays one. +//! +//! These tests are about behaviour rather than speed. The property that would +//! actually bite a consumer is the one in +//! `deleted_rows_are_unreachable_through_every_index`: a bulk delete that +//! removed rows from the primary index but left a secondary index pointing at +//! their storage would still pass a naive `select` test, and would resolve to +//! reused storage the moment an insert claimed the link. + +use worktable::prelude::*; +use worktable::worktable; + +worktable! ( + name: Evict, + columns: { + id: u64 primary_key autoincrement, + unique_value: u64, + generation: u32, + }, + indexes: { + unique_value_idx: unique_value unique, + generation_idx: generation, + }, +); + +fn row(id: u64, unique_value: u64, generation: u32) -> EvictRow { + EvictRow { + id, + unique_value, + generation, + } +} + +async fn table_with(rows: u64) -> EvictWorkTable { + let table = EvictWorkTable::default(); + let batch: Vec<_> = (0..rows).map(|i| row(i, 1_000 + i, (i % 4) as u32)).collect(); + table.insert_many(batch).await.expect("fixture inserts"); + table +} + +#[tokio::test] +async fn delete_many_removes_exactly_the_named_keys() { + let table = table_with(20).await; + + let deleted = table.delete_many((0..5u64).collect()).await.expect("bulk delete"); + + let expected: Vec = (0..5u64).map(Into::into).collect(); + assert_eq!(deleted, expected); + assert_eq!(table.count(), 15); + for id in 0..5u64 { + assert!(table.select(id).is_none(), "row {id} should be gone"); + } + for id in 5..20u64 { + assert!(table.select(id).is_some(), "row {id} should survive"); + } +} + +/// The property a bulk delete is most likely to get wrong. +/// +/// Removing rows from the primary index while leaving a secondary index +/// pointing at their storage leaves a dangling entry: the link is queued for +/// reuse, so a later insert can claim it and the stale entry then resolves to a +/// live, unrelated row. +/// +/// Reading it back is **not** enough to catch that, and this test asserted only +/// that at first. A ghosted row is filtered out of reads, so a stale index +/// entry is invisible through `select` and the test passed with secondary +/// removal deleted entirely. What catches it is claiming the key again: a +/// unique index that still holds the deleted row's value rejects the insert. +#[tokio::test] +async fn deleted_rows_are_unreachable_through_every_index() { + let table = table_with(20).await; + + table.delete_many((0..5u64).collect()).await.expect("bulk delete"); + + for id in 0..5u64 { + assert!( + table.select_by_unique_value(1_000 + id).is_none(), + "unique index still resolves deleted row {id}" + ); + } + // The non-unique index must have lost exactly the deleted members of each + // group, not the whole group. + let generation_zero = table.select_by_generation(0).execute().expect("non-unique read"); + let surviving: Vec = generation_zero.iter().map(|r| r.id).collect(); + assert!( + surviving.iter().all(|id| *id >= 5), + "non-unique index still resolves deleted rows: {surviving:?}" + ); + assert!( + surviving.contains(&8) && surviving.contains(&12), + "non-unique index lost rows it should have kept: {surviving:?}" + ); + + // The assertion with teeth: reclaiming a deleted row's unique value must + // succeed. If the unique index still holds the entry, this is rejected. + for id in 0..5u64 { + table + .insert(row(100 + id, 1_000 + id, 9)) + .await + .unwrap_or_else(|error| panic!("unique value {} was not released by the delete: {error}", 1_000 + id)); + } +} + +/// A key that is not there is skipped, not an error. +/// +/// A caller evicting a generation does not know which of its keys a concurrent +/// writer already removed, and making them find out first is a race they cannot +/// win. The return value is what was actually deleted, so the caller can tell. +#[tokio::test] +async fn absent_keys_are_skipped_rather_than_failing_the_batch() { + let table = table_with(10).await; + + let deleted = table + .delete_many(vec![1u64, 999, 3, 1_000, 5]) + .await + .expect("absent keys must not fail the batch"); + + let expected: Vec = vec![1u64, 3, 5].into_iter().map(Into::into).collect(); + assert_eq!(deleted, expected); + assert_eq!(table.count(), 7); +} + +/// Deleting the same key twice in one batch is not a double free. +#[tokio::test] +async fn a_repeated_key_is_deleted_once() { + let table = table_with(10).await; + + let deleted = table.delete_many(vec![2u64, 2, 2]).await.expect("repeats must be safe"); + + let expected: Vec = vec![2u64.into()]; + assert_eq!(deleted, expected, "a key already ghosted in this batch is skipped"); + assert_eq!(table.count(), 9); +} + +#[tokio::test] +async fn an_empty_batch_is_a_no_op() { + let table = table_with(4).await; + assert_eq!( + table.delete_many(Vec::::new()).await.expect("empty batch"), + Vec::::new() + ); + assert_eq!(table.count(), 4); +} + +/// Storage from a bulk delete is reused, which is the point of the exercise. +/// +/// Not asserted as an exact byte figure: reuse happens once no reader can +/// reach the links, so the observable property is that a delete-then-insert +/// cycle does not grow the table without bound. A table that never reused a +/// link would grow by the full batch on every cycle. +#[tokio::test] +async fn bulk_delete_then_insert_reuses_storage() { + let table = table_with(200).await; + let before = table.count(); + + for cycle in 0..10u64 { + let keys: Vec = (0..100).collect(); + table.delete_many(keys).await.expect("bulk delete"); + assert_eq!(table.count(), before - 100); + + let refill: Vec<_> = (0..100) + .map(|i| row(i, 500_000 + cycle * 1_000 + i, (i % 4) as u32)) + .collect(); + table.insert_many(refill).await.expect("refill"); + assert_eq!(table.count(), before); + } + + // Every row is still readable through both indexes after ten cycles of + // ghosting and reclaiming the same links. + for id in 0..200u64 { + assert!(table.select(id).is_some(), "row {id} lost after reuse cycles"); + } +} + +/// A bulk delete and the single-row path agree. +/// +/// Cheap to state and the thing most likely to drift: if `delete_many` ever +/// stops doing exactly what a loop of `delete` does, this is where it shows. +#[tokio::test] +async fn delete_many_matches_a_loop_of_delete() { + let batched = table_with(30).await; + let looped = table_with(30).await; + + let keys: Vec = (0..30).filter(|i| i % 3 == 0).collect(); + batched.delete_many(keys.clone()).await.expect("bulk delete"); + for key in &keys { + looped.delete(*key).await.expect("single delete"); + } + + assert_eq!(batched.count(), looped.count()); + for id in 0..30u64 { + assert_eq!( + batched.select(id).is_some(), + looped.select(id).is_some(), + "row {id} disagrees between the batched and looped paths" + ); + } +} + +/// Eviction by span, which is the shape a caller dropping a generation has. +#[tokio::test] +async fn delete_range_removes_the_span_and_nothing_else() { + let table = table_with(20).await; + + let deleted = table + .delete_range(EvictPrimaryKey::from(5u64)..EvictPrimaryKey::from(10u64)) + .await; + let deleted = deleted.expect("range delete"); + + let expected: Vec = (5u64..10).map(Into::into).collect(); + assert_eq!(deleted, expected, "half-open: 10 is not included"); + assert_eq!(table.count(), 15); + for id in 5..10u64 { + assert!(table.select(id).is_none(), "row {id} should be gone"); + assert!( + table.select_by_unique_value(1_000 + id).is_none(), + "unique index still resolves deleted row {id}" + ); + } + assert!(table.select(4u64).is_some(), "the row below the span survives"); + assert!(table.select(10u64).is_some(), "the row at the exclusive end survives"); +} + +/// An inclusive end, and a range that matches nothing. +#[tokio::test] +async fn delete_range_honours_its_bounds() { + let table = table_with(20).await; + + let deleted = table + .delete_range(EvictPrimaryKey::from(0u64)..=EvictPrimaryKey::from(2u64)) + .await + .expect("inclusive range"); + assert_eq!(deleted.len(), 3, "0, 1 and 2"); + + let empty = table + .delete_range(EvictPrimaryKey::from(500u64)..EvictPrimaryKey::from(600u64)) + .await + .expect("a range matching nothing is not an error"); + assert!(empty.is_empty()); + assert_eq!(table.count(), 17); +} + +/// A bulk delete spanning many guard chunks still removes exactly its keys. +/// +/// Mutation guards are striped 64 ways, so a batch wider than that touches +/// every stripe, and taking them all for the batch's whole duration makes a +/// large delete a stop-the-world for every other write. They are taken a chunk +/// at a time instead (`DELETE_CHUNK_KEYS`), which measurably helps: a +/// concurrent writer landed 190 rows during a 20,000 row delete against 71 +/// with whole-batch guards. +/// +/// That ratio is timing, so it is not what is asserted here. What is asserted +/// is the thing chunking could plausibly break: each chunk drops its guards +/// before the next takes them, so a row must not be left out of the indexes +/// and un-ghosted across that boundary, and the batch must still be exact. +#[test] +fn a_bulk_delete_spanning_guard_chunks_removes_exactly_its_keys() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .build() + .unwrap(); + + let table = EvictWorkTable::default(); + let mut victims = Vec::new(); + let mut survivors = Vec::new(); + // Several chunks' worth, with survivors interleaved so a chunk boundary + // cannot coincide with the victim/survivor split. + for i in 0..2_000u64 { + let row = EvictRow { + id: table.get_next_pk().into(), + unique_value: i, + generation: if i % 3 == 0 { 2 } else { 1 }, + }; + if i % 3 == 0 { + survivors.push((row.id, row.unique_value)); + } else { + victims.push(row.id); + } + runtime.block_on(table.insert(row)).unwrap(); + } + + let deleted = runtime.block_on(table.delete_many(victims.clone())).unwrap(); + assert_eq!(deleted.len(), victims.len(), "every victim must be reported deleted"); + + for id in &victims { + assert!( + table.select(*id).is_none(), + "victim {id:?} still readable after the batch" + ); + } + for (id, unique_value) in &survivors { + assert!(table.select(*id).is_some(), "survivor {id:?} was taken by the batch"); + assert!( + table.select_by_unique_value(*unique_value).is_some(), + "survivor {id:?} lost its unique index entry to a neighbouring chunk" + ); + } + assert_eq!( + table.count(), + survivors.len(), + "row count must match the survivors exactly" + ); +} diff --git a/tests/worktable/float.rs b/tests/worktable/float.rs index 1b8af9f5..1c054f66 100644 --- a/tests/worktable/float.rs +++ b/tests/worktable/float.rs @@ -32,8 +32,8 @@ worktable! ( } ); -#[test] -fn unique_float_point_read_revalidates_the_returned_row() { +#[tokio::test] +async fn unique_float_point_read_revalidates_the_returned_row() { let table = TestUniqueFloatWorkTable::default(); let first = TestUniqueFloatRow { id: table.get_next_pk().into(), @@ -43,8 +43,8 @@ fn unique_float_point_read_revalidates_the_returned_row() { id: table.get_next_pk().into(), value: 2.5, }; - table.insert(first.clone()).unwrap(); - table.insert(second.clone()).unwrap(); + table.insert(first.clone()).await.unwrap(); + table.insert(second.clone()).await.unwrap(); let second_link = table .0 @@ -59,8 +59,8 @@ fn unique_float_point_read_revalidates_the_returned_row() { assert_eq!(table.select_by_value(second.value), Some(second)); } -#[test] -fn float_range_read_revalidates_each_resolved_row() { +#[tokio::test] +async fn float_range_read_revalidates_each_resolved_row() { let table = TestFloatWorkTable::default(); let inside = TestFloatRow { id: table.get_next_pk().into(), @@ -74,8 +74,8 @@ fn float_range_read_revalidates_each_resolved_row() { another: 100.0, exchange: "outside".to_string(), }; - table.insert(inside.clone()).unwrap(); - table.insert(outside.clone()).unwrap(); + table.insert(inside.clone()).await.unwrap(); + table.insert(outside.clone()).await.unwrap(); let outside_link = table .0 @@ -90,8 +90,8 @@ fn float_range_read_revalidates_each_resolved_row() { assert_eq!(rows, vec![inside]); } -#[test] -fn select_all_range_float_test() { +#[tokio::test] +async fn select_all_range_float_test() { let table = TestFloatWorkTable::default(); let row1 = TestFloatRow { @@ -113,9 +113,9 @@ fn select_all_range_float_test() { exchange: "P".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let all = table .select_all() @@ -128,8 +128,8 @@ fn select_all_range_float_test() { assert!(all.contains(&row2)) } -#[test] -fn select_by_another_test() { +#[tokio::test] +async fn select_by_another_test() { let table = TestFloatWorkTable::default(); let row1 = TestFloatRow { @@ -151,9 +151,9 @@ fn select_by_another_test() { exchange: "P".to_string(), }; - let _ = table.insert(row1.clone()).unwrap(); - let _ = table.insert(row2.clone()).unwrap(); - let _ = table.insert(row3.clone()).unwrap(); + let _ = table.insert(row1.clone()).await.unwrap(); + let _ = table.insert(row2.clone()).await.unwrap(); + let _ = table.insert(row3.clone()).await.unwrap(); let where_100 = table.select_by_another(100.0).execute().unwrap(); assert_eq!(where_100.len(), 2); @@ -164,8 +164,8 @@ fn select_by_another_test() { assert!(where_200.contains(&row3)); } -#[test] -fn select_by_another_range_test() { +#[tokio::test] +async fn select_by_another_range_test() { let table = TestFloatWorkTable::default(); let rows: Vec = (0..10) @@ -178,7 +178,7 @@ fn select_by_another_range_test() { .collect(); for row in &rows { - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); } let results = table.select_by_another_range(20.0..50.0).execute().unwrap(); diff --git a/tests/worktable/in_place.rs b/tests/worktable/in_place.rs index 6d82a8ac..3a29e7ea 100644 --- a/tests/worktable/in_place.rs +++ b/tests/worktable/in_place.rs @@ -43,7 +43,7 @@ async fn test_update_val_by_id() -> eyre::Result<()> { another: "another".to_string(), something: 0, }; - let pk = table.insert(row)?; + let pk = table.insert(row).await?; for _ in 0..10000 { table.update_val_by_id_in_place(|val| *val += 1, pk.0).await? } @@ -63,7 +63,7 @@ async fn test_update_val2_by_id() -> eyre::Result<()> { another: "another".to_string(), something: 0, }; - let pk = table.insert(row)?; + let pk = table.insert(row).await?; for _ in 0..100 { table.update_val_2_by_id_in_place(|val| *val += 1, pk.0).await? } @@ -83,7 +83,7 @@ async fn test_update_val_by_id_two_thread() -> eyre::Result<()> { another: "another".to_string(), something: 0, }; - let pk = table.insert(row)?; + let pk = table.insert(row).await?; let shared_table = table.clone(); let h = tokio::spawn(async move { for _ in 0..10_000 { @@ -113,7 +113,7 @@ async fn test_update_val_and_val2_by_id_four_thread() -> eyre::Result<()> { another: "another".to_string(), something: 0, }; - let pk = table.insert(row)?; + let pk = table.insert(row).await?; let shared_table = table.clone(); let h1 = tokio::spawn(async move { for _ in 0..10_000 { @@ -164,7 +164,7 @@ async fn test_update_val_by_id_four_thread() -> eyre::Result<()> { another: "another".to_string(), something: 0, }; - let pk = table.insert(row)?; + let pk = table.insert(row).await?; let shared_table = table.clone(); let h1 = tokio::spawn(async move { for _ in 0..10_000 { @@ -218,7 +218,7 @@ async fn test_update_in_place_and_update_sized_multithread() -> eyre::Result<()> another: format!("another_{i}"), something: 0, }; - let _ = table.insert(row.clone())?; + let _ = table.insert(row.clone()).await?; } let shared = table.clone(); let shared_val_state = val_state.clone(); @@ -297,7 +297,7 @@ async fn test_update_in_place_and_update_unsized_multithread() -> eyre::Result<( another: format!("another_{i}"), something: 0, }; - let _ = table.insert(row.clone())?; + let _ = table.insert(row.clone()).await?; } let shared = table.clone(); let shared_val_state = val_state.clone(); diff --git a/tests/worktable/index/insert.rs b/tests/worktable/index/insert.rs index 910e1067..f0ae7c85 100644 --- a/tests/worktable/index/insert.rs +++ b/tests/worktable/index/insert.rs @@ -31,15 +31,15 @@ async fn insert() { attr3: 123456789, attr4: "Attribute4".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, row); assert!(table.select(2).is_none()) } -#[test] -fn unique_point_read_revalidates_the_returned_row() { +#[tokio::test] +async fn unique_point_read_revalidates_the_returned_row() { let table = TestWorkTable::default(); let first = TestRow { id: table.get_next_pk().into(), @@ -57,8 +57,8 @@ fn unique_point_read_revalidates_the_returned_row() { attr3: 2, attr4: "second-unique".to_string(), }; - table.insert(first.clone()).unwrap(); - table.insert(second.clone()).unwrap(); + table.insert(first.clone()).await.unwrap(); + table.insert(second.clone()).await.unwrap(); let second_link = table .0 @@ -87,7 +87,7 @@ async fn insert_when_pk_exists() { attr3: 123456789, attr4: "Attribute4".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let next_row = TestRow { id: pk.0, @@ -97,7 +97,7 @@ async fn insert_when_pk_exists() { attr3: 0, attr4: "Attributee".to_string(), }; - assert!(table.insert(next_row.clone()).is_err()); + assert!(table.insert(next_row.clone()).await.is_err()); assert_eq!(table.select(pk.clone()).unwrap(), row); assert!( table @@ -113,8 +113,8 @@ async fn insert_when_pk_exists() { assert!(table.0.indexes.attr2_idx.get(&row.attr2).is_some()) } -#[test] -fn insert_when_secondary_unique_exists() { +#[tokio::test] +async fn insert_when_secondary_unique_exists() { let table = TestWorkTable::default(); let row = TestRow { id: table.get_next_pk().into(), @@ -124,7 +124,7 @@ fn insert_when_secondary_unique_exists() { attr3: 123456789, attr4: "Attribute4".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let next_row = TestRow { id: table.get_next_pk().into(), @@ -134,7 +134,7 @@ fn insert_when_secondary_unique_exists() { attr3: 0, attr4: "Attributeee".to_string(), }; - assert!(table.insert(next_row.clone()).is_err()); + assert!(table.insert(next_row.clone()).await.is_err()); assert!( table .0 @@ -156,8 +156,8 @@ fn insert_when_secondary_unique_exists() { ); } -#[test] -fn insert_when_secondary_unique_string_exists() { +#[tokio::test] +async fn insert_when_secondary_unique_string_exists() { let table = TestWorkTable::default(); let row = TestRow { id: table.get_next_pk().into(), @@ -167,7 +167,7 @@ fn insert_when_secondary_unique_string_exists() { attr3: 123456789, attr4: "Attribute4".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let next_row = TestRow { id: table.get_next_pk().into(), @@ -177,7 +177,7 @@ fn insert_when_secondary_unique_string_exists() { attr3: 0, attr4: "Attribute4".to_string(), }; - assert!(table.insert(next_row.clone()).is_err()); + assert!(table.insert(next_row.clone()).await.is_err()); assert!( table .0 @@ -212,7 +212,7 @@ async fn insert_when_unique_violated() { attr3: 123456789, attr4: "Attribute4".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row_new_attr_2 = 128; let row_new_attr_4 = row.attr4.clone(); @@ -228,7 +228,7 @@ async fn insert_when_unique_violated() { attr3: 123456789, attr4: row_new_attr_4.clone(), }; - assert!(shared.insert(row).is_err()); + assert!(futures::executor::block_on(shared.insert(row)).is_err()); } }); @@ -245,8 +245,8 @@ async fn insert_when_unique_violated() { h.join().unwrap(); } -#[test] -fn insert_after_unique_violated() { +#[tokio::test] +async fn insert_after_unique_violated() { let table = Arc::new(TestWorkTable::default()); let row = TestRow { @@ -257,7 +257,7 @@ fn insert_after_unique_violated() { attr3: 123456789, attr4: "Attribute4".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row_new_attr_2 = 128; let row_new_attr_4 = row.attr4.clone(); @@ -271,7 +271,7 @@ fn insert_after_unique_violated() { attr3: 123456789, attr4: row_new_attr_4.clone(), }; - assert!(table.insert(row).is_err()); + assert!(table.insert(row).await.is_err()); } for i in 2..5_000 { @@ -284,7 +284,7 @@ fn insert_after_unique_violated() { attr3: 123456789, attr4: format!("{i}"), }; - assert!(table.insert(row).is_ok()); + assert!(table.insert(row).await.is_ok()); } } @@ -300,7 +300,7 @@ async fn insert_when_pk_violated() { attr3: 123456789, attr4: "Attribute4".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let id = row.id; @@ -315,7 +315,7 @@ async fn insert_when_pk_violated() { attr3: 123456789, attr4: "Attribute__4".to_string(), }; - assert!(shared.insert(row).is_err()); + assert!(futures::executor::block_on(shared.insert(row)).is_err()); } }); @@ -343,8 +343,8 @@ worktable!( } ); -#[test] -fn insert_after_unique_violated_strings() { +#[tokio::test] +async fn insert_after_unique_violated_strings() { let table = Arc::new(TestStringsWorkTable::default()); let row = TestStringsRow { @@ -353,7 +353,7 @@ fn insert_after_unique_violated_strings() { attr2: "Attribute_2".to_string(), attr3: "Attribute_3".to_string(), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); let row_new_attr_3 = row.attr3.clone(); for _ in 0..5_000 { @@ -363,7 +363,7 @@ fn insert_after_unique_violated_strings() { attr2: "Attribute_2_NEW".to_string(), attr3: row_new_attr_3.clone(), }; - assert!(table.insert(row).is_err()); + assert!(table.insert(row).await.is_err()); } let row_new_attr_2 = row.attr2.clone(); for i in 0..5_000 { @@ -373,7 +373,7 @@ fn insert_after_unique_violated_strings() { attr2: row_new_attr_2.clone(), attr3: format!("Attribute_3_{i}"), }; - assert!(table.insert(row).is_err()); + assert!(table.insert(row).await.is_err()); } for i in 0..5_000 { @@ -383,6 +383,6 @@ fn insert_after_unique_violated_strings() { attr2: format!("Attribute_2_{i}"), attr3: format!("Attribute_3_{i}"), }; - assert!(table.insert(row).is_ok()); + assert!(table.insert(row).await.is_ok()); } } diff --git a/tests/worktable/index/mod.rs b/tests/worktable/index/mod.rs index db2140f4..dae8dc52 100644 --- a/tests/worktable/index/mod.rs +++ b/tests/worktable/index/mod.rs @@ -101,7 +101,7 @@ async fn update_2_idx() { let attr1_new = "OK".to_string(); let attr2_new = 1337; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update_all_attr_by_id( AllAttrByIdQuery { @@ -143,7 +143,7 @@ async fn update_2_idx_full_row() { let attr1_new = "OK".to_string(); let attr2_new = 1337; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update(Test2Row { id: pk.clone().into(), @@ -207,7 +207,7 @@ async fn update_1_idx() { let attr1_new = "OK".to_string(); - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update_attr_1_by_id( Attr1ByIdQuery { @@ -243,7 +243,7 @@ async fn update_1_idx_full_row() { let attr1_new = "OK".to_string(); - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update(TestRow { attr2: row.attr2, diff --git a/tests/worktable/index/order.rs b/tests/worktable/index/order.rs index 98c6367f..200dc822 100644 --- a/tests/worktable/index/order.rs +++ b/tests/worktable/index/order.rs @@ -50,7 +50,7 @@ async fn insert_failure_rollback_order() { field_b: 1, field_c: 1, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let pk1_val: u64 = pk1.into(); assert_eq!(pk1_val, 0u64); @@ -60,7 +60,7 @@ async fn insert_failure_rollback_order() { field_b: 2, field_c: 2, }; - let err = table.insert(row2).unwrap_err(); + let err = table.insert(row2).await.unwrap_err(); let err_str = err.to_string(); assert!( @@ -81,7 +81,7 @@ async fn insert_success_order() { field_c: 100, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let pk_val: u64 = pk.into(); assert_eq!(pk_val, 0u64); diff --git a/tests/worktable/index/range.rs b/tests/worktable/index/range.rs index d9ac9d71..2ddebea3 100644 --- a/tests/worktable/index/range.rs +++ b/tests/worktable/index/range.rs @@ -39,7 +39,7 @@ async fn idle_select_builder_does_not_pin_retired_links() { id: table.get_next_pk().into(), num: 1, }; - let first_pk = table.insert(first).unwrap(); + let first_pk = table.insert(first).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&first_pk).unwrap().0; // Construct the lazy query but do not consume it. Query configuration is @@ -51,7 +51,7 @@ async fn idle_select_builder_does_not_pin_retired_links() { id: table.get_next_pk().into(), num: 2, }; - let second_pk = table.insert(second).unwrap(); + let second_pk = table.insert(second).await.unwrap(); let second_link = table.0.primary_index.pk_map.get_value(&second_pk).unwrap().0; assert_eq!( @@ -61,8 +61,8 @@ async fn idle_select_builder_does_not_pin_retired_links() { drop(idle_query); } -#[test] -fn range_read_revalidates_each_resolved_row() { +#[tokio::test] +async fn range_read_revalidates_each_resolved_row() { let table = RangeTestWorkTable::default(); let inside = RangeTestRow { id: table.get_next_pk().into(), @@ -74,8 +74,8 @@ fn range_read_revalidates_each_resolved_row() { value: 100, name: "outside".to_string(), }; - table.insert(inside.clone()).unwrap(); - table.insert(outside.clone()).unwrap(); + table.insert(inside.clone()).await.unwrap(); + table.insert(outside.clone()).await.unwrap(); let outside_link = table .0 @@ -93,8 +93,8 @@ fn range_read_revalidates_each_resolved_row() { assert_eq!(rows, vec![inside]); } -#[test] -fn test_range_select_basic() { +#[tokio::test] +async fn test_range_select_basic() { let table = RangeTestWorkTable::default(); for v in 0..6 { @@ -104,6 +104,7 @@ fn test_range_select_basic() { value: v * 10, name: format!("name_{}", v * 10), }) + .await .unwrap(); } @@ -111,8 +112,8 @@ fn test_range_select_basic() { assert_eq!(results.len(), 2); } -#[test] -fn test_range_select_inclusive() { +#[tokio::test] +async fn test_range_select_inclusive() { let table = UniqueRangeTestWorkTable::default(); let base = 10000u64; @@ -122,6 +123,7 @@ fn test_range_select_inclusive() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -129,8 +131,8 @@ fn test_range_select_inclusive() { assert_eq!(results.len(), 6); } -#[test] -fn test_range_select_open_from() { +#[tokio::test] +async fn test_range_select_open_from() { let table = UniqueRangeTestWorkTable::default(); let base = 20000u64; @@ -140,6 +142,7 @@ fn test_range_select_open_from() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -147,8 +150,8 @@ fn test_range_select_open_from() { assert_eq!(results.len(), 6); } -#[test] -fn test_range_select_open_to() { +#[tokio::test] +async fn test_range_select_open_to() { let table = UniqueRangeTestWorkTable::default(); let base = 30000u64; @@ -158,6 +161,7 @@ fn test_range_select_open_to() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -165,8 +169,8 @@ fn test_range_select_open_to() { assert_eq!(results.len(), 5); } -#[test] -fn test_range_select_with_limit() { +#[tokio::test] +async fn test_range_select_with_limit() { let table = UniqueRangeTestWorkTable::default(); let base = 40000u64; @@ -176,6 +180,7 @@ fn test_range_select_with_limit() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -183,8 +188,8 @@ fn test_range_select_with_limit() { assert_eq!(results.len(), 5); } -#[test] -fn test_range_select_with_offset() { +#[tokio::test] +async fn test_range_select_with_offset() { let table = UniqueRangeTestWorkTable::default(); let base = 50000u64; @@ -194,6 +199,7 @@ fn test_range_select_with_offset() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -201,8 +207,8 @@ fn test_range_select_with_offset() { assert_eq!(results.len(), 8); } -#[test] -fn test_range_select_empty_result() { +#[tokio::test] +async fn test_range_select_empty_result() { let table = UniqueRangeTestWorkTable::default(); let base = 60000u64; @@ -212,6 +218,7 @@ fn test_range_select_empty_result() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -219,8 +226,8 @@ fn test_range_select_empty_result() { assert_eq!(results.len(), 0); } -#[test] -fn test_range_select_full_range() { +#[tokio::test] +async fn test_range_select_full_range() { let table = UniqueRangeTestWorkTable::default(); let base = 70000u64; @@ -230,6 +237,7 @@ fn test_range_select_full_range() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -237,8 +245,8 @@ fn test_range_select_full_range() { assert_eq!(results.len(), 11); } -#[test] -fn test_range_select_non_unique_multiple_per_key() { +#[tokio::test] +async fn test_range_select_non_unique_multiple_per_key() { let table = RangeTestWorkTable::default(); for (v, suffix) in [(10, "a"), (10, "b"), (10, "c"), (20, "d"), (20, "e")] { @@ -248,6 +256,7 @@ fn test_range_select_non_unique_multiple_per_key() { value: v, name: format!("item_{}", suffix), }) + .await .unwrap(); } @@ -255,8 +264,8 @@ fn test_range_select_non_unique_multiple_per_key() { assert_eq!(results.len(), 5); } -#[test] -fn test_range_select_with_order() { +#[tokio::test] +async fn test_range_select_with_order() { let table = UniqueRangeTestWorkTable::default(); let base = 80000u64; @@ -266,6 +275,7 @@ fn test_range_select_with_order() { id: table.get_next_pk().into(), num: n, }) + .await .unwrap(); } @@ -280,8 +290,8 @@ fn test_range_select_with_order() { assert_eq!(results.last().unwrap().num, base); } -#[test] -fn test_pk_range_select_basic() { +#[tokio::test] +async fn test_pk_range_select_basic() { let table = PkRangeTestWorkTable::default(); for i in 0..20 { @@ -290,6 +300,7 @@ fn test_pk_range_select_basic() { id: table.get_next_pk().into(), data: format!("data_{}", i), }) + .await .unwrap(); } @@ -297,8 +308,8 @@ fn test_pk_range_select_basic() { assert_eq!(results.len(), 5); } -#[test] -fn test_pk_range_select_inclusive() { +#[tokio::test] +async fn test_pk_range_select_inclusive() { let table = PkRangeTestWorkTable::default(); for i in 0..20 { @@ -307,6 +318,7 @@ fn test_pk_range_select_inclusive() { id: table.get_next_pk().into(), data: format!("data_{}", i), }) + .await .unwrap(); } @@ -314,8 +326,8 @@ fn test_pk_range_select_inclusive() { assert_eq!(results.len(), 6); } -#[test] -fn test_pk_range_select_open_from() { +#[tokio::test] +async fn test_pk_range_select_open_from() { let table = PkRangeTestWorkTable::default(); for i in 0..20 { @@ -324,6 +336,7 @@ fn test_pk_range_select_open_from() { id: table.get_next_pk().into(), data: format!("data_{}", i), }) + .await .unwrap(); } @@ -331,8 +344,8 @@ fn test_pk_range_select_open_from() { assert_eq!(results.len(), 5); } -#[test] -fn test_pk_range_select_open_to() { +#[tokio::test] +async fn test_pk_range_select_open_to() { let table = PkRangeTestWorkTable::default(); for i in 0..20 { @@ -341,6 +354,7 @@ fn test_pk_range_select_open_to() { id: table.get_next_pk().into(), data: format!("data_{}", i), }) + .await .unwrap(); } @@ -348,8 +362,8 @@ fn test_pk_range_select_open_to() { assert_eq!(results.len(), 5); } -#[test] -fn test_pk_range_select_with_limit() { +#[tokio::test] +async fn test_pk_range_select_with_limit() { let table = PkRangeTestWorkTable::default(); for i in 0..50 { @@ -358,6 +372,7 @@ fn test_pk_range_select_with_limit() { id: table.get_next_pk().into(), data: format!("data_{}", i), }) + .await .unwrap(); } @@ -365,8 +380,8 @@ fn test_pk_range_select_with_limit() { assert_eq!(results.len(), 5); } -#[test] -fn test_pk_range_select_with_order() { +#[tokio::test] +async fn test_pk_range_select_with_order() { let table = PkRangeTestWorkTable::default(); for i in 0..20 { @@ -375,6 +390,7 @@ fn test_pk_range_select_with_order() { id: table.get_next_pk().into(), data: format!("data_{}", i), }) + .await .unwrap(); } diff --git a/tests/worktable/index/update_by_pk.rs b/tests/worktable/index/update_by_pk.rs index 44e4bb96..d4877ba1 100644 --- a/tests/worktable/index/update_by_pk.rs +++ b/tests/worktable/index/update_by_pk.rs @@ -25,7 +25,7 @@ async fn update_by_pk_unique_indexes() { let attr2_new = 1337; let attr3_new = 1337; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update_unique_three_attr_by_id( UniqueThreeAttrByIdQuery { @@ -75,7 +75,7 @@ async fn update_by_pk_non_unique_indexes() { let attr2_new = 1337; let attr3_new = 1337; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update_three_attr_by_id( ThreeAttrByIdQuery { @@ -116,7 +116,7 @@ async fn update_by_pk_with_reinsert_and_secondary_unique_violation() { attr3: 65000, id: 0, }; - test_table.insert(row1.clone()).unwrap(); + test_table.insert(row1.clone()).await.unwrap(); let row2 = Test3UniqueRow { val: 1, attr1: "TEST__________________1".to_string(), @@ -124,7 +124,7 @@ async fn update_by_pk_with_reinsert_and_secondary_unique_violation() { attr3: 65001, id: 1, }; - test_table.insert(row2.clone()).unwrap(); + test_table.insert(row2.clone()).await.unwrap(); let update = UniqueThreeAttrByIdQuery { attr1: row2.attr1.clone(), attr2: 999, @@ -157,7 +157,7 @@ async fn update_by_pk_with_secondary_unique_violation() { attr3: 65000, id: 0, }; - test_table.insert(row1.clone()).unwrap(); + test_table.insert(row1.clone()).await.unwrap(); let row2 = Test3UniqueRow { val: 1, attr1: "TEST__________________1".to_string(), @@ -165,7 +165,7 @@ async fn update_by_pk_with_secondary_unique_violation() { attr3: 65001, id: 1, }; - test_table.insert(row2.clone()).unwrap(); + test_table.insert(row2.clone()).await.unwrap(); let update = UniqueThreeAttrByIdQuery { attr1: row1.attr1.clone(), attr2: row2.attr2, diff --git a/tests/worktable/index/update_full.rs b/tests/worktable/index/update_full.rs index a64dd5d6..065ceb27 100644 --- a/tests/worktable/index/update_full.rs +++ b/tests/worktable/index/update_full.rs @@ -21,7 +21,7 @@ async fn update_by_full_row_unique_indexes() { let attr2_new = 1337; let attr3_new = 1337; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update(Test3UniqueRow { attr1: attr1_new.clone(), @@ -70,7 +70,7 @@ async fn update_by_full_row_non_unique_indexes() { let attr2_new = 1000; let attr3_new = 1337; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update(Test3NonUniqueRow { attr1: attr1_new.clone(), @@ -120,7 +120,7 @@ async fn update_by_full_row_unique_with_string_update() { let attr2_new = 1337; let attr3_new = 1337; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update(Test3UniqueRow { attr1: attr1_new.clone(), @@ -169,7 +169,7 @@ async fn update_by_full_row_non_unique_with_string_update() { let attr2_new = 1337; let attr3_new = 65000; - let pk = test_table.insert(row.clone()).unwrap(); + let pk = test_table.insert(row.clone()).await.unwrap(); test_table .update(Test3NonUniqueRow { attr1: attr1_new.clone(), @@ -210,7 +210,7 @@ async fn update_by_full_row_with_reinsert_and_primary_key_violation() { attr3: 65000, id: 0, }; - test_table.insert(row1.clone()).unwrap(); + test_table.insert(row1.clone()).await.unwrap(); let row2 = Test3UniqueRow { val: 1, attr1: "TEST1".to_string(), @@ -218,7 +218,7 @@ async fn update_by_full_row_with_reinsert_and_primary_key_violation() { attr3: 65001, id: 1, }; - test_table.insert(row2.clone()).unwrap(); + test_table.insert(row2.clone()).await.unwrap(); let mut update = row1.clone(); update.id = row2.id; update.attr1 = "TEST_______________________1".to_string(); @@ -246,7 +246,7 @@ async fn update_by_full_row_with_reinsert_and_secondary_unique_violation() { attr3: 65000, id: 0, }; - test_table.insert(row1.clone()).unwrap(); + test_table.insert(row1.clone()).await.unwrap(); let row2 = Test3UniqueRow { val: 1, attr1: "TEST__________________1".to_string(), @@ -254,7 +254,7 @@ async fn update_by_full_row_with_reinsert_and_secondary_unique_violation() { attr3: 65001, id: 1, }; - test_table.insert(row2.clone()).unwrap(); + test_table.insert(row2.clone()).await.unwrap(); let mut update = row1.clone(); update.attr1 = row2.attr1.clone(); assert!(test_table.update(update).await.is_err()); @@ -281,7 +281,7 @@ async fn update_by_full_row_with_secondary_unique_violation() { attr3: 65000, id: 0, }; - test_table.insert(row1.clone()).unwrap(); + test_table.insert(row1.clone()).await.unwrap(); let row2 = Test3UniqueRow { val: 1, attr1: "TEST1".to_string(), @@ -289,7 +289,7 @@ async fn update_by_full_row_with_secondary_unique_violation() { attr3: 65001, id: 1, }; - test_table.insert(row2.clone()).unwrap(); + test_table.insert(row2.clone()).await.unwrap(); let mut update = row1.clone(); update.attr2 = row2.attr2; assert!(test_table.update(update).await.is_err()); diff --git a/tests/worktable/index/update_query.rs b/tests/worktable/index/update_query.rs index 20d0c568..6964ec25 100644 --- a/tests/worktable/index/update_query.rs +++ b/tests/worktable/index/update_query.rs @@ -23,7 +23,7 @@ async fn update_two_via_query_unique_indexes() { let attr1_new = "1337".to_string(); let attr2_new = 1337; - let _ = test_table.insert(row.clone()).unwrap(); + let _ = test_table.insert(row.clone()).await.unwrap(); test_table .update_unique_two_attr_by_third( UniqueTwoAttrByThirdQuery { @@ -60,7 +60,7 @@ async fn update_with_reinsert_and_secondary_unique_violation() { attr3: 65000, id: 0, }; - test_table.insert(row1.clone()).unwrap(); + test_table.insert(row1.clone()).await.unwrap(); let row2 = Test3UniqueRow { val: 1, attr1: "TEST__________________1".to_string(), @@ -68,7 +68,7 @@ async fn update_with_reinsert_and_secondary_unique_violation() { attr3: 65001, id: 1, }; - test_table.insert(row2.clone()).unwrap(); + test_table.insert(row2.clone()).await.unwrap(); let update = UniqueTwoAttrByThirdQuery { attr1: row2.attr1.clone(), attr2: 999, @@ -100,7 +100,7 @@ async fn update_with_secondary_unique_violation() { attr3: 65000, id: 0, }; - test_table.insert(row1.clone()).unwrap(); + test_table.insert(row1.clone()).await.unwrap(); let row2 = Test3UniqueRow { val: 1, attr1: "TEST__________________1".to_string(), @@ -108,7 +108,7 @@ async fn update_with_secondary_unique_violation() { attr3: 65001, id: 1, }; - test_table.insert(row2.clone()).unwrap(); + test_table.insert(row2.clone()).await.unwrap(); let update = UniqueTwoAttrByThirdQuery { attr1: row1.attr1.clone(), attr2: row2.attr2, @@ -148,7 +148,7 @@ async fn update_two_via_query_non_unique_indexes() { let attr1_new = "1337".to_string(); let attr2_new = 1337; - let _ = test_table.insert(row.clone()).unwrap(); + let _ = test_table.insert(row.clone()).await.unwrap(); test_table .update_two_attr_by_third( TwoAttrByThirdQuery { diff --git a/tests/worktable/index_backends.rs b/tests/worktable/index_backends.rs index f789ae39..94126653 100644 --- a/tests/worktable/index_backends.rs +++ b/tests/worktable/index_backends.rs @@ -129,7 +129,7 @@ async fn all_unique_backends_support_crud_ranges_and_conflict_rollback() { arctic_key: 14, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let second = MixedBackendRow { id: table.get_next_pk().into(), wti_key: 31, @@ -137,7 +137,7 @@ async fn all_unique_backends_support_crud_ranges_and_conflict_rollback() { congee_key: 33, arctic_key: 34, }; - table.insert(second.clone()).unwrap(); + table.insert(second.clone()).await.unwrap(); assert_eq!(table.select(pk), Some(row.clone())); assert_eq!(table.select_by_wti_key(11), Some(row.clone())); @@ -154,7 +154,7 @@ async fn all_unique_backends_support_crud_ranges_and_conflict_rollback() { congee_key: if duplicate_backend == "congee" { 13 } else { base + 3 }, arctic_key: if duplicate_backend == "arctic" { 14 } else { base + 4 }, }; - assert!(table.insert(candidate.clone()).is_err()); + assert!(table.insert(candidate.clone()).await.is_err()); assert!(table.select(candidate.id).is_none()); if candidate.wti_key != 11 { assert!(table.select_by_wti_key(candidate.wti_key).is_none()); @@ -216,7 +216,7 @@ async fn alternative_primary_backends_support_point_crud() { id: table.get_next_pk().into(), value: 1, }; - let pk = table.insert(original.clone()).unwrap(); + let pk = table.insert(original.clone()).await.unwrap(); assert_eq!(table.select(pk.clone()), Some(original.clone())); let updated = $row { @@ -256,6 +256,7 @@ async fn upstream_indexset_survives_persist_reload_and_more_writes() { id: table.get_next_pk().into(), unique_key, }) + .await .unwrap(); } table.wait_for_ops().await.unwrap(); @@ -271,6 +272,7 @@ async fn upstream_indexset_survives_persist_reload_and_more_writes() { id: table.get_next_pk().into(), unique_key: 2_000, }) + .await .unwrap(); let added_id: u64 = added_pk.clone().into(); table.delete(10).await.unwrap(); @@ -311,6 +313,7 @@ async fn native_art_backends_survive_wal_reload_and_further_mutation() { id: table.get_next_pk().into(), congee_key, }) + .await .unwrap(); } let rejected_id = table.get_next_pk().0; @@ -320,6 +323,7 @@ async fn native_art_backends_survive_wal_reload_and_further_mutation() { id: rejected_id, congee_key: 77, }) + .await .is_err() ); let accepted_id = table.get_next_pk().0; @@ -328,6 +332,7 @@ async fn native_art_backends_survive_wal_reload_and_further_mutation() { id: accepted_id, congee_key: 300, }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); drop(table); @@ -366,6 +371,7 @@ async fn native_art_backends_survive_wal_reload_and_further_mutation() { id: table.get_next_pk().into(), arctic_key, }) + .await .unwrap(); } table.wait_for_ops().await.unwrap(); @@ -411,7 +417,7 @@ async fn native_art_backends_recover_concurrent_same_row_updates() { let engine = PersistedArcticPersistenceEngine::new(config.clone()).await.unwrap(); let table = Arc::new(PersistedArcticWorkTable::load(engine).await.unwrap()); let id = table.get_next_pk().0; - table.insert(PersistedArcticRow { id, congee_key: 1 }).unwrap(); + table.insert(PersistedArcticRow { id, congee_key: 1 }).await.unwrap(); let barrier = Arc::new(Barrier::new(WORKERS as usize + 1)); let mut workers = Vec::new(); @@ -476,7 +482,10 @@ async fn logical_wti_recovers_concurrent_same_row_updates() { let engine = wti::ProviderSwitchPersistenceEngine::new(config.clone()).await.unwrap(); let table = Arc::new(wti::ProviderSwitchWorkTable::load(engine).await.unwrap()); let id: u64 = table.get_next_pk().into(); - table.insert(wti::ProviderSwitchRow { id, unique_key: 1 }).unwrap(); + table + .insert(wti::ProviderSwitchRow { id, unique_key: 1 }) + .await + .unwrap(); let barrier = Arc::new(Barrier::new(WORKERS as usize + 1)); let mut workers = Vec::new(); @@ -543,6 +552,7 @@ async fn persisted_tables_can_switch_between_wti_and_upstream_without_rebuild() id: table.get_next_pk().into(), unique_key, }) + .await .unwrap(); } table.wait_for_ops().await.unwrap(); @@ -565,6 +575,7 @@ async fn persisted_tables_can_switch_between_wti_and_upstream_without_rebuild() id: table.get_next_pk().into(), unique_key: 2_000, }) + .await .unwrap(); table.wait_for_ops().await.unwrap(); drop(table); @@ -579,3 +590,125 @@ async fn persisted_tables_can_switch_between_wti_and_upstream_without_rebuild() remove_dir_if_exists(ROOT.to_string()).await; } + +worktable! { + name: SignedArctic, + persist: false, + columns: { + id: i64 primary_key using arctic, + payload: i64, + }, + indexes: { + payload_idx: payload unique using arctic, + }, +} + +/// An ART orders by the bytes of the key, and two's complement puts negatives +/// after positives when read that way. Arctic maps signed keys onto the +/// unsigned space by flipping the sign bit, so this asserts the ordering a +/// caller actually sees rather than the mapping in isolation. +/// +/// A signed primary key was rejected by the DSL until now, which made the +/// choice of backend depend on the sign of the key rather than on anything +/// about the data. +#[tokio::test] +async fn arctic_takes_signed_keys_in_signed_order() { + let table = SignedArcticWorkTable::default(); + + let keys = [i64::MIN, -1_000, -1, 0, 1, 1_000, i64::MAX]; + for key in [0, i64::MAX, -1, 1_000, i64::MIN, 1, -1_000] { + table.insert(SignedArcticRow { id: key, payload: key }).await.unwrap(); + } + + for key in keys { + assert_eq!(table.select(key).map(|row| row.id), Some(key), "point lookup for {key}"); + assert_eq!( + table.select_by_payload(key).map(|row| row.id), + Some(key), + "secondary lookup for {key}" + ); + } + + let mut seen: Vec = table + .select_all() + .execute() + .unwrap() + .into_iter() + .map(|r| r.id) + .collect(); + assert_eq!(seen.len(), keys.len()); + seen.sort_unstable(); + assert_eq!(seen, keys); + + table.delete(i64::MIN).await.unwrap(); + assert!( + table.select(i64::MIN).is_none(), + "the most negative key must be removable" + ); + assert_eq!(table.select(i64::MAX).map(|row| row.id), Some(i64::MAX)); +} + +worktable! { + name: SignedArcticPersisted, + persist: true, + columns: { + id: i64 primary_key using arctic, + payload: i64, + }, +} + +/// Signed keys had no `ArtPersistenceKey` impl at all, so a persisted table +/// with one could not be declared. This covers the WAL round trip for them. +/// +/// It does *not* prove the encoding is order-preserving. Nothing currently +/// sorts or ranges over encoded key bytes, so removing the sign-bit flip from +/// that encoding leaves this test passing. Checked, rather than assumed. +#[tokio::test] +async fn persisted_arctic_reloads_signed_keys_in_signed_order() { + const ROOT: &str = "tests/data/index_backend_arctic_signed"; + remove_dir_if_exists(ROOT.to_string()).await; + + let config = DiskConfig::new_with_table_name( + ROOT, + SignedArcticPersistedWorkTable::name_snake_case(), + SignedArcticPersistedWorkTable::version(), + ); + let keys = [i64::MIN, -1_000, -1, 0, 1, 1_000, i64::MAX]; + + { + let engine = SignedArcticPersistedPersistenceEngine::new(config.clone()) + .await + .unwrap(); + let table = SignedArcticPersistedWorkTable::load(engine).await.unwrap(); + for key in [0, i64::MAX, -1, 1_000, i64::MIN, 1, -1_000] { + table + .insert(SignedArcticPersistedRow { id: key, payload: key }) + .await + .unwrap(); + } + table.wait_for_ops().await.unwrap(); + table.close().await.unwrap(); + } + + let engine = SignedArcticPersistedPersistenceEngine::new(config.clone()) + .await + .unwrap(); + let table = SignedArcticPersistedWorkTable::load(engine).await.unwrap(); + + for key in keys { + assert_eq!( + table.select(key).map(|row| row.payload), + Some(key), + "{key} did not survive the reload" + ); + } + let mut seen: Vec = table + .select_all() + .execute() + .unwrap() + .into_iter() + .map(|r| r.id) + .collect(); + seen.sort_unstable(); + assert_eq!(seen, keys, "the reloaded tree must hold exactly the keys written"); +} diff --git a/tests/worktable/insert_many.rs b/tests/worktable/insert_many.rs index 55d49aa3..866a7180 100644 --- a/tests/worktable/insert_many.rs +++ b/tests/worktable/insert_many.rs @@ -25,12 +25,12 @@ fn row(id: u64, unique_value: u64) -> BatchRow { } } -#[test] -fn insert_many_returns_pks_and_reads_see_every_row() { +#[tokio::test] +async fn insert_many_returns_pks_and_reads_see_every_row() { let table = BatchWorkTable::default(); let rows: Vec<_> = (0..10).map(|i| row(i, 100 + i)).collect(); - let pks = table.insert_many(rows).unwrap(); + let pks = table.insert_many(rows).await.unwrap(); assert_eq!(pks.len(), 10); assert_eq!(table.count(), 10); @@ -44,10 +44,10 @@ fn insert_many_returns_pks_and_reads_see_every_row() { } } -#[test] -fn insert_many_of_no_rows_is_ok() { +#[tokio::test] +async fn insert_many_of_no_rows_is_ok() { let table = BatchWorkTable::default(); - assert!(table.insert_many(vec![]).unwrap().is_empty()); + assert!(table.insert_many(vec![]).await.unwrap().is_empty()); assert_eq!(table.count(), 0); } @@ -62,9 +62,9 @@ fn assert_batch_fully_rejected(table: &BatchWorkTable, batch: &[BatchRow], preex } } -fn collision_case(collide_at: usize) { +async fn collision_case(collide_at: usize) { let table = BatchWorkTable::default(); - table.insert(row(1000, 999)).unwrap(); + table.insert(row(1000, 999)).await.unwrap(); let batch: Vec<_> = (0..5) .map(|i| { @@ -73,7 +73,7 @@ fn collision_case(collide_at: usize) { }) .collect(); - let error = table.insert_many(batch.clone()).unwrap_err(); + let error = table.insert_many(batch.clone()).await.unwrap_err(); match error { BatchInsertError::Row { row_index, source } => { assert_eq!(row_index, collide_at, "the offending row must be named"); @@ -103,34 +103,37 @@ fn collision_case(collide_at: usize) { assert_eq!(table.select_by_unique_value(999).unwrap().id, 1000); // The table stays fully usable after the rollback. - table.insert_many((0..5).map(|i| row(i, 200 + i)).collect()).unwrap(); + table + .insert_many((0..5).map(|i| row(i, 200 + i)).collect()) + .await + .unwrap(); assert_eq!(table.count(), 6); } -#[test] -fn unique_collision_at_first_row_rejects_the_batch() { - collision_case(0); +#[tokio::test] +async fn unique_collision_at_first_row_rejects_the_batch() { + collision_case(0).await; } -#[test] -fn unique_collision_at_middle_row_rejects_the_batch() { - collision_case(2); +#[tokio::test] +async fn unique_collision_at_middle_row_rejects_the_batch() { + collision_case(2).await; } -#[test] -fn unique_collision_at_last_row_rejects_the_batch() { - collision_case(4); +#[tokio::test] +async fn unique_collision_at_last_row_rejects_the_batch() { + collision_case(4).await; } -#[test] -fn unique_collision_between_two_batch_rows_rejects_the_batch() { +#[tokio::test] +async fn unique_collision_between_two_batch_rows_rejects_the_batch() { let table = BatchWorkTable::default(); let mut batch: Vec<_> = (0..5).map(|i| row(i, 300 + i)).collect(); // Rows 1 and 4 collide with each other inside the batch. batch[4].unique_value = batch[1].unique_value; - let error = table.insert_many(batch.clone()).unwrap_err(); + let error = table.insert_many(batch.clone()).await.unwrap_err(); match error { BatchInsertError::Row { row_index, source } => { assert_eq!(row_index, 4, "the later of the two colliding rows is the offender"); @@ -145,14 +148,14 @@ fn unique_collision_between_two_batch_rows_rejects_the_batch() { } } -#[test] -fn duplicate_primary_key_inside_the_batch_rejects_the_batch() { +#[tokio::test] +async fn duplicate_primary_key_inside_the_batch_rejects_the_batch() { let table = BatchWorkTable::default(); let mut batch: Vec<_> = (0..4).map(|i| row(i, 400 + i)).collect(); batch[3].id = batch[1].id; - let error = table.insert_many(batch).unwrap_err(); + let error = table.insert_many(batch).await.unwrap_err(); match error { BatchInsertError::Row { row_index, source } => { assert_eq!(row_index, 3); @@ -163,13 +166,13 @@ fn duplicate_primary_key_inside_the_batch_rejects_the_batch() { assert_eq!(table.count(), 0); } -#[test] -fn duplicate_primary_key_with_an_existing_row_rejects_the_batch() { +#[tokio::test] +async fn duplicate_primary_key_with_an_existing_row_rejects_the_batch() { let table = BatchWorkTable::default(); - table.insert(row(7, 500)).unwrap(); + table.insert(row(7, 500)).await.unwrap(); let batch = vec![row(20, 501), row(21, 502), row(7, 503)]; - let error = table.insert_many(batch).unwrap_err(); + let error = table.insert_many(batch).await.unwrap_err(); match error { BatchInsertError::Row { row_index, source } => { assert_eq!(row_index, 2); @@ -181,8 +184,8 @@ fn duplicate_primary_key_with_an_existing_row_rejects_the_batch() { assert_eq!(table.select(7).unwrap().unique_value, 500); } -#[test] -fn reserve_pks_hands_out_contiguous_keys_that_interleave_with_get_next_pk() { +#[tokio::test] +async fn reserve_pks_hands_out_contiguous_keys_that_interleave_with_get_next_pk() { let table = BatchWorkTable::default(); let first: u64 = table.get_next_pk().into(); @@ -193,7 +196,7 @@ fn reserve_pks_hands_out_contiguous_keys_that_interleave_with_get_next_pk() { assert_eq!(after, range.end, "get_next_pk continues after the reservation"); let rows: Vec<_> = range.clone().map(|id| row(id, 600 + id)).collect(); - let pks = table.insert_many(rows).unwrap(); + let pks = table.insert_many(rows).await.unwrap(); assert_eq!(pks.len(), 5); for id in range { assert_eq!(table.select(id).unwrap().unique_value, 600 + id); @@ -203,14 +206,14 @@ fn reserve_pks_hands_out_contiguous_keys_that_interleave_with_get_next_pk() { /// A rejected batch must never be observable: a reader polling the batch's /// primary keys and unique values while rejected batches are retried in a /// loop may not see a single row. -#[test] -fn concurrent_reader_never_observes_a_rejected_batch() { +#[tokio::test] +async fn concurrent_reader_never_observes_a_rejected_batch() { const BATCH: u64 = 16; const ITERATIONS: usize = 200; let table = Arc::new(BatchWorkTable::default()); // The poison row that every batch's last row collides with. - table.insert(row(5000, 999)).unwrap(); + table.insert(row(5000, 999)).await.unwrap(); let stop = Arc::new(AtomicBool::new(false)); let reader_table = table.clone(); @@ -233,7 +236,7 @@ fn concurrent_reader_never_observes_a_rejected_batch() { for _ in 0..ITERATIONS { let mut batch: Vec<_> = (0..BATCH).map(|id| row(id, 700 + id)).collect(); batch.last_mut().unwrap().unique_value = 999; - let error = table.insert_many(batch).unwrap_err(); + let error = table.insert_many(batch).await.unwrap_err(); assert!(matches!(error, BatchInsertError::Row { row_index, .. } if row_index == BATCH as usize - 1)); } @@ -249,8 +252,8 @@ fn concurrent_reader_never_observes_a_rejected_batch() { /// Successful batches become visible in row order, so visibility is /// prefix-monotone: whenever a reader can see row `k` of a batch, every /// earlier row of that batch is visible too. -#[test] -fn concurrent_reader_sees_successful_batches_in_prefix_order() { +#[tokio::test] +async fn concurrent_reader_sees_successful_batches_in_prefix_order() { const BATCH: u64 = 32; const BATCHES: u64 = 100; @@ -290,7 +293,7 @@ fn concurrent_reader_sees_successful_batches_in_prefix_order() { let start = batch_index * BATCH; published_start.store(start, Ordering::Release); let rows: Vec<_> = (start..start + BATCH).map(|id| row(id, 1_000_000 + id)).collect(); - table.insert_many(rows).unwrap(); + table.insert_many(rows).await.unwrap(); } stop.store(true, Ordering::Release); diff --git a/tests/worktable/key_widths.rs b/tests/worktable/key_widths.rs new file mode 100644 index 00000000..37ab5f35 --- /dev/null +++ b/tests/worktable/key_widths.rs @@ -0,0 +1,97 @@ +//! Every key width the macro says a backend supports, actually works. +//! +//! `validate_index_backends` rejects a declaration whose key type the backend +//! cannot serve, and the accepted sets are narrow and specific: congee takes +//! `u8`, `u16`, `u32`, `u64`, `usize`; arctic takes `u16`, `u32`, `u64`, +//! `u128`. That list is a promise the macro makes to a consumer. +//! +//! Nothing tested it. Before this file the suite instantiated `u64` with both +//! backends and `u128` once, so most of the advertised matrix had never been +//! generated, let alone exercised. The backends test their own key widths in +//! their own repositories, which is the right place for the data structure and +//! the wrong place for this: what is unverified here is not whether arctic +//! handles a `u16`, it is whether *a generated WorkTable over an arctic `u16` +//! index* stores, finds and removes a row. +//! +//! Deliberately shallow per cell. One row through insert, select by that index, +//! and delete is enough to catch a width that was never wired up; depth belongs +//! in the backend's own suite. + +use worktable::prelude::*; +use worktable::worktable; + +/// One table per (backend, key width). Separate modules because the generated +/// idents collide otherwise. +macro_rules! width_case { + ($module:ident, $backend:ident, $key:ident, $label:literal) => { + mod $module { + use super::*; + + worktable!( + name: Width, + persist: false, + columns: { + id: u64 primary_key, + key: $key, + }, + indexes: { key_idx: key unique using $backend }, + ); + + #[tokio::test] + async fn the_advertised_key_width_round_trips() { + let table = WidthWorkTable::default(); + + // Three keys rather than one, so ordering has something to be + // wrong about on a trie backend. + for (id, key) in [(1u64, 7 as $key), (2, 42 as $key), (3, 5 as $key)] { + table.insert(WidthRow { id, key }).await.unwrap_or_else(|error| { + panic!("{}: insert failed for key type {}: {error}", $label, stringify!($key)) + }); + } + + for (id, key) in [(1u64, 7 as $key), (2, 42 as $key), (3, 5 as $key)] { + let found = table + .select_by_key(key) + .unwrap_or_else(|| panic!("{}: {} key {key} not found by index", $label, stringify!($key))); + assert_eq!(found.id, id, "{}: {} index returned the wrong row", $label, stringify!($key)); + } + + // A key that was never inserted must not resolve. + assert!( + table.select_by_key(99 as $key).is_none(), + "{}: {} index resolved a key that was never inserted", + $label, + stringify!($key) + ); + + // And the entry comes out again. + futures::executor::block_on(table.delete(2u64)).expect("delete"); + assert!( + table.select_by_key(42 as $key).is_none(), + "{}: {} index still resolves a deleted row", + $label, + stringify!($key) + ); + assert_eq!(table.count(), 2); + } + } + }; +} + +// Congee: u8, u16, u32, u64, usize. +width_case!(congee_u8, congee, u8, "congee"); +width_case!(congee_u16, congee, u16, "congee"); +width_case!(congee_u32, congee, u32, "congee"); +width_case!(congee_u64, congee, u64, "congee"); +width_case!(congee_usize, congee, usize, "congee"); + +// Arctic: u16, u32, u64, u128. +width_case!(arctic_u16, arctic, u16, "arctic"); +width_case!(arctic_u32, arctic, u32, "arctic"); +width_case!(arctic_u64, arctic, u64, "arctic"); +width_case!(arctic_u128, arctic, u128, "arctic"); + +// The default backend takes any ordered key, so it is the control: if a width +// fails here too, the problem is not the backend. +width_case!(wti_u16, worktables_index, u16, "wti"); +width_case!(wti_u128, worktables_index, u128, "wti"); diff --git a/tests/worktable/leak_probe.rs b/tests/worktable/leak_probe.rs index 5962cf4b..e0aa521a 100644 --- a/tests/worktable/leak_probe.rs +++ b/tests/worktable/leak_probe.rs @@ -38,6 +38,7 @@ async fn update_churn_reclaims_under_concurrent_readers() { id: 1, payload: "0000".to_string(), }) + .await .unwrap(); for i in 0..100u64 { table @@ -105,6 +106,7 @@ async fn update_churn_does_not_grow_storage_unbounded() { id: 1, payload: "0000".to_string(), }) + .await .unwrap(); // One row, many same-length updates. Logical cardinality stays 1 the whole diff --git a/tests/worktable/lock_order.rs b/tests/worktable/lock_order.rs index 6a5d26ab..3c00c6cd 100644 --- a/tests/worktable/lock_order.rs +++ b/tests/worktable/lock_order.rs @@ -51,6 +51,7 @@ async fn multi_row_update_locks_in_primary_key_order_not_index_order() { group_b: 1, value: 0, }) + .await .unwrap(); } diff --git a/tests/worktable/mod.rs b/tests/worktable/mod.rs index 723f1aaf..5bf547e5 100644 --- a/tests/worktable/mod.rs +++ b/tests/worktable/mod.rs @@ -3,15 +3,18 @@ mod base; mod bench; mod borrowed_primary_key; mod cancel_safety; +mod concurrency; mod config; mod count; mod custom_pk; mod delete; +mod delete_many; mod float; mod in_place; mod index; mod index_backends; mod insert_many; +mod key_widths; mod leak_probe; mod lock_order; mod multi_row_deadlock; @@ -21,14 +24,17 @@ mod nonunique_arctic; mod option; mod partitioned; mod reinsert_visibility; +mod schema_const; mod tuple_primary_key; mod unique_fixed_unsized; mod unsized_; mod update_delete_race; mod update_in_place_unsized; mod upsert; +mod upsert_guard; mod uuid; mod vacuum; +mod vacuum_invariants; mod vacuum_no_row_loss; mod with_enum; mod wrong_row_update; diff --git a/tests/worktable/multi_row_deadlock.rs b/tests/worktable/multi_row_deadlock.rs index 20d2c8fb..a144b775 100644 --- a/tests/worktable/multi_row_deadlock.rs +++ b/tests/worktable/multi_row_deadlock.rs @@ -43,6 +43,7 @@ async fn overlapping_multi_row_updates_do_not_deadlock() { group_b: 1, name: "seed".to_string(), }) + .await .unwrap(); } diff --git a/tests/worktable/mutation_gate_deadlock.rs b/tests/worktable/mutation_gate_deadlock.rs index 97c1209c..aa21af2c 100644 --- a/tests/worktable/mutation_gate_deadlock.rs +++ b/tests/worktable/mutation_gate_deadlock.rs @@ -71,8 +71,8 @@ fn concurrent_same_stripe_updates_do_not_deadlock() { runtime.block_on(async move { let table = Arc::new(GateBenchWorkTable::default()); - table.insert(GateBenchRow { id: a, val: 0 }).unwrap(); - table.insert(GateBenchRow { id: b, val: 0 }).unwrap(); + table.insert(GateBenchRow { id: a, val: 0 }).await.unwrap(); + table.insert(GateBenchRow { id: b, val: 0 }).await.unwrap(); let ta = { let table = table.clone(); @@ -118,7 +118,7 @@ fn many_same_stripe_updates_do_not_starve_worker_pool() { runtime.block_on(async move { let table = Arc::new(GateBenchWorkTable::default()); for k in [a, b] { - table.insert(GateBenchRow { id: k, val: 0 }).unwrap(); + table.insert(GateBenchRow { id: k, val: 0 }).await.unwrap(); } let mut handles = Vec::new(); diff --git a/tests/worktable/nid.rs b/tests/worktable/nid.rs index b2279b0b..e42cef5b 100644 --- a/tests/worktable/nid.rs +++ b/tests/worktable/nid.rs @@ -26,7 +26,7 @@ async fn insert_and_select() { another: 42, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, row); diff --git a/tests/worktable/nonunique_arctic.rs b/tests/worktable/nonunique_arctic.rs index ea0da930..bb9505ec 100644 --- a/tests/worktable/nonunique_arctic.rs +++ b/tests/worktable/nonunique_arctic.rs @@ -48,13 +48,13 @@ fn row(table: &ArcticAdjacencyWorkTable, source_hash: u128, edge_hash: u128, wei const SOURCE_A: u128 = u128::MAX - 5; const SOURCE_B: u128 = 1 << 90; -#[test] -fn select_by_non_unique_key_returns_every_matching_row() { +#[tokio::test] +async fn select_by_non_unique_key_returns_every_matching_row() { let table = ArcticAdjacencyWorkTable::default(); for edge in 0..100u128 { - table.insert(row(&table, SOURCE_A, edge, edge as u64)).unwrap(); + table.insert(row(&table, SOURCE_A, edge, edge as u64)).await.unwrap(); } - table.insert(row(&table, SOURCE_B, 1000, 1)).unwrap(); + table.insert(row(&table, SOURCE_B, 1000, 1)).await.unwrap(); let rows = table.select_by_source_hash(SOURCE_A).execute().unwrap(); assert_eq!(rows.len(), 100); @@ -71,8 +71,8 @@ fn select_by_non_unique_key_returns_every_matching_row() { #[tokio::test] async fn delete_removes_rows_from_the_non_unique_index() { let table = ArcticAdjacencyWorkTable::default(); - let pk = table.insert(row(&table, SOURCE_A, 1, 10)).unwrap(); - table.insert(row(&table, SOURCE_A, 2, 20)).unwrap(); + let pk = table.insert(row(&table, SOURCE_A, 1, 10)).await.unwrap(); + table.insert(row(&table, SOURCE_A, 2, 20)).await.unwrap(); table.delete(pk).await.unwrap(); let rows = table.select_by_source_hash(SOURCE_A).execute().unwrap(); @@ -80,7 +80,7 @@ async fn delete_removes_rows_from_the_non_unique_index() { assert_eq!(rows[0].edge_hash, 2); // Custom delete-by-non-unique-key drains the whole key. - table.insert(row(&table, SOURCE_A, 3, 30)).unwrap(); + table.insert(row(&table, SOURCE_A, 3, 30)).await.unwrap(); table.delete_by_source(SOURCE_A).await.unwrap(); assert!(table.select_by_source_hash(SOURCE_A).execute().unwrap().is_empty()); assert_eq!(table.count(), 0); @@ -89,8 +89,8 @@ async fn delete_removes_rows_from_the_non_unique_index() { #[tokio::test] async fn update_moves_a_row_between_non_unique_keys() { let table = ArcticAdjacencyWorkTable::default(); - let pk = table.insert(row(&table, SOURCE_A, 1, 10)).unwrap(); - table.insert(row(&table, SOURCE_A, 2, 20)).unwrap(); + let pk = table.insert(row(&table, SOURCE_A, 1, 10)).await.unwrap(); + table.insert(row(&table, SOURCE_A, 2, 20)).await.unwrap(); table .update_source_by_id(SourceByIdQuery { source_hash: SOURCE_B }, pk.clone()) @@ -114,15 +114,15 @@ async fn update_moves_a_row_between_non_unique_keys() { assert_eq!(rows[0].source_hash, SOURCE_A); } -#[test] -fn unique_collision_unwinds_the_non_unique_entries() { +#[tokio::test] +async fn unique_collision_unwinds_the_non_unique_entries() { let table = ArcticAdjacencyWorkTable::default(); - table.insert(row(&table, SOURCE_A, 42, 10)).unwrap(); + table.insert(row(&table, SOURCE_A, 42, 10)).await.unwrap(); // `source_idx` and `weight_idx` are declared before `edge_idx`, so both // non-unique entries are already inserted when the unique index rejects // the duplicate edge hash; the failed insert must remove them again. - let err = table.insert(row(&table, SOURCE_B, 42, 999)).unwrap_err(); + let err = table.insert(row(&table, SOURCE_B, 42, 999)).await.unwrap_err(); assert!(matches!(err, WorkTableError::AlreadyExists(_))); assert!(table.select_by_source_hash(SOURCE_B).execute().unwrap().is_empty()); @@ -130,13 +130,14 @@ fn unique_collision_unwinds_the_non_unique_entries() { assert_eq!(table.count(), 1); } -#[test] -fn range_select_over_non_unique_arctic_keys() { +#[tokio::test] +async fn range_select_over_non_unique_arctic_keys() { let table = ArcticAdjacencyWorkTable::default(); for key in 0..10u64 { for copy in 0..3u128 { table .insert(row(&table, key as u128, key as u128 * 100 + copy, key)) + .await .unwrap(); } } @@ -150,8 +151,8 @@ fn range_select_over_non_unique_arctic_keys() { assert!(rows.iter().all(|r| r.weight >= 8)); } -#[test] -fn concurrent_inserts_and_deletes_keep_the_index_consistent() { +#[tokio::test] +async fn concurrent_inserts_and_deletes_keep_the_index_consistent() { let table = Arc::new(ArcticAdjacencyWorkTable::default()); let keys = 8u128; let writers = 4; @@ -164,7 +165,7 @@ fn concurrent_inserts_and_deletes_keep_the_index_consistent() { for n in 0..per_writer { let edge = (writer as u128) << 64 | n as u128; let source = (n as u128) % keys; - table.insert(row(&table, source, edge, n)).unwrap(); + futures::executor::block_on(table.insert(row(&table, source, edge, n))).unwrap(); // Interleave point reads to race the writers. let _ = table.select_by_source_hash(source).execute().unwrap(); } @@ -196,7 +197,7 @@ async fn concurrent_deletes_leave_no_stale_links() { let table = Arc::new(ArcticAdjacencyWorkTable::default()); let mut pks = Vec::new(); for n in 0..400u128 { - pks.push(table.insert(row(&table, n % 4, n, n as u64)).unwrap()); + pks.push(table.insert(row(&table, n % 4, n, n as u64)).await.unwrap()); } let mut tasks = Vec::new(); @@ -219,11 +220,11 @@ async fn concurrent_deletes_leave_no_stale_links() { assert_eq!(table.count(), 0); } -#[test] -fn system_info_reports_the_non_unique_arctic_index() { +#[tokio::test] +async fn system_info_reports_the_non_unique_arctic_index() { let table = ArcticAdjacencyWorkTable::default(); for n in 0..5u128 { - table.insert(row(&table, SOURCE_A, n, n as u64)).unwrap(); + table.insert(row(&table, SOURCE_A, n, n as u64)).await.unwrap(); } let info = table.system_info(); @@ -282,13 +283,13 @@ mod persisted { let hot = u128::MAX - 3; let mut deleted_pk = None; for n in 0..30u64 { - let pk = table.insert(row(&table, hot, n)).unwrap(); + let pk = table.insert(row(&table, hot, n)).await.unwrap(); if n == 7 { deleted_pk = Some(pk); } } - table.insert(row(&table, 5, 100)).unwrap(); - let moved_pk = table.insert(row(&table, 5, 101)).unwrap(); + table.insert(row(&table, 5, 100)).await.unwrap(); + let moved_pk = table.insert(row(&table, 5, 101)).await.unwrap(); // Delete one row under the hot key, and move one row between keys. table.delete(deleted_pk.unwrap()).await.unwrap(); @@ -313,7 +314,7 @@ mod persisted { assert_eq!(moved_rows, vec![moved]); // The reloaded index keeps accepting writes that survive another cycle. - table.insert(row(&table, hot, 500)).unwrap(); + table.insert(row(&table, hot, 500)).await.unwrap(); table.wait_for_ops().await.unwrap(); drop(table); @@ -368,6 +369,7 @@ mod persisted { source_hash: 1, edge_hash: 42, }) + .await .unwrap(); let err = table .insert(PersistedEdgesRow { @@ -375,6 +377,7 @@ mod persisted { source_hash: 2, edge_hash: 42, }) + .await .unwrap_err(); assert!(matches!(err, WorkTableError::AlreadyExists(_))); assert!(table.select_by_source_hash(2).execute().unwrap().is_empty()); @@ -427,6 +430,7 @@ mod persisted { source_hash: key, weight: worker * ROWS_PER_WORKER + n, }) + .await .unwrap(); if n % 5 == 4 { table.delete(pk).await.unwrap(); diff --git a/tests/worktable/option.rs b/tests/worktable/option.rs index 847b890a..47999a60 100644 --- a/tests/worktable/option.rs +++ b/tests/worktable/option.rs @@ -33,7 +33,7 @@ async fn update() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let new_row = TestRow { id: pk.clone().into(), test: Some(1), @@ -54,7 +54,7 @@ async fn update_by_another() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); table .update_test_by_another(TestByAnotherQuery { test: Some(1) }, 1) .await @@ -72,7 +72,7 @@ async fn update_by_exchange() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); table .update_test_by_exchange(TestByExchangeQuery { test: Some(1) }, 1) .await @@ -90,7 +90,7 @@ async fn update_none_to_some() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(pk.clone()).unwrap().test, None); table @@ -111,7 +111,7 @@ async fn update_some_to_none() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(pk.clone()).unwrap().test, Some(100)); table @@ -133,7 +133,7 @@ async fn update_multiple_values() { another: 1, exchange: 1, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), @@ -141,7 +141,7 @@ async fn update_multiple_values() { another: 2, exchange: 2, }; - let pk2 = table.insert(row2).unwrap(); + let pk2 = table.insert(row2).await.unwrap(); table .update_test_by_id(TestByIdQuery { test: Some(30) }, pk1.clone()) @@ -182,7 +182,7 @@ async fn custom_update() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let test_uuid = Uuid::new_v4(); let new_row = TestCustomRow { id: pk.clone().into(), @@ -204,7 +204,7 @@ async fn custom_update_by_another() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let test_uuid = Uuid::new_v4(); table .update_custom_test_by_another(CustomTestByAnotherQuery { test: Some(test_uuid) }, 1) @@ -223,7 +223,7 @@ async fn custom_update_by_exchange() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let test_uuid = Uuid::new_v4(); table .update_custom_test_by_exchange(CustomTestByExchangeQuery { test: Some(test_uuid) }, 1) @@ -242,7 +242,7 @@ async fn custom_update_none_to_some() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(pk.clone()).unwrap().test, None); let test_uuid = Uuid::new_v4(); @@ -265,7 +265,7 @@ async fn custom_update_some_to_none() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); assert_eq!(table.select(pk.clone()).unwrap().test, Some(test_uuid)); table @@ -289,7 +289,7 @@ async fn custom_update_multiple_uuids() { another: 1, exchange: 1, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let row2 = TestCustomRow { id: table.get_next_pk().into(), @@ -297,7 +297,7 @@ async fn custom_update_multiple_uuids() { another: 2, exchange: 2, }; - let pk2 = table.insert(row2).unwrap(); + let pk2 = table.insert(row2).await.unwrap(); let uuid3 = Uuid::new_v4(); table @@ -342,7 +342,7 @@ async fn indexed_insert_and_select_by_uuid_some() { another: 1, exchange: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); // Select by the indexed UUID field with Some value let result = table.select_by_test(Some(test_uuid)).execute().unwrap(); @@ -360,7 +360,7 @@ async fn indexed_select_by_uuid_none() { another: 1, exchange: 1, }; - let pk = table.insert(row).unwrap(); + let pk = table.insert(row).await.unwrap(); // Select by None in the indexed field let result = table.select_by_test(None).execute().unwrap(); @@ -379,7 +379,7 @@ async fn indexed_multiple_rows_same_uuid() { another: 1, exchange: 1, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let row2 = TestIndexRow { id: table.get_next_pk().into(), @@ -387,7 +387,7 @@ async fn indexed_multiple_rows_same_uuid() { another: 2, exchange: 2, }; - let pk2 = table.insert(row2).unwrap(); + let pk2 = table.insert(row2).await.unwrap(); let row3 = TestIndexRow { id: table.get_next_pk().into(), @@ -395,7 +395,7 @@ async fn indexed_multiple_rows_same_uuid() { another: 3, exchange: 3, }; - let pk3 = table.insert(row3).unwrap(); + let pk3 = table.insert(row3).await.unwrap(); // Should find all three rows with the same UUID let result = table.select_by_test(Some(test_uuid)).execute().unwrap(); @@ -415,7 +415,7 @@ async fn indexed_multiple_rows_none() { another: 1, exchange: 1, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let row2 = TestIndexRow { id: table.get_next_pk().into(), @@ -423,7 +423,7 @@ async fn indexed_multiple_rows_none() { another: 2, exchange: 2, }; - let pk2 = table.insert(row2).unwrap(); + let pk2 = table.insert(row2).await.unwrap(); let row3 = TestIndexRow { id: table.get_next_pk().into(), @@ -431,7 +431,7 @@ async fn indexed_multiple_rows_none() { another: 3, exchange: 3, }; - let pk3 = table.insert(row3).unwrap(); + let pk3 = table.insert(row3).await.unwrap(); // Should find all three rows with None let result = table.select_by_test(None).execute().unwrap(); @@ -452,7 +452,7 @@ async fn indexed_update_indexed_field() { another: 1, exchange: 1, }; - let pk = table.insert(row).unwrap(); + let pk = table.insert(row).await.unwrap(); // Verify initial UUID is indexed let result = table.select_by_test(Some(uuid1)).execute().unwrap(); @@ -487,7 +487,7 @@ async fn indexed_update_from_some_to_none() { another: 1, exchange: 1, }; - let pk = table.insert(row).unwrap(); + let pk = table.insert(row).await.unwrap(); // Verify initial UUID is indexed let result = table.select_by_test(Some(test_uuid)).execute().unwrap(); @@ -519,7 +519,7 @@ async fn indexed_update_from_none_to_some() { another: 1, exchange: 1, }; - let pk = table.insert(row).unwrap(); + let pk = table.insert(row).await.unwrap(); // Verify None is indexed let result = table.select_by_test(None).execute().unwrap(); @@ -554,7 +554,7 @@ async fn indexed_update_via_another_index() { another: 999, exchange: 1, }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); // Update via the unique 'another' index table @@ -582,7 +582,7 @@ async fn indexed_update_via_non_unique_index() { another: 1, exchange: 100, }; - let pk1 = table.insert(row1).unwrap(); + let pk1 = table.insert(row1).await.unwrap(); let row2 = TestIndexRow { id: table.get_next_pk().into(), @@ -590,7 +590,7 @@ async fn indexed_update_via_non_unique_index() { another: 2, exchange: 100, }; - let pk2 = table.insert(row2).unwrap(); + let pk2 = table.insert(row2).await.unwrap(); // Update both rows via the non-unique 'exchange' index table @@ -624,7 +624,7 @@ async fn indexed_mixed_none_and_some() { another: 1, exchange: 1, }; - table.insert(row1).unwrap(); + table.insert(row1).await.unwrap(); let row2 = TestIndexRow { id: table.get_next_pk().into(), @@ -632,7 +632,7 @@ async fn indexed_mixed_none_and_some() { another: 2, exchange: 2, }; - table.insert(row2).unwrap(); + table.insert(row2).await.unwrap(); let row3 = TestIndexRow { id: table.get_next_pk().into(), @@ -640,7 +640,7 @@ async fn indexed_mixed_none_and_some() { another: 3, exchange: 3, }; - table.insert(row3).unwrap(); + table.insert(row3).await.unwrap(); let row4 = TestIndexRow { id: table.get_next_pk().into(), @@ -648,7 +648,7 @@ async fn indexed_mixed_none_and_some() { another: 4, exchange: 4, }; - table.insert(row4).unwrap(); + table.insert(row4).await.unwrap(); // Verify counts let result_none = table.select_by_test(None).execute().unwrap(); @@ -672,7 +672,7 @@ async fn indexed_delete_row_with_uuid() { another: 1, exchange: 1, }; - let pk = table.insert(row).unwrap(); + let pk = table.insert(row).await.unwrap(); // Verify UUID is indexed let result = table.select_by_test(Some(test_uuid)).execute().unwrap(); @@ -696,7 +696,7 @@ async fn indexed_delete_row_with_none() { another: 1, exchange: 1, }; - let pk = table.insert(row).unwrap(); + let pk = table.insert(row).await.unwrap(); // Verify None is indexed let result = table.select_by_test(None).execute().unwrap(); @@ -721,7 +721,7 @@ async fn indexed_no_match_for_uuid() { another: 1, exchange: 1, }; - table.insert(row).unwrap(); + table.insert(row).await.unwrap(); // Search for a different UUID let other_uuid = Uuid::new_v4(); @@ -741,7 +741,7 @@ async fn indexed_no_match_for_none_when_all_have_values() { another: 1, exchange: 1, }; - table.insert(row1).unwrap(); + table.insert(row1).await.unwrap(); let row2 = TestIndexRow { id: table.get_next_pk().into(), @@ -749,7 +749,7 @@ async fn indexed_no_match_for_none_when_all_have_values() { another: 2, exchange: 2, }; - table.insert(row2).unwrap(); + table.insert(row2).await.unwrap(); // Search for None when all rows have Some values let result = table.select_by_test(None).execute().unwrap(); diff --git a/tests/worktable/partitioned.rs b/tests/worktable/partitioned.rs index ed312e17..56df81c2 100644 --- a/tests/worktable/partitioned.rs +++ b/tests/worktable/partitioned.rs @@ -36,16 +36,16 @@ fn row(exchange_id: u8, bid: f64) -> PriceRow { } } -#[test] -fn partitions_are_independent_tables() { +#[tokio::test] +async fn partitions_are_independent_tables() { let prices = PricePartitions::new(); assert!(prices.is_empty()); assert!(prices.partition(7).is_none(), "reading must not create"); let btc = prices.partition_or_create(7).unwrap(); let eth = prices.partition_or_create(9).unwrap(); - btc.insert(row(1, 100.0)).unwrap(); - eth.insert(row(1, 200.0)).unwrap(); + btc.insert(row(1, 100.0)).await.unwrap(); + eth.insert(row(1, 200.0)).await.unwrap(); // The same primary key in two partitions is two different rows. This is // the semantic change partitioning makes, so it is asserted rather than @@ -56,35 +56,35 @@ fn partitions_are_independent_tables() { assert_eq!(prices.keys(), vec![7u16, 9]); } -#[test] -fn a_key_maps_to_one_table_however_often_it_is_asked_for() { +#[tokio::test] +async fn a_key_maps_to_one_table_however_often_it_is_asked_for() { let prices = PricePartitions::new(); let a = prices.partition_or_create(3).unwrap(); - a.insert(row(0, 1.0)).unwrap(); + a.insert(row(0, 1.0)).await.unwrap(); let b = prices.partition_or_create(3).unwrap(); // Same table, so the row inserted through `a` is visible through `b`. assert_eq!(b.select(0).unwrap().bid, 1.0); assert_eq!(prices.len(), 1); } -#[test] -fn keys_are_typed_and_span_chunk_boundaries() { +#[tokio::test] +async fn keys_are_typed_and_span_chunk_boundaries() { let prices = PricePartitions::new(); for k in [5000u16, 0, 1024, 1023, 2048] { prices.partition_or_create(k).unwrap(); } assert_eq!(prices.keys(), vec![0u16, 1023, 1024, 2048, 5000]); for (k, table) in prices.iter() { - table.insert(row(0, k as f64)).unwrap(); + table.insert(row(0, k as f64)).await.unwrap(); assert_eq!(table.select(0).unwrap().bid, k as f64); } } -#[test] -fn removing_a_partition_leaves_held_handles_alive() { +#[tokio::test] +async fn removing_a_partition_leaves_held_handles_alive() { let prices = PricePartitions::new(); let held = prices.partition_or_create(4).unwrap(); - held.insert(row(2, 9.0)).unwrap(); + held.insert(row(2, 9.0)).await.unwrap(); assert!(prices.remove(4).is_some()); assert_eq!(prices.len(), 0); @@ -94,14 +94,14 @@ fn removing_a_partition_leaves_held_handles_alive() { assert!(prices.remove(4).is_none()); } -#[test] -fn insert_with_a_custom_initialiser_runs_once_per_key() { +#[tokio::test] +async fn insert_with_a_custom_initialiser_runs_once_per_key() { let prices = PricePartitions::new(); let seeded = prices .partition_or_insert_with(11, || { let t = PriceWorkTable::default(); for e in 0..3u8 { - t.insert(row(e, e as f64)).unwrap(); + futures::executor::block_on(t.insert(row(e, e as f64))).unwrap(); } t }) @@ -125,12 +125,14 @@ async fn partitioning_composes_with_indexes_and_queries() { tag: 1, px: 10.0, }) + .await .unwrap(); b.insert(QuoteRow { id: b.get_next_pk().0, tag: 1, px: 20.0, }) + .await .unwrap(); // `tag` is a unique index, and tag 1 exists in both partitions, because @@ -144,14 +146,14 @@ async fn partitioning_composes_with_indexes_and_queries() { assert_eq!(from_a.id, from_b.id); } -#[test] -fn memory_and_rows_are_reported_per_key() { +#[tokio::test] +async fn memory_and_rows_are_reported_per_key() { let prices = PricePartitions::new(); assert_eq!(prices.memory_total(), 0); for k in [1u16, 2, 3] { let t = prices.partition_or_create(k).unwrap(); for e in 0..(k as u8) { - t.insert(row(e, 1.0)).unwrap(); + t.insert(row(e, 1.0)).await.unwrap(); } } let by_key = prices.memory_by_key(); @@ -175,7 +177,7 @@ fn concurrent_creation_and_reading_is_sound() { let table = prices.partition_or_create(k).unwrap(); // Every thread writes the same row for a key, so whichever // wins the insert the value must match the key. - let _ = table.insert(row(0, k as f64)); + let _ = futures::executor::block_on(table.insert(row(0, k as f64))); let got = prices.partition(k).unwrap().select(0).unwrap(); assert_eq!(got.bid, k as f64, "thread {t} saw a torn partition at {k}"); } @@ -198,8 +200,8 @@ async fn updates_are_scoped_to_one_partition() { let prices = PricePartitions::new(); let a = prices.partition_or_create(1).unwrap(); let b = prices.partition_or_create(2).unwrap(); - a.insert(row(7, 100.0)).unwrap(); - b.insert(row(7, 200.0)).unwrap(); + a.insert(row(7, 100.0)).await.unwrap(); + b.insert(row(7, 200.0)).await.unwrap(); a.update(PriceRow { exchange_id: 7, @@ -222,8 +224,8 @@ async fn deletes_are_scoped_to_one_partition() { let prices = PricePartitions::new(); let a = prices.partition_or_create(1).unwrap(); let b = prices.partition_or_create(2).unwrap(); - let pk = a.insert(row(7, 100.0)).unwrap(); - b.insert(row(7, 200.0)).unwrap(); + let pk = a.insert(row(7, 100.0)).await.unwrap(); + b.insert(row(7, 200.0)).await.unwrap(); a.delete(pk).await.unwrap(); @@ -247,6 +249,7 @@ async fn a_unique_index_collides_only_inside_its_own_partition() { tag: 42, px: 1.0, }) + .await .unwrap(); // The same tag in a sibling partition is fine. b.insert(QuoteRow { @@ -254,21 +257,24 @@ async fn a_unique_index_collides_only_inside_its_own_partition() { tag: 42, px: 2.0, }) + .await .unwrap(); // The same tag again in the same partition is not. - let dup = a.insert(QuoteRow { - id: a.get_next_pk().0, - tag: 42, - px: 3.0, - }); + let dup = a + .insert(QuoteRow { + id: a.get_next_pk().0, + tag: 42, + px: 3.0, + }) + .await; assert!(dup.is_err(), "a unique index must still be unique within its partition"); assert_eq!(a.select_by_tag(42).unwrap().px, 1.0); assert_eq!(b.select_by_tag(42).unwrap().px, 2.0); } -#[test] -fn autoincrement_counts_independently_in_each_partition() { +#[tokio::test] +async fn autoincrement_counts_independently_in_each_partition() { let quotes = QuotePartitions::new(); let a = quotes.partition_or_create(1).unwrap(); let b = quotes.partition_or_create(2).unwrap(); @@ -279,6 +285,7 @@ fn autoincrement_counts_independently_in_each_partition() { tag: i, px: 1.0, }) + .await .unwrap(); } // `b` has had no inserts, so its counter has not moved. @@ -288,6 +295,7 @@ fn autoincrement_counts_independently_in_each_partition() { tag: 100, px: 2.0, }) + .await .unwrap(); assert_eq!( @@ -302,11 +310,11 @@ fn autoincrement_counts_independently_in_each_partition() { // Reclamation through the generated facade. // --------------------------------------------------------------------------- -#[test] -fn a_removed_partition_waits_out_its_readers_then_frees_through_a_shared_handle() { +#[tokio::test] +async fn a_removed_partition_waits_out_its_readers_then_frees_through_a_shared_handle() { let prices = PricePartitions::new(); let held = prices.partition_or_create(4).unwrap(); - held.insert(row(2, 9.0)).unwrap(); + held.insert(row(2, 9.0)).await.unwrap(); // A pinned borrow models the reader that resolved the partition before // the removal; its grace period keeps the removal retired. @@ -405,8 +413,8 @@ fn the_full_range_of_a_u16_key_is_routable() { // unit tests' trivial payload. // --------------------------------------------------------------------------- -#[test] -fn concurrent_writers_on_disjoint_partitions_do_not_interfere() { +#[tokio::test] +async fn concurrent_writers_on_disjoint_partitions_do_not_interfere() { use std::sync::Arc; const THREADS: u16 = 8; const ROWS: u8 = 32; @@ -418,7 +426,7 @@ fn concurrent_writers_on_disjoint_partitions_do_not_interfere() { std::thread::spawn(move || { let table = prices.partition_or_create(t).unwrap(); for e in 0..ROWS { - table.insert(row(e, t as f64 * 1000.0 + e as f64)).unwrap(); + futures::executor::block_on(table.insert(row(e, t as f64 * 1000.0 + e as f64))).unwrap(); } }) }) @@ -444,8 +452,8 @@ fn concurrent_writers_on_disjoint_partitions_do_not_interfere() { ); } -#[test] -fn readers_survive_partitions_being_removed_under_them() { +#[tokio::test] +async fn readers_survive_partitions_being_removed_under_them() { use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; const KEYS: u16 = 24; @@ -482,7 +490,7 @@ fn readers_survive_partitions_being_removed_under_them() { let t = prices .partition_or_insert_with(k, || { let t = PriceWorkTable::default(); - t.insert(row(0, k as f64)).unwrap(); + futures::executor::block_on(t.insert(row(0, k as f64))).unwrap(); t }) .unwrap(); @@ -518,13 +526,13 @@ fn readers_survive_partitions_being_removed_under_them() { // residency budget. // --------------------------------------------------------------------------- -#[test] -fn retired_bytes_accounts_for_what_removal_has_not_freed_yet() { +#[tokio::test] +async fn retired_bytes_accounts_for_what_removal_has_not_freed_yet() { let prices = PricePartitions::new(); for k in 0..4u16 { let t = prices.partition_or_create(k).unwrap(); for e in 0..(k as u8 + 1) { - t.insert(row(e, 1.0)).unwrap(); + t.insert(row(e, 1.0)).await.unwrap(); } } let live_before = prices.memory_total(); @@ -565,13 +573,13 @@ fn retired_bytes_accounts_for_what_removal_has_not_freed_yet() { assert_eq!(prices.retired_len(), 0); } -#[test] -fn metrics_agree_with_each_other() { +#[tokio::test] +async fn metrics_agree_with_each_other() { let prices = PricePartitions::new(); for k in [1u16, 7, 2048] { let t = prices.partition_or_create(k).unwrap(); for e in 0..(k as u8 % 5 + 1) { - t.insert(row(e, 1.0)).unwrap(); + t.insert(row(e, 1.0)).await.unwrap(); } } @@ -599,12 +607,12 @@ fn metrics_agree_with_each_other() { // assert the batch form behaves identically to the per-call one. // --------------------------------------------------------------------------- -#[test] -fn a_pinned_scope_sees_what_partition_ref_sees() { +#[tokio::test] +async fn a_pinned_scope_sees_what_partition_ref_sees() { let prices = PricePartitions::new(); for k in [0u16, 7, 1023, 1024, 5000] { let t = prices.partition_or_create(k).unwrap(); - t.insert(row(1, k as f64)).unwrap(); + t.insert(row(1, k as f64)).await.unwrap(); } let pinned = prices.pinned(); @@ -622,11 +630,11 @@ fn a_pinned_scope_sees_what_partition_ref_sees() { assert!(pinned.get(u16::MAX).is_none()); } -#[test] -fn a_borrow_taken_in_a_pinned_scope_survives_removal() { +#[tokio::test] +async fn a_borrow_taken_in_a_pinned_scope_survives_removal() { let prices = PricePartitions::new(); let created = prices.partition_or_create(3).unwrap(); - created.insert(row(1, 30.0)).unwrap(); + created.insert(row(1, 30.0)).await.unwrap(); drop(created); let pinned = prices.pinned(); @@ -656,15 +664,15 @@ fn a_borrow_taken_in_a_pinned_scope_survives_removal() { assert!(prices.partition(3).is_none()); } -#[test] -fn pinned_scopes_work_from_several_threads_at_once() { +#[tokio::test] +async fn pinned_scopes_work_from_several_threads_at_once() { use std::sync::Arc; const KEYS: u16 = 64; let prices = Arc::new(PricePartitions::new()); for k in 0..KEYS { let t = prices.partition_or_create(k).unwrap(); - t.insert(row(1, k as f64)).unwrap(); + t.insert(row(1, k as f64)).await.unwrap(); } let readers: Vec<_> = (0..4) diff --git a/tests/worktable/reinsert_visibility.rs b/tests/worktable/reinsert_visibility.rs index 95fb1c03..9da32deb 100644 --- a/tests/worktable/reinsert_visibility.rs +++ b/tests/worktable/reinsert_visibility.rs @@ -38,8 +38,8 @@ async fn failed_reinsert_never_exposes_uncommitted_values() { val: 2, payload: "occupant".to_string(), }; - table.insert(row_a.clone()).unwrap(); - table.insert(row_b.clone()).unwrap(); + table.insert(row_a.clone()).await.unwrap(); + table.insert(row_b.clone()).await.unwrap(); let stop = Arc::new(AtomicBool::new(false)); let reader = { diff --git a/tests/worktable/schema_const.rs b/tests/worktable/schema_const.rs new file mode 100644 index 00000000..243cbf6a --- /dev/null +++ b/tests/worktable/schema_const.rs @@ -0,0 +1,44 @@ +//! Is the embedded schema const actually usable, or just emitted? +//! +//! Each generated table carries its own declaration as text. The point is that +//! a migration tool or designer can read what a compiled binary was built +//! from, without the source. That only works if the text parses back into the +//! schema it came from. + +use worktable::prelude::*; +use worktable::worktable; + +worktable!( + name: Embedded, + persist: false, + columns: { + id: u64 primary_key autoincrement, + owner: u64, + label: String, + }, + indexes: { + owner_idx: owner, + label_idx: label unique, + }, +); + +#[test] +fn the_embedded_declaration_parses_back_into_the_same_schema() { + // The const the macro baked in. + let text: &str = EMBEDDED_SCHEMA; + assert!(!text.is_empty(), "the table carries no declaration"); + + let parsed = worktable::worktable_dsl::Schema::parse(text).expect("the embedded text must parse"); + + assert_eq!(parsed.name, "Embedded"); + let cols: Vec<&str> = parsed.columns.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(cols, ["id", "owner", "label"], "columns lost in the round trip"); + + let idx: Vec<(&str, bool)> = parsed.indexes.iter().map(|i| (i.name.as_str(), i.unique)).collect(); + assert_eq!(idx, [("owner_idx", false), ("label_idx", true)], "indexes lost"); + + // And it survives a second round trip, which is what a migration tool does + // when it compares a checkout against what a binary was built from. + let again = worktable::worktable_dsl::Schema::parse(&parsed.to_dsl()).expect("re-emitted text must parse"); + assert_eq!(again, parsed, "schema is not stable across emit/parse"); +} diff --git a/tests/worktable/tuple_primary_key.rs b/tests/worktable/tuple_primary_key.rs index 36150c16..7c9eec39 100644 --- a/tests/worktable/tuple_primary_key.rs +++ b/tests/worktable/tuple_primary_key.rs @@ -18,7 +18,7 @@ async fn insert() { test: 1, another: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, row); diff --git a/tests/worktable/unique_fixed_unsized.rs b/tests/worktable/unique_fixed_unsized.rs index 9d687583..e81f0c85 100644 --- a/tests/worktable/unique_fixed_unsized.rs +++ b/tests/worktable/unique_fixed_unsized.rs @@ -33,6 +33,7 @@ async fn unique_keyed_fixed_size_update_on_unsized_row_works() { amount: 0, note: "unsized part".to_string(), }) + .await .unwrap(); table diff --git a/tests/worktable/unsized_.rs b/tests/worktable/unsized_.rs index a1b8916e..f09186bc 100644 --- a/tests/worktable/unsized_.rs +++ b/tests/worktable/unsized_.rs @@ -41,7 +41,7 @@ async fn test_update_string_full_row() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); table @@ -65,6 +65,10 @@ async fn test_update_string_full_row() { exchange: "bigger test to test string update".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); assert_eq!(table.0.data.get_empty_links().first().unwrap(), &first_link) } @@ -77,7 +81,7 @@ async fn test_update_string_by_unique() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row = ExchangeByTestQuery { @@ -96,6 +100,10 @@ async fn test_update_string_by_unique() { exchange: "bigger test to test string update".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); assert_eq!(table.0.data.get_empty_links().first().unwrap(), &first_link) } @@ -108,7 +116,7 @@ async fn test_update_string_by_pk() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row = ExchangeByIdQuery { @@ -127,6 +135,10 @@ async fn test_update_string_by_pk() { exchange: "bigger test to test string update".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); assert_eq!(table.0.data.get_empty_links().first().unwrap(), &first_link) } @@ -139,7 +151,7 @@ async fn test_update_string_by_non_unique() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row1.clone()).unwrap(); + let pk = table.insert(row1.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row2 = TestRow { id: table.get_next_pk().into(), @@ -147,7 +159,7 @@ async fn test_update_string_by_non_unique() { another: 1, exchange: "test".to_string(), }; - let pk = table.insert(row2.clone()).unwrap(); + let pk = table.insert(row2.clone()).await.unwrap(); let second_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row = ExchangeByAbotherQuery { @@ -176,6 +188,10 @@ async fn test_update_string_by_non_unique() { exchange: "bigger test to test string update".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); let empty_links = table.0.data.get_empty_links(); assert_eq!(empty_links.len(), 1); let l = Link { @@ -196,7 +212,7 @@ async fn update_many_times() { another: 1, exchange: format!("test_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let mut i_state = HashMap::new(); for _ in 0..1000 { @@ -236,7 +252,7 @@ async fn update_parallel() { another: 1, exchange: format!("test_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let shared_i_state = i_state.clone(); @@ -333,7 +349,7 @@ async fn test_update_many_strings_by_unique() { some_string: "some".to_string(), other_srting: "other".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row = ExchangeAndSomeByTestQuery { @@ -355,6 +371,10 @@ async fn test_update_many_strings_by_unique() { other_srting: "other".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); assert_eq!(table.0.data.get_empty_links().first().unwrap(), &first_link) } @@ -369,7 +389,7 @@ async fn test_update_many_strings_by_pk() { some_string: "some".to_string(), other_srting: "other".to_string(), }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row = ExchangeAndSomeByIdQuery { @@ -391,6 +411,10 @@ async fn test_update_many_strings_by_pk() { other_srting: "other".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); assert_eq!(table.0.data.get_empty_links().first().unwrap(), &first_link) } @@ -405,7 +429,7 @@ async fn test_update_many_strings_by_non_unique() { some_string: "some".to_string(), other_srting: "other".to_string(), }; - let pk = table.insert(row1.clone()).unwrap(); + let pk = table.insert(row1.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row2 = TestMoreStringsRow { id: table.get_next_pk().into(), @@ -415,7 +439,7 @@ async fn test_update_many_strings_by_non_unique() { some_string: "some".to_string(), other_srting: "other".to_string(), }; - let pk = table.insert(row2.clone()).unwrap(); + let pk = table.insert(row2.clone()).await.unwrap(); let second_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row = ExchangeAndSomeByAnotherQuery { @@ -449,6 +473,10 @@ async fn test_update_many_strings_by_non_unique() { other_srting: "other".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); let empty_links = table.0.data.get_empty_links(); assert_eq!(empty_links.len(), 1); let l = Link { @@ -470,7 +498,7 @@ async fn test_update_many_strings_by_string() { some_string: "something".to_string(), other_srting: "other er".to_string(), }; - let pk = table.insert(row1.clone()).unwrap(); + let pk = table.insert(row1.clone()).await.unwrap(); let first_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row2 = TestMoreStringsRow { id: table.get_next_pk().into(), @@ -480,7 +508,7 @@ async fn test_update_many_strings_by_string() { some_string: "some ome".to_string(), other_srting: "other".to_string(), }; - let pk = table.insert(row2.clone()).unwrap(); + let pk = table.insert(row2.clone()).await.unwrap(); let second_link = table.0.primary_index.pk_map.get_value(&pk).unwrap(); let row = SomeOtherByExchangeQuery { @@ -517,6 +545,10 @@ async fn test_update_many_strings_by_string() { exchange: "test".to_string(), } ); + // Reclamation is deferred: the row's old storage is queued when the + // update relocates it, and swept by whoever next needs space. Ask for the + // sweep so the free list can be observed directly. + table.0.data.reclaim_pending(); let empty_links = table.0.data.get_empty_links(); assert_eq!(empty_links.len(), 1); let l = Link { @@ -541,7 +573,7 @@ async fn update_parallel_more_strings() { some_string: format!("some_{i}"), other_srting: format!("other_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let shared_e_state = e_state.clone(); @@ -614,7 +646,7 @@ async fn update_parallel_more_strings_more_threads() { some_string: format!("some_{i}"), other_srting: format!("other_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let shared_e_state = e_state.clone(); @@ -709,7 +741,7 @@ async fn update_parallel_more_strings_with_select_non_unique() { some_string: format!("some_{s_val}"), other_srting: format!("other_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let shared_e_state = e_state.clone(); @@ -786,7 +818,7 @@ async fn delete_parallel() { some_string: format!("some_{s_val}"), other_srting: format!("other_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let h1 = tokio::spawn(async move { @@ -801,7 +833,7 @@ async fn delete_parallel() { some_string: format!("some_{s_val}"), other_srting: format!("other_{i}"), }; - let _ = shared.insert(row.clone()).unwrap(); + let _ = shared.insert(row.clone()).await.unwrap(); } }); let shared = table.clone(); @@ -848,7 +880,7 @@ async fn update_parallel_more_strings_with_select_unique() { some_string: format!("some_{s_val}"), other_srting: format!("other_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let shared_e_state = e_state.clone(); @@ -919,7 +951,7 @@ async fn upsert_parallel() { some_string: format!("some_{s_val}"), other_srting: format!("other_{i}"), }; - let _ = table.insert(row.clone()).unwrap(); + let _ = table.insert(row.clone()).await.unwrap(); } let shared = table.clone(); let shared_e_state = e_state.clone(); diff --git a/tests/worktable/update_delete_race.rs b/tests/worktable/update_delete_race.rs index a7d4ee0b..e34afbfd 100644 --- a/tests/worktable/update_delete_race.rs +++ b/tests/worktable/update_delete_race.rs @@ -31,6 +31,7 @@ async fn concurrent_update_and_delete_never_panics() { name: "seed".to_string(), value: 0, }) + .await .unwrap(); let updater = { @@ -64,11 +65,13 @@ async fn concurrent_update_and_delete_never_panics() { Err(WorkTableError::NotFound) => {} Err(e) => panic!("unexpected delete error: {e:?}"), } - let _ = table.insert(UpdateDeleteRaceRow { - id: 1, - name: "restored".to_string(), - value: 0, - }); + let _ = table + .insert(UpdateDeleteRaceRow { + id: 1, + name: "restored".to_string(), + value: 0, + }) + .await; tokio::task::yield_now().await; } }) diff --git a/tests/worktable/update_in_place_unsized.rs b/tests/worktable/update_in_place_unsized.rs index 54601776..7b1353d7 100644 --- a/tests/worktable/update_in_place_unsized.rs +++ b/tests/worktable/update_in_place_unsized.rs @@ -76,7 +76,7 @@ macro_rules! unsized_in_place_suite { payload: "abcdefgh".to_string(), // 8 bytes balance: 1.0, }) - .unwrap(); + .await.unwrap(); let before = link_of(&table, 1); @@ -110,7 +110,7 @@ macro_rules! unsized_in_place_suite { payload: "abcdefghij".to_string(), balance: 1.0, }) - .unwrap(); + .await.unwrap(); table .update_payload( @@ -152,7 +152,7 @@ macro_rules! unsized_in_place_suite { payload: "0000".to_string(), balance: 1.0, }) - .unwrap(); + .await.unwrap(); let writer = { let table = table.clone(); @@ -216,7 +216,7 @@ macro_rules! unsized_in_place_suite { payload: "out-of-line payload that must remain unchanged".to_string(), balance: 1.0, }) - .unwrap(); + .await.unwrap(); let before = link_of(&table, 1); table diff --git a/tests/worktable/upsert.rs b/tests/worktable/upsert.rs index c189856e..6011fe9d 100644 --- a/tests/worktable/upsert.rs +++ b/tests/worktable/upsert.rs @@ -54,7 +54,7 @@ async fn raw_insert_delete_churn_never_panics_or_stalls() { let mut delete_successes = 0; let mut delete_misses = 0; for i in 0..5_000u64 { - match table.insert(UpsertChurnRow { id: KEY, val: i }) { + match table.insert(UpsertChurnRow { id: KEY, val: i }).await { Ok(_) => insert_successes += 1, Err(WorkTableError::PrimaryAlreadyExists) => insert_conflicts += 1, Err(error) => panic!("raw insert returned an unexpected error: {error:?}"), diff --git a/tests/worktable/upsert_guard.rs b/tests/worktable/upsert_guard.rs new file mode 100644 index 00000000..6eb99188 --- /dev/null +++ b/tests/worktable/upsert_guard.rs @@ -0,0 +1,88 @@ +//! `upsert` must not release the row it is deciding about. +//! +//! When `upsert` finds the key absent it wants to insert without letting go of +//! the full row lock it just took. It could not: `insert` re-acquires the same +//! per-key mutation gate that lock's guard already holds, so holding on would +//! deadlock against itself. So it dropped the guard, inserted, and retried +//! with backoff if another writer won the gap. +//! +//! `insert_locked` is that same insert without the acquisition, which lets the +//! decision and the write be one critical section. +//! +//! This is also the guard for making `insert` async. Async does not remove the +//! self-deadlock, it only makes the window worse: the drop-to-insert gap would +//! then contain an await, so a task can be descheduled inside it and a gap of +//! a few hundred nanoseconds of straight-line code becomes a scheduling +//! quantum. If a future change routes this path back through a gate-acquiring +//! insert, this test hangs and then fails on the timeout. + +use std::sync::Arc; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use worktable::prelude::*; +use worktable::worktable; + +worktable!( + name: UpsertGuard, + columns: { id: u64 primary_key, v: u64 }, +); + +/// Inserting under a gate the caller already holds must complete. +/// +/// Red before `insert_locked` existed: the only way to insert was `insert`, +/// which takes the gate again and blocks forever against the guard held here. +#[test] +fn inserting_under_a_held_mutation_gate_completes() { + let table = Arc::new(UpsertGuardWorkTable::default()); + let pk: UpsertGuardPrimaryKey = 1u64.into(); + + // Exactly what `upsert` holds when it discovers the key is absent. + let _gate = table.0.lock_manager.mutation_guard(&pk); + + // Detached, not scoped. A scope joins its threads, so when the insert + // deadlocks the test would hang at the end of the scope instead of + // failing on the timeout, and a hanging test reports nothing. + let (tx, rx) = mpsc::channel(); + let worker = Arc::clone(&table); + thread::spawn(move || { + let outcome = worker.0.insert_locked(UpsertGuardRow { id: 1, v: 7 }); + let _ = tx.send(outcome.is_ok()); + }); + + match rx.recv_timeout(Duration::from_secs(5)) { + Ok(inserted) => assert!(inserted, "the insert itself should succeed"), + Err(_) => panic!( + "inserting under a held mutation gate did not finish: the insert path re-acquired the \ + gate its caller already holds and deadlocked. `upsert` cannot hold its row lock \ + across the insert while that is true, which is the window it used to drop the guard \ + for." + ), + } + + assert_eq!(table.select(1).expect("row present").v, 7); +} + +/// And the ordinary path still takes the gate, so a caller that is not already +/// holding one is still serialised. +#[tokio::test] +async fn upsert_still_serialises_concurrent_writers() { + use std::sync::Arc; + let table = Arc::new(UpsertGuardWorkTable::default()); + + let mut handles = Vec::new(); + for v in 0..32u64 { + let table = table.clone(); + handles.push(tokio::spawn( + async move { table.upsert(UpsertGuardRow { id: 1, v }).await }, + )); + } + for h in handles { + h.await.expect("task").expect("upsert"); + } + + // Exactly one row, whichever writer landed last. + assert_eq!(table.count(), 1); + assert!(table.select(1).is_some()); +} diff --git a/tests/worktable/uuid.rs b/tests/worktable/uuid.rs index 1e8ab427..e29cb5c9 100644 --- a/tests/worktable/uuid.rs +++ b/tests/worktable/uuid.rs @@ -17,7 +17,7 @@ async fn insert() { id: Uuid::new_v4(), another: 1, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, row); diff --git a/tests/worktable/vacuum.rs b/tests/worktable/vacuum.rs index ea314f46..1bdea8c3 100644 --- a/tests/worktable/vacuum.rs +++ b/tests/worktable/vacuum.rs @@ -27,7 +27,11 @@ worktable!( #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn vacuum_parallel_with_selects() { let config = VacuumManagerConfig { - check_interval: Duration::from_millis(5), + // Wake on any freed byte. The reactive equivalent of "run vacuum + // eagerly": there is no interval to turn down any more, because a + // short one made the fallback timer win every wake and neither the + // threshold nor the settle did anything. + wake_threshold_bytes: 1, ..Default::default() }; let vacuum_manager = Arc::new(VacuumManager::with_config(config)); @@ -42,7 +46,7 @@ async fn vacuum_parallel_with_selects() { data: format!("test_data_{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.push((id, row)); } let rows = Arc::new(rows); @@ -77,7 +81,11 @@ async fn vacuum_parallel_with_selects() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn vacuum_parallel_with_inserts() { let config = VacuumManagerConfig { - check_interval: Duration::from_millis(5), + // Wake on any freed byte. The reactive equivalent of "run vacuum + // eagerly": there is no interval to turn down any more, because a + // short one made the fallback timer win every wake and neither the + // threshold nor the settle did anything. + wake_threshold_bytes: 1, ..Default::default() }; let vacuum_manager = Arc::new(VacuumManager::with_config(config)); @@ -92,7 +100,7 @@ async fn vacuum_parallel_with_inserts() { data: format!("test_data_{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.push((id, row)); } let rows = Arc::new(rows); @@ -118,7 +126,7 @@ async fn vacuum_parallel_with_inserts() { data: format!("test_data_{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); inserted_rows.push((id, row)); } @@ -145,7 +153,11 @@ async fn vacuum_parallel_with_inserts() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn vacuum_parallel_with_upserts() { let config = VacuumManagerConfig { - check_interval: Duration::from_millis(5), + // Wake on any freed byte. The reactive equivalent of "run vacuum + // eagerly": there is no interval to turn down any more, because a + // short one made the fallback timer win every wake and neither the + // threshold nor the settle did anything. + wake_threshold_bytes: 1, ..Default::default() }; let vacuum_manager = Arc::new(VacuumManager::with_config(config)); @@ -160,7 +172,7 @@ async fn vacuum_parallel_with_upserts() { data: format!("test_data_{}", i), }; let id = row.id; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); rows.push((id, row)); } let rows = Arc::new(rows); @@ -208,6 +220,31 @@ async fn vacuum_parallel_with_upserts() { delete_task.await.unwrap(); + // Every secondary entry must resolve to a row carrying its key. + // + // Checked before the per-row comparison below, because it fails closer to + // the cause. The per-row check reports "expected id 1501, got id 63", + // which is the damage; this reports which index entry points at storage + // holding something else, which is the defect. + { + let stale: Vec<_> = table + .0 + .indexes + .value_idx + .iter() + .filter_map(|(key, link)| { + let at = table.0.data.select_non_ghosted(link.into()); + match at { + Ok(row) if row.value == key => None, + Ok(row) => Some(format!("value_idx[{key}] -> link holding value {} (id {})", row.value, row.id)), + Err(e) => Some(format!("value_idx[{key}] -> unreadable link: {e:?}")), + } + }) + .take(5) + .collect(); + assert!(stale.is_empty(), "secondary index entries do not match their rows:\n{}", stale.join("\n")); + } + let g = row_state.lock(); // Verify all inserted rows are accessible @@ -226,7 +263,8 @@ async fn vacuum_loop_test() { const SOAK_DURATION: Duration = Duration::from_secs(10); let config = VacuumManagerConfig { - check_interval: Duration::from_millis(1_000), + // No interval any more; wake on any freed byte instead. + wake_threshold_bytes: 1, ..Default::default() }; let vacuum_manager = Arc::new(VacuumManager::with_config(config)); @@ -239,7 +277,7 @@ async fn vacuum_loop_test() { value: chrono::Utc::now().timestamp_nanos_opt().unwrap(), data: format!("test_data_{}", i), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); } let vacuum = table.vacuum(); @@ -256,7 +294,7 @@ async fn vacuum_loop_test() { value: chrono::Utc::now().timestamp_nanos_opt().unwrap(), data: format!("test_data_{}", i), }; - insert_table.insert(row.clone()).unwrap(); + insert_table.insert(row.clone()).await.unwrap(); tokio::time::sleep(Duration::from_micros(500)).await; i += 1; } diff --git a/tests/worktable/vacuum_invariants.rs b/tests/worktable/vacuum_invariants.rs new file mode 100644 index 00000000..c80bd584 --- /dev/null +++ b/tests/worktable/vacuum_invariants.rs @@ -0,0 +1,237 @@ +//! The invariants vacuum must not break, checked under concurrent mutation. +//! +//! Four defects were fixed in reclamation this week and written out they are +//! one defect: an index entry resolving to storage that now holds a different +//! row. A page and its inner links handed to two allocators; `value_idx[792]` +//! returning a row holding `2703`; `upsert` returning `PrimaryUpdateTry`; a +//! page reclaimed with a live row still on it. Each was found by a test that +//! happened to notice the damage downstream, and each needed its own +//! investigation to get back to the cause. +//! +//! This asserts the property instead of the symptom, so the next one in that +//! class fails here rather than as a wrong row somewhere far away: +//! +//! 1. every primary entry resolves to a row carrying that key +//! 2. every secondary entry resolves to a row carrying that key +//! 3. the forward and reverse primary indexes agree +//! 4. no two primary keys name the same storage +//! +//! Run across all three primary-index backends, because they do not have the +//! same shape: arctic boxes every value, congee holds no non-unique index, and +//! WTI is the default. A reclamation bug that only reproduces on one of them +//! is the kind that ships. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use worktable::prelude::*; +use worktable::vacuum::{VacuumManager, VacuumManagerConfig}; +use worktable::worktable; + +/// Rows the storm starts from. +const SEED_ROWS: u64 = 1_500; +/// How long the mutation storm runs. +/// +/// Duration rather than a count, because what matters is how many vacuum +/// passes the mutations overlap. A fixed count finished in 200ms against a 5ms +/// check interval, which is far too few passes to catch a reclamation race: +/// the first version of this test passed with the page-reclaim fix reverted. +const STORM: Duration = Duration::from_millis(1_500); + +macro_rules! vacuum_invariant_suite { + ($module:ident, $backend:ident) => { + mod $module { + use super::*; + + worktable!( + name: VacInv, + persist: false, + columns: { + id: u64 primary_key autoincrement using $backend, + uniq: u64, + tag: u32, + payload: String + }, + indexes: { + // The unique secondary follows the primary's backend, so + // each arm exercises one implementation end to end. Only + // parameterising the primary left every arm resolving its + // secondaries through the same map, which is the one that + // measured slowest and is not what a congee or arctic + // table would be doing. + uniq_idx: uniq unique using $backend, + // Non-unique stays general purpose: congee holds no + // non-unique index at all, so this cannot follow. + tag_idx: tag, + } + ); + + fn row(id: u64, uniq: u64) -> VacInvRow { + VacInvRow { + id, + uniq, + tag: (id % 8) as u32, + payload: format!("payload-{id}"), + } + } + + /// Everything the four bugs violated, in one place. + /// + /// Reads through the raw link deliberately: going through `select` + /// would hide exactly the failure being looked for, because it + /// revalidates and retries. + fn assert_indexes_resolve_to_their_own_rows(table: &VacInvWorkTable, phase: &str) { + // Enumerated through the reverse index, which is always the + // general-purpose map whatever the primary backend is. Arctic + // and congee expose no iterator, which is the three-backend + // difference in miniature: a check written against one of them + // does not compile against the others. + let mut seen_links: HashMap = HashMap::new(); + + for (link, pk) in table.0.primary_index.reverse_pk_map.iter() { + let link: Link = link.into(); + let key: u64 = pk.clone().into(); + let row = table + .0 + .data + .select_non_ghosted(link) + .unwrap_or_else(|e| panic!("{phase}: reverse entry {key} points at unreadable storage: {e:?}")); + assert_eq!( + row.id, key, + "{phase}: reverse entry {key} resolves to a row whose id is {}", + row.id + ); + + // Forward and reverse must agree, and must agree on the + // same storage. Vacuum plans from the reverse map, so a + // disagreement is a page drained against a stale picture. + let forward: Option = table + .0 + .primary_index + .pk_map + .get_value(&pk) + .map(Into::into); + assert_eq!( + forward, + Some(link), + "{phase}: key {key} is at {link:?} in the reverse index and {forward:?} in the forward one" + ); + + let packed = (u32::from(link.page_id) as u64) << 32 | link.offset as u64; + if let Some(other) = seen_links.insert(packed, key) { + panic!("{phase}: keys {other} and {key} name the same storage"); + } + } + + // Iterated directly on every backend. Arctic and congee + // exposed no inherent `iter` until this suite needed one, so + // the first version of this check probed by key to stay + // portable. The alias makes the backends interchangeable here, + // which is the point: a check that only reads one of them is + // a check that only guards one of them. + for (uniq, link) in table.0.indexes.uniq_idx.iter() { + let link: Link = link.into(); + let row = table + .0 + .data + .select_non_ghosted(link) + .unwrap_or_else(|e| panic!("{phase}: uniq_idx[{uniq}] points at unreadable storage: {e:?}")); + assert_eq!( + row.uniq, uniq, + "{phase}: uniq_idx[{uniq}] resolves to a row holding {}", + row.uniq + ); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn vacuum_never_leaves_an_index_pointing_at_another_row() { + let manager = Arc::new(VacuumManager::with_config(VacuumManagerConfig { + // Aggressive on purpose: the defects only appear when + // reclamation overlaps mutation. + // No interval any more; wake on any freed byte instead. + wake_threshold_bytes: 1, + ..Default::default() + })); + let table = Arc::new(VacInvWorkTable::default()); + + for id in 0..SEED_ROWS { + table.insert(row(id, 1_000_000 + id)).await.expect("seed"); + } + assert_indexes_resolve_to_their_own_rows(&table, "seed"); + + manager.register(table.vacuum()); + let vacuum_task = Arc::clone(&manager).run_vacuum_task(); + + // Deletes free the space, inserts take it, vacuum reclaims + // underneath. All three are required: with any one missing the + // class does not reproduce. + let deadline = tokio::time::Instant::now() + STORM; + + // Deletes free the space, inserts take it, vacuum reclaims + // underneath. All three are required: with any one missing the + // class does not reproduce. + let deleter = { + let table = Arc::clone(&table); + tokio::spawn(async move { + let mut id = 0u64; + while tokio::time::Instant::now() < deadline { + let _ = table.delete(id % SEED_ROWS).await; + id += 2; + if id % 64 == 0 { + tokio::task::yield_now().await; + } + } + }) + }; + let inserter = { + let table = Arc::clone(&table); + tokio::spawn(async move { + let mut n = 0u64; + while tokio::time::Instant::now() < deadline { + let id = SEED_ROWS + n; + let _ = table.insert(row(id, 1_000_000 + id)).await; + n += 1; + if n % 64 == 0 { + tokio::task::yield_now().await; + } + } + }) + }; + let upserter = { + let table = Arc::clone(&table); + tokio::spawn(async move { + let mut n = 0u64; + while tokio::time::Instant::now() < deadline { + let id = (n * 7) % SEED_ROWS; + // A distinct unique value, so a rejected write is + // never what this measures. + let _ = table.upsert(row(id, 5_000_000 + n)).await; + n += 1; + if n % 64 == 0 { + tokio::task::yield_now().await; + } + } + }) + }; + + deleter.await.expect("deleter"); + inserter.await.expect("inserter"); + upserter.await.expect("upserter"); + + // Let vacuum run once more against the wreckage, then stop it + // so the check reads a still table. + tokio::time::sleep(Duration::from_millis(60)).await; + vacuum_task.abort(); + tokio::time::sleep(Duration::from_millis(20)).await; + + assert_indexes_resolve_to_their_own_rows(&table, "after churn"); + } + } + }; +} + +vacuum_invariant_suite!(wti, worktables_index); +vacuum_invariant_suite!(arctic, arctic); +vacuum_invariant_suite!(congee, congee); diff --git a/tests/worktable/vacuum_no_row_loss.rs b/tests/worktable/vacuum_no_row_loss.rs index 6eaa57c3..8b9bf9e4 100644 --- a/tests/worktable/vacuum_no_row_loss.rs +++ b/tests/worktable/vacuum_no_row_loss.rs @@ -40,7 +40,11 @@ worktable!( #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn vacuum_never_loses_surviving_rows() { let config = VacuumManagerConfig { - check_interval: Duration::from_millis(5), + // Wake on any freed byte. The reactive equivalent of "run vacuum + // eagerly": there is no interval to turn down any more, because a + // short one made the fallback timer win every wake and neither the + // threshold nor the settle did anything. + wake_threshold_bytes: 1, ..Default::default() }; let vacuum_manager = Arc::new(VacuumManager::with_config(config)); @@ -55,7 +59,7 @@ async fn vacuum_never_loses_surviving_rows() { value: i, data: format!("{i:04}-{}", "d".repeat(4_000)), }; - table.insert(row.clone()).unwrap(); + table.insert(row.clone()).await.unwrap(); all.insert(row.id, row); } diff --git a/tests/worktable/with_enum.rs b/tests/worktable/with_enum.rs index afdad03b..4b1e6175 100644 --- a/tests/worktable/with_enum.rs +++ b/tests/worktable/with_enum.rs @@ -30,7 +30,7 @@ async fn insert() { id: 1, test: SomeEnum::First, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let selected_row = table.select(pk).unwrap(); assert_eq!(selected_row, row); @@ -44,7 +44,7 @@ async fn update() { id: 1, test: SomeEnum::First, }; - let pk = table.insert(row.clone()).unwrap(); + let pk = table.insert(row.clone()).await.unwrap(); let updated = TestRow { id: 1, test: SomeEnum::Second, diff --git a/tests/worktable/wrong_row_update.rs b/tests/worktable/wrong_row_update.rs index d7290170..d29764c1 100644 --- a/tests/worktable/wrong_row_update.rs +++ b/tests/worktable/wrong_row_update.rs @@ -35,6 +35,7 @@ async fn unique_update_does_not_mutate_a_row_that_stole_the_value() { code: 10, value: 0, }) + .await .unwrap(); let pk = WrongRowPrimaryKey(1); @@ -84,6 +85,7 @@ async fn unique_update_does_not_mutate_a_row_that_stole_the_value() { code: 10, value: 0, }) + .await .unwrap(); blocker.unlock(); diff --git a/tests/worktable_version/basic.rs b/tests/worktable_version/basic.rs index 5264efed..500eb159 100644 --- a/tests/worktable_version/basic.rs +++ b/tests/worktable_version/basic.rs @@ -56,6 +56,7 @@ fn test_version_reads_persisted_data() { name: "Alice".to_string(), email: "alice@example.com".to_string(), }) + .await .unwrap(); table @@ -64,6 +65,7 @@ fn test_version_reads_persisted_data() { name: "Bob".to_string(), email: "bob@example.com".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() diff --git a/tests/worktable_version/string_primary_key.rs b/tests/worktable_version/string_primary_key.rs index 60f844de..552eb425 100644 --- a/tests/worktable_version/string_primary_key.rs +++ b/tests/worktable_version/string_primary_key.rs @@ -59,6 +59,7 @@ fn test_version_reads_persisted_data_with_string_primary_key() { title: "Alpha".to_string(), author: "Alice".to_string(), }) + .await .unwrap(); table @@ -67,6 +68,7 @@ fn test_version_reads_persisted_data_with_string_primary_key() { title: "Beta".to_string(), author: "Bob".to_string(), }) + .await .unwrap(); table.wait_for_ops().await.unwrap() diff --git a/wt-review.md b/wt-review.md new file mode 100644 index 00000000..46a23170 --- /dev/null +++ b/wt-review.md @@ -0,0 +1,200 @@ +# WorkTable PRs 70–72: HFT performance review + +Review date: 2026-08-31 +Reviewed range: `752a90b..d60eb6f` +Pull requests: [#70](https://github.com/pathscale/WorkTable/pull/70), [#71](https://github.com/pathscale/WorkTable/pull/71), [#72](https://github.com/pathscale/WorkTable/pull/72) + +## Verdict + +Do not call the reviewed `partition()` API on every tick from multiple threads. The public path is not the sub-nanosecond `Vec` lookup used to justify the design: it performs an `Arc` increment and decrement on every call, turning the strong-count cache line into a same-symbol contention point. + +The feature can be operated safely before a complete fix if all partitions are pre-created, each worker caches its `Arc` instead of routing per tick, the router is treated as append-only, and partition metrics run only on a control-plane thread. Those restrictions need to be explicit because the current API makes all four unsafe usage patterns look normal. + +PR 71's shutdown fix is sound from a performance perspective. Its additional queue poll occurs only during `Closing`, and its `yield_now()` is behind `cfg(test)`. PR 72 changes code generation tests and CI tooling, with no production runtime cost. + +## Findings + +### P1 — `partition()` creates a shared refcount write hotspot + +Reviewed code: [`src/partition/mod.rs:171`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/src/partition/mod.rs#L171-L177), [`codegen/src/generators/partitions.rs:40`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/codegen/src/generators/partitions.rs#L40-L42) + +`partition()` performs: + +1. A bounds check. +2. An acquire load of the chunk pointer. +3. An acquire load of the slot pointer. +4. `Arc::increment_strong_count`. +5. A refcount decrement when the returned `Arc` is dropped. + +Steps 4 and 5 are atomic read-modify-writes to the same strong count for every thread routing to the same symbol. That is exactly the cache-coherence traffic partitioning is meant to remove from the data structures below it. + +A release-mode sanity benchmark against the actual public `PartitionSet` API on the same Apple M4 Max class named in the design notes produced: + +| case | observed result | +| --- | ---: | +| cached `Arc` dereference | 0.37–0.38 ns/op | +| `contains()` (the two routing loads, no refcount) | 0.79–1.06 ns/op | +| `partition()` plus returned `Arc` drop | 3.52–3.69 ns/op | +| one thread, same key | about 284 Mops/s | +| eight threads, same key | about 15–17 Mops/s | +| eight threads, distinct keys | about 160–249 Mops/s | + +The threaded result is the important one: aggregate throughput goes down as same-key readers are added. The benchmark was not core-pinned and does not measure percentiles, so it is directional evidence rather than a production latency claim. It is nevertheless sufficient to reject the documentation's implication that the public call is equivalent to a plain `Vec` lookup. + +Required action: + +- Add a borrowed hot-path API, `partition_ref(&self, key) -> Option<&T>`, whose lifetime is tied to the set. The existing retire discipline makes this sound: reclamation and drop need `&mut self`, so they cannot run while the borrow is live. +- Keep the `Arc` API for handles that must outlive the router borrow or move to another task. +- Add a committed benchmark for the actual generated facade, covering cached-handle, borrowed lookup, `Arc` lookup, and 1/2/4/8 same-key readers. For HFT sign-off, pin threads and collect p50/p99/p99.9 rather than reporting only average throughput. + +The shared worktree currently contains an uncommitted `partition_ref` implementation. A quick release measurement put that path at about 0.74 ns/op, so the direction is promising, but it was not part of PR 72 at the reviewed head and still needs its own contended benchmark and review. + +### P1 — Partition metrics copy table pages and allocate repeatedly + +Reviewed code: [`codegen/src/generators/partitions.rs:108`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/codegen/src/generators/partitions.rs#L108-L145), [`src/table/system_info.rs:64`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/src/table/system_info.rs#L64-L84), [`src/in_memory/pages.rs:769`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/src/in_memory/pages.rs#L769-L775) + +`memory_by_key()` and `rows_by_key()` call `table.system_info()` for every live partition. At the reviewed head, `system_info()` calls `get_bytes()`, which builds a `Vec<([u8; DATA_LENGTH], u32)>` and copies every full page image merely to sum each page's `free_offset`. It also collects the empty-link registry just to read its length. + +The partition wrapper compounds the work: + +- `inner.iter()` allocates a key vector, scans all allocated 1,024-slot chunks, performs another lookup/refcount operation per live key, and collects an `(id, Arc)` vector. +- The generated typed wrapper maps and collects that into another vector. +- `memory_total()` first builds the entire `memory_by_key()` vector and then sums it. +- `rows_by_key()` copies all page images even though it only needs `primary_index.pk_map.len()`. + +At the default 16 KiB page, 500 one-page partitions cause roughly 8 MiB of page copying per call per partitioned table, before vector and index-info allocations. Eight such table sets would stream roughly 64 MiB through cache on each metrics poll. On an HFT process this can evict useful order-book/index data and create latency spikes even when the metrics task runs on another thread. + +Required action: + +- Read `free_offset` counters directly; never materialize page byte arrays for accounting. +- Expose direct lightweight accessors for row count, used bytes, empty-slot count, and index bytes instead of routing `rows_by_key()` through the full `SystemInfo` builder. +- Fold `memory_total()` directly without first allocating `memory_by_key()`. +- Prefer a callback/iterator that visits slots once over `keys() -> partition() -> collect()`. +- Keep metrics off latency-critical cores and set a deliberately low polling frequency. + +The shared worktree currently has an uncommitted `used_bytes()` change that removes the full-page copies. That addresses the largest cost, but `rows_by_key()` still goes through the full `system_info()` path and the partition aggregation still allocates and scans multiple times. + +### P1 — `remove()` retains whole tables indefinitely in the normal shared-router shape + +Reviewed code: [`src/partition/mod.rs:261`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/src/partition/mod.rs#L261-L285), [`codegen/src/generators/partitions.rs:124`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/codegen/src/generators/partitions.rs#L124-L136) + +Every successful `remove()` clones the table `Arc` into `grow`, which doubles as an unbounded retire list. Reclamation requires `gc(&mut self)`. The expected production shape is an `Arc>` shared across threads, where obtaining `&mut self` requires every other router handle to disappear. In a long-lived service, `gc()` is therefore effectively unreachable. + +Delist/relist or evict/recreate cycles retain one complete table every time. The PR's own measurements put a table at roughly 15.7–48.3 KiB in memory and about 110 KiB when persisted. This becomes allocator pressure, larger RSS, more TLB/cache pressure, and eventually latency variance or OOM. `memory_total()` makes the operational risk worse by reporting only live slots; after `remove()` the reported total falls even though the retired table is still resident. + +Required action: + +- Implement reclamation usable under shared ownership (epoch-based reclamation, hazard pointers, or an explicit reader guard/quiescence protocol), or +- Remove/disable runtime `remove()` and document the router as append-only until reclamation exists. +- Report retired bytes, not only `retired_len`, if retirement remains part of the API. + +Adding a warning to `remove()`/`gc()` documentation is useful but does not make eviction real. This is a resource-lifetime design gap, not just a documentation gap. + +### P2 — One global mutex serializes construction of unrelated partitions + +Reviewed code: [`src/partition/mod.rs:224`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/src/partition/mod.rs#L224-L253) + +`get_or_create()` takes the single `grow` mutex before calling the caller-supplied `make()`. Different keys and different chunks therefore cannot initialize concurrently, and `remove()` waits behind any constructor as well. + +The PR's measurements make the tail-latency consequence concrete: + +- About 25.7 µs to construct an in-memory instance. +- About 6.1 ms to construct a persisted instance. + +A burst of new symbols can turn first-touch routing into a serialized queue. A persisted constructor blocks every other create/remove for milliseconds. This is acceptable only if all creation happens before the process begins serving latency-sensitive traffic. + +Required action: + +- Immediate mitigation: pre-create every routable symbol before market open and make `partition_or_create()` forbidden on the tick path. +- Structural fix: use chunk-local or slot-local initialization state. If construction must remain exactly-once because closures can have side effects, use a per-chunk lock/once state rather than constructing outside the only global lock. If duplicate construction is acceptable, construct outside the lock and publish with compare/exchange, dropping the loser. +- Benchmark concurrent creation of distinct keys, including a slow constructor, so head-of-line blocking stays visible. + +### P2 — The memory API under-reports the memory that affects latency + +Reviewed code: [`codegen/src/generators/partitions.rs:104`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/codegen/src/generators/partitions.rs#L104-L122) + +The API describes `memory_by_key()` as “memory held per partition,” but it sums used row bytes and secondary-index heap size. It omits at least: + +- The table's measured fixed floor (about 14.5 KiB irreducible and roughly 30.9 KiB with the default page in the PR's probes). +- Reserved but unused page capacity. +- Router spine/chunks and `Arc` allocation overhead. +- Every table on the retire list. + +This is not just naming. A residency budget or alert based on `memory_total()` can say memory dropped after eviction while RSS only rises. Capacity planning based on it will over-pack the process and increase cache/TLB pressure. + +Required action: + +- Rename the existing value to `used_row_and_index_bytes`, or make it report actual resident/capacity bytes. +- Provide separate used, reserved/capacity, live-table overhead, and retired-byte metrics. +- Test empty, sparse, full, and removed partitions against the semantics promised by each metric. + +### P2 — A panicking initializer permanently disables router mutation + +Reviewed code: [`src/partition/mod.rs:216`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/src/partition/mod.rs#L216-L275) + +At the reviewed head the global lock is `std::sync::Mutex`. Because `make()` runs while it is held, any unwinding panic poisons the mutex. Every later create returns `PartitionError::Poisoned`, while `remove()` converts the same failure to `None` and misleadingly reports the key as absent. In a long-lived market-data process, one bad symbol initializer can prevent every future symbol from being added or removed. + +Required action: + +- Do not run untrusted/complex construction while holding a poisonable global lock. +- If the protected invariants remain valid after unwind, use non-poisoning lock semantics or explicitly recover the inner guard. +- Make removal failures typed; do not collapse lock failure into “not found.” + +The shared worktree currently switches the native build to `parking_lot::Mutex`, which is a reasonable recovery for this specific failure mode. It is not part of reviewed PR 72 head yet. + +### P3 — Maintenance APIs scan and allocate more than their signatures suggest + +Reviewed code: [`src/partition/mod.rs:190`](https://github.com/pathscale/WorkTable/blob/d60eb6f8092b57f68ec09f49235897c5f2532b34/src/partition/mod.rs#L190-L213) + +`keys()` scans every slot in every allocated chunk and allocates a vector. `iter()` calls `keys()`, then looks up each key again, increments every table's refcount, and allocates another vector. The generated typed `iter()` collects a third vector. This is acceptable as a clearly labeled control-plane snapshot, but expensive if used for periodic fan-out or telemetry. + +Required action: + +- Document these calls as O(allocated chunks + live partitions) and allocating. +- Add a visitor or borrowing iterator that scans each slot once. +- Do not expose `memory_total()` as a cheap scalar if it internally builds multiple vectors. + +## Benchmark and regression gap + +The committed probes measure table construction/memory and the design documents quote plain `Vec` and string-map routing experiments. No committed benchmark exercises the new public `PartitionSet::partition()` or generated facade. There is also no same-key contended-reader benchmark, despite same-symbol contention being the relevant HFT case. + +Before claiming a routing win, benchmark the complete operation the application will execute: + +1. Resolve/cached handle only. +2. Borrowed router lookup only. +3. `Arc` router lookup and drop. +4. Router lookup plus representative table select/update. +5. Same key and distinct keys at 1/2/4/8 pinned threads. +6. Warm L1, warm LLC, and deliberately cold key distributions. +7. p50, p99, p99.9 and max pause, with metrics polling both off and on. + +The benchmark should live in `benches/` so future refcount, ordering, allocation, or telemetry regressions are visible. The documentation's “within 0.2 ns of a flat Vec” claim should cite that benchmark or be removed. + +## What does not regress the hot path + +- Tables without `partition_by` generate no router facade; the new generic module has no per-operation runtime cost for them. +- Integer conversion, power-of-two chunk division/modulo, and the two acquire loads are reasonable for the routed read path. +- Lazy 8 KiB chunk allocation is a sensible dense-key trade-off for the stated ~500-symbol HFT case. +- PR 71's extra `immediate_pop()` runs only during orderly shutdown. +- PR 72's parser tests and CI script do not affect production binaries. + +## Verification performed + +- Audited the effective 23-file delta `752a90b..d60eb6f`; PR 71's commit is also the first commit shown in PR 70 after rebase. +- `cargo test --release --lib partition`: 26 passed. +- `cargo test --release --test mod partitioned`: 18 passed. +- Targeted PR 71 shutdown regression: passed. +- `git diff --check 752a90b..d60eb6f`: clean. +- Release sanity benchmark: Apple M4 Max, macOS 26.5, rustc 1.97.1, 50 million single-thread iterations and 5 million operations per worker for threaded runs. + +The tests establish behavior and memory-safety coverage; they do not invalidate the performance and resource-lifetime findings above. + +## Recommended order for PR 72 fixes + +1. Land and benchmark the borrowed `partition_ref` API; update the HFT usage example to cache or borrow instead of cloning per tick. +2. Remove page-image copies and full `system_info()` calls from partition metrics. +3. Decide whether removal is append-only-for-now or implement shared-owner reclamation; do not leave the current API implying usable eviction. +4. Make metrics truthful about fixed, reserved, and retired memory. +5. Remove global construction head-of-line blocking or enforce preload as a runtime invariant. +6. Make initializer panic recovery explicit and keep mutation errors typed. +