diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a81023a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +* +!Cargo.toml +!Cargo.lock +!rust-toolchain.toml +!src/ +!src/** +!benches/ +!benches/** +!LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2942ed..cc0f318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -27,8 +27,102 @@ jobs: - run: cargo fmt --all -- --check - run: cargo clippy --all-targets --all-features --locked --offline -- -D warnings - run: cargo test --all-targets --all-features --locked --offline + - run: cargo test --doc --all-features --locked --offline + - run: cargo run --locked --offline -- --version + - name: Process crash recovery + env: + FTWDB_POWER_CUT_ITERATIONS: 32 + FTWDB_POWER_CUT_SEED: 20260907 + run: cargo test --test power_cut --locked --offline always_recovers_every_acknowledged_batch_after_sigkill -- --ignored # The privileged Linux NBD smoke script stays outside this portable job. - run: cargo build --manifest-path bench/sd-card-emulator/Cargo.toml --all-targets --locked --offline - run: cargo fmt --manifest-path bench/sd-card-emulator/Cargo.toml -- --check - run: cargo clippy --manifest-path bench/sd-card-emulator/Cargo.toml --all-targets --locked --offline -- -D warnings - run: cargo test --manifest-path bench/sd-card-emulator/Cargo.toml --all-targets --locked --offline + + packages: + uses: ./.github/workflows/packages.yml + + dependencies: + name: Dependency advisories + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: 1.97.1 + - run: cargo install cargo-audit --locked --version 0.22.2 + - run: cargo audit --deny warnings + - run: cargo audit --deny warnings --file bench/sd-card-emulator/Cargo.lock + + storage-faults: + name: Linux filesystem faults + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: 1.97.1 + - name: Run the ext4 fault and recovery checks on a test NBD device + run: | + set -euo pipefail + # Keep the healthy card's cache/power-loss model and full workload. + # Artificial timing belongs in SD performance runs, not this gate. + python3 - <<'PY' + import json + from pathlib import Path + profiles = Path("bench/sd-card-emulator/profiles") + card = json.loads((profiles / "healthy.json").read_text()) + fast = json.loads((profiles / "full-disk-64m.json").read_text()) + card.update(name="ci-durability", read=fast["read"], write=fast["write"]) + Path("bench-results").mkdir(exist_ok=True) + Path("bench-results/ci-profile.json").write_text(json.dumps(card, indent=2)) + PY + sudo modprobe nbd nbds_max=1 + test ! -e /sys/block/nbd0/pid + for scenario in smoke full-disk mid-commit; do + sudo env "PATH=$PATH" "CARGO_HOME=$HOME/.cargo" "RUSTUP_HOME=$HOME/.rustup" \ + "FTW_NBD_OUTPUT=$PWD/bench-results/linux-nbd-smoke" \ + "FTW_NBD_FULL_OUTPUT=$PWD/bench-results/linux-nbd-full-disk" \ + "FTW_NBD_MID_OUTPUT=$PWD/bench-results/linux-nbd-mid-commit" \ + "FTW_NBD_PROFILE=$PWD/bench-results/ci-profile.json" \ + "FTW_SD_EMULATOR_COMMIT=$GITHUB_SHA" \ + bash "bench/sd-card-emulator/linux-$scenario.sh" + done + - name: Collect reports without walking private database snapshots + if: always() + run: | + sudo env "FTW_REPORT_UID=$(id -u)" "FTW_REPORT_GID=$(id -g)" python3 - <<'PY' + import os + from pathlib import Path + import shutil + root = Path("bench-results") + owner = (int(os.environ["FTW_REPORT_UID"]), int(os.environ["FTW_REPORT_GID"])) + reports = root / "evidence" + reports.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chown(reports, *owner) + sources = list(root.glob("linux-nbd-*")) + for source in sources: + destination = reports / source.name + destination.mkdir(mode=0o700) + os.chown(destination, *owner) + # Only direct report files: never traverse store/backup trees. + for item in source.iterdir(): + if item.is_file() and item.suffix in (".json", ".jsonl", ".txt", ".log", ".stdout", ".stderr"): + target = destination / item.name + shutil.copy2(item, target) + os.chown(target, *owner) + profile = root / "ci-profile.json" + if profile.is_file(): + shutil.copy2(profile, reports / profile.name) + os.chown(reports / profile.name, *owner) + PY + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: linux-filesystem-faults + path: bench-results/evidence/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000..ba09350 --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,45 @@ +name: Packages + +on: + workflow_call: + +permissions: + contents: read + +jobs: + package: + name: package (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: 1.97.1 + - name: Build Linux binaries on the supported Debian baseline + if: runner.os == 'Linux' + run: | + set -euo pipefail + docker build -t ftwdb-package . + bash packaging/check-container.sh ftwdb-package + container=$(docker create ftwdb-package) + trap 'docker rm -f "$container" >/dev/null' EXIT + mkdir -p target/release + docker cp "$container":/usr/local/bin/. target/release/ + - name: Build macOS binaries + if: runner.os == 'macOS' + run: cargo build --release --locked --bins + - name: Pack and run all three archived binaries + run: python3 packaging/build-archive.py --require-clean + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ matrix.os }} + path: | + dist/*.tar.gz + dist/*.sha256 + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4814e4a..459ca56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,9 +15,11 @@ concurrency: jobs: verify: name: verify release - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: toolchain: 1.97.1 @@ -27,6 +29,7 @@ jobs: run: | set -euo pipefail tag="${GITHUB_REF_NAME}" + git merge-base --is-ancestor "${GITHUB_SHA}" origin/main if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then echo "invalid release tag: ${tag}" >&2 exit 1 @@ -57,42 +60,8 @@ jobs: - run: cargo package --locked --offline build: - name: build (${{ matrix.os }}) needs: verify - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master - with: - toolchain: 1.97.1 - - run: cargo build --release --locked - - name: Build archive - id: archive - shell: bash - run: | - set -euo pipefail - host="$(rustc -vV | sed -n 's/^host: //p')" - name="ftw-${GITHUB_REF_NAME}-${host}" - mkdir -p "dist/${name}" - cp target/release/ftw README.md CHANGELOG.md LICENSE "dist/${name}/" - tar -C dist -czf "dist/${name}.tar.gz" "${name}" - ( - cd dist - shasum -a 256 "${name}.tar.gz" > "${name}.tar.gz.sha256" - ) - echo "name=${name}" >> "${GITHUB_OUTPUT}" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-${{ steps.archive.outputs.name }} - path: | - dist/${{ steps.archive.outputs.name }}.tar.gz - dist/${{ steps.archive.outputs.name }}.tar.gz.sha256 - if-no-files-found: error - retention-days: 14 + uses: ./.github/workflows/packages.yml publish: name: publish GitHub release @@ -116,6 +85,7 @@ jobs: run: | set -euo pipefail sort -k2 dist/*.sha256 > dist/SHA256SUMS + (cd dist && sha256sum -c SHA256SUMS) git for-each-ref --format='%(contents)' \ "refs/tags/${GITHUB_REF_NAME}" > dist/RELEASE_NOTES.md if [[ ! -s dist/RELEASE_NOTES.md ]]; then diff --git a/.gitignore b/.gitignore index a8de5b5..19cdf28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target/ +/dist/ /bench/sd-card-emulator/target/ /bench-results/ __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b83115b..c75b120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,84 @@ steps in [docs/releases.md](docs/releases.md). ## [Unreleased] +## [0.1.0-alpha.2] + +Candidate for bounded shadow collection alongside FTW beta. Not yet published. + +### Added + +- A 512 MiB sidecar store limit and a 512 MiB free-disk reserve reject new + writes before append. Exact stored retries still work at either limit; + health reports degraded state and the client receives a retryable error. +- Native Linux ARM64 and AMD64 archives now include all three tools, private + service examples, recovery docs, source identity, and per-binary checksums. +- A Debian 12 container runs as UID 100 and GID 101 with only a Unix socket. +- CI runs native ARM64 tests, container and archive checks, process crashes, + and Linux ext4/NBD full-disk and mid-commit recovery checks. + +- Sealed raw segments are published through the store manifest and used on the + query path. `Store::seal_and_reclaim` rewrites `active.wlog` to catalog, + identity receipts, and the unsealed tail so reopen does not reload every + historical point. +- Salvage recovers sealed `.wseg` coverage and the live `active.wlog` tail + instead of refusing stores that have already sealed raw history. A missing + or unreadable sealed segment fails closed. +- Ordered ingress frames now store source, sequence, commit ID, and transaction + as one checked unit. Exact retries compare the original bytes and return the + original receipt across reopen. +- A bounded single-writer runtime separates request errors from storage faults + and tracks accepted and durable progress per source. +- A draft, hand-written metadata wire codec and local Unix shadow sidecar + carry catalog, run, plan, point, and outcome data without joining FTW's + control path. +- The sidecar checks each Unix peer's effective UID and drains the writer on + SIGTERM or SIGINT before it removes the socket and exits. +- Checked systemd and launchd examples keep the store and socket private and + give the sidecar time to finish a clean stop. +- An offline reconcile command compares exact source commit frames with stored + receipts, catalog state, and point bits and emits a bounded JSON summary. +- Reconciliation binds every receipt to its exact canonical payload, caps raw + points scanned before time filtering, and bounds regular-file inputs. +- A clean client EOF at a frame boundary no longer raises the sidecar's client + error count; partial frames still do. + +### Limits of this candidate + +- Collection keeps SQLite/Parquet authoritative and is opt-in. The sidecar + does not establish complete replication or serve production reads. +- Bounded collection runs without automatic rollups, sealing, or retention. + It stops at its storage limit. Preserve or replace its store as an operator + action; do not delete the current store while the sidecar runs. +- Physical SD-card power cuts, target-box soak, resource measurements, and an + off-card backup/restore drill still gate a standalone FTWDB beta release. +- Older binaries cannot open the new ingress or reclaimed receipt format. + Rollback needs a pre-upgrade snapshot or a fresh disposable shadow store. + ### Fixed +- Reconciliation charges full overlapping blocks before decoding and stops at + its scan/output limits across sealed history and the live tail. +- Manifest v3 binds sealed raw file contents, counts, and time bounds. Open, + integrity checks, and salvage reject valid but unrelated replacements. + Open streams all sealed bytes for verification. Published alpha.1 stores + still load; unpublished v2 stores with sealed raw segments need a pre-seal + snapshot or a fresh shadow store. + +- Postcard no longer enables an unused embedded heapless backend, removing + the archived atomic-polyfill dependency from the lockfile. + - Release publication now reads notes from the annotated tag through a file, which works with an explicit GitHub repository target. +- A live or later-sealed correction now wins when all three time keys match an + older sealed point. +- Log reclaim sorts ingress receipts and retains their exact bytes. CRC32 + equality alone can no longer accept a changed retry. +- This version still reads the old exact-receipt index, but older binaries + cannot read a store after the new writer reclaims it. +- Catalog compaction rejects run and plan cycles instead of writing a partial + catalog. +- Store paths, segment links, manifest order, reserved fields, rollup values, + and SD-card ACK evidence now fail on invalid input. ## [0.1.0-alpha.1] - 2026-07-21 diff --git a/Cargo.lock b/Cargo.lock index 6948ca9..3e3e0d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,15 +38,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -86,12 +77,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "cast" version = "0.3.0" @@ -219,12 +204,6 @@ dependencies = [ "itertools", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -363,7 +342,7 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "ftwdb" -version = "0.1.0-alpha.1" +version = "0.1.0-alpha.2" dependencies = [ "crc32fast", "criterion", @@ -438,15 +417,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -474,20 +444,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "heapless" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" -dependencies = [ - "atomic-polyfill", - "hash32", - "rustc_version", - "serde", - "spin", - "stable_deref_trait", -] - [[package]] name = "itertools" version = "0.13.0" @@ -590,15 +546,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.33" @@ -725,7 +672,6 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", - "heapless", "serde", ] @@ -905,15 +851,6 @@ dependencies = [ "sqlite-wasm-rs", ] -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -954,18 +891,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -1033,15 +958,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -1054,12 +970,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "syn" version = "2.0.119" diff --git a/Cargo.toml b/Cargo.toml index 430619a..d024c5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "ftwdb" -version = "0.1.0-alpha.1" +default-run = "ftw" +version = "0.1.0-alpha.2" edition = "2024" rust-version = "1.97" description = "Forecasts, Telemetry & Watts: an SD-card-conscious embedded energy database" @@ -17,14 +18,14 @@ support = "Unix targets only; Linux and macOS are tested in CI" crc32fast = "1.5.0" flate2 = "1.1.5" jiff = "0.2.34" +libc = "0.2.187" lz4_flex = "0.14.0" -postcard = { version = "1.1.3", features = ["use-std"] } -rustix = { version = "1.1.4", features = ["fs"] } +postcard = { version = "1.1.3", default-features = false, features = ["use-std"] } +rustix = { version = "1.1.4", features = ["fs", "process"] } serde = { version = "1.0.229", features = ["derive"] } [dev-dependencies] criterion = "0.8.2" -libc = "0.2.187" proptest = "1.11.0" rusqlite = { version = "0.40.1", features = ["bundled"] } serde_json = "1.0.149" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52fb2fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM rust:1.97.1-slim-bookworm@sha256:2775a09d208ff0d7c1f50490c45b62db929e87ba1dcbc3f2132ac71a704bcdd3 AS build +WORKDIR /src +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY src ./src +COPY benches ./benches +RUN cargo build --release --locked --bins + +FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 +LABEL org.opencontainers.image.source="https://github.com/srcfl/ftwdb" +COPY --from=build /src/target/release/ftw /src/target/release/ftwdb-shadow /src/target/release/ftwdb-shadow-reconcile /usr/local/bin/ +COPY LICENSE /usr/share/doc/ftwdb/LICENSE +RUN install -d -o 100 -g 101 -m 0700 /var/lib/ftwdb-shadow /run/ftwdb-shadow +USER 100:101 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/ftwdb-shadow"] +CMD ["/var/lib/ftwdb-shadow", "/run/ftwdb-shadow/shadow.sock"] diff --git a/README.md b/README.md index 46b0ca4..3fbfcea 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,18 @@ This repository currently contains the first executable storage slice: - immutable compressed raw and rollup segments with checksummed manifests; - persistent fixed and IANA-calendar rollups, including DST-correct energy; - automatic late-data invalidation, rebuild, cached queries, and retention gates; +- exact per-source ingress replay receipts that survive reopen; +- a draft, bounded Go-portable shadow protocol and local Unix sidecar; - tests, property tests, Criterion benchmarks, and a competitor benchmark plan. +The current source prepares `0.1.0-alpha.2` for bounded, opt-in shadow +collection alongside the FTW beta. SQLite/Parquet remain authoritative. See +[collection limits and rollback](docs/shadow-sidecar.md#bounded-collection-in-the-ftw-beta). + It is **not production-ready**. The active log still rebuilds an in-memory read -index, raw compaction/deletion is intentionally disabled, and real SD-card -power-cut evidence has not yet been collected. +index, and real SD-card power-cut evidence has not yet been collected. Writable +stores can seal live raw into immutable segments, reclaim the active log, and +run explicit background maintenance for rollups. ## Platform support @@ -74,6 +81,15 @@ cargo run --release -- restore ./backups/energy-2026-07-21.ftwdb ./restored-ener cargo run --release -- salvage ./damaged-energy.ftwdb ./salvaged-energy.ftwdb ``` +Run the local shadow sidecar with sync-on-every-batch durability: + +```sh +cargo run --release --bin ftwdb-shadow -- ./shadow.ftwdb ./run/ftwdb-shadow.sock +``` + +The sidecar protocol is still a draft. Do not make FTW control or production +reads depend on it. See the shadow guide and its beta gates below. + ## Design documents - [Architecture and invariants](docs/architecture.md) @@ -82,6 +98,7 @@ cargo run --release -- salvage ./damaged-energy.ftwdb ./salvaged-energy.ftwdb - [Immutable segment format](docs/segment-format.md) - [Persistent rollups and retention](docs/rollups.md) - [Integrity checks, backup, restore, and salvage](docs/operations.md) +- [FTW shadow sidecar and beta gates](docs/shadow-sidecar.md) - [OSS database research](docs/research.md) - [Benchmark protocol](docs/benchmarking.md) - [Deterministic energy workload](docs/workload.md) diff --git a/bench/sd-card-emulator/README.md b/bench/sd-card-emulator/README.md index be6a3e7..d8c8fce 100644 --- a/bench/sd-card-emulator/README.md +++ b/bench/sd-card-emulator/README.md @@ -4,6 +4,12 @@ This standalone Rust crate exposes a sparse file as an NBD block device. Put a real Linux filesystem on `/dev/nbd0`, then run FTWDB on that filesystem. This keeps the filesystem, page cache, block layer, and database in the test path. +CI uses the full workload and the healthy card's cache and power-loss settings, +with fast read/write timing from the full-disk profile. It checks filesystem +faults and recovery, not SD latency. The generated profile travels with the CI +evidence. Local scripts keep the slow healthy profile unless `FTW_NBD_PROFILE` +selects another profile; use those runs for the separate timing experiment. + The model can inject: - read and write bandwidth and IOPS limits; @@ -142,6 +148,53 @@ different exit code, or either store check fails. This privileged script stays outside normal CI. The quick emulator result covers the #17 disk-full gate; physical SD-card power cuts remain a separate M4 release gate. +## Linux mid-commit power-cut + +The smoke test cuts after `sync`. `linux-mid-commit.sh` cuts **during** a live +`Durability::Always` ingest. The writer fsyncs one JSONL watermark line after +each durable commit (`--ack-log`). After `ctl power-loss` (or a seeded +`--power-loss-after-ops` cut), the script runs e2fsck, reopens the store, and +verifies recovered counts against the last complete ACK: every acked batch is +present, at most one in-flight batch is missing, and a torn tail is only an +incomplete header or payload. + +Run it from the repository root on Linux, or in the same privileged container: + +```sh +docker run --rm --privileged \ + -e FTW_SD_EMULATOR_COMMIT="$(git rev-parse --short=12 HEAD)" \ + -e FTW_NBD_MID_OUTPUT=/work/bench-results/linux-nbd-mid-commit \ + -v "$PWD":/work -w /work rust:1.97-slim-bookworm \ + bash bench/sd-card-emulator/linux-mid-commit.sh +``` + +`FTW_NBD_PROFILE` selects the emulator profile (`healthy.json` by default). +Set it to `bench/sd-card-emulator/profiles/cheap-consumer.json` to include +false flushes. `FTW_NBD_CUT_AFTER_ACKS` (default 3) is how many durable ACK +lines must land before `ctl power-loss`. For a seeded cut, set +`FTW_NBD_POWER_LOSS_AFTER_OPS` and use `profiles/sudden-power-loss.json`. +`FTW_NBD_MID_OUTPUT` must be absent or empty. + +Host software tests cover the ACK parser and prefix verifier without NBD. +This privileged script stays outside normal CI. + +## Write amplification + +`ctl status` and `--metrics FILE.jsonl` report `write_bytes`, `persisted_bytes`, +and `write_amplification` (`persisted_bytes / write_bytes` when any writes +landed). Those are emulator-model ratios for that run, not a claim about a +named SD card. Read them from the JSONL after a Linux NBD job. Do not invent +or copy numbers from another profile. + +## Nearly-worn EIO + +`profiles/nearly-worn.json` raises fault rates and can return `EIO` during +reads and writes. Format the filesystem on `healthy.json` first: the same +profile's EIO probability can fail `mkfs`. Then reopen the backing image with +`nearly-worn.json` and ingest until the writer sees `EIO`. Keep the durable +prefix with `check-store`. This path is probabilistic; pin a seed and keep the +JSONL. Physical wear-out remains an M4 hardware gate. + ## Power-loss run Start the writer, then cut the virtual card from another shell: diff --git a/bench/sd-card-emulator/linux-mid-commit.sh b/bench/sd-card-emulator/linux-mid-commit.sh new file mode 100755 index 0000000..43a93ce --- /dev/null +++ b/bench/sd-card-emulator/linux-mid-commit.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cut NBD power during a live Durability::Always ingest. Verify recovered +# counts against the last fsynced ACK, not the full-workload totals. +# +# This needs Linux, root, and the nbd module. On macOS run it in the same +# privileged container used by linux-smoke.sh. + +source bench/sd-card-emulator/linux-nbd-common.sh + +out=${FTW_NBD_MID_OUTPUT:-/work/bench-results/linux-nbd-mid-commit} +profile=${FTW_NBD_PROFILE:-bench/sd-card-emulator/profiles/healthy.json} +cut_after_acks=${FTW_NBD_CUT_AFTER_ACKS:-3} +batch_points=${FTW_NBD_BATCH_POINTS:-10000} +seed=${FTW_NBD_SEED:-42} + +if [[ -e "$out" ]] && find "$out" -mindepth 1 -print -quit | grep -q .; then + printf 'output directory must be absent or empty: %s\n' "$out" >&2 + exit 1 +fi +linux_nbd_prepare +cleanup() { + if [[ -n ${writer_pid:-} ]]; then + kill "$writer_pid" >/dev/null 2>&1 || true + wait "$writer_pid" >/dev/null 2>&1 || true + fi + linux_nbd_cleanup +} +trap cleanup EXIT + +serve_extra=() +if [[ -n ${FTW_NBD_POWER_LOSS_AFTER_OPS:-} ]]; then + serve_extra+=(--power-loss-after-ops "$FTW_NBD_POWER_LOSS_AFTER_OPS") +fi +linux_nbd_start "$profile" "$seed" "${serve_extra[@]}" +linux_nbd_connect +mkfs.ext4 -q -F /dev/nbd0 +linux_nbd_mount + +ack_log=$out/ack.jsonl +: >"$ack_log" + +set +e +"$ftw" bench-real-fixture \ + bench/fixtures/ftw-real-v1/points.csv.gz \ + "$mount_dir/database" \ + --durability always \ + --batch-points "$batch_points" \ + --ack-log "$ack_log" \ + >"$out/writer.stdout" 2>"$out/writer.stderr" & +writer_pid=$! +set -e + +wait_for_acks() { + local needed=$1 + local file=$2 + local n + for _ in $(seq 1 600); do + if [[ -f "$file" ]]; then + n=$(grep -c '"format":"ftwdb-ack-watermark-v1"' "$file" || true) + if [[ "$n" -ge "$needed" ]]; then + return 0 + fi + fi + if ! kill -0 "$writer_pid" 2>/dev/null; then + return 1 + fi + sleep 0.1 + done + return 1 +} + +if [[ -z ${FTW_NBD_POWER_LOSS_AFTER_OPS:-} ]]; then + if ! wait_for_acks "$cut_after_acks" "$ack_log"; then + set +e + wait "$writer_pid" + writer_exit=$? + set -e + printf 'writer exited before %s durable acks (exit=%d)\n' "$cut_after_acks" "$writer_exit" >&2 + cat "$out/writer.stderr" >&2 + exit 1 + fi + "$emulator" ctl power-loss >"$out/power-loss.json" +fi + +set +e +wait "$writer_pid" +writer_exit=$? +set -e +printf '%d\n' "$writer_exit" >"$out/writer-exit.txt" +if [[ "$writer_exit" -eq 0 ]]; then + printf 'writer finished the workload before the mid-commit cut\n' >&2 + exit 1 +fi + +linux_nbd_unmount_lazy || true +linux_nbd_disconnect_best_effort || true +"$emulator" ctl reset >"$out/reset.json" +linux_nbd_connect + +set +e +e2fsck -fy /dev/nbd0 >"$out/fsck.txt" 2>&1 +fsck_exit=$? +set -e +if [[ "$fsck_exit" -gt 1 ]]; then + printf 'fsck_exit=%d\n' "$fsck_exit" >&2 + exit "$fsck_exit" +fi +printf '%d\n' "$fsck_exit" >"$out/fsck-exit.txt" + +linux_nbd_mount +"$ftw" check-store "$mount_dir/database" >"$out/check-after.json" +"$ftw" inspect "$mount_dir/database/active.wlog" >"$out/inspect-after.txt" +"$emulator" ctl status >"$out/status-after-recovery.json" + +"$emulator" verify \ + --emulator "$out/status-after-recovery.json" \ + --check "$out/check-after.json" \ + --inspect "$out/inspect-after.txt" \ + --ack-log "$ack_log" \ + --max-in-flight-commits 1 \ + --max-in-flight-points "$batch_points" \ + --checksum-ok true \ + --writer-exit "$writer_exit" \ + --output "$out/verification.jsonl" \ + >"$out/verification.json" + +linux_nbd_finish +trap - EXIT + +printf 'linux_nbd_mid_commit=passed\n' +printf 'profile=%s\n' "$profile" +printf 'writer_exit=%d\n' "$writer_exit" +printf 'fsck_exit=%d\n' "$fsck_exit" +cat "$out/verification.json" diff --git a/bench/sd-card-emulator/linux-nbd-common.sh b/bench/sd-card-emulator/linux-nbd-common.sh index 5fc1303..00ab859 100755 --- a/bench/sd-card-emulator/linux-nbd-common.sh +++ b/bench/sd-card-emulator/linux-nbd-common.sh @@ -45,12 +45,14 @@ linux_nbd_cleanup() { linux_nbd_start() { local profile=$1 local seed=$2 + shift 2 "$emulator" serve \ --config "$profile" \ --backing "$out/card.img" \ --seed "$seed" \ --metrics "$out/emulator.jsonl" \ + "$@" \ >"$out/server.json" 2>"$out/server.log" & emulator_pid=$! diff --git a/bench/sd-card-emulator/linux-smoke.sh b/bench/sd-card-emulator/linux-smoke.sh index 9340e5e..c93211e 100755 --- a/bench/sd-card-emulator/linux-smoke.sh +++ b/bench/sd-card-emulator/linux-smoke.sh @@ -10,7 +10,7 @@ if [[ -e "$out" ]] && find "$out" -mindepth 1 -print -quit | grep -q .; then fi linux_nbd_prepare trap linux_nbd_cleanup EXIT -linux_nbd_start bench/sd-card-emulator/profiles/healthy.json 42 +linux_nbd_start "${FTW_NBD_PROFILE:-bench/sd-card-emulator/profiles/healthy.json}" 42 linux_nbd_connect mkfs.ext4 -q -F /dev/nbd0 linux_nbd_mount diff --git a/bench/sd-card-emulator/src/main.rs b/bench/sd-card-emulator/src/main.rs index 215c76a..1f35912 100644 --- a/bench/sd-card-emulator/src/main.rs +++ b/bench/sd-card-emulator/src/main.rs @@ -177,6 +177,9 @@ fn verify_run(arguments: &[String]) -> Result<(), String> { let mut writer_exit = None; let mut writer_signal = None; let mut checksum_ok = None; + let mut ack_log = None; + let mut max_in_flight_commits = 1; + let mut max_in_flight_points = None; let mut index = 0; while index < arguments.len() { let flag = &arguments[index]; @@ -195,23 +198,46 @@ fn verify_run(arguments: &[String]) -> Result<(), String> { "--writer-exit" => writer_exit = Some(parse_i32("writer exit", value)?), "--writer-signal" => writer_signal = Some(parse_i32("writer signal", value)?), "--checksum-ok" => checksum_ok = Some(parse_bool("checksum status", value)?), + "--ack-log" => ack_log = Some(PathBuf::from(value)), + "--max-in-flight-commits" => { + max_in_flight_commits = parse_u64("in-flight commits", value)? + } + "--max-in-flight-points" => { + max_in_flight_points = Some(parse_u64("in-flight points", value)?) + } _ => return Err(format!("unknown verify option {flag:?}")), } } let emulator = emulator.ok_or_else(|| "verify needs --emulator".to_owned())?; let check = check.ok_or_else(|| "verify needs --check".to_owned())?; let inspect = inspect.ok_or_else(|| "verify needs --inspect".to_owned())?; + if ack_log.is_none() { + if expected_points.is_none() { + return Err("verify needs --expected-points".to_owned()); + } + if expected_commits.is_none() { + return Err("verify needs --expected-commits".to_owned()); + } + } else if max_in_flight_points.is_none() { + return Err("verify --ack-log needs --max-in-flight-points".to_owned()); + } + let max_in_flight_points = max_in_flight_points.unwrap_or(0); let report = verify(&VerifyInput { emulator: &emulator, check: &check, inspect: &inspect, - expected_points: expected_points - .ok_or_else(|| "verify needs --expected-points".to_owned())?, - expected_commits: expected_commits - .ok_or_else(|| "verify needs --expected-commits".to_owned())?, + expected_points: expected_points.unwrap_or(0), + expected_commits: expected_commits.unwrap_or(0), writer_exit, writer_signal, checksum_ok, + ack_log: ack_log.as_deref(), + max_in_flight_commits: if ack_log.is_some() { + max_in_flight_commits + } else { + 0 + }, + max_in_flight_points, }) .map_err(|error| error.to_string())?; println!( @@ -252,6 +278,6 @@ fn print_help() { serve --config PROFILE.json --backing CARD.img [--listen HOST:PORT] \\\n+ [--control HOST:PORT] [--seed N] [--metrics FILE.jsonl] \\\n+ [--power-loss-after-ops N]\n\ ctl [--control HOST:PORT] status|power-loss|reset|detach|read-only|read-write|flush|shutdown\n\ validate PROFILE.json\n\ - verify --emulator METRICS.jsonl --check CHECK.json --inspect INSPECT.txt \\\n+ --expected-points N --expected-commits N [--checksum-ok BOOL] \\\n+ [--writer-exit N] [--writer-signal N] [--output RESULT.jsonl]" + verify --emulator METRICS.jsonl --check CHECK.json --inspect INSPECT.txt \\\n+ [--expected-points N --expected-commits N | --ack-log ACK.jsonl] \\\n+ [--max-in-flight-commits N] [--max-in-flight-points N] [--checksum-ok BOOL] \\\n+ [--writer-exit N] [--writer-signal N] [--output RESULT.jsonl]" ); } diff --git a/bench/sd-card-emulator/src/report.rs b/bench/sd-card-emulator/src/report.rs index 5568891..f7b6ad6 100644 --- a/bench/sd-card-emulator/src/report.rs +++ b/bench/sd-card-emulator/src/report.rs @@ -14,6 +14,22 @@ pub struct VerifyInput<'a> { pub writer_exit: Option, pub writer_signal: Option, pub checksum_ok: Option, + pub ack_log: Option<&'a Path>, + pub max_in_flight_commits: u64, + pub max_in_flight_points: u64, +} + +impl<'a> VerifyInput<'a> { + fn mid_commit(&self) -> bool { + self.ack_log.is_some() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AckWatermark { + pub commits: u64, + pub points: u64, + pub durable: bool, } #[derive(Debug, Serialize)] @@ -31,6 +47,12 @@ pub struct FaultRunReport { pub recovered_commits: u64, pub recovered_points: u64, pub recovered_tail_bytes: u64, + pub in_flight_commits: u64, + pub in_flight_points: u64, + pub contains_every_ack: bool, + pub has_at_most_one_unacknowledged: bool, + pub silent_partial_frame: bool, + pub write_amplification: f64, pub check_store_ok: bool, pub checksum_ok: Option, pub injected_operations: u64, @@ -47,6 +69,71 @@ pub struct FaultRunReport { pub passed: bool, } +pub fn last_durable_ack(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + let lines: Vec<_> = contents.split('\n').collect(); + let has_unterminated_final_line = !contents.ends_with('\n'); + let mut previous = None::; + let mut last_durable = None; + for (index, line) in lines.iter().enumerate() { + // A JSONL record is complete only after its newline lands. A power + // cut can leave a valid JSON prefix as the final unterminated line, + // so never use that line as durability evidence. + if has_unterminated_final_line && index + 1 == lines.len() { + break; + } + let line = line.trim(); + if line.is_empty() { + continue; + } + let value = match serde_json::from_str::(line) { + Ok(value) => value, + Err(error) => { + return Err(VerifyError::Invalid(format!( + "ack log line {} is not valid JSON: {error}", + index + 1 + ))); + } + }; + if string(&value, "format")? != "ftwdb-ack-watermark-v1" { + return Err(VerifyError::Invalid(format!( + "ack log line {} has the wrong format", + index + 1 + ))); + } + let durable = value + .get("durable") + .and_then(Value::as_bool) + .ok_or_else(|| { + VerifyError::Invalid(format!( + "ack log line {} lacks a boolean durable field", + index + 1 + )) + })?; + let watermark = AckWatermark { + commits: unsigned(&value, "commits")?, + points: unsigned(&value, "points")?, + durable, + }; + if let Some(previous) = previous + && (watermark.commits <= previous.commits || watermark.points < previous.points) + { + return Err(VerifyError::Invalid(format!( + "ack log line {} regresses its cumulative watermark", + index + 1 + ))); + } + previous = Some(watermark); + if !durable { + continue; + } + last_durable = Some(watermark); + } + last_durable.ok_or_else(|| { + VerifyError::Invalid("ack log has no complete durable watermark line".to_owned()) + }) +} + pub fn verify(input: &VerifyInput<'_>) -> Result { let emulator = last_json_line(input.emulator)?; let stats = emulator.get("stats").unwrap_or(&emulator); @@ -64,6 +151,8 @@ pub fn verify(input: &VerifyInput<'_>) -> Result { let recovered_points = unsigned(&check, "raw_points")?; let recovered_commits = unsigned(&check, "raw_commits")?; let recovered_tail_bytes = parse_recovered_tail(&fs::read_to_string(input.inspect)?)?; + let check_tail_bytes = unsigned(&check, "raw_recovered_tail_bytes")?; + let recovered_tail_kind = string(&check, "raw_recovered_tail")?; let injected_torn_operations = unsigned(stats, "injected_torn_operations")?; let injected_torn_bytes = unsigned(stats, "injected_torn_bytes")?; let power_torn_operations = unsigned(stats, "torn_operations")?; @@ -75,9 +164,41 @@ pub fn verify(input: &VerifyInput<'_>) -> Result { let dropped_operations = unsigned(stats, "dropped_operations")?; let dropped_bytes = unsigned(stats, "dropped_bytes")?; let checksum_passed = input.checksum_ok != Some(false); - let passed = recovered_points == input.expected_points - && recovered_commits == input.expected_commits - && checksum_passed; + let write_amplification = stats + .get("write_amplification") + .and_then(Value::as_f64) + .unwrap_or(0.0); + + let (acknowledged_commits, acknowledged_points) = if let Some(ack_log) = input.ack_log { + let ack = last_durable_ack(ack_log)?; + (ack.commits, ack.points) + } else { + (input.expected_commits, input.expected_points) + }; + + let contains_every_ack = + recovered_commits >= acknowledged_commits && recovered_points >= acknowledged_points; + let in_flight_commits = recovered_commits.saturating_sub(acknowledged_commits); + let in_flight_points = recovered_points.saturating_sub(acknowledged_points); + let has_at_most_one_unacknowledged = in_flight_commits <= input.max_in_flight_commits + && in_flight_points <= input.max_in_flight_points; + let silent_partial_frame = check_tail_bytes != recovered_tail_bytes + || !matches!( + recovered_tail_kind, + "none" | "incomplete-header" | "incomplete-payload" + ) + || (recovered_tail_kind == "none" && recovered_tail_bytes != 0); + + let passed = if input.mid_commit() { + contains_every_ack + && has_at_most_one_unacknowledged + && !silent_partial_frame + && checksum_passed + } else { + recovered_points == acknowledged_points + && recovered_commits == acknowledged_commits + && checksum_passed + }; Ok(FaultRunReport { schema_version: "ftw-sd-fault-run-v1", @@ -88,11 +209,17 @@ pub fn verify(input: &VerifyInput<'_>) -> Result { fault_operation: optional_unsigned(stats, "last_fault_operation"), writer_exit: input.writer_exit, writer_signal: input.writer_signal, - acknowledged_commits: input.expected_commits, - acknowledged_points: input.expected_points, + acknowledged_commits, + acknowledged_points, recovered_commits, recovered_points, recovered_tail_bytes, + in_flight_commits, + in_flight_points, + contains_every_ack, + has_at_most_one_unacknowledged, + silent_partial_frame, + write_amplification, check_store_ok: true, checksum_ok: input.checksum_ok, injected_operations: injected_eio @@ -250,7 +377,7 @@ mod tests { .unwrap(); fs::write( &check, - b"{\"format\":\"ftwdb-integrity-v1\",\"raw_points\":100,\"raw_commits\":10}\n", + b"{\"format\":\"ftwdb-integrity-v1\",\"raw_points\":100,\"raw_commits\":10,\"raw_recovered_tail_bytes\":39,\"raw_recovered_tail\":\"incomplete-header\"}\n", ) .unwrap(); fs::write(&inspect, b"recovered_tail_bytes: 39\n").unwrap(); @@ -263,6 +390,9 @@ mod tests { writer_exit: Some(1), writer_signal: None, checksum_ok: Some(true), + ack_log: None, + max_in_flight_commits: 0, + max_in_flight_points: 0, }; let report = verify(&input).unwrap(); assert!(report.passed); @@ -278,4 +408,160 @@ mod tests { assert!(!failed.passed); fs::remove_dir_all(directory).unwrap(); } + + #[test] + fn last_durable_ack_skips_a_truncated_final_line() { + let directory = std::env::temp_dir().join(format!("ftw-sd-ack-log-{}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ack.jsonl"); + fs::write( + &path, + concat!( + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":1,\"points\":10,\"durable\":true}\n", + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":2,\"points\":20,\"durable\":true}\n", + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":3,\"poi" + ), + ) + .unwrap(); + let ack = last_durable_ack(&path).unwrap(); + assert_eq!(ack.commits, 2); + assert_eq!(ack.points, 20); + + fs::write( + &path, + concat!( + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":1,\"points\":10,\"durable\":true}\n", + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":2,\"points\":20,\"durable\":true}" + ), + ) + .unwrap(); + let ack = last_durable_ack(&path).unwrap(); + assert_eq!(ack.commits, 1); + assert_eq!(ack.points, 10); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn last_durable_ack_rejects_malformed_complete_and_regressing_lines() { + let directory = + std::env::temp_dir().join(format!("ftw-sd-bad-ack-log-{}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ack.jsonl"); + let first = "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":2,\"points\":20,\"durable\":true}\n"; + + fs::write(&path, format!("{first}not-json\n")).unwrap(); + assert!(last_durable_ack(&path).is_err()); + + fs::write( + &path, + format!( + "{first}{{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":1,\"points\":10,\"durable\":true}}\n" + ), + ) + .unwrap(); + assert!(last_durable_ack(&path).is_err()); + + fs::write( + &path, + format!( + "{first}{{\"format\":\"wrong\",\"commits\":3,\"points\":30,\"durable\":true}}\n" + ), + ) + .unwrap(); + assert!(last_durable_ack(&path).is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn mid_commit_verify_accepts_one_in_flight_batch_and_rejects_lost_acks() { + let directory = + std::env::temp_dir().join(format!("ftw-sd-mid-commit-{}", std::process::id())); + fs::create_dir_all(&directory).unwrap(); + let emulator = directory.join("emulator.json"); + let check = directory.join("check.json"); + let inspect = directory.join("inspect.txt"); + let ack_log = directory.join("ack.jsonl"); + fs::write( + &emulator, + serde_json::to_vec(&json!({ + "schema_version": "ftw-sd-emulator-stats-v1", + "profile": "healthy", + "seed": 1, + "injected_torn_operations": 0, + "injected_torn_bytes": 0, + "torn_operations": 0, + "torn_bytes": 0, + "injected_eio_operations": 0, + "injected_corruptions": 0, + "false_flushes": 0, + "power_losses": 1, + "dropped_operations": 0, + "dropped_bytes": 0, + "reordered_operations": 0, + "max_erase_count": 0, + "bad_blocks": 0, + "write_amplification": 1.5, + "emulator_version": "0.1.0", + "emulator_commit": "abc" + })) + .unwrap(), + ) + .unwrap(); + fs::write( + &check, + b"{\"format\":\"ftwdb-integrity-v1\",\"raw_points\":30,\"raw_commits\":3,\"raw_recovered_tail_bytes\":7,\"raw_recovered_tail\":\"incomplete-header\"}\n", + ) + .unwrap(); + fs::write(&inspect, b"recovered_tail_bytes: 7\n").unwrap(); + fs::write( + &ack_log, + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":2,\"points\":20,\"durable\":true}\n", + ) + .unwrap(); + let input = VerifyInput { + emulator: &emulator, + check: &check, + inspect: &inspect, + expected_points: 0, + expected_commits: 0, + writer_exit: Some(1), + writer_signal: None, + checksum_ok: Some(true), + ack_log: Some(&ack_log), + max_in_flight_commits: 1, + max_in_flight_points: 10, + }; + let report = verify(&input).unwrap(); + assert!(report.passed); + assert!(report.contains_every_ack); + assert!(report.has_at_most_one_unacknowledged); + assert!(!report.silent_partial_frame); + assert_eq!(report.in_flight_commits, 1); + assert_eq!(report.write_amplification, 1.5); + + fs::write( + &ack_log, + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":4,\"points\":40,\"durable\":true}\n", + ) + .unwrap(); + let lost = verify(&input).unwrap(); + assert!(!lost.passed); + assert!(!lost.contains_every_ack); + + fs::write( + &ack_log, + "{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":1,\"points\":10,\"durable\":true}\n", + ) + .unwrap(); + fs::write( + &check, + b"{\"format\":\"ftwdb-integrity-v1\",\"raw_points\":40,\"raw_commits\":4,\"raw_recovered_tail_bytes\":0,\"raw_recovered_tail\":\"none\"}\n", + ) + .unwrap(); + fs::write(&inspect, b"recovered_tail_bytes: 0\n").unwrap(); + let extra = verify(&input).unwrap(); + assert!(!extra.passed); + assert!(!extra.has_at_most_one_unacknowledged); + fs::remove_dir_all(directory).unwrap(); + } } diff --git a/benches/model_queries.rs b/benches/model_queries.rs index fcc0bdf..9e9f0a4 100644 --- a/benches/model_queries.rs +++ b/benches/model_queries.rs @@ -36,51 +36,71 @@ fn model_query_benchmarks(criterion: &mut Criterion) { let mut group = criterion.benchmark_group("model_queries"); group.bench_function("latest", |bencher| { bencher.iter(|| { - black_box(database.query_latest( - FORECAST_SERIES, - fixture.forecast_start, - fixture.forecast_end, - )) + black_box( + database + .query_latest( + FORECAST_SERIES, + fixture.forecast_start, + fixture.forecast_end, + ) + .unwrap(), + ) }) }); group.bench_function("history", |bencher| { bencher.iter(|| { - black_box(database.query_history( - FORECAST_SERIES, - fixture.forecast_start, - fixture.forecast_end, - )) + black_box( + database + .query_history( + FORECAST_SERIES, + fixture.forecast_start, + fixture.forecast_end, + ) + .unwrap(), + ) }) }); group.bench_function("as_of", |bencher| { bencher.iter(|| { - black_box(database.query_as_of( - FORECAST_SERIES, - fixture.forecast_start, - fixture.forecast_end, - 15, - )) + black_box( + database + .query_as_of( + FORECAST_SERIES, + fixture.forecast_start, + fixture.forecast_end, + 15, + ) + .unwrap(), + ) }) }); group.bench_function("run", |bencher| { bencher.iter(|| { - black_box(database.query_run( - FORECAST_SERIES, - ORIGINAL_FORECAST_RUN, - fixture.forecast_start, - fixture.forecast_end, - )) + black_box( + database + .query_run( + FORECAST_SERIES, + ORIGINAL_FORECAST_RUN, + fixture.forecast_start, + fixture.forecast_end, + ) + .unwrap(), + ) }) }); group.bench_function("plan_outcome", |bencher| { bencher.iter(|| { - black_box(database.compare_plan_to_actual( - PLANNED_SERIES, - ACTUAL_SERIES, - OPTIMIZATION_RUN, - 0, - fixture.actual_end, - )) + black_box( + database + .compare_plan_to_actual( + PLANNED_SERIES, + ACTUAL_SERIES, + OPTIMIZATION_RUN, + 0, + fixture.actual_end, + ) + .unwrap(), + ) }) }); group.bench_function("gauge_rollup_5m", |bencher| { @@ -274,55 +294,65 @@ fn forecast_point(valid_time: i64, revision_time: i64, run_id: u128, value: f64) fn verify_answers(fixture: &Fixture) { let database = fixture.store.database(); - let history = database.query_history( - FORECAST_SERIES, - fixture.forecast_start, - fixture.forecast_end, - ); + let history = database + .query_history( + FORECAST_SERIES, + fixture.forecast_start, + fixture.forecast_end, + ) + .unwrap(); assert_eq!(history.len(), FORECAST_TIMES * 3); for (index, revisions) in history.chunks_exact(3).enumerate() { assert_eq!(revisions, &forecast_points_for(index)); } - let latest = database.query_latest( - FORECAST_SERIES, - fixture.forecast_start, - fixture.forecast_end, - ); + let latest = database + .query_latest( + FORECAST_SERIES, + fixture.forecast_start, + fixture.forecast_end, + ) + .unwrap(); assert_eq!(latest.len(), FORECAST_TIMES); for (index, point) in latest.iter().enumerate() { assert_eq!(*point, forecast_points_for(index)[2]); } - let as_of = database.query_as_of( - FORECAST_SERIES, - fixture.forecast_start, - fixture.forecast_end, - 15, - ); + let as_of = database + .query_as_of( + FORECAST_SERIES, + fixture.forecast_start, + fixture.forecast_end, + 15, + ) + .unwrap(); assert_eq!(as_of.len(), FORECAST_TIMES); for (index, point) in as_of.iter().enumerate() { assert_eq!(*point, forecast_points_for(index)[0]); } - let run = database.query_run( - FORECAST_SERIES, - ORIGINAL_FORECAST_RUN, - fixture.forecast_start, - fixture.forecast_end, - ); + let run = database + .query_run( + FORECAST_SERIES, + ORIGINAL_FORECAST_RUN, + fixture.forecast_start, + fixture.forecast_end, + ) + .unwrap(); assert_eq!(run.len(), FORECAST_TIMES); for (index, point) in run.iter().enumerate() { assert_eq!(*point, forecast_points_for(index)[1]); } - let outcomes = database.compare_plan_to_actual( - PLANNED_SERIES, - ACTUAL_SERIES, - OPTIMIZATION_RUN, - 0, - fixture.actual_end, - ); + let outcomes = database + .compare_plan_to_actual( + PLANNED_SERIES, + ACTUAL_SERIES, + OPTIMIZATION_RUN, + 0, + fixture.actual_end, + ) + .unwrap(); assert_eq!(outcomes.len(), ACTUAL_POINTS); for (index, outcome) in outcomes.iter().enumerate() { assert_eq!(*outcome, expected_outcome(index)); diff --git a/benches/segment.rs b/benches/segment.rs index 496a88e..2f26817 100644 --- a/benches/segment.rs +++ b/benches/segment.rs @@ -46,7 +46,7 @@ fn segment_benchmarks(criterion: &mut Criterion) { stats.stored_bytes, stats.logical_point_bytes as f64 / stats.stored_bytes as f64 ); - let mut segment = Segment::open(path).unwrap(); + let segment = Segment::open(path).unwrap(); let mut group = criterion.benchmark_group("segment_query_100k"); group.throughput(Throughput::Elements(POINT_COUNT as u64)); group.bench_function("full_series", |bencher| { diff --git a/benches/storage.rs b/benches/storage.rs index 0f59af7..fa7d9ca 100644 --- a/benches/storage.rs +++ b/benches/storage.rs @@ -60,7 +60,7 @@ fn storage_benchmarks(criterion: &mut Criterion) { let mut group = criterion.benchmark_group("query_100k"); group.throughput(Throughput::Elements(100_000)); group.bench_function("latest", |bencher| { - bencher.iter(|| database.query_latest(1, 0, i64::MAX)); + bencher.iter(|| database.query_latest(1, 0, i64::MAX).unwrap()); }); group.bench_function("materialize_5m_gauge_rollup", |bencher| { bencher.iter(|| { @@ -81,6 +81,31 @@ fn storage_benchmarks(criterion: &mut Criterion) { bencher.iter(|| rollup.range(0, i64::MAX)); }); group.finish(); + + let mut group = criterion.benchmark_group("query_tail_1k_of_100k"); + group.throughput(Throughput::Elements(1_000)); + group.bench_function("latest_last_1000", |bencher| { + bencher.iter(|| database.query_latest(1, 99_000_000_000, i64::MAX).unwrap()); + }); + group.finish(); + + drop(database); + let path = directory.path().join("query.ftwdb"); + let mut group = criterion.benchmark_group("reopen_100k"); + group.throughput(Throughput::Elements(100_000)); + group.bench_function("scan_and_recover", |bencher| { + bencher.iter(|| { + Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap() + }); + }); + group.finish(); } criterion_group!(benches, storage_benchmarks); diff --git a/docs/architecture.md b/docs/architecture.md index 25205e0..04739e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,11 +45,15 @@ database/ MANIFEST.00000000000000000001 rollups/ g1-s42-f300000000-*.rseg + segments/ + g2-*.wseg ``` -This is the current M3 directory shape. Raw immutable segments exist as a -standalone format but are not yet installed into the manifest or used to -reclaim the mixed active log. +This is the current directory shape. Immutable raw segments are published +through the same manifest generations as rollups. After `seal_and_reclaim`, +`active.wlog` holds catalog records, identity receipts, and the unsealed tail. +Open replays only that tail and streams sealed files to verify their manifest +checksums; it decodes older raw points from segments only when a query needs them. ## Write path @@ -76,9 +80,10 @@ Readers use a stable manifest snapshot. The planner: 4. merges immutable blocks and recent committed frames; 5. applies revision winner rules `(knowledge_time, change_time, append_order)`. -The active log currently rebuilds an in-memory per-series index on open. -Materialized rollups use verified immutable files and a process-local cache; -M4 moves raw reads to sparse segment indexes and bounds both caches. +Open rebuilds an in-memory per-series index from the unsealed tail only. +Sealed raw points stay on the sparse segment index and are merged at query +time. Materialized rollups use verified immutable files and a process-local +cache. A full sparse on-disk tail index remains later M4 hardening. ## Crash and corruption model @@ -106,3 +111,22 @@ The first production shape is one writer with snapshot readers. This matches edge ingestion, makes commit order unambiguous, and avoids a coordination-heavy write path. Concurrent producers feed one bounded writer queue. Independent readers never mutate segment files. + +## FTW shadow boundary + +The first FTW link runs FTWDB as a local Unix sidecar while FTW keeps its +current data path and all control duties. The sidecar has its own process, +failure state, update path, and kill switch. A missing, slow, full, corrupt, or +stopped sidecar must not delay device reads, planning, dispatch, or safety +checks. + +One versioned wire batch maps to one atomic ordered-ingress frame. The batch +keeps catalog changes, runs, plans, points, and its source/sequence/commit +identity in the same recovery unit. A bounded nonblocking queue feeds the only +writer. Client errors reject one request; storage errors poison the writer and +require a checked reopen. + +FTWDB does not serve authoritative FTW reads during shadow collection. Read +promotion starts with a diagnostic comparison after replay, retry, overload, +soak, target-board power-cut, and rollback checks pass. See +[FTW shadow sidecar](shadow-sidecar.md). diff --git a/docs/format.md b/docs/format.md index be4419b..ed9e3c1 100644 --- a/docs/format.md +++ b/docs/format.md @@ -12,13 +12,16 @@ uncompressed; it is a durability test vehicle, not the final segment format. | 10 | 2 | reserved flags | | 12 | 4 | CRC32 of bytes 0..12 | +Reserved database, transaction, and transaction-record fields must be zero. +Readers reject non-zero values even when the checksum is valid. + ## Batch frame header (24 bytes) | Offset | Bytes | Field | |---:|---:|---| | 0 | 4 | ASCII `WBAT` | | 4 | 2 | frame version (`1`) | -| 6 | 2 | frame kind: `0` legacy points, `1` mixed transaction, `2` identified mixed transaction | +| 6 | 2 | frame kind: `0` legacy points, `1` mixed transaction, `2` identified mixed transaction, `3` ordered ingress transaction, `4` seal checkpoint, `5` identity index | | 8 | 4 | item count: points or transaction records | | 12 | 4 | payload bytes | | 16 | 4 | CRC32 of payload | @@ -74,5 +77,69 @@ whose identifier is already present writes nothing and reports deduplication. A duplicate identifier encountered in the log itself is reported as corruption, since the writer never appends one. +## Ordered ingress transaction payload + +A frame of kind `3` starts with this fixed 40-byte identity: + +| Offset | Bytes | Field | +|---:|---:|---| +| 0 | 16 | `source_id` as little-endian `u128` | +| 16 | 8 | `sequence` as little-endian `u64` | +| 24 | 16 | `commit_id` as little-endian `u128` | + +The identity is followed by the canonical kind `1` transaction payload. The +frame checksum covers both parts. + +FTWDB accepts any first sequence for a new non-zero source ID. It then accepts +only a strictly greater cursor; gaps are valid. An exact retry of a stored source and sequence reads +the original transaction bytes from the log and compares every byte. It +returns the original frame offset, record count, point count, and byte count +without writing. A matching CRC is only a fast check and never replaces the +byte comparison. Reusing a source sequence or commit ID for other data fails +without poisoning the writer. + +Recovery rebuilds the source watermarks and receipt indexes from complete +kind `3` frames. A torn last frame exposes neither its identity nor its data. +Duplicate keys or a source cursor that does not increase inside a complete log +are corruption. Kinds +`0` through `2` remain byte-compatible. + +## Seal checkpoint payload + +A frame of kind `4` carries a fixed 16-byte payload: + +| Offset | Bytes | Field | +|---:|---:|---| +| 0 | 8 | sealed manifest generation as little-endian `u64` | +| 8 | 8 | sealed point count as little-endian `u64` | + +The checkpoint is appended before the live log is reclaimed. Recovery treats an +item count other than zero, invalid payload length, generation zero, or a sealed +point count that differs from the live prefix as corruption. + +## Identity index payload + +A frame of kind `5` stores an index of identified and ordered-ingress receipts +written during log reclamation. The current payload starts with ASCII +`WIDX0002`, followed by the Postcard-encoded index. Each receipt keeps its exact +transaction payload as well as its length and CRC32. Receipts are sorted by +commit ID or `(source_id, sequence)`. The writer splits large indexes across +bounded kind-5 frames and sets their item count to zero. Recovery validates +each frame checksum, order, counts, payload metadata, and exact embedded +transaction before it accepts the compact log. An invalid or truncated index +causes a corruption error. + +After reclaim, identity replay verification compares the retained receipt +bytes in the compact log. Recovery uses those durable bytes to reject a +duplicate or cursor regression; it does not rely on a frame CRC alone. + +The reader still accepts the first kind-5 payload shape, which lacks retained +bytes. It keeps those IDs known but rejects any retry because length plus CRC32 +cannot prove byte equality. A later reclaim keeps that behavior. + +This compatibility is one-way. A reader that predates `WIDX0002` cannot open a +log after the new writer has reclaimed identified receipts. Take a verified +backup before the upgrade; a binary rollback also needs a pre-upgrade store. + The immutable segment format will be separately versioned and use per-column encoding, block checksums, sparse indexes, and footer redundancy. diff --git a/docs/operations.md b/docs/operations.md index e88189a..0faee26 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -5,8 +5,10 @@ `ftw check-store ` opens the store read-only: the active commit log is opened without write access under a shared lock, torn-tail recovery is simulated in memory, and no manifest generation is published, pruned, or swept. -It loads the highest valid manifest generation and re-opens every active rollup -segment, validating checksums, encoded lengths, aggregate invariants, +It checks each sealed raw file against its manifest content checksum, point +count, and time bounds. It loads the highest valid manifest generation and +re-opens every active rollup segment, validating checksums, encoded lengths, +aggregate invariants, descriptor coverage, and raw-source watermarks. The command emits a single JSON record with raw commit/point counts, active rollup file/bucket/byte counts, and `raw_recovered_tail_bytes` plus `raw_recovered_tail` fields that distinguish an @@ -58,7 +60,30 @@ the link error kind. If both fail, the returned I/O error keeps the copy error kind and its text and source also include the hard-link failure. This is a local consistent snapshot, not yet a remote backup policy. Encryption, -incremental upload, retention, and salvage of a corrupted source remain open. +incremental upload and remote retention remain open. A damaged source uses the +separate [strict salvage](#strict-salvage) path below. + +## Scheduled snapshot runbook + +Keep the scheduler on the host, not on the SD card. Cron or a systemd timer is +enough; do not add a second writer inside the sidecar. + +1. Stop `ftwdb-shadow`, or take the store's exclusive writer lock by stopping + every process that can append. A backup opens the source read-only under a + shared lock, so a live sidecar would block or race with the snapshot. +2. Run `ftw backup ` on a path that is + **not** on the same card as the live store. +3. Copy that destination off the card (rsync, `cp` to USB/NAS, or an existing + host backup job). Keep the on-host snapshot until the copy verifies. +4. Restore-verify: `ftw restore ` to a new + directory, then confirm `source_snapshot_crc32` equals + `destination_snapshot_crc32` and run `ftw check-store` on the target. +5. Restart the sidecar only after the backup JSON and CRC check succeed. + +Example timer (systemd): stop the sidecar, run backup to `/var/backups/ftwdb`, +copy that tree off-card, restore-verify to a throwaway directory, then start +the sidecar again. Example cron: the same steps in a root script invoked from +`crontab` on the host. Neither scheduler replaces physical media tests. ## Strict restore @@ -80,7 +105,8 @@ The selected snapshot contains: - `active.wlog`, always copied; - the selected manifest generation, when one exists; -- each active immutable rollup named by that manifest. +- each active immutable rollup named by that manifest; +- each sealed raw segment named by that manifest. Every selected path must be a regular file according to `symlink_metadata` and the opened file identity. Read-only file opens use no-follow and nonblocking @@ -114,19 +140,26 @@ on the result. ## Strict salvage `ftw salvage ` copies the longest valid raw-log -prefix into a new store. The command exits with code 2 for missing or extra +prefix into a new store, together with sealed raw segments the recovered +manifest still names. Pass `--drop-orphan-segments` to ignore `.wseg` files on +disk that the recovered manifest does not reference instead of failing closed. +The command exits with code 2 for missing or extra arguments. A valid database header followed by a damaged frame produces a verified `partial` result and exit code 0. Header, source-open, lock, -source-race, stage-check, or publication errors use exit code 1. The command -never changes the source. It has no replace option, and it leaves any existing -target, including an empty directory or dangling symlink, unchanged. +source-race, sealed-segment, stage-check, or publication errors use exit code +1. The command never changes the source. It has no replace option, and it +leaves any existing target, including an empty directory or dangling symlink, +unchanged. Salvage opens the source directory without following a symlink, then opens -only `active.wlog` relative to that directory with no-follow and nonblocking -flags. Both paths must keep the same file identity during the run, and -`active.wlog` must be a regular file. The command takes a shared lock on that -file. It does not read a manifest or rollup, so damaged, stale, and orphan -derived files do not affect the recovered raw prefix. +`active.wlog` relative to that directory with no-follow and nonblocking flags. +Both the directory and that file must keep the same file identity during the +run, and `active.wlog` must be a regular file. The command takes a shared lock +on that file. Damaged, stale, and orphan rollups do not affect the recovered +raw prefix. Sealed `.wseg` files named by a readable manifest are copied and +re-attached so historical raw stays queryable. A missing or unreadable sealed +segment fails closed; salvage never publishes a store that silently dropped +that coverage. The scanner first requires a valid FTWDB-v1 database header. It then validates each frame in order: bounds, kind and version, header and payload CRC, payload @@ -144,10 +177,13 @@ The fixed `stop_reason` values are: - `invalid-frame-magic`, `unsupported-frame-version`, and `frame-header-checksum-mismatch`; - `invalid-legacy-frame-size`, `transaction-frame-too-large`, - `identified-transaction-too-short`, and `unknown-frame-kind`; + `identified-transaction-too-short`, `ingress-transaction-too-short`, and + `unknown-frame-kind`; - `payload-checksum-mismatch`, `duplicate-commit-id`, + `duplicate-ingress-sequence`, `invalid-ingress-sequence`, `invalid-transaction`, `transaction-point-count-too-large`, and - `invalid-catalog-transaction`. + `invalid-catalog-transaction`; +- `seal-checkpoint-invalid` and `identity-index-invalid`. After a valid database header, each listed frame or transaction fault yields a `partial` result, including a fault in the first frame. A header-only source is @@ -155,18 +191,20 @@ After a valid database header, each listed frame or transaction fault yields a commits and points. An invalid or unsupported database header is fatal and publishes no target. -The new store contains the header and validated frames as `active.wlog`, plus -empty `manifests` and `rollups` directories. Salvage uses the same hidden-stage, +The new store contains the header and validated frames as `active.wlog`, empty +`rollups`, and — when sealed coverage exists — the recovered segment files plus +a manifest that names only those segments. Salvage uses the same hidden-stage, file sync, directory sync, identity rollback, and atomic no-clobber publication as restore. It opens both the stage and target read-only and runs the full store check with no recovery. The shared stage lock stays held through publication, the target check, and any rollback. -The source-prefix CRC32 uses the restore snapshot domain and one relative path, -`active.wlog`. It covers that path length and bytes, the recovered prefix -length, and the exact prefix bytes. The destination snapshot covers the same -path and bytes. Salvage compares the values before and after publication. CRC32 -detects many accidental changes but does not prove authenticity. +The source-prefix CRC32 uses the restore snapshot domain. It always covers +`active.wlog` at the recovered prefix length and bytes. When sealed segments +are recovered, their relative paths and exact bytes are included too. The +destination snapshot covers the same paths and bytes. Salvage compares the +values before and after publication. CRC32 detects many accidental changes but +does not prove authenticity. On success, `ftwdb-salvage-v1` reports `status`, `source_bytes`, `recovered_prefix_bytes`, `discarded_bytes`, `stop_offset`, `stop_reason`, @@ -190,6 +228,11 @@ until `ENOSPC`, then checks the readable durable prefix. See the emulator README for its command, output files, and pass result. This does not replace the M4 physical SD-card power-cut release gate. +[`linux-mid-commit.sh`](../bench/sd-card-emulator/linux-mid-commit.sh) cuts +NBD power **during** a live `Durability::Always` ingest and checks recovered +counts against the last fsynced ACK, not the full fixture. It needs Linux, +root, and NBD. Host unit tests cover the watermark verifier without a device. + ## Command-line checks `tests/cli.rs` runs the built `ftw` binary as a subprocess. It fixes the usage diff --git a/docs/releases.md b/docs/releases.md index 54a6f81..d767d01 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -16,6 +16,11 @@ changelog heading, and the tag without its leading `v` must match exactly. - **Stable** requires the relevant roadmap exit, a stated compatibility and support window, and no open release-blocking defect. +An alpha FTWDB candidate may be included as an opt-in, disposable shadow +collector in an FTW beta while SQLite/Parquet remain authoritative. That does +not satisfy the standalone FTWDB beta gate above. Release notes must state the +copy's coverage, loss conditions, resource limits, and rollback steps. + Pre-release versions may break APIs and formats between releases. Stable versions follow the compatibility promise published with that release. @@ -49,13 +54,18 @@ Pushing a matching tag starts `.github/workflows/release.yml`. The workflow: 1. checks that the tag is annotated and matches the exact Cargo/changelog version; 2. repeats the release test and package checks; -3. builds native `ftw` archives on GitHub's Linux and macOS runners; +3. builds and tests all three tools for Linux AMD64, Linux ARM64, and macOS; 4. creates `SHA256SUMS` for the archives; and 5. creates a GitHub prerelease when the tag contains a pre-release suffix. The archive name includes the tag and Rust host target. Each archive contains -the `ftw` binary, README, changelog, and license. GitHub Actions records the -source commit and workflow run. Alpha releases stay on GitHub; publishing the +`ftw`, `ftwdb-shadow`, `ftwdb-shadow-reconcile`, README, changelog, license, +recovery and protocol docs, and private service examples. `SOURCE.json` names +the exact source commit, version, target, and binary SHA-256 checksums. +`packaging/build-archive.py` unpacks the archive and runs all three versions +before upload. Linux binaries build in the pinned Debian 12 container so they +do not depend on the runner's newer glibc. CI runs the same packaging workflow +before a tag exists. GitHub Actions records the workflow run. Alpha releases stay on GitHub; publishing the crate to crates.io needs a separate decision and an explicit publish step. ## Maintainer steps @@ -67,7 +77,7 @@ crate to crates.io needs a separate decision and an explicit publish step. 5. Create an annotated tag on the exact merge commit with concise release notes and known limits. 6. Push only that tag and watch the Release workflow to completion. -7. Check the GitHub release, both archives, `SHA256SUMS`, and the source commit. +7. Check the GitHub release, all three archives, `SHA256SUMS`, and the source commit. Do not create a release from a feature branch, a dirty tree, or a commit whose CI result is unknown. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9aaaf4c..f4ec31b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -106,3 +106,24 @@ process-lock, sync-failure, `/dev/full`, and NBD tests, this closes the robustness gaps tracked in issue #17. Tests on the target board and SD cards, power cuts during commits, soak runs, remote backup policy, and the remaining result-verified adapters stay open and are required for the M4 exit. + +## M5: FTW shadow integration — foundation in progress + +- portable, versioned local wire contract for catalog, runs, plans, and points; +- durable source, sequence, and commit identity with exact replay receipts; +- bounded single-writer runtime with clear overload and poison states; +- local Unix sidecar with private path modes, peer-UID checks, clean signal shutdown, + and time, frame, client, and memory bounds; +- managed service files and live operational metrics; +- FTW async dual-write adapter, cross-language golden fixtures, and reconcile report; +- box metrics, week soak, live SD-write measurements, physical power cuts, and rollback. + +Exit: selected FTW beta boxes can enable and disable shadow capture without any +control-path effect; every accepted batch has a clear durability state; Go and +Rust agree on every contract fixture; restart, retry, disk-full, corruption, +and power-cut tests preserve one exact durable prefix; operators can compare, +export, restore, rotate, and remove the shadow store. + +The storage ingress frame, protocol draft, bounded writer, and sidecar work are +the first slice. FTWDB remains a shadow sink until every M4 hardware gate and +the M5 exit above pass. diff --git a/docs/rollups.md b/docs/rollups.md index 132084f..564cd5a 100644 --- a/docs/rollups.md +++ b/docs/rollups.md @@ -27,14 +27,25 @@ calculation on the hot materialized path. ## Durable publication -`Store::maintain(now)` performs this order: - -1. sync the raw commit log; -2. compute every completed configured bucket; -3. write and checksum an immutable `.rseg` file; -4. sync the file and publish its no-replace hard link; -5. sync the rollup directory; -6. publish and sync a new `MANIFEST.` file. +`Store::maintain(now)` syncs the raw commit log, then materializes only gauge +series that need work. A series is skipped when existing rollups already cover +every completed shard — typically because its append-only revision vector is +unchanged and `now` has not closed a new shard. Retention deactivation still +runs from existing descriptors. Unchanged series keep their `.rseg` files; if +another series moved the global point count, their active descriptors receive +a metadata-only `source_points` stamp so `query_gauge` stays on the +materialized path. When every active rollup is already current and nothing +needs retention or a newly closed shard, maintain publishes nothing. + +When a series does need work: + +1. compute completed configured buckets from the latest revisions in the + unfinished time window — sealed segments plus the live tail — rather than + scanning every historical point in RAM; +2. write and checksum an immutable `.rseg` file; +3. sync the file and publish its no-replace hard link; +4. sync the rollup directory; +5. publish and sync a new `MANIFEST.` file. Fixed 5-minute, 30-minute, and hourly buckets are grouped into stable completed UTC-day segments. Calendar day/month buckets are each an independent segment. @@ -74,6 +85,7 @@ range plus the series maximum-gap context, rather than rescanning all history. Raw retention is currently a safety report, not a deletion operation. A series is eligible only when every configured tier is current and covers all raw data -through the cutoff. The active log mixes catalog records and points, so actual -reclamation remains disabled until M4 compaction can rewrite retained records -without losing metadata or provenance. +through the cutoff. `Store::seal_and_reclaim` is the separate compaction that +moves live raw points into an immutable segment and rewrites `active.wlog` to +catalog plus identity receipts; it does not delete retained history. Physical +raw deletion stays gated until retention can drop sealed segment coverage. diff --git a/docs/segment-format.md b/docs/segment-format.md index e87a1d9..7ee96c0 100644 --- a/docs/segment-format.md +++ b/docs/segment-format.md @@ -16,7 +16,8 @@ segment names are never overwritten. The segment header contains magic/version, block and point counts, index bounds, and a header CRC32. The index has its own CRC32 and must exactly cover every -block byte in sorted `(series_id, min_valid_time)` order. +block byte in sorted `(series_id, min_valid_time)` order. Blocks in one series +must not overlap, apart from a shared boundary timestamp. Each block belongs to one series and stores min/max valid time, point count, encoding/compression IDs, encoded/stored sizes, a payload CRC32, and a header @@ -24,6 +25,30 @@ CRC32. A block holds at most 262,144 points, which bounds both decompression and the decoded point vector even when valid data reaches LZ4's maximum compression ratio. A query scans the small index and reads only overlapping blocks. +Reconciliation seeks into that index and charges every point in an overlapping +block against its scan limit before decoding. Timestamp filtering does not +hide decode work. It counts matches as it visits them, without collecting the +whole result first. The same limits span all series, segments, and the live tail. + +## Manifest binding + +Manifest version 3 stores a CRC32 of each raw segment's complete file. Open, +integrity checks, and salvage verify that checksum, point count, and exact +valid-time bounds before accepting the segment. A valid segment copied over +another store's named file therefore fails the check. CRC32 detects accidental +damage and file mix-ups; it does not authenticate files against deliberate edits. + +Open streams sealed files through a 64 KiB buffer to check this binding. It does +not decode points into the live index, but startup now reads all sealed bytes. +Measure that cost on the target card before enabling sealing in a live service. + +The reader still accepts the published alpha.1 manifest (version 1) and version +2 with no raw segments. Version 2 with sealed raw files was an unpublished +development format without content binding; the reader rejects it without +falling back to an older manifest. Keep the source and use a pre-seal snapshot +or a fresh shadow store. The new writer does not invent checksums for those +old files, since it cannot prove which contents the old writer published. + ## Column encoding The v1 encoded payload has seven length-delimited columns: @@ -50,4 +75,7 @@ data-driven by the energy corpus rather than globally fixed. Segment creation sorts a snapshot in memory. Reads are bounded per compressed block plus returned result, but large compactions still need an external/streamed merge path. Segments currently contain raw points only; catalog state remains -in the commit log and the M3 manifest coordinates segment publication. +in the commit log. The store manifest publishes each sealed segment, and +`query_latest` / `query_history` / `query_run` merge those files with the +unsealed tail. After reclaim, open does not reload sealed points into the live +index. diff --git a/docs/shadow-sidecar.md b/docs/shadow-sidecar.md new file mode 100644 index 0000000..eb9d09c --- /dev/null +++ b/docs/shadow-sidecar.md @@ -0,0 +1,264 @@ +# FTW shadow sidecar + +## Scope + +The shadow sidecar lets FTW copy data into FTWDB without changing control, +dispatch, or the current source of truth. FTW must keep running when the +sidecar is missing, slow, full, corrupt, or stopped. + +The first supported link is a local Unix socket. It has no TCP listener and no +remote write path. One sidecar owns one FTWDB store and one bounded writer. + +The sidecar is an evaluation path, not a production data authority. FTW keeps +its current SQLite and Parquet paths until the shadow checks below pass on real +boxes. + +## Data contract + +One accepted wire batch maps to one atomic FTWDB transaction. A batch can hold: + +- entities and topology relations; +- series definitions with units and physical meaning; +- forecast, import, control, optimization, and reconciliation runs; +- plan records and planned setpoints; +- telemetry, prices, forecasts, decisions, and hardware outcomes as points. + +Every point keeps FTWDB's full time and source record: + +- `valid_time` and exclusive `valid_time_end` in UTC microseconds; +- `knowledge_time`, when the value became known; +- `change_time`, when this revision was recorded; +- `run_id`, which links the value to its source run; +- `quality` and `flags`; +- an IEEE-754 `f64` value. + +FTW uses positive power into the site and negative power out of the site. The +adapter must apply that rule before it sends a batch. The catalog must state +the physical quantity, unit, and series meaning; the server must not infer +them from a series name. + +## Identity and retry + +Each source assigns a stable `source_id`, a strictly increasing `sequence`, and +a unique `commit_id` to every logical batch. A retry must reuse all three IDs +and the exact same transaction bytes. + +FTWDB stores the ingress identity in the same checked frame as the records. +After a restart it can return the first receipt for an exact retry. It rejects +a reused source sequence or commit ID when the transaction differs. This rule +avoids both duplicate points and silent replacement of a prior decision. + +The client may discard a batch only after the acknowledgement says that the +batch is durable. An accepted but non-durable batch must stay in the client's +bounded memory queue, or in an existing source store that can recreate the +same batch. The shadow path must not add a second small-write spool on the same +SD card. + +## Failure boundary + +The FTW adapter must use a bounded, nonblocking queue. A full queue drops or +marks shadow work; it never waits in a control or device loop. Connect, encode, +socket write, acknowledgement wait, and retry all run outside those loops. + +The sidecar owns the only writable FTWDB handle. It processes commits and +flushes in queue order. A storage I/O or sync error poisons that writer, rejects +later writes, and requires a reopen. A bad client batch returns a request error +without taking down a healthy writer. + +The current wire health reply reports writer status, queued operations, the +connected source's accepted and durable watermarks, overload and protocol-error +counts, database bytes, points, commits, recovered tail bytes, the live sync +policy, and whether the last acknowledgement was durable. Older v1 health +frames that omit the trailing ops fields still decode with zeroed counts and +`always` sync policy. The in-process runtime also tracks queue limits, +accepted, acknowledged, and failed counts, all known source watermarks, and the +latest fatal writer error. On clean shutdown, the service log reports the same +ops fields next to accepted clients, peer-auth failures, and client errors. + +Snapshot backup is still `ftw backup`. Stop the sidecar first (it holds the +exclusive writer lock), copy the published snapshot off the card, and +restore-verify CRC as in [`operations.md`](operations.md). + +## Flash-write policy + +The sidecar keeps `Durability::Always` fixed during the first beta work and +sends useful batches instead of one point per transaction. This gives a clear +acknowledgement contract but can issue too many syncs if the source sends small +batches. Do not change that default until target-box write counts and physical +power cuts prove a safer policy. + +`Durability::EveryBytes` exists in the storage layer, but the sidecar must not +use it for beta. A later change needs its own target-box write-count results, +physical power-cut evidence, and proof that the client retains and replays each +non-durable batch with the same IDs. + +Measure these values on the target box for each policy: + +- logical point and transaction bytes; +- bytes written to the FTWDB store; +- sync calls and batches per sync; +- p50, p95, p99, and maximum acknowledgement time; +- queue high-water mark and dropped shadow batches; +- boot replay time and peak resident memory; +- recovered prefix after each forced power cut. + +Do not enable raw deletion until immutable raw segments, rollups, manifests, +and restore tests prove that all required data remains available. + +## Bounded collection in the FTW beta + +The current candidate copies live history alongside SQLite/Parquet. It is an +opt-in experiment, not complete replication: client restart, a full queue, or +a long sidecar outage can leave gaps. The client must report those gaps. It +must not claim that historical corrections, deletes, forecasts, or config are +covered merely because the sidecar accepts those record types. + +The command has two positive byte-count settings: + +| Setting | Default | Effect | +|---|---:|---| +| `FTWDB_SHADOW_MAX_STORE_BYTES` | 536870912 (512 MiB) | Reject a new frame if it would exceed the store limit. | +| `FTWDB_SHADOW_MIN_FREE_BYTES` | 536870912 (512 MiB) | Keep this much free space, measured with the service user's available blocks, after the frame. | + +Invalid settings stop startup. A write that reaches either limit gets the +existing retryable `Overloaded` response with a fixed reason. Health becomes +`Degraded`; accepted and durable watermarks do not advance. Exact retries of +already stored data still receive their durable receipt, including after +restart. A changed retry still conflicts. After freeing disk space or raising the budget, restart the sidecar to +resume new writes. + +The bounded writer requires a store with no active rollups and runs without +background maintenance, sealing, or retention. That keeps the size check on +the only append path. `FTWDB_SHADOW_MAINTAIN_SECS` is no longer accepted by the +command. Use a fresh dedicated shadow store. Keep offline maintenance work on +a copy until its peak disk use has a tested budget. + +This check does not reserve filesystem blocks against other processes. Keep +SQLite's own disk alerts, monitor memory and CPU, and set service/container +limits. Do not treat a shared filesystem as full isolation. The systemd +example caps memory at 512 MiB and CPU at half a core. Those are evaluation +limits, not measured target-box requirements. + +On rollback, stop the sidecar and source copy, retain the current store, and +use a verified snapshot from before the upgrade or a new empty shadow store. +Do not open the upgraded store with the old alpha binary. Do not change the +SQLite/Parquet paths during this drill. + +## Local access + +The service creates or checks a store root owned by its effective user with +mode `0700`. It refuses a symlink, another owner, or any group or world access. +It also creates a private socket directory and a Unix socket with mode `0600`. +For every accepted connection, Linux and macOS ask the kernel for the peer's +effective UID. The service closes the connection before reading a frame unless +that UID matches the configured service UID. Never expose this protocol through +a TCP proxy in the beta. + +The command installs small SIGTERM and SIGINT handlers that only set an atomic +flag. A helper thread turns that flag into a server stop request. The server +then drains the writer, syncs the store, removes its socket, and returns a +normal exit code. The current two-second frame deadline also bounds shutdown +when a connected client stops sending bytes. + +The client must send `HELLO` first. The server rejects an unknown major version, +an unknown message kind, set reserved bits, a bad checksum, a frame above the +fixed size limit, a malformed record, and a request before `HELLO`. +A clean EOF before the next frame is a normal disconnect. An EOF inside a +header or body is a client error. + +## Frozen protocol fixtures + +`testdata/shadow-protocol-v1` contains one hex-encoded frame for every v1 +request and response kind, plus separate commit and flush acknowledgements. +The Rust integration test checks both directions against those bytes. The Go +adapter must use the same files; copied values or a second fixture generator do +not count as a shared contract test. + +The v1 source sequence is an opaque, strictly increasing source cursor. It may +contain gaps. An exact retry must reuse the same source ID, sequence, commit ID, +and transaction bytes. + +## Reconciliation report + +`shadow_reconcile::reconcile_shadow_batches` compares a bounded source window +without writing to the store. It checks: + +- each source and sequence against its stored ingress receipt; +- the exact canonical transaction bytes stored in that receipt's frame; +- commit ID, record count, point count, and current durability proof; +- the last supplied state of each entity, relation, series, run, and plan; +- exact point multiplicity and every point bit inside each supplied series' + smallest covered timestamp span. + +The report keeps full counts but caps mismatch details. Separate limits cap +input batches, metadata, expected points, observed points, and all raw series +entries visited before timestamp filtering. Catalog checks are one-way because +catalog objects do not yet keep an ingress source ID. The caller must pass +batches in the intended cross-source catalog order. A read-only open can prove +content but cannot claim that a prior writer synced a receipt. + +`ftwdb-shadow-reconcile ...` runs this +check offline against exact v1 commit frames and writes one stable JSON summary. +Stop the sidecar first: the read-only opener takes the store's shared lock and +will not bypass its active writer. +Exit code `3` means the command completed and found a content mismatch; a read or input error +uses exit code `2`. The JSON states that a read-only run has no durability +proof, so pair it with the sidecar's live durable watermark. +Each input must be a regular hex file no larger than one encoded protocol +frame. The command also caps frame count, decoded bytes, metadata records, and +points before it opens the store. Its current decoded-input cap is 256 MiB. + +## Required tests before an FTW beta + +### Contract + +- frozen byte fixtures for every v1 message; +- round trips for every catalog record and every point field; +- unknown version, kind, flag, trailing byte, bad checksum, short read, and + maximum-size cases; +- clean frame-boundary EOF versus a partial-frame EOF; +- Go and Rust encode/decode checks against the same fixtures; +- sign, unit, UTC, interval, revision, run, plan, and outcome examples. + +### Retry and order + +- exact retry before and after reopen returns the original receipt; +- same sequence with another commit ID fails; +- same commit ID with other data fails; +- a new cursor that is equal to or below the prior cursor fails without poisoning the writer; +- an acknowledgement lost after a durable write does not duplicate data; +- a flush covers every earlier accepted batch and no later batch. + +### Failure and load + +- hard queue bound under slow and stopped writers; +- stalled and malformed clients cannot grow memory without limit; +- invalid input does not stop later valid input; +- storage write and sync faults poison the writer and stop later storage calls; +- `ENOSPC`, process kill, torn frame, corrupt frame, and restart checks; +- hour, day, and week soaks at the highest real box rate; +- real target-board power cuts during writes and flushes; +- fixed memory and boot-time limits at 14 days, 90 days, and the planned + retention limit. + +### FTW detachment + +- unplug or kill the sidecar while FTW controls real or simulated hardware; +- fill the queue and the filesystem while control timing stays within its + current limit; +- corrupt the shadow store while FTW keeps its current source of truth; +- disable the source flag and sidecar service separately; +- compare source rows, shadow rows, plans, decisions, and outcomes with a + stable reconciliation report whose receipt checks use exact stored bytes. + +## Rollout gates + +1. Run protocol and storage tests in CI. Keep the FTW adapter disabled. +2. Enable bounded shadow writes on test boxes. Do not serve reads from FTWDB. +3. Run a long soak and physical power-cut set. Record write volume and recovery. +4. Enable shadow reads only in a diagnostic comparison view. +5. Let selected beta users opt in after rollback, export, and alert checks pass. +6. Move one read path at a time. Keep all hardware control on the current path. + +FTWDB must not become a control dependency during these gates. A later move +from shadow data to advisory or live control needs a separate safety review. diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..6de1a3d --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,84 @@ +# Managed shadow sidecar examples + +These files are install examples, not ready-made production units. Change the +binary path, service user, group, store path, and socket path for each target. +The FTW client and `ftwdb-shadow` must run with the same effective user ID. The +server rejects a client with another user ID. + +Keep the store and socket parent at mode `0700`. Do not put either path in +`/tmp`, a home directory, or a directory shared with another service. The +examples expose only a Unix socket. They do not open a TCP or UDP port. + +## systemd on Linux + +Copy `systemd/ftwdb-shadow.service` to the system unit directory after changing +all target values. `StateDirectory` and `RuntimeDirectory` create the two +writable paths while `ProtectSystem=strict` keeps the rest of the file system +read-only for the process. + +The sample user is `ftw`. Use the account that also runs the FTW client. Do not +set `DynamicUser=yes`: the sidecar and client need one stable, shared user ID. + +Run these checks on the target box before enabling the unit: + +```sh +systemd-analyze verify /etc/systemd/system/ftwdb-shadow.service +systemd-analyze security ftwdb-shadow.service +systemctl start ftwdb-shadow.service +systemctl stop ftwdb-shadow.service +``` + +Confirm that start creates a `0700` store directory, a `0700` runtime +directory, and a `0600` socket. Confirm that stop lets the process exit before +the 30-second limit. The Linux unit still needs a test on each target box. Old +systemd or kernel versions may not support every hardening setting. + +## launchd on macOS + +Change the values in `launchd/com.sourceful.ftwdb-shadow.plist`. Create and own +the paths before loading the job because launchd does not create them: + +```sh +sudo install -d -o ftw -g ftw -m 0700 /var/db/ftwdb-shadow +sudo install -d -o ftw -g ftw -m 0700 /var/run/ftwdb-shadow +plutil -lint packaging/launchd/com.sourceful.ftwdb-shadow.plist +``` + +Install the plist under `/Library/LaunchDaemons` as a root-owned file with mode +`0644`, then use `launchctl bootstrap system` to load it. launchd sends +`SIGTERM` on stop and gives the process 30 seconds to finish. The job restarts +after a failed exit but stays down after a clean stop. + +Run the local regression check with: + +```sh +cargo test --test service_examples +``` + +## Container + +Build with `docker build -t ftwdb-shadow .`. The image contains all three tools +and runs as UID `100`, GID `101`. It uses `/var/lib/ftwdb-shadow` for data and +`/run/ftwdb-shadow/shadow.sock` for its only listener. Prepare mounted directories +with this owner and mode `0700`; a root-owned bind mount is not writable by the +service. The FTW client must share UID `100` and the socket directory. + +The Core repository owns the opt-in Compose overlay. Run the sidecar with +`network_mode: none`, a read-only root, separate writable data and socket +mounts, no extra capabilities, and explicit CPU/memory limits. Keep its store +separate from SQLite's data directory. Set the disk budget for that volume; +a filesystem with less than the default free-space reserve will reject writes. + +Run `bash packaging/check-container.sh ftwdb-shadow` for a bounded start/stop +check with no network and private temporary mounts. The script verifies the +user, directory and socket modes, all three tools, and the clean exit code. +It does not replace a test on the physical box. + +## Native archive + +Run `cargo build --release --locked --bins`, then +`python3 packaging/build-archive.py --require-clean`. The script creates an +archive under `dist/`, checks its extracted binaries, and writes a SHA-256 +file. The archive includes `SOURCE.json`, operational docs, and both service +examples. CI builds Linux ARM64 and AMD64 from the same Debian 12 image and +also builds a native macOS archive. diff --git a/packaging/build-archive.py b/packaging/build-archive.py new file mode 100644 index 0000000..316c6e6 --- /dev/null +++ b/packaging/build-archive.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Build and check a native release archive from already compiled binaries.""" + +import argparse +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tarfile +import tempfile + +ROOT = Path(__file__).resolve().parent.parent +BINARIES = ("ftw", "ftwdb-shadow", "ftwdb-shadow-reconcile") +DOCUMENTS = ( + "README.md", "CHANGELOG.md", "LICENSE", "docs/operations.md", "docs/format.md", + "docs/shadow-sidecar.md", "docs/releases.md", "packaging/README.md", + "packaging/systemd/ftwdb-shadow.service", + "packaging/launchd/com.sourceful.ftwdb-shadow.plist", + "testdata/shadow-protocol-v1/SHA256SUMS", +) + + +def output(*args): + return subprocess.check_output(args, cwd=ROOT, text=True).strip() + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--require-clean", action="store_true") + args = parser.parse_args() + package = json.loads(output("cargo", "metadata", "--no-deps", "--format-version", "1", "--locked", "--offline")) + version = next(p["version"] for p in package["packages"] if p["name"] == "ftwdb") + target = next(line.removeprefix("host: ") for line in output("rustc", "-vV").splitlines() if line.startswith("host: ")) + dirty = bool(output("git", "status", "--porcelain")) + if args.require_clean and dirty: + raise SystemExit("release archive requires a clean source tree") + name = f"ftw-v{version}-{target}" + dist = ROOT / "dist" + dist.mkdir(exist_ok=True) + archive = dist / f"{name}.tar.gz" + with tempfile.TemporaryDirectory(prefix="ftwdb-archive-") as temporary: + stage = Path(temporary) / name + stage.mkdir() + for binary in BINARIES: + shutil.copy2(ROOT / "target" / "release" / binary, stage / binary) + for document in DOCUMENTS: + destination = stage / document + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / document, destination) + metadata = { + "schema": "ftwdb-release-v1", "version": version, "target": target, + "source_commit": output("git", "rev-parse", "HEAD"), "source_dirty": dirty, + "binaries": {binary: sha256(stage / binary) for binary in BINARIES}, + } + (stage / "SOURCE.json").write_text(json.dumps(metadata, indent=2) + "\n") + with tarfile.open(archive, "w:gz") as bundle: + bundle.add(stage, arcname=name) + # Run the files from the actual archive, including both shadow tools. + extracted = Path(temporary) / "checked" + with tarfile.open(archive) as bundle: + for member in bundle: + relative = Path(member.name) + if relative.is_absolute() or ".." in relative.parts: + raise SystemExit("unsafe archive path") + if member.isdir(): + continue + if not member.isfile(): + raise SystemExit("archive must contain only regular files") + destination = extracted / relative + destination.parent.mkdir(parents=True, exist_ok=True) + with bundle.extractfile(member) as source, destination.open("wb") as target_file: + shutil.copyfileobj(source, target_file) + destination.chmod(0o755 if relative.name in BINARIES else 0o644) + for binary in BINARIES: + path = extracted / name / binary + if sha256(path) != metadata["binaries"][binary]: + raise SystemExit(f"archive checksum mismatch: {binary}") + if output(str(path), "--version") != f"{binary} {version}": + raise SystemExit(f"archive version mismatch: {binary}") + digest = sha256(archive) + (dist / f"{archive.name}.sha256").write_text(f"{digest} {archive.name}\n") + print(archive) + + +if __name__ == "__main__": + main() diff --git a/packaging/check-container.sh b/packaging/check-container.sh new file mode 100644 index 0000000..76fae22 --- /dev/null +++ b/packaging/check-container.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +image=${1:?usage: check-container.sh IMAGE} +container=$(docker create --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges --memory 512m --cpus 0.5 --pids-limit 32 \ + --tmpfs /var/lib/ftwdb-shadow:rw,noexec,nosuid,size=16m,uid=100,gid=101,mode=0700 \ + --tmpfs /run/ftwdb-shadow:rw,noexec,nosuid,size=1m,uid=100,gid=101,mode=0700 \ + --env FTWDB_SHADOW_MIN_FREE_BYTES=1048576 "$image") +trap 'docker rm -f "$container" >/dev/null' EXIT +docker start "$container" >/dev/null +for _ in {1..100}; do + if docker exec "$container" test -S /run/ftwdb-shadow/shadow.sock; then + break + fi + sleep 0.1 +done +docker exec "$container" sh -ec ' + test "$(id -u)" = 100 + test "$(id -g)" = 101 + test "$(stat -c %a /var/lib/ftwdb-shadow)" = 700 + test "$(stat -c %a /run/ftwdb-shadow)" = 700 + test "$(stat -c %a /run/ftwdb-shadow/shadow.sock)" = 600 + ftw --version + ftwdb-shadow --version + ftwdb-shadow-reconcile --version +' +docker stop --time 10 "$container" >/dev/null +test "$(docker inspect --format '{{.State.ExitCode}}' "$container")" = 0 +docker logs "$container" 2>&1 | grep -F 'ftwdb-shadow: stopped' diff --git a/packaging/launchd/com.sourceful.ftwdb-shadow.plist b/packaging/launchd/com.sourceful.ftwdb-shadow.plist new file mode 100644 index 0000000..e57aa1f --- /dev/null +++ b/packaging/launchd/com.sourceful.ftwdb-shadow.plist @@ -0,0 +1,51 @@ + + + + + Label + com.sourceful.ftwdb-shadow + + ProgramArguments + + /usr/local/libexec/ftwdb-shadow + /var/db/ftwdb-shadow + /var/run/ftwdb-shadow/ftwdb-shadow.sock + + + + UserName + ftw + GroupName + ftw + + WorkingDirectory + /var/db/ftwdb-shadow + Umask + 63 + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 5 + ExitTimeOut + 30 + ProcessType + Background + + SoftResourceLimits + + NumberOfFiles + 1024 + + HardResourceLimits + + NumberOfFiles + 1024 + + + diff --git a/packaging/systemd/ftwdb-shadow.service b/packaging/systemd/ftwdb-shadow.service new file mode 100644 index 0000000..a04aef0 --- /dev/null +++ b/packaging/systemd/ftwdb-shadow.service @@ -0,0 +1,56 @@ +[Unit] +Description=FTWDB shadow database sidecar +Documentation=https://github.com/srcfl/ftwdb +After=local-fs.target +StartLimitIntervalSec=60s +StartLimitBurst=5 + +[Service] +Type=simple + +# The FTW client must run as this same user. The sidecar checks the peer UID. +User=ftw +Group=ftw +UMask=0077 + +RuntimeDirectory=ftwdb-shadow +RuntimeDirectoryMode=0700 +StateDirectory=ftwdb-shadow +StateDirectoryMode=0700 +ExecStart=/usr/local/libexec/ftwdb-shadow /var/lib/ftwdb-shadow /run/ftwdb-shadow/ftwdb-shadow.sock + +Restart=on-failure +RestartSec=5s +KillSignal=SIGTERM +TimeoutStopSec=30s + +NoNewPrivileges=yes +CapabilityBoundingSet= +AmbientCapabilities= +PrivateDevices=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +RestrictAddressFamilies=AF_UNIX +IPAddressDeny=any +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +TasksMax=64 +LimitNOFILE=1024 +MemoryMax=512M +CPUQuota=50% +Nice=10 +IOSchedulingClass=idle + +[Install] +WantedBy=multi-user.target diff --git a/src/aggregate.rs b/src/aggregate.rs index 7c24351..3dea948 100644 --- a/src/aggregate.rs +++ b/src/aggregate.rs @@ -59,10 +59,17 @@ impl GaugeAggregate { if sample.timestamp < self.last.timestamp { return Err(Error::InvalidArgument("samples must be time ordered")); } - let gap = sample.timestamp - self.last.timestamp; + let gap = sample + .timestamp + .checked_sub(self.last.timestamp) + .ok_or(Error::InvalidArgument("sample timestamps overflow a gap"))?; if gap <= max_gap_micros { + let covered = self + .covered_micros + .checked_add(gap) + .ok_or(Error::InvalidArgument("aggregate coverage overflows"))?; self.integral_value_micros += self.last.value * gap as f64; - self.covered_micros += gap; + self.covered_micros = covered; } self.count += 1; self.sum += sample.value; @@ -84,12 +91,23 @@ impl GaugeAggregate { "aggregate states must be time ordered", )); } - let gap = next.first.timestamp - self.last.timestamp; + let gap = next + .first + .timestamp + .checked_sub(self.last.timestamp) + .ok_or(Error::InvalidArgument( + "aggregate timestamps overflow a gap", + ))?; let (bridge_integral, bridge_coverage) = if gap <= max_gap_micros { (self.last.value * gap as f64, gap) } else { (0.0, 0) }; + let covered_micros = self + .covered_micros + .checked_add(bridge_coverage) + .and_then(|value| value.checked_add(next.covered_micros)) + .ok_or(Error::InvalidArgument("aggregate coverage overflows"))?; Ok(Self { count: self.count + next.count, @@ -101,7 +119,7 @@ impl GaugeAggregate { integral_value_micros: self.integral_value_micros + bridge_integral + next.integral_value_micros, - covered_micros: self.covered_micros + bridge_coverage + next.covered_micros, + covered_micros, }) } @@ -201,6 +219,17 @@ mod tests { use super::{CounterAggregate, GaugeAggregate, Sample}; use crate::Error; + #[test] + fn overflowing_sample_gap_errors_instead_of_panicking() { + let mut aggregate = GaugeAggregate::from_sample(Sample::new(i64::MIN, 1.0)); + assert!(matches!( + aggregate.push(Sample::new(i64::MAX, 2.0), i64::MAX), + Err(Error::InvalidArgument(_)) + )); + assert_eq!(aggregate.count, 1); + assert_eq!(aggregate.last.timestamp, i64::MIN); + } + #[test] fn merged_gauge_matches_direct_aggregation() { let max_gap = 10_000_000; diff --git a/src/bin/ftw.rs b/src/bin/ftw.rs index 2f73daf..012e20e 100644 --- a/src/bin/ftw.rs +++ b/src/bin/ftw.rs @@ -1,13 +1,13 @@ use flate2::read::GzDecoder; use ftwdb::{ - BackupReport, Config, Database, Durability, EnergyWorkload, Error, RestoreReport, - RollupResolution, SalvageReport, Store, Transaction, WorkloadConfig, gauge_bucket_checksum, - load_real_fixture, load_tsbs_iot, + BackupReport, Config, Database, Durability, EnergyWorkload, Error, MaintenanceReport, + RestoreReport, RollupResolution, SalvageOptions, SalvageReport, SealReport, Store, Transaction, + WorkloadConfig, gauge_bucket_checksum, load_real_fixture_with_ack, load_tsbs_iot, }; use std::env; use std::fs::File; -use std::io::{BufReader, stdin}; -use std::path::Path; +use std::io::{BufReader, Write, stdin}; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::Instant; @@ -37,9 +37,15 @@ impl From for CliError { fn main() -> ExitCode { let arguments: Vec<_> = env::args().collect(); + if arguments.len() == 2 && arguments[1] == "--version" { + println!("ftw {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } let result = match arguments.get(1).map(String::as_str) { Some("inspect") => inspect(&arguments[2..]), Some("check-store") => check_store(&arguments[2..]), + Some("seal") => seal(&arguments[2..]), + Some("maintain") => maintain(&arguments[2..]), Some("backup") => backup(&arguments[2..]), Some("restore") => restore(&arguments[2..]), Some("salvage") => salvage(&arguments[2..]), @@ -78,6 +84,7 @@ fn bench_real_fixture(arguments: &[String]) -> CliResult<()> { let mut durability = Durability::Always; let mut durability_name = "always".to_owned(); let mut batch_points = 10_000_usize; + let mut ack_log = None::; let mut index = 2; while index < arguments.len() { let option = &arguments[index]; @@ -86,6 +93,7 @@ fn bench_real_fixture(arguments: &[String]) -> CliResult<()> { .ok_or_else(|| usage_error(format!("missing value for {option}")))?; match option.as_str() { "--batch-points" => batch_points = parse(value, option)?, + "--ack-log" => ack_log = Some(PathBuf::from(value)), "--durability" if value == "always" => { durability = Durability::Always; durability_name = value.clone(); @@ -128,10 +136,21 @@ fn bench_real_fixture(arguments: &[String]) -> CliResult<()> { )?; let file = File::open(input)?; let reader = BufReader::new(GzDecoder::new(file)); + let mut ack_file = ack_log.as_ref().map(File::create).transpose()?; let started = Instant::now(); - let report = load_real_fixture(reader, &mut store, batch_points)?; + let report = load_real_fixture_with_ack(reader, &mut store, batch_points, |ack| { + if let Some(file) = ack_file.as_mut() { + writeln!( + file, + "{{\"format\":\"ftwdb-ack-watermark-v1\",\"commits\":{},\"points\":{},\"durable\":{}}}", + ack.commits, ack.points, ack.durable + )?; + file.sync_data()?; + } + Ok(()) + })?; let ingest_seconds = started.elapsed().as_secs_f64(); - let stored_bytes = directory_bytes(database_directory)?; + let stored_bytes = store.stored_bytes()?; let points_per_second = report.points as f64 / ingest_seconds; let bytes_per_point = if report.points == 0 { 0.0 @@ -229,7 +248,7 @@ fn bench_tsbs_iot(arguments: &[String]) -> CliResult<()> { load_tsbs_iot(BufReader::new(File::open(input)?), &mut store, batch_rows)? }; let ingest_seconds = started.elapsed().as_secs_f64(); - let stored_bytes = directory_bytes(database_directory)?; + let stored_bytes = store.stored_bytes()?; let points_per_second = report.points as f64 / ingest_seconds; let rows_per_second = report.rows as f64 / ingest_seconds; let bytes_per_point = if report.points == 0 { @@ -282,6 +301,86 @@ fn check_store(arguments: &[String]) -> CliResult<()> { Ok(()) } +fn seal(arguments: &[String]) -> CliResult<()> { + if arguments.len() != 1 { + return Err(usage_error("seal requires one store directory")); + } + let mut store = Store::open(&arguments[0])?; + let report = store.seal_and_reclaim()?; + println!("{}", seal_report_json(&report)); + Ok(()) +} + +fn maintain(arguments: &[String]) -> CliResult<()> { + let mut store_path = None; + let mut now_micros = None; + let mut index = 0; + while index < arguments.len() { + match arguments[index].as_str() { + "--now-micros" => { + let value = arguments + .get(index + 1) + .ok_or_else(|| usage_error("maintain --now-micros requires a value"))?; + now_micros = Some(parse(value, "--now-micros")?); + index += 2; + } + option if option.starts_with("--") => { + return Err(usage_error(format!("maintain does not support {option}"))); + } + path => { + if store_path.is_some() { + return Err(usage_error("maintain accepts one store directory")); + } + store_path = Some(path.to_owned()); + index += 1; + } + } + } + let Some(store_path) = store_path else { + return Err(usage_error("maintain requires one store directory")); + }; + let mut store = Store::open(&store_path)?; + let now_micros = now_micros.unwrap_or_else(current_time_micros); + let report = store.maintain(now_micros)?; + println!("{}", maintain_report_json(&report)); + Ok(()) +} + +fn current_time_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros(), + ) + .unwrap_or(i64::MAX) +} + +fn seal_report_json(report: &SealReport) -> String { + format!( + "{{\"format\":\"ftwdb-seal-v1\",\"manifest_generation\":{},\"segment_file\":\"{}\",\"sealed_points\":{},\"live_points\":{},\"segment_bytes\":{},\"log_bytes\":{}}}", + report.manifest_generation, + report.segment_file, + report.sealed_points, + report.live_points, + report.segment_bytes, + report.log_bytes + ) +} + +fn maintain_report_json(report: &MaintenanceReport) -> String { + format!( + "{{\"format\":\"ftwdb-maintain-v1\",\"manifest_generation\":{},\"rollup_files_written\":{},\"rollup_buckets_written\":{},\"rollup_bytes_written\":{},\"retention_gates\":{}}}", + report.manifest_generation, + report.rollup_files_written, + report.rollup_buckets_written, + report.rollup_bytes_written, + report.retention_gates.len() + ) +} + fn backup(arguments: &[String]) -> CliResult<()> { if arguments.len() != 2 { return Err(usage_error( @@ -320,12 +419,36 @@ fn restore_report_json(report: &RestoreReport) -> String { } fn salvage(arguments: &[String]) -> CliResult<()> { - if arguments.len() != 2 { + let mut drop_orphan_segments = false; + let mut positional = Vec::new(); + let mut index = 0; + while index < arguments.len() { + match arguments[index].as_str() { + "--drop-orphan-segments" => { + drop_orphan_segments = true; + index += 1; + } + option if option.starts_with("--") => { + return Err(usage_error(format!("salvage does not support {option}"))); + } + value => { + positional.push(value.to_owned()); + index += 1; + } + } + } + if positional.len() != 2 { return Err(usage_error( "salvage requires a damaged store and absent target directory", )); } - let report = Store::salvage_from(&arguments[0], &arguments[1])?; + let report = Store::salvage_from_with_options( + &positional[0], + &positional[1], + SalvageOptions { + drop_orphan_segments, + }, + )?; println!("{}", salvage_report_json(&report)); Ok(()) } @@ -525,7 +648,7 @@ fn bench_ftwdb(arguments: &[String]) -> CliResult<()> { if gauge_bucket_checksum(&warm_query.buckets) != result_crc { return Err(runtime_invalid("cold and warm rollup query results differ")); } - let stored_bytes = directory_bytes(database_directory)?; + let stored_bytes = store.stored_bytes()?; let points_per_second = summary.points as f64 / ingest_seconds; println!( @@ -547,20 +670,6 @@ fn bench_ftwdb(arguments: &[String]) -> CliResult<()> { Ok(()) } -fn directory_bytes(path: &Path) -> CliResult { - let mut total = 0_u64; - for entry in std::fs::read_dir(path)? { - let entry = entry?; - let metadata = entry.metadata()?; - if metadata.is_dir() { - total = total.saturating_add(directory_bytes(&entry.path())?); - } else { - total = total.saturating_add(metadata.len()); - } - } - Ok(total) -} - fn parse(value: &str, option: &str) -> CliResult where T: std::str::FromStr, @@ -602,7 +711,7 @@ fn runtime_invalid(reason: impl Into) -> CliError { fn usage(program: &str) { eprintln!( - "usage:\n {program} inspect \n {program} check-store \n {program} backup \n {program} restore \n {program} salvage \n {program} generate [--seed N] [--sites N] [--days N] [--cadence-seconds N] [--start-micros N]\n {program} bench-ftwdb [--durability always|manual] [--batch-points N]\n {program} bench-real-fixture [--durability always|manual|every-bytes:N] [--batch-points N]\n {program} bench-tsbs-iot [--durability always|manual|every-bytes:N] [--batch-rows N]" + "usage:\n {program} inspect \n {program} check-store \n {program} seal \n {program} maintain [--now-micros N]\n {program} backup \n {program} restore \n {program} salvage [--drop-orphan-segments]\n {program} generate [--seed N] [--sites N] [--days N] [--cadence-seconds N] [--start-micros N]\n {program} bench-ftwdb [--durability always|manual] [--batch-points N]\n {program} bench-real-fixture [--durability always|manual|every-bytes:N] [--batch-points N] [--ack-log FILE]\n {program} bench-tsbs-iot [--durability always|manual|every-bytes:N] [--batch-rows N]" ); } diff --git a/src/bin/ftwdb-shadow-reconcile.rs b/src/bin/ftwdb-shadow-reconcile.rs new file mode 100644 index 0000000..8a62250 --- /dev/null +++ b/src/bin/ftwdb-shadow-reconcile.rs @@ -0,0 +1,295 @@ +use ftwdb::Store; +use ftwdb::shadow_protocol::{self, Request, WireMessage}; +use ftwdb::shadow_reconcile::{ShadowReconcileLimits, ShadowReconciliationReport}; +use std::env; +use std::fs::{self, File}; +use std::io::Read; +use std::path::Path; +use std::process::ExitCode; + +const MAX_HEX_FILE_BYTES: usize = shadow_protocol::MAX_FRAME_BYTES * 2 + 2; +const MAX_TOTAL_FRAME_BYTES: usize = 256 * 1024 * 1024; + +fn main() -> ExitCode { + if env::args_os().len() == 2 && env::args_os().nth(1).as_deref() == Some("--version".as_ref()) { + println!("ftwdb-shadow-reconcile {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + match run(env::args().skip(1)) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(3), + Err(error) => { + eprintln!("ftwdb-shadow-reconcile: {error}"); + usage(); + ExitCode::from(2) + } + } +} + +fn run(arguments: impl IntoIterator) -> Result { + let limits = ShadowReconcileLimits::default(); + let (store_argument, frame_paths) = collect_arguments(arguments, limits.max_batches)?; + let store_path = Path::new(&store_argument); + let mut batches = Vec::with_capacity(frame_paths.len()); + let mut total_frame_bytes = 0_usize; + let mut total_metadata_records = 0_usize; + let mut total_points = 0_usize; + for frame_path in &frame_paths { + let text = read_hex_file(Path::new(frame_path))?; + let frame = decode_hex(&text).map_err(|error| format!("decode {frame_path}: {error}"))?; + total_frame_bytes = add_with_limit( + total_frame_bytes, + frame.len(), + MAX_TOTAL_FRAME_BYTES, + "decoded frame bytes", + )?; + let message = shadow_protocol::decode(&frame) + .map_err(|error| format!("parse {frame_path}: {error}"))?; + let WireMessage::Request(Request::CommitBatch(batch)) = message else { + return Err(format!("{frame_path} is not a commit-batch request")); + }; + let metadata_records = batch + .entities + .len() + .checked_add(batch.relations.len()) + .and_then(|count| count.checked_add(batch.series.len())) + .and_then(|count| count.checked_add(batch.runs.len())) + .and_then(|count| count.checked_add(batch.plans.len())) + .ok_or_else(|| "metadata record count overflows".to_owned())?; + total_metadata_records = add_with_limit( + total_metadata_records, + metadata_records, + limits.max_metadata_records, + "metadata records", + )?; + total_points = add_with_limit( + total_points, + batch.points.len(), + limits.max_expected_points, + "expected points", + )?; + batches.push(batch); + } + + let store = Store::open_read_only(store_path) + .map_err(|error| format!("open {} read-only: {error}", store_path.display()))?; + let report = store + .reconcile_shadow_batches(&batches, limits) + .map_err(|error| format!("reconcile: {error}"))?; + println!("{}", report_json(&report)); + for detail in &report.mismatch_details { + eprintln!("mismatch: {detail:?}"); + } + Ok(report.content_matches()) +} + +fn collect_arguments( + arguments: impl IntoIterator, + max_batches: usize, +) -> Result<(String, Vec), String> { + let mut arguments = arguments.into_iter(); + let Some(store) = arguments.next() else { + return Err("a store directory and at least one commit frame are required".to_owned()); + }; + let mut frame_paths = Vec::new(); + for frame_path in arguments { + if frame_paths.len() == max_batches { + return Err(format!( + "commit frame count exceeds the limit of {max_batches}" + )); + } + frame_paths.push(frame_path); + } + if frame_paths.is_empty() { + return Err("a store directory and at least one commit frame are required".to_owned()); + } + Ok((store, frame_paths)) +} + +fn read_hex_file(path: &Path) -> Result { + let path_text = path.display(); + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("inspect {path_text}: {error}"))?; + if !metadata.file_type().is_file() { + return Err(format!("{path_text} is not a regular file")); + } + if metadata.len() > MAX_HEX_FILE_BYTES as u64 { + return Err(format!("{path_text} exceeds the encoded frame limit")); + } + + let mut file = File::open(path).map_err(|error| format!("open {path_text}: {error}"))?; + let opened_metadata = file + .metadata() + .map_err(|error| format!("inspect opened {path_text}: {error}"))?; + if !opened_metadata.file_type().is_file() { + return Err(format!("{path_text} changed to a non-regular file")); + } + let mut bytes = Vec::with_capacity( + usize::try_from(opened_metadata.len()) + .unwrap_or(MAX_HEX_FILE_BYTES) + .min(MAX_HEX_FILE_BYTES), + ); + file.by_ref() + .take((MAX_HEX_FILE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("read {path_text}: {error}"))?; + if bytes.len() > MAX_HEX_FILE_BYTES { + return Err(format!("{path_text} exceeds the encoded frame limit")); + } + String::from_utf8(bytes).map_err(|_| format!("{path_text} is not UTF-8 hex text")) +} + +fn add_with_limit( + current: usize, + addition: usize, + maximum: usize, + name: &str, +) -> Result { + let total = current + .checked_add(addition) + .ok_or_else(|| format!("{name} count overflows"))?; + if total > maximum { + return Err(format!("{name} exceed the limit of {maximum}")); + } + Ok(total) +} + +fn decode_hex(input: &str) -> Result, &'static str> { + let input = input.trim(); + if !input.len().is_multiple_of(2) { + return Err("hex has an odd number of digits"); + } + if input.len() / 2 > shadow_protocol::MAX_FRAME_BYTES { + return Err("frame exceeds the protocol limit"); + } + input + .as_bytes() + .chunks_exact(2) + .map(|pair| { + let high = hex_digit(pair[0])?; + let low = hex_digit(pair[1])?; + Ok((high << 4) | low) + }) + .collect() +} + +const fn hex_digit(value: u8) -> Result { + match value { + b'0'..=b'9' => Ok(value - b'0'), + b'a'..=b'f' => Ok(value - b'a' + 10), + b'A'..=b'F' => Ok(value - b'A' + 10), + _ => Err("frame contains a non-hex byte"), + } +} + +fn report_json(report: &ShadowReconciliationReport) -> String { + format!( + concat!( + "{{\"content_matches\":{},\"read_only_durability_proof\":false,", + "\"expected_batches\":{},\"matching_receipts\":{},", + "\"missing_receipts\":{},\"conflicting_receipts\":{},", + "\"receipt_shape_mismatches\":{},\"receipt_payload_mismatches\":{},", + "\"nondurable_receipts\":{},", + "\"expected_catalog_objects\":{},\"matching_catalog_objects\":{},", + "\"missing_catalog_objects\":{},\"different_catalog_objects\":{},", + "\"expected_points\":{},\"scanned_points\":{},\"observed_points\":{},", + "\"matching_points\":{},\"missing_points\":{},", + "\"unexpected_points\":{},\"mismatch_groups\":{},", + "\"reported_mismatch_details\":{},\"details_truncated\":{}}}" + ), + report.content_matches(), + report.expected_batches, + report.matching_receipts, + report.missing_receipts, + report.conflicting_receipts, + report.receipt_shape_mismatches, + report.receipt_payload_mismatches, + report.nondurable_receipts, + report.expected_catalog_objects, + report.matching_catalog_objects, + report.missing_catalog_objects, + report.different_catalog_objects, + report.expected_points, + report.scanned_points, + report.observed_points, + report.matching_points, + report.missing_points, + report.unexpected_points, + report.mismatch_groups, + report.mismatch_details.len(), + report.details_truncated, + ) +} + +fn usage() { + eprintln!( + "usage: ftwdb-shadow-reconcile [commit-request.hex ...]" + ); +} + +#[cfg(test)] +mod tests { + use super::{ + MAX_HEX_FILE_BYTES, add_with_limit, collect_arguments, decode_hex, read_hex_file, + report_json, + }; + use ftwdb::shadow_reconcile::ShadowReconciliationReport; + use std::fs::File; + + #[test] + fn bounded_hex_decoder_rejects_bad_input() { + assert_eq!(decode_hex("00aF").unwrap(), [0, 0xaf]); + assert!(decode_hex("0").is_err()); + assert!(decode_hex("0g").is_err()); + } + + #[test] + fn argument_file_and_total_limits_apply_before_reconciliation() { + let error = collect_arguments( + ["store", "one.hex", "two.hex", "three.hex"] + .into_iter() + .map(str::to_owned), + 2, + ) + .unwrap_err(); + assert!(error.contains("frame count")); + + let directory = tempfile::tempdir().unwrap(); + assert!( + read_hex_file(directory.path()) + .unwrap_err() + .contains("regular file") + ); + let oversized = directory.path().join("oversized.hex"); + File::create(&oversized) + .unwrap() + .set_len((MAX_HEX_FILE_BYTES + 1) as u64) + .unwrap(); + assert!( + read_hex_file(&oversized) + .unwrap_err() + .contains("encoded frame limit") + ); + assert_eq!(add_with_limit(3, 2, 5, "items").unwrap(), 5); + assert!(add_with_limit(5, 1, 5, "items").is_err()); + } + + #[test] + fn report_line_is_stable_json() { + let report = ShadowReconciliationReport { + expected_batches: 2, + receipt_payload_mismatches: 1, + scanned_points: 3, + missing_points: 1, + mismatch_groups: 2, + ..ShadowReconciliationReport::default() + }; + let line = report_json(&report); + assert!(line.starts_with("{\"content_matches\":false,")); + assert!(line.contains("\"expected_batches\":2")); + assert!(line.contains("\"receipt_payload_mismatches\":1")); + assert!(line.contains("\"scanned_points\":3")); + assert!(line.contains("\"missing_points\":1")); + assert!(line.ends_with("\"details_truncated\":false}")); + } +} diff --git a/src/bin/ftwdb-shadow.rs b/src/bin/ftwdb-shadow.rs new file mode 100644 index 0000000..1d7e5bc --- /dev/null +++ b/src/bin/ftwdb-shadow.rs @@ -0,0 +1,292 @@ +use ftwdb::shadow_protocol::MAX_BATCH_POINTS; +use ftwdb::shadow_runtime::{ShadowRuntime, ShadowRuntimeConfig, ShadowStorageLimits}; +use ftwdb::shadow_server::{ShadowServerConfig, ShadowStopToken, serve}; +use ftwdb::{Config, Durability, Store}; +use std::env; +use std::error::Error; +use std::fs::{self, DirBuilder}; +use std::io; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +static TERMINATION_REQUESTED: AtomicBool = AtomicBool::new(false); + +fn main() { + if env::args_os().len() == 2 && env::args_os().nth(1).as_deref() == Some("--version".as_ref()) { + println!("ftwdb-shadow {}", env!("CARGO_PKG_VERSION")); + return; + } + if let Err(error) = run() { + eprintln!("ftwdb-shadow: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let mut arguments = env::args_os().skip(1); + let Some(store_path) = arguments.next() else { + return Err(usage().into()); + }; + let Some(socket_path) = arguments.next() else { + return Err(usage().into()); + }; + if arguments.next().is_some() { + return Err(usage().into()); + } + + let limits = ShadowStorageLimits { + max_store_bytes: limit_from_env("FTWDB_SHADOW_MAX_STORE_BYTES", 512 * 1024 * 1024)?, + minimum_free_bytes: limit_from_env("FTWDB_SHADOW_MIN_FREE_BYTES", 512 * 1024 * 1024)?, + }; + if env::var_os("FTWDB_SHADOW_MAINTAIN_SECS").is_some() { + return Err("bounded shadow collection does not run background maintenance; remove FTWDB_SHADOW_MAINTAIN_SECS".into()); + } + let stop = ShadowStopToken::new(); + let _signals = TerminationSignals::install(stop.clone())?; + let store_path = PathBuf::from(store_path); + prepare_private_store_root(&store_path)?; + // Bound active-log replay before allocating its in-memory index. A lower + // limit needs an operator decision about the existing evaluation store. + match fs::symlink_metadata(store_path.join("active.wlog")) { + Ok(metadata) if metadata.len() > limits.max_store_bytes => { + return Err("existing active log exceeds FTWDB_SHADOW_MAX_STORE_BYTES".into()); + } + Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error.into()), + _ => {} + } + let store = Store::open_with( + store_path, + Config { + durability: Durability::Always, + max_batch_points: MAX_BATCH_POINTS, + // The wire codec bounds input frames separately. Keep the storage + // limit: its canonical encoding differs from the wire encoding. + ..Config::default() + }, + )?; + let runtime = ShadowRuntime::start_store( + store, + ShadowRuntimeConfig { + queue_capacity: 8, + max_queued_points: 32_768, + storage_limits: Some(limits), + ..ShadowRuntimeConfig::default() + }, + )?; + let submitter = runtime.submitter(); + eprintln!( + "ftwdb-shadow: version={} max_store_bytes={} minimum_free_bytes={} maintenance=off", + env!("CARGO_PKG_VERSION"), + limits.max_store_bytes, + limits.minimum_free_bytes + ); + let server_config = ShadowServerConfig::new(PathBuf::from(socket_path)); + let result = serve(&server_config, submitter, &stop); + let shutdown = runtime.shutdown(); + let report = result?; + shutdown?; + eprintln!( + "ftwdb-shadow: stopped accepted_clients={} peer_auth_failures={} client_errors={} overload_count={} protocol_error_count={} database_bytes={} database_points={} database_commits={} recovered_tail_bytes={} sync_policy={} last_ack_durable={}", + report.accepted_clients, + report.peer_auth_failures, + report.client_errors, + report.overload_count, + report.protocol_error_count, + report.database_bytes, + report.database_points, + report.database_commits, + report.recovered_tail_bytes, + report.sync_policy, + report.last_ack_durable + ); + Ok(()) +} + +extern "C" fn request_termination(_signal: libc::c_int) { + // AtomicBool is lock-free on the supported Linux and macOS targets. The + // handler does no allocation, locking, I/O, or cleanup work. + TERMINATION_REQUESTED.store(true, Ordering::Relaxed); +} + +struct TerminationSignals { + previous_sigint: libc::sigaction, + previous_sigterm: libc::sigaction, + cancel_watcher: Arc, + watcher: Option>, +} + +impl TerminationSignals { + fn install(stop: ShadowStopToken) -> io::Result { + TERMINATION_REQUESTED.store(false, Ordering::Relaxed); + let previous_sigterm = install_signal(libc::SIGTERM)?; + let previous_sigint = match install_signal(libc::SIGINT) { + Ok(previous) => previous, + Err(error) => { + restore_signal(libc::SIGTERM, &previous_sigterm); + return Err(error); + } + }; + + let cancel_watcher = Arc::new(AtomicBool::new(false)); + let watcher_cancel = Arc::clone(&cancel_watcher); + let watcher = match thread::Builder::new() + .name("ftwdb-shadow-signals".to_owned()) + .spawn(move || { + while !watcher_cancel.load(Ordering::Acquire) { + if TERMINATION_REQUESTED.load(Ordering::Relaxed) { + stop.stop(); + return; + } + thread::park_timeout(Duration::from_millis(10)); + } + }) { + Ok(watcher) => watcher, + Err(error) => { + restore_signal(libc::SIGINT, &previous_sigint); + restore_signal(libc::SIGTERM, &previous_sigterm); + return Err(error); + } + }; + + Ok(Self { + previous_sigint, + previous_sigterm, + cancel_watcher, + watcher: Some(watcher), + }) + } +} + +impl Drop for TerminationSignals { + fn drop(&mut self) { + // Restore first so a signal that arrives during teardown keeps the + // launcher's prior behavior instead of getting lost. + restore_signal(libc::SIGINT, &self.previous_sigint); + restore_signal(libc::SIGTERM, &self.previous_sigterm); + self.cancel_watcher.store(true, Ordering::Release); + if let Some(watcher) = self.watcher.take() { + watcher.thread().unpark(); + let _ = watcher.join(); + } + } +} + +fn install_signal(signal: libc::c_int) -> io::Result { + // SAFETY: zero is a valid starting state for sigaction on the supported + // targets. sigemptyset initializes the mask before sigaction reads it. + let mut action = unsafe { std::mem::zeroed::() }; + action.sa_sigaction = request_termination as *const () as libc::sighandler_t; + action.sa_flags = 0; + // SAFETY: action owns valid mask storage. + if unsafe { libc::sigemptyset(&mut action.sa_mask) } != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: both pointers refer to valid sigaction values for this call. + let mut previous = unsafe { std::mem::zeroed::() }; + if unsafe { libc::sigaction(signal, &action, &mut previous) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(previous) +} + +fn restore_signal(signal: libc::c_int, previous: &libc::sigaction) { + // SAFETY: previous came from a successful sigaction call in this process. + let _ = unsafe { libc::sigaction(signal, previous, std::ptr::null_mut()) }; +} + +fn usage() -> &'static str { + "usage: ftwdb-shadow " +} + +fn limit_from_env(name: &str, default: u64) -> Result> { + match env::var(name) { + Ok(value) => value + .parse::() + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| format!("{name} must be a positive byte count").into()), + Err(env::VarError::NotPresent) => Ok(default), + Err(error) => Err(error.into()), + } +} + +fn prepare_private_store_root(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) => return check_private_store_root(path, &metadata), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + + let mut builder = DirBuilder::new(); + builder.mode(0o700).create(path)?; + // The process umask may only remove bits, but set the exact mode so the + // service does not depend on its launcher configuration. + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + let metadata = fs::symlink_metadata(path)?; + check_private_store_root(path, &metadata) +} + +fn check_private_store_root(path: &Path, metadata: &fs::Metadata) -> io::Result<()> { + if !metadata.file_type().is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("store root is not a real directory: {}", path.display()), + )); + } + if metadata.uid() != rustix::process::geteuid().as_raw() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("store root has another owner: {}", path.display()), + )); + } + if metadata.permissions().mode() & 0o777 != 0o700 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("store root must have mode 0700: {}", path.display()), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::prepare_private_store_root; + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + + #[test] + fn creates_a_private_store_root() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("shadow-store"); + prepare_private_store_root(&root).unwrap(); + assert_eq!( + fs::symlink_metadata(root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + + #[test] + fn rejects_a_store_root_visible_to_other_users() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("shadow-store"); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + let error = prepare_private_store_root(&root).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + } + + #[test] + fn rejects_a_symlink_store_root() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target"); + let root = directory.path().join("shadow-store"); + fs::create_dir(&target).unwrap(); + symlink(&target, &root).unwrap(); + let error = prepare_private_store_root(&root).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } +} diff --git a/src/catalog.rs b/src/catalog.rs index 73ed624..1179ed8 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -1,6 +1,6 @@ use crate::model::{ - Entity, EntityId, Plan, PlanStatus, Relation, RelationId, Run, RunId, RunKind, RunStatus, - SeriesDefinition, + Entity, EntityId, Plan, PlanStatus, Properties, PropertyValue, Relation, RelationId, Run, + RunId, RunKind, RunStatus, SeriesDefinition, }; use crate::transaction::Record; use crate::{Error, Point, Result}; @@ -81,6 +81,90 @@ impl Catalog { } } + /// Catalog records in an apply-safe order so a compact log can rebuild + /// the current identity set without replaying historical frames. + pub(crate) fn snapshot_records(&self) -> Result> { + // Reclaim must never turn an invalid dependency graph into a partial + // catalog. Normal commits keep this invariant true; checking it again + // here makes compaction fail closed if a later code change regresses + // validation or an in-memory catalog is otherwise inconsistent. + self.validate_references()?; + let mut records = Vec::with_capacity( + self.entities.len() + + self.relations.len() + + self.series.len() + + self.runs.len() + + self.plans.len(), + ); + let mut remaining_entities: BTreeSet<_> = self.entities.keys().copied().collect(); + while !remaining_entities.is_empty() { + let ready: Vec<_> = remaining_entities + .iter() + .copied() + .filter(|id| { + self.entities[id] + .parent + .is_none_or(|parent| !remaining_entities.contains(&parent)) + }) + .collect(); + if ready.is_empty() { + return invalid("entity dependency graph cannot be snapshotted".to_owned()); + } + for id in ready { + remaining_entities.remove(&id); + records.push(Record::Entity(self.entities[&id].clone())); + } + } + for relation in self.relations.values() { + records.push(Record::Relation(relation.clone())); + } + for series in self.series.values() { + records.push(Record::Series(series.clone())); + } + let mut remaining_runs: BTreeSet<_> = self.runs.keys().copied().collect(); + while !remaining_runs.is_empty() { + let ready: Vec<_> = remaining_runs + .iter() + .copied() + .filter(|id| { + let run = &self.runs[id]; + run.parent_run + .is_none_or(|parent| !remaining_runs.contains(&parent)) + && run + .input_snapshot + .is_none_or(|input| !remaining_runs.contains(&input)) + }) + .collect(); + if ready.is_empty() { + return invalid("run dependency graph cannot be snapshotted".to_owned()); + } + for id in ready { + remaining_runs.remove(&id); + records.push(Record::Run(self.runs[&id].clone())); + } + } + let mut remaining_plans: BTreeSet<_> = self.plans.keys().copied().collect(); + while !remaining_plans.is_empty() { + let ready: Vec<_> = remaining_plans + .iter() + .copied() + .filter(|id| { + self.plans[id] + .supersedes + .is_none_or(|previous| !remaining_plans.contains(&previous)) + }) + .collect(); + if ready.is_empty() { + return invalid("plan dependency graph cannot be snapshotted".to_owned()); + } + for id in ready { + remaining_plans.remove(&id); + records.push(Record::Plan(self.plans[&id].clone())); + } + } + Ok(records) + } + pub(crate) fn validate_and_apply(&self, records: &[Record]) -> Result { let mut candidate = self.clone(); for record in records { @@ -95,15 +179,35 @@ impl Catalog { Ok(candidate) } + /// Point-only transactions leave the catalog identity unchanged so ingest + /// does not clone entities, series, runs, and plans on every telemetry batch. + pub(crate) fn apply_records(&self, records: &[Record]) -> Result> { + if records + .iter() + .all(|record| matches!(record, Record::Points(_))) + { + for record in records { + if let Record::Points(points) = record { + self.validate_points(points)?; + } + } + return Ok(None); + } + self.validate_and_apply(records).map(Some) + } + pub(crate) fn apply_recovered(&mut self, records: &[Record], offset: u64) -> Result<()> { - let candidate = self - .validate_and_apply(records) - .map_err(|error| Error::Corruption { + match self.apply_records(records) { + Ok(None) => Ok(()), + Ok(Some(candidate)) => { + *self = candidate; + Ok(()) + } + Err(error) => Err(Error::Corruption { offset, reason: format!("invalid recovered transaction: {error}"), - })?; - *self = candidate; - Ok(()) + }), + } } fn apply(&mut self, record: &Record) -> Result<()> { @@ -132,6 +236,7 @@ impl Catalog { Record::Plan(plan) => { plan.validate() .map_err(|reason| Error::InvalidModel(reason.to_owned()))?; + validate_properties("plan", &plan.attributes)?; if let Some(previous) = self.plans.get(&plan.id) { validate_plan_transition(previous, plan)?; } @@ -183,6 +288,7 @@ impl Catalog { return invalid(format!("run {} has missing provenance", run.id.0)); } } + validate_no_run_cycles(&self.runs)?; for plan in self.plans.values() { let Some(run) = self.runs.get(&plan.run_id) else { return invalid(format!("plan {} refers to a missing run", plan.id)); @@ -200,6 +306,7 @@ impl Catalog { return invalid(format!("plan {} supersedes a missing plan", plan.id)); } } + validate_no_plan_cycles(&self.plans)?; Ok(()) } @@ -223,12 +330,15 @@ impl Catalog { /// The catalog-independent point invariants. Transaction commits enforce /// these through `validate_points`; the legacy catalog-less `append` path /// enforces exactly this subset so both writers reject a malformed interval -/// with the same error. +/// or a non-finite value with the same error. pub(crate) fn validate_point_intervals(points: &[Point]) -> Result<()> { for point in points { if point.valid_time_end < point.valid_time { return invalid("point interval ends before it starts".to_owned()); } + if !point.value.is_finite() { + return invalid("point value must be finite".to_owned()); + } } Ok(()) } @@ -246,6 +356,7 @@ pub(crate) fn validate_entity(entity: &Entity) -> Result<()> { if entity.valid_to.is_some_and(|end| end <= entity.valid_from) { return invalid("entity validity interval must be positive".to_owned()); } + validate_properties("entity", &entity.properties)?; Ok(()) } @@ -259,6 +370,7 @@ fn validate_relation(relation: &Relation) -> Result<()> { { return invalid("relation validity interval must be positive".to_owned()); } + validate_properties("relation", &relation.properties)?; Ok(()) } @@ -269,6 +381,19 @@ pub(crate) fn validate_run(run: &Run) -> Result<()> { if run.parent_run == Some(run.id) || run.input_snapshot == Some(run.id) { return invalid("run cannot refer to itself".to_owned()); } + validate_properties("run", &run.attributes)?; + Ok(()) +} + +fn validate_properties(label: &str, properties: &Properties) -> Result<()> { + if properties.iter().any(|(name, value)| { + name.trim().is_empty() + || matches!(value, PropertyValue::Float(number) if !number.is_finite()) + }) { + return invalid(format!( + "{label} properties require names and finite float values" + )); + } Ok(()) } @@ -329,6 +454,156 @@ fn validate_no_parent_cycle(id: EntityId, entities: &BTreeMap) Ok(()) } +#[derive(Clone, Copy, Eq, PartialEq)] +enum VisitState { + Active, + Complete, +} + +fn validate_no_run_cycles(runs: &BTreeMap) -> Result<()> { + let mut states = BTreeMap::::new(); + for root in runs.keys().copied() { + if states.get(&root) == Some(&VisitState::Complete) { + continue; + } + let mut stack = vec![(root, false)]; + while let Some((id, finish)) = stack.pop() { + if finish { + states.insert(id, VisitState::Complete); + continue; + } + match states.get(&id) { + Some(VisitState::Complete) => continue, + Some(VisitState::Active) => { + return invalid(format!("run provenance cycle contains {}", id.0)); + } + None => {} + } + states.insert(id, VisitState::Active); + stack.push((id, true)); + let run = &runs[&id]; + if let Some(input) = run.input_snapshot { + stack.push((input, false)); + } + if let Some(parent) = run.parent_run { + stack.push((parent, false)); + } + } + } + Ok(()) +} + +fn validate_no_plan_cycles(plans: &BTreeMap) -> Result<()> { + let mut states = BTreeMap::::new(); + for root in plans.keys().copied() { + if states.get(&root) == Some(&VisitState::Complete) { + continue; + } + let mut stack = vec![(root, false)]; + while let Some((id, finish)) = stack.pop() { + if finish { + states.insert(id, VisitState::Complete); + continue; + } + match states.get(&id) { + Some(VisitState::Complete) => continue, + Some(VisitState::Active) => { + return invalid(format!("plan supersession cycle contains {id}")); + } + None => {} + } + states.insert(id, VisitState::Active); + stack.push((id, true)); + if let Some(previous) = plans[&id].supersedes { + stack.push((previous, false)); + } + } + } + Ok(()) +} + fn invalid(reason: String) -> Result { Err(Error::InvalidModel(reason)) } + +#[cfg(test)] +mod tests { + use super::{Catalog, validate_entity}; + use crate::transaction::Record; + use crate::{ + Entity, EntityId, Plan, PlanStatus, PropertyValue, Run, RunId, RunKind, RunStatus, + }; + use std::collections::BTreeMap; + + fn run(id: u128, parent_run: Option, input_snapshot: Option) -> Run { + Run { + id: RunId(id), + kind: RunKind::Forecast, + status: RunStatus::Pending, + created_at: 1, + knowledge_time: 1, + workflow: "test".to_owned(), + model: String::new(), + model_version: String::new(), + parent_run: parent_run.map(RunId), + input_snapshot: input_snapshot.map(RunId), + attributes: BTreeMap::new(), + } + } + + fn plan(id: u128, supersedes: Option) -> Plan { + Plan { + id, + run_id: RunId(1), + status: PlanStatus::Candidate, + horizon_start: 0, + horizon_end: 10, + resolution_micros: 1, + scenario: "test".to_owned(), + objective_terms: BTreeMap::new(), + objective_value: None, + supersedes, + attributes: BTreeMap::new(), + } + } + + #[test] + fn rejects_multi_node_run_provenance_cycles() { + let records = vec![ + Record::Run(run(1, Some(2), None)), + Record::Run(run(2, None, Some(1))), + ]; + let error = Catalog::default().validate_and_apply(&records).unwrap_err(); + assert!(error.to_string().contains("run provenance cycle")); + } + + #[test] + fn rejects_multi_node_plan_supersession_cycles() { + let mut optimization = run(1, None, None); + optimization.kind = RunKind::Optimization; + let records = vec![ + Record::Run(optimization), + Record::Plan(plan(10, Some(11))), + Record::Plan(plan(11, Some(10))), + ]; + let error = Catalog::default().validate_and_apply(&records).unwrap_err(); + assert!(error.to_string().contains("plan supersession cycle")); + } + + #[test] + fn rejects_non_finite_property_values() { + let mut entity = Entity { + id: EntityId(1), + kind: "site".to_owned(), + name: "Site".to_owned(), + parent: None, + valid_from: 0, + valid_to: None, + properties: BTreeMap::new(), + }; + entity + .properties + .insert("rating".to_owned(), PropertyValue::Float(f64::INFINITY)); + assert!(validate_entity(&entity).is_err()); + } +} diff --git a/src/error.rs b/src/error.rs index 733b684..3005201 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,8 @@ pub enum Error { maximum: usize, }, InvalidConfig(&'static str), + /// A shadow write would exceed its storage budget. No write took place. + ResourceLimit(&'static str), /// A caller-supplied argument violates a documented API precondition, /// such as a non-positive rollup resolution or out-of-order aggregate /// samples. Unlike `InvalidConfig`, which rejects a durable handle or @@ -40,6 +42,24 @@ pub enum Error { SourceChanged { path: PathBuf, }, + /// A producer reused one source sequence with a different transaction or + /// commit identifier. The writer remains usable. + IngressSourceSequenceConflict { + source_id: u128, + sequence: u64, + }, + /// A producer reused one commit identifier for another source, sequence, + /// or transaction payload. The writer remains usable. + IngressCommitIdConflict { + commit_id: u128, + }, + /// A producer supplied a new cursor that did not advance. Gaps are valid: + /// the sequence is an opaque source cursor, not a dense counter. + IngressSequenceNotIncreasing { + source_id: u128, + previous: u64, + actual: u64, + }, } impl fmt::Display for Error { @@ -57,6 +77,7 @@ impl fmt::Display for Error { write!(f, "batch has {points} points; maximum is {maximum}") } Self::InvalidConfig(reason) => write!(f, "invalid configuration: {reason}"), + Self::ResourceLimit(reason) => write!(f, "shadow storage limit: {reason}"), Self::InvalidArgument(reason) => write!(f, "invalid argument: {reason}"), Self::InvalidModel(reason) => write!(f, "invalid energy model: {reason}"), Self::Serialization(reason) => write!(f, "serialization error: {reason}"), @@ -83,6 +104,25 @@ impl fmt::Display for Error { "source file {} changed while it was being checked", path.display() ), + Self::IngressSourceSequenceConflict { + source_id, + sequence, + } => write!( + f, + "ingress source {source_id:032x} sequence {sequence} conflicts with its stored transaction" + ), + Self::IngressCommitIdConflict { commit_id } => write!( + f, + "commit identifier {commit_id:032x} conflicts with its stored transaction" + ), + Self::IngressSequenceNotIncreasing { + source_id, + previous, + actual, + } => write!( + f, + "ingress source {source_id:032x} requires a cursor above {previous}, got {actual}" + ), } } } diff --git a/src/lib.rs b/src/lib.rs index 5ccb0df..3402f8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,10 @@ mod real_fixture; mod rollup; mod rollup_segment; mod segment; +pub mod shadow_protocol; +pub mod shadow_reconcile; +pub mod shadow_runtime; +pub mod shadow_server; mod snapshot; mod storage; mod store; @@ -27,25 +31,27 @@ mod workload; pub use aggregate::{CounterAggregate, GaugeAggregate, Sample}; pub use catalog::{Catalog, CatalogStats}; pub use error::{Error, Result}; -pub use manifest::RollupDescriptor; +pub use manifest::{RawSegmentDescriptor, RollupDescriptor}; pub use model::{ CalendarUnit, Entity, EntityId, Plan, PlanStatus, Properties, PropertyValue, Relation, RelationId, RollupPolicy, RollupResolution, RollupTier, Run, RunId, RunKind, RunStatus, SeriesDefinition, SeriesSemantics, }; -pub use real_fixture::{RealFixtureLoadReport, load_real_fixture}; +pub use real_fixture::{ + FixtureAck, RealFixtureLoadReport, load_real_fixture, load_real_fixture_with_ack, +}; pub use rollup::{CalendarGaugeRollup, FixedGaugeRollup, GaugeBucket}; pub use rollup_segment::{RollupSegment, RollupSegmentStats}; pub use segment::{Segment, SegmentStats}; pub use storage::{ - Commit, Config, Database, Durability, PlanOutcome, Point, RecoveredTail, SalvageStopReason, - Stats, + Commit, Config, Database, Durability, IngressReceipt, IngressWatermarks, PlanOutcome, Point, + RecoveredTail, SalvageStopReason, Stats, }; pub use store::{ BackupReport, IntegrityReport, MaintenanceReport, RestoreReport, RetentionGate, RollupQuery, - RollupSource, SalvageReport, SalvageStatus, Store, + RollupSource, SalvageOptions, SalvageReport, SalvageStatus, SealReport, Store, }; -pub use transaction::Transaction; +pub use transaction::{IngressIdentity, Transaction}; pub use tsbs::{TsbsIotLoadReport, load_tsbs_iot}; pub use workload::{ EnergyWorkload, MAX_WORKLOAD_BUNDLE_BYTES, MAX_WORKLOAD_DAYS, MAX_WORKLOAD_POINTS, diff --git a/src/manifest.rs b/src/manifest.rs index 75348a2..837505a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,11 +4,14 @@ use crc32fast::hash; use serde::{Deserialize, Serialize}; use std::fs::OpenOptions; use std::io::{Read, Write}; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Component, Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; const MAGIC: &[u8; 8] = b"WMAN0001"; -const VERSION: u16 = 1; +const VERSION_V1: u16 = 1; +const VERSION_V2: u16 = 2; +const VERSION: u16 = 3; const HEADER_BYTES: usize = 24; const MAX_PAYLOAD_BYTES: usize = 64 * 1024 * 1024; const PREFIX: &str = "MANIFEST."; @@ -32,10 +35,57 @@ pub struct RollupDescriptor { pub active: bool, } +/// One immutable raw-point segment published through a manifest generation. +/// +/// `generation` matches the seal checkpoint written to `active.wlog` before +/// this descriptor becomes visible, so a reopen can drop already-sealed +/// frames from the live index even when reclaim has not yet rewritten the +/// log. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RawSegmentDescriptor { + pub file: String, + pub generation: u64, + pub points: u64, + pub source_commit: u64, + pub source_points: u64, + pub min_valid_time: i64, + pub max_valid_time: i64, + pub content_crc32: u32, +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct Manifest { pub generation: u64, pub rollups: Vec, + #[serde(default)] + pub segments: Vec, +} + +#[derive(Deserialize)] +struct ManifestV1 { + generation: u64, + rollups: Vec, +} + +// Version 2 was an unpublished development format. Its raw descriptors did +// not bind file contents. Accept its empty segment list, but do not silently +// accept old sealed files by computing a checksum from whatever is there now. +#[derive(Deserialize)] +struct ManifestV2 { + generation: u64, + rollups: Vec, + segments: Vec, +} + +#[derive(Deserialize)] +struct RawSegmentDescriptorV2 { + _file: String, + _generation: u64, + _points: u64, + _source_commit: u64, + _source_points: u64, + _min_valid_time: i64, + _max_valid_time: i64, } impl Manifest { @@ -49,6 +99,7 @@ impl Manifest { for (generation, path) in candidates { match read_manifest(&path, generation) { Ok(manifest) => return Ok(manifest), + Err(error @ Error::InvalidConfig(_)) => return Err(error), Err(error) => last_error = Some(error), } } @@ -87,6 +138,7 @@ impl Manifest { let mut file = OpenOptions::new() .create_new(true) .write(true) + .mode(0o600) .open(&temporary)?; file.write_all(&header)?; file.write_all(&payload)?; @@ -177,6 +229,20 @@ pub(crate) fn referenced_rollup_files( Ok(referenced) } +/// Every raw-segment filename referenced by the given retained generations. +pub(crate) fn referenced_segment_files( + retained: &[(u64, PathBuf)], +) -> Result> { + let mut referenced = std::collections::HashSet::new(); + for (generation, path) in retained { + let manifest = read_manifest(path, *generation)?; + for segment in manifest.segments { + referenced.insert(segment.file); + } + } + Ok(referenced) +} + fn read_manifest(path: &Path, expected_generation: u64) -> Result { let mut file = open_regular_file_read_only(path)?; let file_len = file.metadata()?.len(); @@ -189,12 +255,15 @@ fn read_manifest(path: &Path, expected_generation: u64) -> Result { return corruption("invalid manifest magic"); } let version = u16::from_le_bytes(header[8..10].try_into().unwrap()); - if version != VERSION { + if version != VERSION && version != VERSION_V1 && version != VERSION_V2 { return corruption("unsupported manifest version"); } if hash(&header[..20]) != u32::from_le_bytes(header[20..24].try_into().unwrap()) { return corruption("manifest header checksum mismatch"); } + if header[10..12] != [0, 0] { + return corruption("manifest reserved flags are non-zero"); + } let payload_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize; if payload_len > MAX_PAYLOAD_BYTES || file_len != (HEADER_BYTES + payload_len) as u64 { return corruption("invalid manifest length"); @@ -204,30 +273,103 @@ fn read_manifest(path: &Path, expected_generation: u64) -> Result { if hash(&payload) != u32::from_le_bytes(header[16..20].try_into().unwrap()) { return corruption("manifest payload checksum mismatch"); } - let manifest: Manifest = postcard::from_bytes(&payload) - .map_err(|error| Error::Serialization(format!("manifest decode failed: {error}")))?; + let manifest = decode_manifest_payload(&payload, version).map_err(|error| match error { + Error::InvalidConfig(_) => error, + _ => Error::Corruption { + offset: 0, + reason: format!("manifest payload is invalid: {error}"), + }, + })?; if manifest.generation != expected_generation { return corruption("manifest generation does not match its filename"); } - validate_manifest(&manifest)?; + validate_manifest(&manifest).map_err(|error| Error::Corruption { + offset: 0, + reason: format!("manifest descriptors are invalid: {error}"), + })?; Ok(manifest) } +fn decode_manifest_payload(payload: &[u8], version: u16) -> Result { + if version == VERSION_V1 { + let parsed: ManifestV1 = postcard::from_bytes(payload) + .map_err(|error| Error::Serialization(format!("manifest decode failed: {error}")))?; + return Ok(Manifest { + generation: parsed.generation, + rollups: parsed.rollups, + segments: Vec::new(), + }); + } + if version == VERSION_V2 { + let parsed: ManifestV2 = postcard::from_bytes(payload) + .map_err(|error| Error::Serialization(format!("manifest decode failed: {error}")))?; + if !parsed.segments.is_empty() { + return Err(Error::InvalidConfig( + "development v2 raw segments lack a content checksum; restore a pre-seal snapshot or use a fresh shadow store", + )); + } + return Ok(Manifest { + generation: parsed.generation, + rollups: parsed.rollups, + segments: Vec::new(), + }); + } + postcard::from_bytes(payload) + .map_err(|error| Error::Serialization(format!("manifest decode failed: {error}"))) +} + fn validate_manifest(manifest: &Manifest) -> Result<()> { + let mut rollup_files = std::collections::HashSet::new(); for rollup in &manifest.rollups { - let path = Path::new(&rollup.file); - if path.components().count() != 1 - || !matches!(path.components().next(), Some(Component::Normal(_))) + validate_safe_filename(&rollup.file, "manifest rollup filename")?; + if !rollup_files.insert(rollup.file.as_str()) + || rollup.series_id == 0 + || rollup.end <= rollup.start { return Err(Error::InvalidModel( - "manifest rollup filename must be a single safe path component".to_owned(), + "manifest rollup descriptor has a duplicate file, invalid identity, or invalid bounds" + .to_owned(), )); } - if rollup.series_id == 0 || rollup.end <= rollup.start { + } + let mut segment_files = std::collections::HashSet::new(); + let mut segment_generations = std::collections::HashSet::new(); + let mut previous_generation = 0_u64; + let mut cumulative_points = 0_u64; + for segment in &manifest.segments { + validate_safe_filename(&segment.file, "manifest raw segment filename")?; + cumulative_points = cumulative_points + .checked_add(segment.points) + .ok_or_else(|| { + Error::InvalidModel("manifest raw segment point count overflows".to_owned()) + })?; + if !segment_files.insert(segment.file.as_str()) + || !segment_generations.insert(segment.generation) + || segment.generation == 0 + || segment.generation <= previous_generation + || segment.generation > manifest.generation + || segment.points == 0 + || segment.source_points != cumulative_points + || segment.max_valid_time < segment.min_valid_time + { return Err(Error::InvalidModel( - "manifest rollup descriptor has invalid identity or bounds".to_owned(), + "manifest raw segment descriptor has invalid order, identity, or coverage" + .to_owned(), )); } + previous_generation = segment.generation; + } + Ok(()) +} + +fn validate_safe_filename(file: &str, label: &str) -> Result<()> { + let path = Path::new(file); + if path.components().count() != 1 + || !matches!(path.components().next(), Some(Component::Normal(_))) + { + return Err(Error::InvalidModel(format!( + "{label} must be a single safe path component" + ))); } Ok(()) } @@ -256,9 +398,9 @@ fn corruption(reason: &str) -> Result { #[cfg(test)] mod tests { - use super::{Manifest, RollupDescriptor}; + use super::{Manifest, RawSegmentDescriptor, RollupDescriptor, validate_manifest}; use crate::RollupResolution; - use std::io::{Seek, SeekFrom, Write}; + use std::io::{Read, Seek, SeekFrom, Write}; use tempfile::tempdir; fn manifest(generation: u64) -> Manifest { @@ -274,9 +416,100 @@ mod tests { source_points: 10, active: true, }], + segments: Vec::new(), } } + fn raw_segment( + file: &str, + generation: u64, + points: u64, + source_points: u64, + ) -> RawSegmentDescriptor { + RawSegmentDescriptor { + file: file.to_owned(), + generation, + points, + source_commit: generation, + source_points, + min_valid_time: 0, + max_valid_time: 1, + content_crc32: 0, + } + } + + #[test] + fn raw_segments_must_keep_unique_seal_order_and_cumulative_coverage() { + let mut value = manifest(3); + value.segments = vec![ + raw_segment("one.wseg", 1, 2, 2), + raw_segment("two.wseg", 3, 3, 5), + ]; + assert!(validate_manifest(&value).is_ok()); + + let mut reversed = value.clone(); + reversed.segments.reverse(); + assert!(validate_manifest(&reversed).is_err()); + + let mut duplicate = value.clone(); + duplicate.segments[1].file = duplicate.segments[0].file.clone(); + assert!(validate_manifest(&duplicate).is_err()); + + let mut wrong_total = value; + wrong_total.segments[1].source_points = 4; + assert!(validate_manifest(&wrong_total).is_err()); + } + + #[test] + fn legacy_rollup_manifests_load_but_unbound_development_segments_do_not_fall_back() { + fn write_legacy(directory: &std::path::Path, version: u16, payload: &[u8]) { + let mut header = [0_u8; super::HEADER_BYTES]; + header[..8].copy_from_slice(super::MAGIC); + header[8..10].copy_from_slice(&version.to_le_bytes()); + header[12..16].copy_from_slice(&(payload.len() as u32).to_le_bytes()); + header[16..20].copy_from_slice(&crc32fast::hash(payload).to_le_bytes()); + let crc = crc32fast::hash(&header[..20]); + header[20..24].copy_from_slice(&crc.to_le_bytes()); + std::fs::write( + directory.join("MANIFEST.00000000000000000002"), + [header.as_slice(), payload].concat(), + ) + .unwrap(); + } + let directory = tempdir().unwrap(); + manifest(1).publish(directory.path()).unwrap(); + let expected = manifest(2); + let v1 = postcard::to_stdvec(&(expected.generation, &expected.rollups)).unwrap(); + write_legacy(directory.path(), super::VERSION_V1, &v1); + assert_eq!(Manifest::load(directory.path()).unwrap(), expected); + let v2 = postcard::to_stdvec(&(expected.generation, &expected.rollups, Vec::::new())) + .unwrap(); + write_legacy(directory.path(), super::VERSION_V2, &v2); + assert_eq!(Manifest::load(directory.path()).unwrap(), expected); + let unbound = postcard::to_stdvec(&( + expected.generation, + &expected.rollups, + vec![("old.wseg", 2_u64, 1_u64, 1_u64, 1_u64, 0_i64, 0_i64)], + )) + .unwrap(); + write_legacy(directory.path(), super::VERSION_V2, &unbound); + assert!( + matches!(Manifest::load(directory.path()), Err(crate::Error::InvalidConfig(reason)) + if reason.contains("lack a content checksum")) + ); + } + + #[test] + fn raw_content_checksum_survives_manifest_publication() { + let directory = tempdir().unwrap(); + let mut expected = manifest(2); + let mut segment = raw_segment("bound.wseg", 2, 5, 5); + segment.content_crc32 = 0x5ce0_1357; + expected.segments.push(segment); + expected.publish(directory.path()).unwrap(); + assert_eq!(Manifest::load(directory.path()).unwrap(), expected); + } + #[test] fn loads_the_highest_valid_generation() { let directory = tempdir().unwrap(); @@ -299,6 +532,28 @@ mod tests { assert_eq!(Manifest::load(directory.path()).unwrap().generation, 1); } + #[test] + fn rejects_non_zero_reserved_manifest_flags() { + let directory = tempdir().unwrap(); + manifest(1).publish(directory.path()).unwrap(); + let path = directory.path().join("MANIFEST.00000000000000000001"); + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + let mut header = [0_u8; super::HEADER_BYTES]; + file.read_exact(&mut header).unwrap(); + header[10] = 1; + let checksum = crc32fast::hash(&header[..20]); + header[20..24].copy_from_slice(&checksum.to_le_bytes()); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&header).unwrap(); + file.sync_all().unwrap(); + + assert!(Manifest::load(directory.path()).is_err()); + } + #[test] fn pruning_keeps_the_newest_generation_and_its_fallback_window() { let directory = tempdir().unwrap(); diff --git a/src/model.rs b/src/model.rs index f984f2c..4f1e426 100644 --- a/src/model.rs +++ b/src/model.rs @@ -114,6 +114,13 @@ impl SeriesDefinition { if self.maximum_gap_micros.is_some_and(|gap| gap < 0) { return Err("maximum gap must not be negative"); } + if self + .rollup_policy + .raw_retain_for_micros + .is_some_and(|retention| retention <= 0) + { + return Err("raw retention must be positive or forever"); + } for tier in &self.rollup_policy.tiers { match &tier.resolution { RollupResolution::FixedMicros(value) if *value <= 0 => { @@ -124,6 +131,11 @@ impl SeriesDefinition { { return Err("calendar rollups require an IANA timezone"); } + RollupResolution::Calendar { iana_timezone, .. } + if jiff::tz::TimeZone::get(iana_timezone).is_err() => + { + return Err("calendar rollups require a valid IANA timezone"); + } _ => {} } if tier @@ -211,6 +223,17 @@ impl Plan { if self.scenario.trim().is_empty() { return Err("plan scenario must not be empty"); } + if self.supersedes == Some(self.id) { + return Err("plan cannot supersede itself"); + } + if self + .objective_terms + .iter() + .any(|(name, value)| name.trim().is_empty() || !value.is_finite()) + || self.objective_value.is_some_and(|value| !value.is_finite()) + { + return Err("plan objectives require names and finite values"); + } Ok(()) } } @@ -243,6 +266,23 @@ mod tests { }, }; assert_eq!(series.validate(), Ok(())); + + let mut invalid_retention = series.clone(); + invalid_retention.rollup_policy.raw_retain_for_micros = Some(0); + assert_eq!( + invalid_retention.validate(), + Err("raw retention must be positive or forever") + ); + + let mut invalid_timezone = series; + invalid_timezone.rollup_policy.tiers[0].resolution = RollupResolution::Calendar { + unit: super::CalendarUnit::Day, + iana_timezone: "Not/A-Time-Zone".to_owned(), + }; + assert_eq!( + invalid_timezone.validate(), + Err("calendar rollups require a valid IANA timezone") + ); } #[test] @@ -264,5 +304,15 @@ mod tests { plan.validate(), Err("plan horizon must have positive duration") ); + + let mut invalid_objective = plan; + invalid_objective.horizon_end = 200; + invalid_objective + .objective_terms + .insert("cost".to_owned(), f64::NAN); + assert_eq!( + invalid_objective.validate(), + Err("plan objectives require names and finite values") + ); } } diff --git a/src/real_fixture.rs b/src/real_fixture.rs index 9c6691f..db9a7ae 100644 --- a/src/real_fixture.rs +++ b/src/real_fixture.rs @@ -22,16 +22,39 @@ pub struct RealFixtureLoadReport { pub last_offset_millis: i64, } +/// Cumulative store watermark after one fixture commit returned. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FixtureAck { + pub commits: u64, + pub points: u64, + pub durable: bool, +} + /// Loads the repository's sanitized real-installation CSV fixture. /// /// The input uses offsets instead of source timestamps. This keeps cadence, /// jitter, gaps, ordering, and values without retaining the installation's /// exact dates. The caller must decompress `points.csv.gz` before calling. pub fn load_real_fixture( - mut reader: R, + reader: R, store: &mut Store, batch_points: usize, ) -> Result { + load_real_fixture_with_ack(reader, store, batch_points, |_| Ok(())) +} + +/// Same as [`load_real_fixture`], and calls `on_ack` after every successful +/// commit so a power-cut harness can record the last durable watermark. +pub fn load_real_fixture_with_ack( + mut reader: R, + store: &mut Store, + batch_points: usize, + mut on_ack: F, +) -> Result +where + R: BufRead, + F: FnMut(FixtureAck) -> Result<()>, +{ if batch_points == 0 || batch_points > crate::Config::default().max_batch_points { return Err(Error::InvalidModel(format!( "real fixture batch points must be between 1 and {}", @@ -144,6 +167,11 @@ pub fn load_real_fixture( let commit = store.commit(std::mem::take(&mut transaction))?; commits += 1; durable_commits += u64::from(commit.durable); + on_ack(FixtureAck { + commits, + points: points_total, + durable: commit.durable, + })?; } } @@ -155,6 +183,11 @@ pub fn load_real_fixture( let commit = store.commit(transaction)?; commits += 1; durable_commits += u64::from(commit.durable); + on_ack(FixtureAck { + commits, + points: points_total, + durable: commit.durable, + })?; } store.flush()?; @@ -217,7 +250,7 @@ fn update_point_checksum(hasher: &mut Hasher, point: Point) { #[cfg(test)] mod tests { - use super::{REAL_FIXTURE_START_MICROS, load_real_fixture}; + use super::{REAL_FIXTURE_START_MICROS, load_real_fixture, load_real_fixture_with_ack}; use crate::{Config, Durability, Store}; use std::io::Cursor; @@ -249,16 +282,45 @@ mod tests { assert_eq!(report.first_offset_millis, 0); assert_eq!(report.last_offset_millis, 20); - let history = store.database().query_history( - 1, - REAL_FIXTURE_START_MICROS, - REAL_FIXTURE_START_MICROS + 21_000, - ); + let history = store + .database() + .query_history( + 1, + REAL_FIXTURE_START_MICROS, + REAL_FIXTURE_START_MICROS + 21_000, + ) + .unwrap(); assert_eq!(history.len(), 2); assert_eq!(history[0].value, 12.5); assert_eq!(history[1].value, 13.5); } + #[test] + fn ack_callback_records_each_durable_commit_watermark() { + let directory = tempfile::tempdir().unwrap(); + let mut store = Store::open_with( + directory.path(), + Config { + durability: Durability::Always, + ..Config::default() + }, + ) + .unwrap(); + let mut acks = Vec::new(); + let report = load_real_fixture_with_ack(Cursor::new(FIXTURE), &mut store, 2, |ack| { + acks.push(ack); + Ok(()) + }) + .unwrap(); + assert_eq!(report.commits, 2); + assert_eq!(acks.len(), 2); + assert!(acks.iter().all(|ack| ack.durable)); + assert_eq!(acks[0].commits, 1); + assert_eq!(acks[0].points, 2); + assert_eq!(acks[1].commits, 2); + assert_eq!(acks[1].points, 4); + } + #[test] fn rejects_changed_series_owner_and_invalid_header() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/rollup.rs b/src/rollup.rs index 5c31b4d..e81a68c 100644 --- a/src/rollup.rs +++ b/src/rollup.rs @@ -150,8 +150,12 @@ impl CalendarGaugeRollup { for pair in ordered.windows(2) { let previous = pair[0]; let next = pair[1]; - let gap = next.valid_time - previous.valid_time; - if gap <= 0 || gap > max_gap_micros { + let Some(gap) = next.valid_time.checked_sub(previous.valid_time) else { + return Err(Error::InvalidArgument( + "calendar sample timestamps overflow a gap", + )); + }; + if gap == 0 || gap > max_gap_micros { continue; } add_calendar_interval( @@ -220,17 +224,24 @@ impl FixedGaugeRollup { for point in &ordered { let start = bucket_start(point.valid_time, resolution_micros); + let end = start + .checked_add(resolution_micros) + .ok_or(Error::InvalidArgument("rollup bucket end overflows"))?; buckets .entry(start) - .or_insert_with(|| GaugeBucket::empty(start, start + resolution_micros)) + .or_insert_with(|| GaugeBucket::empty(start, end)) .add_sample(Sample::new(point.valid_time, point.value)); } for pair in ordered.windows(2) { let previous = pair[0]; let next = pair[1]; - let gap = next.valid_time - previous.valid_time; - if gap <= 0 || gap > max_gap_micros { + let Some(gap) = next.valid_time.checked_sub(previous.valid_time) else { + return Err(Error::InvalidArgument( + "rollup sample timestamps overflow a gap", + )); + }; + if gap == 0 || gap > max_gap_micros { continue; } add_interval( @@ -239,7 +250,7 @@ impl FixedGaugeRollup { previous.valid_time, next.valid_time, previous.value, - ); + )?; } Ok(Self { @@ -274,17 +285,23 @@ fn add_interval( mut start: i64, end: i64, value: f64, -) { +) -> Result<()> { while start < end { let bucket = bucket_start(start, resolution); - let bucket_end = bucket + resolution; + let bucket_end = bucket + .checked_add(resolution) + .ok_or(Error::InvalidArgument("rollup bucket end overflows"))?; let interval_end = end.min(bucket_end); + let duration = interval_end + .checked_sub(start) + .ok_or(Error::InvalidArgument("rollup coverage overflows"))?; buckets .entry(bucket) .or_insert_with(|| GaugeBucket::empty(bucket, bucket_end)) - .add_covered_interval(value, interval_end - start); + .add_covered_interval(value, duration); start = interval_end; } + Ok(()) } fn add_calendar_interval( @@ -298,10 +315,13 @@ fn add_calendar_interval( while start < end { let (bucket_start, bucket_end) = calendar_bucket_bounds(start, unit, timezone)?; let interval_end = end.min(bucket_end); + let duration = interval_end + .checked_sub(start) + .ok_or(Error::InvalidArgument("calendar coverage overflows"))?; buckets .entry(bucket_start) .or_insert_with(|| GaugeBucket::empty(bucket_start, bucket_end)) - .add_covered_interval(value, interval_end - start); + .add_covered_interval(value, duration); start = interval_end; } Ok(()) @@ -369,6 +389,27 @@ mod tests { const SECOND: i64 = 1_000_000; + #[test] + fn overflowing_bucket_end_errors_instead_of_wrapping() { + let points = [Point::actual(1, i64::MAX, 1.0)]; + assert!(matches!( + FixedGaugeRollup::build(&points, 2, SECOND), + Err(Error::InvalidArgument(_)) + )); + } + + #[test] + fn extreme_calendar_timestamps_error_instead_of_panicking() { + assert!(matches!( + calendar_bucket_bounds(i64::MAX, CalendarUnit::Day, "UTC"), + Err(Error::InvalidModel(_)) + )); + assert!(matches!( + calendar_bucket_bounds(i64::MIN, CalendarUnit::Day, "UTC"), + Err(Error::InvalidModel(_)) + )); + } + #[test] fn invalid_build_arguments_error_instead_of_panicking() { let points = [Point::actual(1, 0, 1.0)]; diff --git a/src/rollup_segment.rs b/src/rollup_segment.rs index 36554d7..08285bf 100644 --- a/src/rollup_segment.rs +++ b/src/rollup_segment.rs @@ -4,6 +4,7 @@ use crc32fast::hash; use lz4_flex::block::{compress_prepend_size, decompress}; use std::fs::OpenOptions; use std::io::{Read, Write}; +use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -87,6 +88,9 @@ impl RollupSegment { if hash(&header[..44]) != u32::from_le_bytes(header[44..48].try_into().unwrap()) { return corruption(0, "rollup segment header checksum mismatch"); } + if header[11] != 0 { + return corruption(0, "rollup segment reserved header byte is non-zero"); + } let compression = header[10]; let bucket_count = u32::from_le_bytes(header[12..16].try_into().unwrap()); let uncompressed_len = u32::from_le_bytes(header[16..20].try_into().unwrap()) as usize; @@ -148,8 +152,17 @@ impl RollupSegment { } let buckets: Vec<_> = decoded .chunks_exact(BUCKET_BYTES) - .map(decode_bucket) - .collect(); + .enumerate() + .map(|(index, raw)| { + if raw[96] & !0x0f != 0 || raw[97..104] != [0_u8; 7] { + return corruption( + HEADER_BYTES as u64 + index as u64 * BUCKET_BYTES as u64, + "rollup bucket reserved bits are non-zero", + ); + } + Ok(decode_bucket(raw)) + }) + .collect::>()?; validate_buckets(&buckets, HEADER_BYTES as u64)?; if buckets .first() @@ -183,11 +196,9 @@ impl RollupSegment { #[must_use] pub fn query(&self, start: i64, end: i64) -> Vec { - self.buckets - .iter() - .filter(|bucket| bucket.end > start && bucket.start < end) - .copied() - .collect() + let lo = self.buckets.partition_point(|bucket| bucket.end <= start); + let hi = lo + self.buckets[lo..].partition_point(|bucket| bucket.start < end); + self.buckets[lo..hi].to_vec() } } @@ -223,7 +234,11 @@ fn write_temporary(path: &Path, buckets: &[GaugeBucket]) -> Result GaugeBucket { fn validate_buckets(buckets: &[GaugeBucket], offset: u64) -> Result<()> { for (index, bucket) in buckets.iter().enumerate() { + let bucket_offset = offset + index as u64 * BUCKET_BYTES as u64; if bucket.end <= bucket.start || bucket.covered_micros < 0 { - return corruption( - offset + index as u64 * BUCKET_BYTES as u64, - "invalid bucket bounds", - ); + return corruption(bucket_offset, "invalid bucket bounds"); } if index > 0 && buckets[index - 1].end > bucket.start { - return corruption( - offset + index as u64 * BUCKET_BYTES as u64, - "rollup buckets overlap or are unsorted", - ); + return corruption(bucket_offset, "rollup buckets overlap or are unsorted"); } - if (bucket.count == 0) != (bucket.first.is_none() && bucket.last.is_none()) + let duration = bucket + .end + .checked_sub(bucket.start) + .ok_or_else(|| Error::Corruption { + offset: bucket_offset, + reason: "rollup bucket duration overflows".to_owned(), + })?; + let has_samples = bucket.count > 0; + let has_values = has_samples || bucket.covered_micros > 0; + if has_samples != (bucket.first.is_some() && bucket.last.is_some()) + || has_values != (bucket.min.is_some() && bucket.max.is_some()) || bucket.min.is_some() != bucket.max.is_some() || bucket.first.is_some() != bucket.last.is_some() { - return corruption( - offset + index as u64 * BUCKET_BYTES as u64, - "inconsistent rollup aggregate presence", - ); + return corruption(bucket_offset, "inconsistent rollup aggregate presence"); + } + if bucket.covered_micros > duration { + return corruption(bucket_offset, "rollup coverage exceeds bucket duration"); } - if bucket.covered_micros > bucket.end - bucket.start { + if !bucket.sum.is_finite() + || !bucket.integral_value_micros.is_finite() + || bucket.min.is_some_and(|value| !value.is_finite()) + || bucket.max.is_some_and(|value| !value.is_finite()) + || bucket.first.is_some_and(|sample| !sample.value.is_finite()) + || bucket.last.is_some_and(|sample| !sample.value.is_finite()) + { return corruption( - offset + index as u64 * BUCKET_BYTES as u64, - "rollup coverage exceeds bucket duration", + bucket_offset, + "rollup aggregate contains a non-finite value", ); } + if let (Some(min), Some(max)) = (bucket.min, bucket.max) + && min > max + { + return corruption(bucket_offset, "rollup aggregate minimum exceeds maximum"); + } + if let (Some(first), Some(last), Some(min), Some(max)) = + (bucket.first, bucket.last, bucket.min, bucket.max) + && (first.timestamp < bucket.start + || first.timestamp >= bucket.end + || last.timestamp < bucket.start + || last.timestamp >= bucket.end + || first.timestamp > last.timestamp + || first.value < min + || first.value > max + || last.value < min + || last.value > max + || (bucket.count == 1 && first != last)) + { + return corruption(bucket_offset, "rollup samples violate aggregate bounds"); + } } Ok(()) } @@ -372,7 +418,7 @@ mod tests { }; use crate::{Error, FixedGaugeRollup, Point}; use crc32fast::hash; - use std::io::{Seek, SeekFrom, Write}; + use std::io::{Read, Seek, SeekFrom, Write}; use tempfile::tempdir; fn buckets() -> Vec { @@ -409,6 +455,48 @@ mod tests { assert!(RollupSegment::create(&path, &buckets()).is_err()); } + #[test] + fn rejects_overflowing_bounds_and_non_finite_aggregates_without_panicking() { + let directory = tempdir().unwrap(); + let extreme = crate::GaugeBucket::empty(i64::MIN, i64::MAX); + assert!(matches!( + RollupSegment::create(directory.path().join("extreme.rseg"), &[extreme]), + Err(Error::Corruption { .. }) + )); + + let mut invalid = buckets(); + invalid[0].sum = f64::INFINITY; + assert!(matches!( + RollupSegment::create(directory.path().join("infinite.rseg"), &invalid), + Err(Error::Corruption { .. }) + )); + } + + #[test] + fn rejects_non_zero_reserved_header_byte() { + let directory = tempdir().unwrap(); + let path = directory.path().join("reserved.rseg"); + RollupSegment::create(&path, &buckets()).unwrap(); + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let mut header = [0_u8; HEADER_BYTES]; + file.read_exact(&mut header).unwrap(); + header[11] = 1; + let header_crc = hash(&header[..44]); + header[44..48].copy_from_slice(&header_crc.to_le_bytes()); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&header).unwrap(); + file.sync_all().unwrap(); + + assert!(matches!( + RollupSegment::open(&path), + Err(Error::Corruption { .. }) + )); + } + /// Builds a rollup segment file with a consistent header around an /// arbitrary LZ4 payload, so only the decompression bounds can reject it. fn crafted_lz4_segment(bucket_count: u32, payload: &[u8]) -> Vec { diff --git a/src/segment.rs b/src/segment.rs index e4790a3..066b80a 100644 --- a/src/segment.rs +++ b/src/segment.rs @@ -1,9 +1,10 @@ -use crate::storage::sync_parent_directory; +use crate::storage::{open_regular_file_read_only, sync_parent_directory}; use crate::{Error, Point, Result}; use crc32fast::hash; use lz4_flex::block::{compress_prepend_size, decompress}; use std::fs::{File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::{FileExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -74,6 +75,7 @@ impl Segment { "segment block_points must be in 1..=262144", )); } + crate::catalog::validate_point_intervals(points)?; let path = path.as_ref(); if path.exists() { return Err(Error::Io(std::io::Error::new( @@ -105,7 +107,7 @@ impl Segment { } pub fn open(path: impl AsRef) -> Result { - let mut file = OpenOptions::new().read(true).open(path)?; + let mut file = open_regular_file_read_only(path.as_ref())?; let file_len = file.metadata()?.len(); if file_len < (SEGMENT_HEADER_BYTES + INDEX_HEADER_BYTES) as u64 { return corruption(0, "segment is too small"); @@ -117,7 +119,7 @@ impl Segment { return corruption(0, "invalid segment magic"); } let version = u16::from_le_bytes(header[8..10].try_into().unwrap()); - if version != SEGMENT_VERSION { + if version != SEGMENT_VERSION || header[10..12] != [0, 0] { return corruption(0, "unsupported segment version"); } let expected_header_crc = u32::from_le_bytes(header[36..40].try_into().unwrap()); @@ -185,8 +187,49 @@ impl Segment { self.stats } + /// Checks the exact open file with a fixed-size buffer. This detects a + /// valid but unrelated segment copied over a manifest's named file. + pub(crate) fn content_crc32(&self) -> Result { + let mut hasher = crc32fast::Hasher::new(); + let mut buffer = [0_u8; 64 * 1024]; + let mut offset = 0; + while offset < self.stats.stored_bytes { + let read = (self.stats.stored_bytes - offset).min(buffer.len() as u64) as usize; + self.file.read_exact_at(&mut buffer[..read], offset)?; + hasher.update(&buffer[..read]); + offset += read as u64; + } + if self.file.metadata()?.len() != self.stats.stored_bytes { + return corruption(0, "segment length changed after open"); + } + Ok(hasher.finalize()) + } + + pub(crate) fn valid_bounds(&self) -> Option<(i64, i64)> { + Some(( + self.index.iter().map(|entry| entry.min_time).min()?, + self.index.iter().map(|entry| entry.max_time).max()?, + )) + } + + /// Reads and checksums every indexed block. Used by integrity checks and + /// salvage so a corrupt payload cannot pass as healthy coverage. + pub fn verify_blocks(&self) -> Result<()> { + for entry in &self.index { + read_block(&self.file, *entry)?; + } + Ok(()) + } + + #[cfg(test)] + pub(crate) fn first_block_payload_offset(&self) -> Option { + self.index + .first() + .map(|entry| entry.offset + BLOCK_HEADER_BYTES as u64 + 1) + } + /// Reads one series/time range, touching only overlapping indexed blocks. - pub fn query(&mut self, series_id: u64, start: i64, end: i64) -> Result> { + pub fn query(&self, series_id: u64, start: i64, end: i64) -> Result> { let entries: Vec<_> = self .index .iter() @@ -197,7 +240,7 @@ impl Segment { .collect(); let mut result = Vec::new(); for entry in entries { - let points = read_block(&mut self.file, entry)?; + let points = read_block(&self.file, entry)?; result.extend( points .into_iter() @@ -206,6 +249,75 @@ impl Segment { } Ok(result) } + + /// Reserve the full decoded block count before reading it, then visit + /// matches one at a time. Callers can bound both decoding and output. + pub(crate) fn visit_query>( + &self, + series_id: u64, + start: i64, + end: i64, + reserve: &mut impl FnMut(usize) -> std::result::Result<(), E>, + visit: &mut impl FnMut(Point) -> std::result::Result<(), E>, + ) -> std::result::Result<(), E> { + let first = self.index.partition_point(|entry| { + entry.series_id < series_id || (entry.series_id == series_id && entry.max_time < start) + }); + for entry in self.index[first..] + .iter() + .take_while(|entry| entry.series_id == series_id && entry.min_time < end) + { + reserve(entry.points as usize)?; + for point in read_block(&self.file, *entry)? { + if point.valid_time >= start && point.valid_time < end { + visit(point)?; + } + } + } + Ok(()) + } + + /// Inclusive valid-time bounds for one series, from the sparse index. + #[must_use] + pub fn series_bounds(&self, series_id: u64) -> Option<(i64, i64)> { + let mut min_time = i64::MAX; + let mut max_time = i64::MIN; + for entry in &self.index { + if entry.series_id == series_id { + min_time = min_time.min(entry.min_time); + max_time = max_time.max(entry.max_time); + } + } + (min_time <= max_time).then_some((min_time, max_time)) + } + + #[must_use] + pub fn series_ids(&self) -> Vec { + let mut ids = Vec::new(); + for entry in &self.index { + if ids.last() != Some(&entry.series_id) { + ids.push(entry.series_id); + } + } + ids + } + + #[must_use] + pub fn series_point_count(&self, series_id: u64) -> u64 { + self.index + .iter() + .filter(|entry| entry.series_id == series_id) + .map(|entry| u64::from(entry.points)) + .sum() + } + + /// True when this segment's sparse index overlaps the requested range. + #[must_use] + pub fn overlaps(&self, series_id: u64, start: i64, end: i64) -> bool { + self.index.iter().any(|entry| { + entry.series_id == series_id && entry.max_time >= start && entry.min_time < end + }) + } } fn write_temporary_segment( @@ -226,6 +338,7 @@ fn write_temporary_segment( .create_new(true) .read(true) .write(true) + .mode(0o600) .open(path)?; file.write_all(&[0_u8; SEGMENT_HEADER_BYTES])?; @@ -494,10 +607,9 @@ fn split_columns(encoded: &[u8], offset: u64) -> Result> { Ok(columns) } -fn read_block(file: &mut File, entry: IndexEntry) -> Result> { - file.seek(SeekFrom::Start(entry.offset))?; +fn read_block(file: &File, entry: IndexEntry) -> Result> { let mut header = [0_u8; BLOCK_HEADER_BYTES]; - file.read_exact(&mut header)?; + file.read_exact_at(&mut header, entry.offset)?; if &header[..4] != BLOCK_MAGIC { return corruption(entry.offset, "invalid block magic"); } @@ -505,6 +617,9 @@ fn read_block(file: &mut File, entry: IndexEntry) -> Result> { if version != BLOCK_VERSION || header[7] != BLOCK_ENCODING_COLUMN_V1 { return corruption(entry.offset, "unsupported block encoding"); } + if header[52..56] != [0, 0, 0, 0] { + return corruption(entry.offset, "non-zero reserved block header bytes"); + } let expected_header_crc = u32::from_le_bytes(header[48..52].try_into().unwrap()); if hash(&header[..48]) != expected_header_crc { return corruption(entry.offset, "block header checksum mismatch"); @@ -534,7 +649,7 @@ fn read_block(file: &mut File, entry: IndexEntry) -> Result> { return corruption(entry.offset, "block uncompressed length is out of bounds"); } let mut payload = vec![0_u8; payload_len]; - file.read_exact(&mut payload)?; + file.read_exact_at(&mut payload, entry.offset + BLOCK_HEADER_BYTES as u64)?; if hash(&payload) != expected_payload_crc { return corruption(entry.offset, "block payload checksum mismatch"); } @@ -578,7 +693,26 @@ fn read_block(file: &mut File, entry: IndexEntry) -> Result> { if decoded.len() != uncompressed_len { return corruption(entry.offset, "block uncompressed length mismatch"); } - decode_columns(&decoded, series_id, points as usize, entry.offset) + let points = decode_columns(&decoded, series_id, points as usize, entry.offset)?; + if points + .first() + .is_none_or(|point| point.valid_time != min_time) + || points + .last() + .is_none_or(|point| point.valid_time != max_time) + || points + .windows(2) + .any(|pair| pair[0].valid_time > pair[1].valid_time) + || points + .iter() + .any(|point| point.valid_time_end < point.valid_time || !point.value.is_finite()) + { + return corruption( + entry.offset, + "decoded block violates point or index invariants", + ); + } + Ok(points) } #[allow(clippy::too_many_arguments)] @@ -658,9 +792,13 @@ fn validate_index(index: &[IndexEntry], index_offset: u64, point_count: u64) -> ); } if let Some(previous) = previous - && (entry.series_id, entry.min_time) < (previous.series_id, previous.min_time) + && ((entry.series_id, entry.min_time) < (previous.series_id, previous.min_time) + || (entry.series_id == previous.series_id && entry.min_time < previous.max_time)) { - return corruption(index_offset, "segment index is not sorted"); + return corruption( + index_offset, + "segment index is not sorted or has overlapping blocks", + ); } indexed_points += u64::from(entry.points); expected_offset = entry.offset + u64::from(entry.length); @@ -870,7 +1008,7 @@ mod tests { assert!(stats.blocks > 2); assert!(stats.stored_bytes < stats.logical_point_bytes / 2); - let mut segment = Segment::open(&path).unwrap(); + let segment = Segment::open(&path).unwrap(); assert_eq!(segment.stats(), stats); let selected = segment.query(2, 123_000_000, 140_000_000).unwrap(); let expected: Vec<_> = input @@ -898,7 +1036,7 @@ mod tests { file.write_all(&[0xAA]).unwrap(); file.sync_all().unwrap(); - let mut segment = Segment::open(&path).unwrap(); + let segment = Segment::open(&path).unwrap(); assert!(matches!( segment.query(1, i64::MIN, i64::MAX), Err(Error::Corruption { .. }) @@ -947,7 +1085,7 @@ mod tests { // under the index's compression-ratio bound, so the block is only // rejected when its columns are decoded. claim_first_block_points(&path, 1_000); - let mut segment = Segment::open(&path).unwrap(); + let segment = Segment::open(&path).unwrap(); assert!(matches!( segment.query(1, i64::MIN, i64::MAX), Err(Error::Corruption { .. }) @@ -985,7 +1123,7 @@ mod tests { // structural bound on the header length must reject the block before // any decompression buffer is sized. tamper_first_block_lz4(&path, Some(u32::MAX), u32::MAX); - let mut segment = Segment::open(&path).unwrap(); + let segment = Segment::open(&path).unwrap(); assert!(matches!( segment.query(1, i64::MIN, i64::MAX), Err(Error::Corruption { .. }) @@ -1011,7 +1149,7 @@ mod tests { tamper_first_block_lz4(&path, Some(claimed_length), claimed_length); claim_first_block_points(&path, claimed_points); - let mut segment = Segment::open(&path).unwrap(); + let segment = Segment::open(&path).unwrap(); assert!(matches!( segment.query(1, i64::MIN, i64::MAX), Err(Error::Corruption { reason, .. }) if reason.contains("exceeds LZ4 capacity") @@ -1027,7 +1165,7 @@ mod tests { // Only the in-payload size prefix claims ~4 GiB; it must be rejected // against the validated header length instead of sizing an allocation. tamper_first_block_lz4(&path, None, u32::MAX); - let mut segment = Segment::open(&path).unwrap(); + let segment = Segment::open(&path).unwrap(); assert!(matches!( segment.query(1, i64::MIN, i64::MAX), Err(Error::Corruption { .. }) @@ -1046,4 +1184,25 @@ mod tests { )); assert_eq!(std::fs::read(&path).unwrap(), before); } + + #[test] + fn create_rejects_invalid_points_before_publishing_a_file() { + let directory = tempdir().unwrap(); + let path = directory.path().join("invalid.seg"); + let mut invalid = points(1); + invalid[0].value = f64::NAN; + assert!(Segment::create(&path, &invalid, 1).is_err()); + assert!(!path.exists()); + } + + #[test] + fn open_rejects_a_symlink_to_a_valid_segment() { + let directory = tempdir().unwrap(); + let target = directory.path().join("target.seg"); + let link = directory.path().join("linked.seg"); + Segment::create(&target, &points(10), 10).unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert!(matches!(Segment::open(&link), Err(Error::Io(_)))); + } } diff --git a/src/shadow_protocol.rs b/src/shadow_protocol.rs new file mode 100644 index 0000000..0a65abb --- /dev/null +++ b/src/shadow_protocol.rs @@ -0,0 +1,1797 @@ +//! Bounded version-one wire protocol for the FTWDB shadow sidecar. +//! +//! Version 1 is a draft until the Go sidecar passes the same golden fixtures. +//! All integers and IEEE-754 bit patterns use big-endian byte order. A frame is +//! `magic[4] | version:u16 | kind:u8 | reserved:u8 | payload_len:u32 | payload +//! | crc32:u32`; CRC-32 covers the header and payload. +//! +//! A commit payload starts with `source_id:u128 | sequence:u64 | commit_id:u128`, +//! then six collections in this exact order: entities, relations, series, +//! runs, plans, points. Each collection starts with a `u32` count. Metadata +//! fields follow their Rust declaration order. Text is `u16 bytes | UTF-8`. +//! Optional values are `presence:u8` (`0` or `1`) followed by the value when +//! present. Maps are `u32 count` followed by key/value pairs in ascending key +//! order. Enums use the explicit one-byte tags in the codec below. A point is +//! exactly 72 bytes: `u64 | i64 | i64 | i64 | i64 | u128 | f64-bits | u32 | +//! u32`, retaining all UTC-microsecond timestamps and provenance. +//! +//! Message kinds are request `1=hello, 2=commit, 3=flush, 4=health` and +//! response `128=hello, 129=ack, 130=health, 131=error`. Hello request is +//! `source_id | node_id | client_version | capabilities`; hello response is +//! `selected_version | session_id[16] | server_time_micros`. Flush is +//! `source_id | through_sequence`; a health request is `nonce`. Ack fields and +//! health-response fields follow their public struct order. An optional +//! watermark uses the normal presence encoding. Error is +//! `code:u8 | retryable:u8 | message`. +//! +//! Metadata layouts are: Entity `id | kind | name | parent | valid_from | +//! valid_to | properties`; Relation `id | kind | source | target | valid_from +//! | valid_to | properties`; SeriesDefinition `id | owner_entity | +//! owner_relation | name | physical_quantity | canonical_unit | semantics | +//! maximum_gap | rollup_policy`; Run `id | kind | status | created_at | +//! knowledge_time | workflow | model | model_version | parent_run | +//! input_snapshot | attributes`; Plan `id | run_id | status | horizon_start | +//! horizon_end | resolution_micros | scenario | objective_terms | +//! objective_value | supersedes | attributes`. Properties use tags +//! `0=null, 1=bool, 2=i64, 3=f64, 4=text`. + +use crate::{ + CalendarUnit, Entity, EntityId, Plan, PlanStatus, Point, Properties, PropertyValue, Relation, + RelationId, RollupPolicy, RollupResolution, RollupTier, Run, RunId, RunKind, RunStatus, + SeriesDefinition, SeriesSemantics, Transaction, +}; +use crc32fast::Hasher; +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt; +use std::io::{self, Read, Write}; + +pub const PROTOCOL_VERSION: u16 = 1; +pub const FRAME_MAGIC: [u8; 4] = *b"FTWS"; +pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; +pub const MAX_BATCH_POINTS: usize = 16_384; +pub const MAX_METADATA_RECORDS: usize = 16_384; +pub const MAX_QUEUE_ENTRIES: u32 = 65_536; +pub const MAX_PROPERTIES: usize = 1_024; +pub const MAX_ROLLUP_TIERS: usize = 64; +pub const MAX_TEXT_BYTES: usize = 4_096; + +const HEADER_BYTES: usize = 12; +const CHECKSUM_BYTES: usize = 4; +const MAX_PAYLOAD_BYTES: usize = MAX_FRAME_BYTES - HEADER_BYTES - CHECKSUM_BYTES; +const MAX_KEY_BYTES: usize = 256; +const MAX_ERROR_TEXT_BYTES: usize = 512; +const HELLO_REQUEST: u8 = 1; +const COMMIT_BATCH_REQUEST: u8 = 2; +const FLUSH_REQUEST: u8 = 3; +const HEALTH_REQUEST: u8 = 4; +const HELLO_RESPONSE: u8 = 128; +const ACK_RESPONSE: u8 = 129; +const HEALTH_RESPONSE: u8 = 130; +const ERROR_RESPONSE: u8 = 131; + +#[derive(Clone, Debug, PartialEq)] +pub enum WireMessage { + Request(Request), + Response(Response), +} +#[derive(Clone, Debug, PartialEq)] +pub enum Request { + Hello(HelloRequest), + CommitBatch(CommitBatchRequest), + Flush(FlushRequest), + Health(HealthRequest), +} +#[derive(Clone, Debug, PartialEq)] +pub enum Response { + Hello(HelloResponse), + Ack(Ack), + Health(HealthResponse), + Error(ErrorResponse), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HelloRequest { + pub source_id: u128, + pub node_id: String, + pub client_version: String, + pub capabilities: u64, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HelloResponse { + pub selected_version: u16, + pub session_id: [u8; 16], + pub server_time_micros: i64, +} + +/// One sidecar identity and sequence form the retry key. `commit_id` maps to +/// `Transaction::with_commit_id`; the receiver must reject the same identity +/// with different canonical bytes as an idempotency conflict. +#[derive(Clone, Debug, PartialEq)] +pub struct CommitBatchRequest { + pub source_id: u128, + pub sequence: u64, + pub commit_id: u128, + pub entities: Vec, + pub relations: Vec, + pub series: Vec, + pub runs: Vec, + pub plans: Vec, + /// Each point contains all 72 FTWDB point bytes and UTC microsecond times. + pub points: Vec, +} + +/// Builds the one canonical storage transaction used by both the sidecar and +/// read-only reconciliation. Keeping this mapping in one place prevents the +/// verifier from blessing bytes that the server would store differently. +pub(crate) fn transaction_from_batch(batch: CommitBatchRequest) -> Transaction { + let mut transaction = Transaction::new(); + for entity in batch.entities { + transaction.upsert_entity(entity); + } + for relation in batch.relations { + transaction.upsert_relation(relation); + } + for series in batch.series { + transaction.define_series(series); + } + for run in batch.runs { + transaction.upsert_run(run); + } + for plan in batch.plans { + transaction.upsert_plan(plan); + } + if !batch.points.is_empty() { + transaction.append_points(batch.points); + } + transaction +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FlushRequest { + pub source_id: u128, + pub through_sequence: u64, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HealthRequest { + pub nonce: u64, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AckKind { + CommitBatch, + Flush, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Ack { + pub kind: AckKind, + pub source_id: u128, + pub sequence: u64, + /// Zero for a flush acknowledgement. + pub commit_id: u128, + pub accepted_through_sequence: Option, + pub durable_through_sequence: Option, + pub durable: bool, + pub deduplicated: bool, + pub frame_offset: u64, + pub records: u32, + pub points: u32, + pub bytes_written: u64, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HealthStatus { + Healthy, + Degraded, + Unavailable, +} +/// Sidecar sync policy reported on health. Matches [`crate::Durability`] +/// tags so operators can see the live writer without a second endpoint. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum SyncPolicy { + #[default] + Always, + Manual, + EveryBytes(u64), +} +impl fmt::Display for SyncPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Always => formatter.write_str("always"), + Self::Manual => formatter.write_str("manual"), + Self::EveryBytes(bytes) => write!(formatter, "every-bytes:{bytes}"), + } + } +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HealthResponse { + pub nonce: u64, + pub source_id: u128, + pub status: HealthStatus, + pub queue_entries: u32, + pub accepted_through_sequence: Option, + pub durable_through_sequence: Option, + pub overload_count: u64, + pub protocol_error_count: u64, + pub database_bytes: u64, + pub database_points: u64, + pub database_commits: u64, + pub recovered_tail_bytes: u64, + pub sync_policy: SyncPolicy, + pub last_ack_durable: bool, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ErrorResponse { + pub code: ErrorCode, + pub retryable: bool, + pub message: String, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ErrorCode { + InvalidRequest, + Overloaded, + Internal, + Unsupported, + IdempotencyConflict, +} + +#[derive(Debug)] +pub enum ProtocolError { + Io(io::Error), + Truncated { expected: usize, actual: usize }, + TrailingBytes { count: usize }, + InvalidMagic, + UnsupportedVersion(u16), + UnknownMessageType(u8), + ReservedBitsSet(u8), + FrameTooLarge { declared: usize, maximum: usize }, + ChecksumMismatch { expected: u32, actual: u32 }, + InvalidField(&'static str), + InvalidEnumValue { field: &'static str, value: u8 }, +} +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Truncated { expected, actual } => write!( + f, + "truncated frame: expected {expected} bytes, got {actual}" + ), + Self::TrailingBytes { count } => write!(f, "frame has {count} trailing bytes"), + Self::InvalidMagic => write!(f, "invalid shadow protocol magic"), + Self::UnsupportedVersion(v) => write!(f, "unsupported shadow protocol version {v}"), + Self::UnknownMessageType(v) => write!(f, "unknown shadow message type {v}"), + Self::ReservedBitsSet(v) => write!(f, "reserved header bits are set: {v}"), + Self::FrameTooLarge { declared, maximum } => { + write!(f, "frame declares {declared} bytes; maximum is {maximum}") + } + Self::ChecksumMismatch { expected, actual } => write!( + f, + "shadow frame checksum mismatch: expected {expected:#010x}, got {actual:#010x}" + ), + Self::InvalidField(v) => write!(f, "invalid shadow protocol field: {v}"), + Self::InvalidEnumValue { field, value } => { + write!(f, "invalid {field} enum value {value}") + } + } + } +} +impl Error for ProtocolError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + if let Self::Io(e) = self { + Some(e) + } else { + None + } + } +} +impl From for ProtocolError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +pub fn encode(message: &WireMessage) -> Result, ProtocolError> { + let mut payload = Vec::new(); + let kind = encode_payload(message, &mut payload)?; + if payload.len() > MAX_PAYLOAD_BYTES { + return Err(too_large(payload.len())); + } + let mut frame = Vec::with_capacity(HEADER_BYTES + payload.len() + CHECKSUM_BYTES); + frame.extend_from_slice(&FRAME_MAGIC); + put_u16(&mut frame, PROTOCOL_VERSION); + frame.push(kind); + frame.push(0); + put_u32(&mut frame, payload.len() as u32); + frame.extend_from_slice(&payload); + let sum = crc32(&frame); + put_u32(&mut frame, sum); + Ok(frame) +} +pub fn decode(frame: &[u8]) -> Result { + let (kind, total) = parse_header(frame)?; + if frame.len() < total { + return Err(ProtocolError::Truncated { + expected: total, + actual: frame.len(), + }); + } + if frame.len() > total { + return Err(ProtocolError::TrailingBytes { + count: frame.len() - total, + }); + } + let actual = u32::from_be_bytes(frame[total - 4..total].try_into().unwrap()); + let expected = crc32(&frame[..total - 4]); + if actual != expected { + return Err(ProtocolError::ChecksumMismatch { expected, actual }); + } + decode_payload(kind, &frame[HEADER_BYTES..total - CHECKSUM_BYTES]) +} +pub fn write_to(writer: &mut W, message: &WireMessage) -> Result<(), ProtocolError> { + writer.write_all(&encode(message)?)?; + Ok(()) +} +/// Validates magic, version, type, reserved byte, and body limit before it allocates or reads a body. +pub fn read_from(reader: &mut R) -> Result { + let mut header = [0; HEADER_BYTES]; + read_exact(reader, &mut header, 0)?; + let (_, total) = parse_header(&header)?; + let mut frame = Vec::with_capacity(total); + frame.extend_from_slice(&header); + let mut tail = vec![0; total - HEADER_BYTES]; + read_exact(reader, &mut tail, HEADER_BYTES)?; + frame.extend_from_slice(&tail); + decode(&frame) +} + +fn parse_header(frame: &[u8]) -> Result<(u8, usize), ProtocolError> { + if frame.len() < HEADER_BYTES { + return Err(ProtocolError::Truncated { + expected: HEADER_BYTES, + actual: frame.len(), + }); + } + if frame[..4] != FRAME_MAGIC { + return Err(ProtocolError::InvalidMagic); + } + let version = u16::from_be_bytes(frame[4..6].try_into().unwrap()); + if version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(version)); + } + let kind = frame[6]; + validate_kind(kind)?; + if frame[7] != 0 { + return Err(ProtocolError::ReservedBitsSet(frame[7])); + } + let payload = u32::from_be_bytes(frame[8..12].try_into().unwrap()) as usize; + let total = HEADER_BYTES + .checked_add(payload) + .and_then(|n| n.checked_add(CHECKSUM_BYTES)) + .ok_or_else(|| too_large(usize::MAX))?; + if total > MAX_FRAME_BYTES { + return Err(too_large(payload)); + } + Ok((kind, total)) +} +fn validate_kind(kind: u8) -> Result<(), ProtocolError> { + match kind { + HELLO_REQUEST | COMMIT_BATCH_REQUEST | FLUSH_REQUEST | HEALTH_REQUEST | HELLO_RESPONSE + | ACK_RESPONSE | HEALTH_RESPONSE | ERROR_RESPONSE => Ok(()), + v => Err(ProtocolError::UnknownMessageType(v)), + } +} +fn too_large(payload: usize) -> ProtocolError { + ProtocolError::FrameTooLarge { + declared: payload.saturating_add(HEADER_BYTES + CHECKSUM_BYTES), + maximum: MAX_FRAME_BYTES, + } +} + +fn encode_payload(message: &WireMessage, o: &mut Vec) -> Result { + match message { + WireMessage::Request(Request::Hello(v)) => { + if v.source_id == 0 { + return Err(ProtocolError::InvalidField("source_id")); + } + put_u128(o, v.source_id); + string(o, &v.node_id, 128, "node_id")?; + string(o, &v.client_version, 64, "client_version")?; + put_u64(o, v.capabilities); + Ok(HELLO_REQUEST) + } + WireMessage::Request(Request::CommitBatch(v)) => { + validate_batch(v)?; + put_u128(o, v.source_id); + put_u64(o, v.sequence); + put_u128(o, v.commit_id); + put_u32(o, v.entities.len() as u32); + for value in &v.entities { + encode_entity(o, value)?; + } + put_u32(o, v.relations.len() as u32); + for value in &v.relations { + encode_relation(o, value)?; + } + put_u32(o, v.series.len() as u32); + for value in &v.series { + encode_series(o, value)?; + } + put_u32(o, v.runs.len() as u32); + for value in &v.runs { + encode_run(o, value)?; + } + put_u32(o, v.plans.len() as u32); + for value in &v.plans { + encode_plan(o, value)?; + } + put_u32(o, v.points.len() as u32); + for p in &v.points { + point(o, *p); + } + Ok(COMMIT_BATCH_REQUEST) + } + WireMessage::Request(Request::Flush(v)) => { + if v.source_id == 0 { + return Err(ProtocolError::InvalidField("source_id")); + } + put_u128(o, v.source_id); + put_u64(o, v.through_sequence); + Ok(FLUSH_REQUEST) + } + WireMessage::Request(Request::Health(v)) => { + put_u64(o, v.nonce); + Ok(HEALTH_REQUEST) + } + WireMessage::Response(Response::Hello(v)) => { + if v.selected_version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(v.selected_version)); + } + put_u16(o, v.selected_version); + o.extend_from_slice(&v.session_id); + put_i64(o, v.server_time_micros); + Ok(HELLO_RESPONSE) + } + WireMessage::Response(Response::Ack(v)) => { + if v.source_id == 0 { + return Err(ProtocolError::InvalidField("source_id")); + } + o.push(match v.kind { + AckKind::CommitBatch => 1, + AckKind::Flush => 2, + }); + put_u128(o, v.source_id); + put_u64(o, v.sequence); + put_u128(o, v.commit_id); + put_watermark(o, v.accepted_through_sequence); + put_watermark(o, v.durable_through_sequence); + o.push(v.durable as u8); + o.push(v.deduplicated as u8); + put_u64(o, v.frame_offset); + put_u32(o, v.records); + put_u32(o, v.points); + put_u64(o, v.bytes_written); + Ok(ACK_RESPONSE) + } + WireMessage::Response(Response::Health(v)) => { + if v.queue_entries > MAX_QUEUE_ENTRIES { + return Err(ProtocolError::InvalidField("queue_entries")); + } + put_u64(o, v.nonce); + if v.source_id == 0 { + return Err(ProtocolError::InvalidField("source_id")); + } + put_u128(o, v.source_id); + o.push(status_byte(v.status)); + put_u32(o, v.queue_entries); + put_watermark(o, v.accepted_through_sequence); + put_watermark(o, v.durable_through_sequence); + put_u64(o, v.overload_count); + put_u64(o, v.protocol_error_count); + put_u64(o, v.database_bytes); + put_u64(o, v.database_points); + put_u64(o, v.database_commits); + put_u64(o, v.recovered_tail_bytes); + put_sync_policy(o, v.sync_policy)?; + o.push(v.last_ack_durable as u8); + Ok(HEALTH_RESPONSE) + } + WireMessage::Response(Response::Error(v)) => { + o.push(error_byte(v.code)); + o.push(v.retryable as u8); + string(o, &v.message, MAX_ERROR_TEXT_BYTES, "error message")?; + Ok(ERROR_RESPONSE) + } + } +} + +fn decode_payload(kind: u8, bytes: &[u8]) -> Result { + let mut i = Input::new(bytes); + let m = match kind { + HELLO_REQUEST => WireMessage::Request(Request::Hello(HelloRequest { + source_id: i.u128()?, + node_id: i.string(128, "node_id")?, + client_version: i.string(64, "client_version")?, + capabilities: i.u64()?, + })), + COMMIT_BATCH_REQUEST => { + let mut remaining = MAX_METADATA_RECORDS; + let v = CommitBatchRequest { + source_id: i.u128()?, + sequence: i.u64()?, + commit_id: i.u128()?, + entities: decode_collection(&mut i, &mut remaining, decode_entity)?, + relations: decode_collection(&mut i, &mut remaining, decode_relation)?, + series: decode_collection(&mut i, &mut remaining, decode_series)?, + runs: decode_collection(&mut i, &mut remaining, decode_run)?, + plans: decode_collection(&mut i, &mut remaining, decode_plan)?, + points: i.points()?, + }; + validate_batch(&v)?; + WireMessage::Request(Request::CommitBatch(v)) + } + FLUSH_REQUEST => WireMessage::Request(Request::Flush(FlushRequest { + source_id: i.u128()?, + through_sequence: i.u64()?, + })), + HEALTH_REQUEST => WireMessage::Request(Request::Health(HealthRequest { nonce: i.u64()? })), + HELLO_RESPONSE => { + let selected_version = i.u16()?; + if selected_version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(selected_version)); + } + let mut session_id = [0; 16]; + session_id.copy_from_slice(i.take(16)?); + WireMessage::Response(Response::Hello(HelloResponse { + selected_version, + session_id, + server_time_micros: i.i64()?, + })) + } + ACK_RESPONSE => WireMessage::Response(Response::Ack(Ack { + kind: match i.u8()? { + 1 => AckKind::CommitBatch, + 2 => AckKind::Flush, + value => return Err(enum_error("ack kind", value)), + }, + source_id: i.u128()?, + sequence: i.u64()?, + commit_id: i.u128()?, + accepted_through_sequence: i.watermark("accepted watermark")?, + durable_through_sequence: i.watermark("durable watermark")?, + durable: boolean(&mut i, "durable")?, + deduplicated: boolean(&mut i, "deduplicated")?, + frame_offset: i.u64()?, + records: i.u32()?, + points: i.u32()?, + bytes_written: i.u64()?, + })), + HEALTH_RESPONSE => { + let mut v = HealthResponse { + nonce: i.u64()?, + source_id: i.u128()?, + status: status_from(i.u8()?)?, + queue_entries: i.u32()?, + accepted_through_sequence: i.watermark("accepted watermark")?, + durable_through_sequence: i.watermark("durable watermark")?, + overload_count: 0, + protocol_error_count: 0, + database_bytes: 0, + database_points: 0, + database_commits: 0, + recovered_tail_bytes: 0, + sync_policy: SyncPolicy::Always, + last_ack_durable: false, + }; + if i.remaining() > 0 { + v.overload_count = i.u64()?; + v.protocol_error_count = i.u64()?; + v.database_bytes = i.u64()?; + v.database_points = i.u64()?; + v.database_commits = i.u64()?; + v.recovered_tail_bytes = i.u64()?; + v.sync_policy = sync_policy_from(&mut i)?; + v.last_ack_durable = boolean(&mut i, "last_ack_durable")?; + } + if v.queue_entries > MAX_QUEUE_ENTRIES { + return Err(ProtocolError::InvalidField("queue_entries")); + } + WireMessage::Response(Response::Health(v)) + } + ERROR_RESPONSE => WireMessage::Response(Response::Error(ErrorResponse { + code: error_from(i.u8()?)?, + retryable: boolean(&mut i, "retryable")?, + message: i.string(MAX_ERROR_TEXT_BYTES, "error message")?, + })), + _ => unreachable!(), + }; + match &m { + WireMessage::Request(Request::Hello(v)) if v.source_id == 0 => { + return Err(ProtocolError::InvalidField("source_id")); + } + WireMessage::Request(Request::Flush(v)) if v.source_id == 0 => { + return Err(ProtocolError::InvalidField("source_id")); + } + WireMessage::Response(Response::Ack(v)) if v.source_id == 0 => { + return Err(ProtocolError::InvalidField("source_id")); + } + WireMessage::Response(Response::Health(v)) if v.source_id == 0 => { + return Err(ProtocolError::InvalidField("source_id")); + } + _ => {} + } + i.finish()?; + Ok(m) +} + +fn validate_batch(v: &CommitBatchRequest) -> Result<(), ProtocolError> { + if v.source_id == 0 { + return Err(ProtocolError::InvalidField("source_id")); + } + let meta = v.entities.len() + v.relations.len() + v.series.len() + v.runs.len() + v.plans.len(); + if meta > MAX_METADATA_RECORDS { + return Err(ProtocolError::InvalidField("too many metadata records")); + } + if v.points.len() > MAX_BATCH_POINTS { + return Err(ProtocolError::InvalidField("too many points")); + } + if meta == 0 && v.points.is_empty() { + return Err(ProtocolError::InvalidField("empty transaction")); + } + for s in &v.series { + s.validate().map_err(ProtocolError::InvalidField)?; + } + for p in &v.plans { + p.validate().map_err(ProtocolError::InvalidField)?; + } + for p in &v.points { + if p.series_id == 0 || p.valid_time_end < p.valid_time || !p.value.is_finite() { + return Err(ProtocolError::InvalidField("invalid point")); + } + } + Ok(()) +} +fn encode_entity(o: &mut Vec, v: &Entity) -> Result<(), ProtocolError> { + put_u128(o, v.id.0); + string(o, &v.kind, MAX_KEY_BYTES, "entity kind")?; + string(o, &v.name, MAX_TEXT_BYTES, "entity name")?; + put_option_u128(o, v.parent.map(|id| id.0)); + put_i64(o, v.valid_from); + put_option_i64(o, v.valid_to); + encode_properties(o, &v.properties) +} +fn decode_entity(i: &mut Input<'_>) -> Result { + Ok(Entity { + id: EntityId(i.u128()?), + kind: i.string(MAX_KEY_BYTES, "entity kind")?, + name: i.string(MAX_TEXT_BYTES, "entity name")?, + parent: i.option_u128("entity parent")?.map(EntityId), + valid_from: i.i64()?, + valid_to: i.option_i64("entity valid_to")?, + properties: decode_properties(i)?, + }) +} +fn encode_relation(o: &mut Vec, v: &Relation) -> Result<(), ProtocolError> { + put_u128(o, v.id.0); + string(o, &v.kind, MAX_KEY_BYTES, "relation kind")?; + put_u128(o, v.source.0); + put_u128(o, v.target.0); + put_i64(o, v.valid_from); + put_option_i64(o, v.valid_to); + encode_properties(o, &v.properties) +} +fn decode_relation(i: &mut Input<'_>) -> Result { + Ok(Relation { + id: RelationId(i.u128()?), + kind: i.string(MAX_KEY_BYTES, "relation kind")?, + source: EntityId(i.u128()?), + target: EntityId(i.u128()?), + valid_from: i.i64()?, + valid_to: i.option_i64("relation valid_to")?, + properties: decode_properties(i)?, + }) +} +fn encode_series(o: &mut Vec, v: &SeriesDefinition) -> Result<(), ProtocolError> { + v.validate().map_err(ProtocolError::InvalidField)?; + put_u64(o, v.id); + put_option_u128(o, v.owner_entity.map(|id| id.0)); + put_option_u128(o, v.owner_relation.map(|id| id.0)); + string(o, &v.name, MAX_KEY_BYTES, "series name")?; + string(o, &v.physical_quantity, MAX_KEY_BYTES, "physical quantity")?; + string(o, &v.canonical_unit, MAX_KEY_BYTES, "canonical unit")?; + o.push(series_semantics_byte(v.semantics)); + put_option_i64(o, v.maximum_gap_micros); + encode_rollup_policy(o, &v.rollup_policy) +} +fn decode_series(i: &mut Input<'_>) -> Result { + let v = SeriesDefinition { + id: i.u64()?, + owner_entity: i.option_u128("owner entity")?.map(EntityId), + owner_relation: i.option_u128("owner relation")?.map(RelationId), + name: i.string(MAX_KEY_BYTES, "series name")?, + physical_quantity: i.string(MAX_KEY_BYTES, "physical quantity")?, + canonical_unit: i.string(MAX_KEY_BYTES, "canonical unit")?, + semantics: series_semantics_from(i.u8()?)?, + maximum_gap_micros: i.option_i64("maximum gap")?, + rollup_policy: decode_rollup_policy(i)?, + }; + v.validate().map_err(ProtocolError::InvalidField)?; + Ok(v) +} +fn encode_rollup_policy(o: &mut Vec, v: &RollupPolicy) -> Result<(), ProtocolError> { + if v.tiers.len() > MAX_ROLLUP_TIERS { + return Err(ProtocolError::InvalidField("too many rollup tiers")); + } + put_option_i64(o, v.raw_retain_for_micros); + put_u32(o, v.tiers.len() as u32); + for tier in &v.tiers { + match &tier.resolution { + RollupResolution::FixedMicros(value) => { + o.push(1); + put_i64(o, *value); + } + RollupResolution::Calendar { + unit, + iana_timezone, + } => { + o.push(2); + o.push(calendar_unit_byte(*unit)); + string(o, iana_timezone, MAX_KEY_BYTES, "IANA timezone")?; + } + } + put_option_i64(o, tier.retain_for_micros); + } + Ok(()) +} +fn decode_rollup_policy(i: &mut Input<'_>) -> Result { + let raw_retain_for_micros = i.option_i64("raw retention")?; + let count = i.count(MAX_ROLLUP_TIERS, "rollup tier count")?; + let mut tiers = Vec::with_capacity(count); + for _ in 0..count { + let resolution = match i.u8()? { + 1 => RollupResolution::FixedMicros(i.i64()?), + 2 => RollupResolution::Calendar { + unit: calendar_unit_from(i.u8()?)?, + iana_timezone: i.string(MAX_KEY_BYTES, "IANA timezone")?, + }, + value => return Err(enum_error("rollup resolution", value)), + }; + tiers.push(RollupTier { + resolution, + retain_for_micros: i.option_i64("tier retention")?, + }); + } + Ok(RollupPolicy { + raw_retain_for_micros, + tiers, + }) +} +fn encode_run(o: &mut Vec, v: &Run) -> Result<(), ProtocolError> { + put_u128(o, v.id.0); + o.push(run_kind_byte(v.kind)); + o.push(run_status_byte(v.status)); + put_i64(o, v.created_at); + put_i64(o, v.knowledge_time); + string(o, &v.workflow, MAX_TEXT_BYTES, "workflow")?; + text(o, &v.model, MAX_TEXT_BYTES, "model")?; + text(o, &v.model_version, MAX_TEXT_BYTES, "model version")?; + put_option_u128(o, v.parent_run.map(|id| id.0)); + put_option_u128(o, v.input_snapshot.map(|id| id.0)); + encode_properties(o, &v.attributes) +} +fn decode_run(i: &mut Input<'_>) -> Result { + Ok(Run { + id: RunId(i.u128()?), + kind: run_kind_from(i.u8()?)?, + status: run_status_from(i.u8()?)?, + created_at: i.i64()?, + knowledge_time: i.i64()?, + workflow: i.string(MAX_TEXT_BYTES, "workflow")?, + model: i.text(MAX_TEXT_BYTES, "model")?, + model_version: i.text(MAX_TEXT_BYTES, "model version")?, + parent_run: i.option_u128("parent run")?.map(RunId), + input_snapshot: i.option_u128("input snapshot")?.map(RunId), + attributes: decode_properties(i)?, + }) +} +fn encode_plan(o: &mut Vec, v: &Plan) -> Result<(), ProtocolError> { + v.validate().map_err(ProtocolError::InvalidField)?; + put_u128(o, v.id); + put_u128(o, v.run_id.0); + o.push(plan_status_byte(v.status)); + put_i64(o, v.horizon_start); + put_i64(o, v.horizon_end); + put_i64(o, v.resolution_micros); + string(o, &v.scenario, MAX_TEXT_BYTES, "scenario")?; + if v.objective_terms.len() > MAX_PROPERTIES { + return Err(ProtocolError::InvalidField("too many objective terms")); + } + put_u32(o, v.objective_terms.len() as u32); + for (key, value) in &v.objective_terms { + string(o, key, MAX_KEY_BYTES, "objective key")?; + put_f64(o, *value, "objective value")?; + } + put_option_f64(o, v.objective_value, "objective value")?; + put_option_u128(o, v.supersedes); + encode_properties(o, &v.attributes) +} +fn decode_plan(i: &mut Input<'_>) -> Result { + let id = i.u128()?; + let run_id = RunId(i.u128()?); + let status = plan_status_from(i.u8()?)?; + let horizon_start = i.i64()?; + let horizon_end = i.i64()?; + let resolution_micros = i.i64()?; + let scenario = i.string(MAX_TEXT_BYTES, "scenario")?; + let count = i.count(MAX_PROPERTIES, "objective term count")?; + let mut objective_terms = BTreeMap::new(); + let mut previous: Option = None; + for _ in 0..count { + let key = i.string(MAX_KEY_BYTES, "objective key")?; + ensure_sorted(&previous, &key, "objective keys")?; + previous = Some(key.clone()); + let value = i.f64("objective value")?; + objective_terms.insert(key, value); + } + let v = Plan { + id, + run_id, + status, + horizon_start, + horizon_end, + resolution_micros, + scenario, + objective_terms, + objective_value: i.option_f64("objective value")?, + supersedes: i.option_u128("supersedes")?, + attributes: decode_properties(i)?, + }; + v.validate().map_err(ProtocolError::InvalidField)?; + Ok(v) +} +fn encode_properties(o: &mut Vec, values: &Properties) -> Result<(), ProtocolError> { + if values.len() > MAX_PROPERTIES { + return Err(ProtocolError::InvalidField("too many properties")); + } + put_u32(o, values.len() as u32); + for (key, value) in values { + string(o, key, MAX_KEY_BYTES, "property key")?; + match value { + PropertyValue::Null => o.push(0), + PropertyValue::Bool(v) => { + o.push(1); + o.push(*v as u8); + } + PropertyValue::Integer(v) => { + o.push(2); + put_i64(o, *v); + } + PropertyValue::Float(v) => { + o.push(3); + put_f64(o, *v, "property float")?; + } + PropertyValue::Text(v) => { + o.push(4); + text(o, v, MAX_TEXT_BYTES, "property text")?; + } + } + } + Ok(()) +} +fn decode_properties(i: &mut Input<'_>) -> Result { + let count = i.count(MAX_PROPERTIES, "property count")?; + let mut values = BTreeMap::new(); + let mut previous: Option = None; + for _ in 0..count { + let key = i.string(MAX_KEY_BYTES, "property key")?; + ensure_sorted(&previous, &key, "property keys")?; + previous = Some(key.clone()); + let value = match i.u8()? { + 0 => PropertyValue::Null, + 1 => PropertyValue::Bool(boolean(i, "property bool")?), + 2 => PropertyValue::Integer(i.i64()?), + 3 => PropertyValue::Float(i.f64("property float")?), + 4 => PropertyValue::Text(i.text(MAX_TEXT_BYTES, "property text")?), + value => return Err(enum_error("property value", value)), + }; + values.insert(key, value); + } + Ok(values) +} +fn decode_collection( + i: &mut Input<'_>, + remaining: &mut usize, + decode: fn(&mut Input<'_>) -> Result, +) -> Result, ProtocolError> { + let count = i.count(*remaining, "metadata record count")?; + *remaining -= count; + let mut values = Vec::with_capacity(count); + for _ in 0..count { + values.push(decode(i)?); + } + Ok(values) +} +fn ensure_sorted( + previous: &Option, + current: &str, + field: &'static str, +) -> Result<(), ProtocolError> { + if previous.as_deref().is_some_and(|value| value >= current) { + Err(ProtocolError::InvalidField(field)) + } else { + Ok(()) + } +} +fn point(o: &mut Vec, p: Point) { + put_u64(o, p.series_id); + put_i64(o, p.valid_time); + put_i64(o, p.valid_time_end); + put_i64(o, p.knowledge_time); + put_i64(o, p.change_time); + put_u128(o, p.run_id); + put_u64(o, p.value.to_bits()); + put_u32(o, p.quality); + put_u32(o, p.flags); +} +fn string(o: &mut Vec, v: &str, max: usize, field: &'static str) -> Result<(), ProtocolError> { + if v.is_empty() || v.len() > max { + return Err(ProtocolError::InvalidField(field)); + } + text(o, v, max, field) +} +fn text(o: &mut Vec, v: &str, max: usize, field: &'static str) -> Result<(), ProtocolError> { + if v.len() > max || v.len() > u16::MAX as usize { + return Err(ProtocolError::InvalidField(field)); + } + put_u16(o, v.len() as u16); + o.extend_from_slice(v.as_bytes()); + Ok(()) +} +fn put_option_u128(o: &mut Vec, value: Option) { + match value { + Some(v) => { + o.push(1); + put_u128(o, v); + } + None => o.push(0), + } +} +fn put_option_i64(o: &mut Vec, value: Option) { + match value { + Some(v) => { + o.push(1); + put_i64(o, v); + } + None => o.push(0), + } +} +fn put_f64(o: &mut Vec, value: f64, field: &'static str) -> Result<(), ProtocolError> { + if !value.is_finite() { + return Err(ProtocolError::InvalidField(field)); + } + put_u64(o, value.to_bits()); + Ok(()) +} +fn put_option_f64( + o: &mut Vec, + value: Option, + field: &'static str, +) -> Result<(), ProtocolError> { + match value { + Some(v) => { + o.push(1); + put_f64(o, v, field)?; + } + None => o.push(0), + } + Ok(()) +} +fn put_u16(o: &mut Vec, v: u16) { + o.extend_from_slice(&v.to_be_bytes()) +} +fn put_u32(o: &mut Vec, v: u32) { + o.extend_from_slice(&v.to_be_bytes()) +} +fn put_u64(o: &mut Vec, v: u64) { + o.extend_from_slice(&v.to_be_bytes()) +} +fn put_u128(o: &mut Vec, v: u128) { + o.extend_from_slice(&v.to_be_bytes()) +} +fn put_i64(o: &mut Vec, v: i64) { + o.extend_from_slice(&v.to_be_bytes()) +} +fn put_watermark(o: &mut Vec, value: Option) { + match value { + Some(value) => { + o.push(1); + put_u64(o, value); + } + None => o.push(0), + } +} +fn crc32(v: &[u8]) -> u32 { + let mut h = Hasher::new(); + h.update(v); + h.finalize() +} +fn boolean(i: &mut Input<'_>, field: &'static str) -> Result { + match i.u8()? { + 0 => Ok(false), + 1 => Ok(true), + value => Err(enum_error(field, value)), + } +} +fn enum_error(field: &'static str, value: u8) -> ProtocolError { + ProtocolError::InvalidEnumValue { field, value } +} +fn series_semantics_byte(v: SeriesSemantics) -> u8 { + match v { + SeriesSemantics::Gauge => 1, + SeriesSemantics::IntervalTotal => 2, + SeriesSemantics::Counter => 3, + SeriesSemantics::State => 4, + SeriesSemantics::Event => 5, + } +} +fn series_semantics_from(v: u8) -> Result { + match v { + 1 => Ok(SeriesSemantics::Gauge), + 2 => Ok(SeriesSemantics::IntervalTotal), + 3 => Ok(SeriesSemantics::Counter), + 4 => Ok(SeriesSemantics::State), + 5 => Ok(SeriesSemantics::Event), + _ => Err(enum_error("series semantics", v)), + } +} +fn calendar_unit_byte(v: CalendarUnit) -> u8 { + match v { + CalendarUnit::Day => 1, + CalendarUnit::Month => 2, + CalendarUnit::Year => 3, + } +} +fn calendar_unit_from(v: u8) -> Result { + match v { + 1 => Ok(CalendarUnit::Day), + 2 => Ok(CalendarUnit::Month), + 3 => Ok(CalendarUnit::Year), + _ => Err(enum_error("calendar unit", v)), + } +} +fn run_kind_byte(v: RunKind) -> u8 { + match v { + RunKind::Forecast => 1, + RunKind::Optimization => 2, + RunKind::Import => 3, + RunKind::Control => 4, + RunKind::Reconciliation => 5, + } +} +fn run_kind_from(v: u8) -> Result { + match v { + 1 => Ok(RunKind::Forecast), + 2 => Ok(RunKind::Optimization), + 3 => Ok(RunKind::Import), + 4 => Ok(RunKind::Control), + 5 => Ok(RunKind::Reconciliation), + _ => Err(enum_error("run kind", v)), + } +} +fn run_status_byte(v: RunStatus) -> u8 { + match v { + RunStatus::Pending => 1, + RunStatus::Running => 2, + RunStatus::Succeeded => 3, + RunStatus::Failed => 4, + RunStatus::Cancelled => 5, + } +} +fn run_status_from(v: u8) -> Result { + match v { + 1 => Ok(RunStatus::Pending), + 2 => Ok(RunStatus::Running), + 3 => Ok(RunStatus::Succeeded), + 4 => Ok(RunStatus::Failed), + 5 => Ok(RunStatus::Cancelled), + _ => Err(enum_error("run status", v)), + } +} +fn plan_status_byte(v: PlanStatus) -> u8 { + match v { + PlanStatus::Candidate => 1, + PlanStatus::Approved => 2, + PlanStatus::Deployed => 3, + PlanStatus::Superseded => 4, + PlanStatus::Cancelled => 5, + } +} +fn plan_status_from(v: u8) -> Result { + match v { + 1 => Ok(PlanStatus::Candidate), + 2 => Ok(PlanStatus::Approved), + 3 => Ok(PlanStatus::Deployed), + 4 => Ok(PlanStatus::Superseded), + 5 => Ok(PlanStatus::Cancelled), + _ => Err(enum_error("plan status", v)), + } +} +fn status_byte(v: HealthStatus) -> u8 { + match v { + HealthStatus::Healthy => 1, + HealthStatus::Degraded => 2, + HealthStatus::Unavailable => 3, + } +} +fn status_from(v: u8) -> Result { + match v { + 1 => Ok(HealthStatus::Healthy), + 2 => Ok(HealthStatus::Degraded), + 3 => Ok(HealthStatus::Unavailable), + _ => Err(enum_error("health status", v)), + } +} +fn put_sync_policy(o: &mut Vec, v: SyncPolicy) -> Result<(), ProtocolError> { + match v { + SyncPolicy::Always => { + o.push(1); + put_u64(o, 0); + } + SyncPolicy::Manual => { + o.push(2); + put_u64(o, 0); + } + SyncPolicy::EveryBytes(0) => { + return Err(ProtocolError::InvalidField("sync every-bytes")); + } + SyncPolicy::EveryBytes(bytes) => { + o.push(3); + put_u64(o, bytes); + } + } + Ok(()) +} +fn sync_policy_from(i: &mut Input<'_>) -> Result { + match i.u8()? { + 1 => { + if i.u64()? != 0 { + return Err(ProtocolError::InvalidField("sync every-bytes")); + } + Ok(SyncPolicy::Always) + } + 2 => { + if i.u64()? != 0 { + return Err(ProtocolError::InvalidField("sync every-bytes")); + } + Ok(SyncPolicy::Manual) + } + 3 => { + let bytes = i.u64()?; + if bytes == 0 { + return Err(ProtocolError::InvalidField("sync every-bytes")); + } + Ok(SyncPolicy::EveryBytes(bytes)) + } + value => Err(enum_error("sync policy", value)), + } +} +fn error_byte(v: ErrorCode) -> u8 { + match v { + ErrorCode::InvalidRequest => 1, + ErrorCode::Overloaded => 2, + ErrorCode::Internal => 3, + ErrorCode::Unsupported => 4, + ErrorCode::IdempotencyConflict => 5, + } +} +fn error_from(v: u8) -> Result { + match v { + 1 => Ok(ErrorCode::InvalidRequest), + 2 => Ok(ErrorCode::Overloaded), + 3 => Ok(ErrorCode::Internal), + 4 => Ok(ErrorCode::Unsupported), + 5 => Ok(ErrorCode::IdempotencyConflict), + _ => Err(enum_error("error code", v)), + } +} +fn read_exact(r: &mut R, b: &mut [u8], base: usize) -> Result<(), ProtocolError> { + let mut n = 0; + while n < b.len() { + match r.read(&mut b[n..]) { + Ok(0) => { + return Err(ProtocolError::Truncated { + expected: base + b.len(), + actual: base + n, + }); + } + Ok(m) => n += m, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) +} +struct Input<'a> { + b: &'a [u8], + p: usize, +} +impl<'a> Input<'a> { + fn new(b: &'a [u8]) -> Self { + Self { b, p: 0 } + } + fn take(&mut self, n: usize) -> Result<&'a [u8], ProtocolError> { + let e = self.p.checked_add(n).ok_or(ProtocolError::Truncated { + expected: usize::MAX, + actual: self.b.len(), + })?; + if e > self.b.len() { + return Err(ProtocolError::Truncated { + expected: e, + actual: self.b.len(), + }); + } + let v = &self.b[self.p..e]; + self.p = e; + Ok(v) + } + fn remaining(&self) -> usize { + self.b.len().saturating_sub(self.p) + } + fn finish(&self) -> Result<(), ProtocolError> { + if self.p == self.b.len() { + Ok(()) + } else { + Err(ProtocolError::TrailingBytes { + count: self.b.len() - self.p, + }) + } + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap())) + } + fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.take(8)?.try_into().unwrap())) + } + fn u128(&mut self) -> Result { + Ok(u128::from_be_bytes(self.take(16)?.try_into().unwrap())) + } + fn i64(&mut self) -> Result { + Ok(i64::from_be_bytes(self.take(8)?.try_into().unwrap())) + } + fn watermark(&mut self, field: &'static str) -> Result, ProtocolError> { + match self.u8()? { + 0 => Ok(None), + 1 => Ok(Some(self.u64()?)), + value => Err(enum_error(field, value)), + } + } + fn string(&mut self, max: usize, field: &'static str) -> Result { + let n = self.u16()? as usize; + if n == 0 || n > max { + return Err(ProtocolError::InvalidField(field)); + } + self.text_bytes(n, field) + } + fn text(&mut self, max: usize, field: &'static str) -> Result { + let n = self.u16()? as usize; + if n > max { + return Err(ProtocolError::InvalidField(field)); + } + self.text_bytes(n, field) + } + fn text_bytes(&mut self, n: usize, field: &'static str) -> Result { + let s = + std::str::from_utf8(self.take(n)?).map_err(|_| ProtocolError::InvalidField(field))?; + Ok(s.into()) + } + fn count(&mut self, maximum: usize, field: &'static str) -> Result { + let count = self.u32()? as usize; + if count > maximum { + Err(ProtocolError::InvalidField(field)) + } else { + Ok(count) + } + } + fn option_u128(&mut self, field: &'static str) -> Result, ProtocolError> { + match self.u8()? { + 0 => Ok(None), + 1 => Ok(Some(self.u128()?)), + value => Err(enum_error(field, value)), + } + } + fn option_i64(&mut self, field: &'static str) -> Result, ProtocolError> { + match self.u8()? { + 0 => Ok(None), + 1 => Ok(Some(self.i64()?)), + value => Err(enum_error(field, value)), + } + } + fn f64(&mut self, field: &'static str) -> Result { + let value = f64::from_bits(self.u64()?); + if value.is_finite() { + Ok(value) + } else { + Err(ProtocolError::InvalidField(field)) + } + } + fn option_f64(&mut self, field: &'static str) -> Result, ProtocolError> { + match self.u8()? { + 0 => Ok(None), + 1 => Ok(Some(self.f64(field)?)), + value => Err(enum_error(field, value)), + } + } + fn points(&mut self) -> Result, ProtocolError> { + let n = self.u32()? as usize; + if n > MAX_BATCH_POINTS { + return Err(ProtocolError::InvalidField("too many points")); + } + let need = n + .checked_mul(72) + .ok_or(ProtocolError::InvalidField("point count"))?; + if self.b.len() - self.p != need { + return Err(ProtocolError::InvalidField("point payload length")); + } + let mut out = Vec::with_capacity(n); + for _ in 0..n { + out.push(Point { + series_id: self.u64()?, + valid_time: self.i64()?, + valid_time_end: self.i64()?, + knowledge_time: self.i64()?, + change_time: self.i64()?, + run_id: self.u128()?, + value: f64::from_bits(self.u64()?), + quality: self.u32()?, + flags: self.u32()?, + }) + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + fn metadata() -> (Entity, Relation, SeriesDefinition, Run, Plan) { + let properties = BTreeMap::from([ + ("a".into(), PropertyValue::Null), + ("b".into(), PropertyValue::Bool(true)), + ("c".into(), PropertyValue::Integer(-2)), + ("d".into(), PropertyValue::Float(1.5)), + ("e".into(), PropertyValue::Text(String::new())), + ]); + let entity = Entity { + id: EntityId(1), + kind: "site".into(), + name: "Alpha".into(), + parent: None, + valid_from: 10, + valid_to: Some(20), + properties, + }; + let relation = Relation { + id: RelationId(2), + kind: "contains".into(), + source: EntityId(1), + target: EntityId(3), + valid_from: 10, + valid_to: None, + properties: BTreeMap::new(), + }; + let series = SeriesDefinition { + id: 4, + owner_entity: Some(EntityId(1)), + owner_relation: None, + name: "power".into(), + physical_quantity: "power".into(), + canonical_unit: "W".into(), + semantics: SeriesSemantics::Gauge, + maximum_gap_micros: Some(5), + rollup_policy: RollupPolicy { + raw_retain_for_micros: Some(100), + tiers: vec![ + RollupTier { + resolution: RollupResolution::FixedMicros(60), + retain_for_micros: None, + }, + RollupTier { + resolution: RollupResolution::Calendar { + unit: CalendarUnit::Day, + iana_timezone: "UTC".into(), + }, + retain_for_micros: Some(1_000), + }, + ], + }, + }; + let run = Run { + id: RunId(5), + kind: RunKind::Forecast, + status: RunStatus::Succeeded, + created_at: 11, + knowledge_time: 12, + workflow: "wf".into(), + model: String::new(), + model_version: "v1".into(), + parent_run: None, + input_snapshot: None, + attributes: BTreeMap::new(), + }; + let plan = Plan { + id: 6, + run_id: RunId(5), + status: PlanStatus::Candidate, + horizon_start: 20, + horizon_end: 80, + resolution_micros: 60, + scenario: "base".into(), + objective_terms: BTreeMap::from([("cost".into(), 1.5)]), + objective_value: Some(1.5), + supersedes: None, + attributes: BTreeMap::new(), + }; + (entity, relation, series, run, plan) + } + fn batch() -> WireMessage { + let (entity, relation, series, run, plan) = metadata(); + WireMessage::Request(Request::CommitBatch(CommitBatchRequest { + source_id: 1, + sequence: 2, + commit_id: 3, + entities: vec![entity], + relations: vec![relation], + series: vec![series], + runs: vec![run], + plans: vec![plan], + points: vec![Point { + series_id: 4, + valid_time: 1_754_382_400_123_456, + valid_time_end: 1_754_382_700_123_456, + knowledge_time: 1_754_382_401_123_456, + change_time: 1_754_382_402_123_456, + run_id: 5, + value: -12.5, + quality: 7, + flags: 8, + }], + })) + } + #[test] + fn round_trip_full_point() { + let m = batch(); + let frame = encode(&m).unwrap(); + assert_eq!(decode(&frame).unwrap(), m) + } + #[test] + fn all_message_kinds_round_trip_with_frozen_tags() { + let messages = vec![ + ( + 1, + WireMessage::Request(Request::Hello(HelloRequest { + source_id: 1, + node_id: "n".into(), + client_version: "v".into(), + capabilities: 1, + })), + ), + (2, batch()), + ( + 3, + WireMessage::Request(Request::Flush(FlushRequest { + source_id: 1, + through_sequence: 2, + })), + ), + ( + 4, + WireMessage::Request(Request::Health(HealthRequest { nonce: 3 })), + ), + ( + 128, + WireMessage::Response(Response::Hello(HelloResponse { + selected_version: 1, + session_id: [4; 16], + server_time_micros: 5, + })), + ), + ( + 129, + WireMessage::Response(Response::Ack(Ack { + kind: AckKind::CommitBatch, + source_id: 1, + sequence: 2, + commit_id: 3, + accepted_through_sequence: Some(2), + durable_through_sequence: None, + durable: false, + deduplicated: true, + frame_offset: 6, + records: 5, + points: 1, + bytes_written: 7, + })), + ), + ( + 130, + WireMessage::Response(Response::Health(HealthResponse { + nonce: 3, + source_id: 1, + status: HealthStatus::Degraded, + queue_entries: 4, + accepted_through_sequence: Some(2), + durable_through_sequence: None, + overload_count: 0, + protocol_error_count: 0, + database_bytes: 0, + database_points: 0, + database_commits: 0, + recovered_tail_bytes: 0, + sync_policy: SyncPolicy::Always, + last_ack_durable: false, + })), + ), + ( + 131, + WireMessage::Response(Response::Error(ErrorResponse { + code: ErrorCode::IdempotencyConflict, + retryable: false, + message: "conflict".into(), + })), + ), + ]; + for (kind, message) in messages { + let frame = encode(&message).unwrap(); + assert_eq!(frame[6], kind); + assert_eq!(decode(&frame).unwrap(), message); + } + } + #[test] + fn enum_tags_are_frozen() { + assert_eq!( + [ + SeriesSemantics::Gauge, + SeriesSemantics::IntervalTotal, + SeriesSemantics::Counter, + SeriesSemantics::State, + SeriesSemantics::Event + ] + .map(series_semantics_byte), + [1, 2, 3, 4, 5] + ); + assert_eq!( + [CalendarUnit::Day, CalendarUnit::Month, CalendarUnit::Year].map(calendar_unit_byte), + [1, 2, 3] + ); + assert_eq!( + [ + RunKind::Forecast, + RunKind::Optimization, + RunKind::Import, + RunKind::Control, + RunKind::Reconciliation + ] + .map(run_kind_byte), + [1, 2, 3, 4, 5] + ); + assert_eq!( + [ + RunStatus::Pending, + RunStatus::Running, + RunStatus::Succeeded, + RunStatus::Failed, + RunStatus::Cancelled + ] + .map(run_status_byte), + [1, 2, 3, 4, 5] + ); + assert_eq!( + [ + PlanStatus::Candidate, + PlanStatus::Approved, + PlanStatus::Deployed, + PlanStatus::Superseded, + PlanStatus::Cancelled + ] + .map(plan_status_byte), + [1, 2, 3, 4, 5] + ); + assert_eq!( + [ + ErrorCode::InvalidRequest, + ErrorCode::Overloaded, + ErrorCode::Internal, + ErrorCode::Unsupported, + ErrorCode::IdempotencyConflict + ] + .map(error_byte), + [1, 2, 3, 4, 5] + ); + let mut always = Vec::new(); + put_sync_policy(&mut always, SyncPolicy::Always).unwrap(); + let mut manual = Vec::new(); + put_sync_policy(&mut manual, SyncPolicy::Manual).unwrap(); + let mut every = Vec::new(); + put_sync_policy(&mut every, SyncPolicy::EveryBytes(64)).unwrap(); + assert_eq!(always[0], 1); + assert_eq!(manual[0], 2); + assert_eq!(every[0], 3); + } + #[test] + fn health_response_decodes_the_legacy_prefix_without_ops_fields() { + let frame = decode_hex( + "465457530001820000000027112233445566778800112233445566778899aabbccddeeff02000000030101020304050607080053c62c77", + ); + match decode(&frame).unwrap() { + WireMessage::Response(Response::Health(health)) => { + assert_eq!(health.nonce, 0x1122_3344_5566_7788); + assert_eq!(health.queue_entries, 3); + assert_eq!( + health.accepted_through_sequence, + Some(0x0102_0304_0506_0708) + ); + assert_eq!(health.durable_through_sequence, None); + assert_eq!(health.overload_count, 0); + assert_eq!(health.protocol_error_count, 0); + assert_eq!(health.sync_policy, SyncPolicy::Always); + assert!(!health.last_ack_durable); + } + other => panic!("expected health, got {other:?}"), + } + } + fn decode_hex(text: &str) -> Vec { + let text = text.trim(); + text.as_bytes() + .chunks_exact(2) + .map(|pair| { + let digits = std::str::from_utf8(pair).unwrap(); + u8::from_str_radix(digits, 16).unwrap() + }) + .collect() + } + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() + } + #[test] + fn frozen_metadata_encodings() { + let (entity, relation, series, run, plan) = metadata(); + let mut encoded = Vec::new(); + encode_entity(&mut encoded, &entity).unwrap(); + assert_eq!( + hex(&encoded), + "000000000000000000000000000000010004736974650005416c70686100000000000000000a0100000000000000140000000500016100000162010100016302fffffffffffffffe000164033ff8000000000000000165040000" + ); + encoded.clear(); + encode_relation(&mut encoded, &relation).unwrap(); + assert_eq!( + hex(&encoded), + "000000000000000000000000000000020008636f6e7461696e730000000000000000000000000000000100000000000000000000000000000003000000000000000a0000000000" + ); + encoded.clear(); + encode_series(&mut encoded, &series).unwrap(); + assert_eq!( + hex(&encoded), + "00000000000000040100000000000000000000000000000001000005706f7765720005706f776572000157010100000000000000050100000000000000640000000201000000000000003c00020100035554430100000000000003e8" + ); + encoded.clear(); + encode_run(&mut encoded, &run).unwrap(); + assert_eq!( + hex(&encoded), + "000000000000000000000000000000050103000000000000000b000000000000000c00027766000000027631000000000000" + ); + encoded.clear(); + encode_plan(&mut encoded, &plan).unwrap(); + assert_eq!( + hex(&encoded), + "00000000000000000000000000000006000000000000000000000000000000050100000000000000140000000000000050000000000000003c000462617365000000010004636f73743ff8000000000000013ff80000000000000000000000" + ); + } + #[test] + fn stream_round_trip() { + let m = batch(); + let mut b = Vec::new(); + write_to(&mut b, &m).unwrap(); + assert_eq!(read_from(&mut Cursor::new(b)).unwrap(), m) + } + #[test] + fn rejects_corruption() { + let mut b = encode(&batch()).unwrap(); + b[HEADER_BYTES + 50] ^= 1; + assert!(matches!( + decode(&b), + Err(ProtocolError::ChecksumMismatch { .. }) + )) + } + #[test] + fn bounds() { + let mut b = vec![0; HEADER_BYTES]; + b[..4].copy_from_slice(&FRAME_MAGIC); + b[4..6].copy_from_slice(&PROTOCOL_VERSION.to_be_bytes()); + b[6] = HEALTH_REQUEST; + b[8..12].copy_from_slice(&((MAX_PAYLOAD_BYTES + 1) as u32).to_be_bytes()); + assert!(matches!( + read_from(&mut Cursor::new(b)), + Err(ProtocolError::FrameTooLarge { .. }) + )); + let mut v = match batch() { + WireMessage::Request(Request::CommitBatch(v)) => v, + _ => unreachable!(), + }; + v.points = vec![v.points[0]; MAX_BATCH_POINTS + 1]; + assert!(encode(&WireMessage::Request(Request::CommitBatch(v))).is_err()) + } + #[test] + fn adversarial_counts_lengths_and_trailing_bytes_are_rejected() { + let mut payload = Vec::new(); + put_u128(&mut payload, 1); + put_u64(&mut payload, 1); + put_u128(&mut payload, 1); + put_u32(&mut payload, (MAX_METADATA_RECORDS + 1) as u32); + assert!(matches!( + decode_payload(COMMIT_BATCH_REQUEST, &payload), + Err(ProtocolError::InvalidField("metadata record count")) + )); + + let mut oversized_name = 1_u128.to_be_bytes().to_vec(); + oversized_name.extend_from_slice(&[0, 129]); + assert!(matches!( + decode_payload(HELLO_REQUEST, &oversized_name), + Err(ProtocolError::InvalidField("node_id")) + )); + + let mut health = 1_u64.to_be_bytes().to_vec(); + health.push(0); + assert!(matches!( + decode_payload(HEALTH_REQUEST, &health), + Err(ProtocolError::TrailingBytes { count: 1 }) + )); + + let mut properties = Vec::new(); + put_u32(&mut properties, 2); + string(&mut properties, "b", MAX_KEY_BYTES, "key").unwrap(); + properties.push(0); + string(&mut properties, "a", MAX_KEY_BYTES, "key").unwrap(); + properties.push(0); + assert!(matches!( + decode_properties(&mut Input::new(&properties)), + Err(ProtocolError::InvalidField("property keys")) + )); + + let (mut entity, _, _, _, _) = metadata(); + entity + .properties + .insert("z".into(), PropertyValue::Float(f64::NAN)); + assert!(matches!( + encode_entity(&mut Vec::new(), &entity), + Err(ProtocolError::InvalidField("property float")) + )); + } + #[test] + fn bad_header_rejected_before_body() { + for (at, value, expected) in [(4, 2, "version"), (6, 99, "type"), (7, 1, "reserved")] { + let mut h = [0; HEADER_BYTES]; + h[..4].copy_from_slice(&FRAME_MAGIC); + h[4..6].copy_from_slice(&PROTOCOL_VERSION.to_be_bytes()); + h[6] = HEALTH_REQUEST; + h[at] = value; + let e = read_from(&mut Cursor::new(h)).unwrap_err(); + assert!(e.to_string().contains(expected)); + } + } + #[test] + fn frozen_golden_health_frame() { + let m = WireMessage::Request(Request::Health(HealthRequest { + nonce: 0x0102_0304_0506_0708, + })); + let expected: Vec = vec![ + 0x46, 0x54, 0x57, 0x53, 0, 1, 4, 0, 0, 0, 0, 8, 1, 2, 3, 4, 5, 6, 7, 8, 36, 198, 92, + 216, + ]; + assert_eq!(encode(&m).unwrap(), expected); + assert_eq!(decode(&expected).unwrap(), m) + } +} diff --git a/src/shadow_reconcile.rs b/src/shadow_reconcile.rs new file mode 100644 index 0000000..c320c7b --- /dev/null +++ b/src/shadow_reconcile.rs @@ -0,0 +1,977 @@ +//! Bounded, read-only comparison of source batches with an FTWDB shadow store. +//! +//! The caller supplies the source batches for one comparison window in their +//! intended cross-source commit order. The report checks every ordered-ingress +//! receipt, the last supplied form of each catalog object, and exact point +//! multiplicities. Point comparison covers the smallest timestamp span that +//! contains the supplied points for each series. Catalog comparison is +//! one-way because catalog records do not retain an ingress source ID. +//! +//! This module does not decode another export format and does not write to the +//! database. It reads the live index and bounded blocks from sealed segments. + +use crate::shadow_protocol::CommitBatchRequest; +use crate::{Database, IngressIdentity, Point}; +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; + +pub const DEFAULT_MAX_RECONCILE_BATCHES: usize = 65_536; +pub const DEFAULT_MAX_RECONCILE_METADATA: usize = 1_000_000; +pub const DEFAULT_MAX_RECONCILE_POINTS: usize = 1_000_000; +pub const DEFAULT_MAX_RECONCILE_SCANNED_POINTS: usize = 1_000_000; +pub const DEFAULT_MAX_RECONCILE_DETAILS: usize = 256; + +/// Work and output limits for one reconciliation window. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ShadowReconcileLimits { + pub max_batches: usize, + pub max_metadata_records: usize, + pub max_expected_points: usize, + pub max_observed_points: usize, + /// Maximum raw series entries visited before timestamp filtering. + pub max_scanned_points: usize, + pub max_mismatch_details: usize, +} + +impl Default for ShadowReconcileLimits { + fn default() -> Self { + Self { + max_batches: DEFAULT_MAX_RECONCILE_BATCHES, + max_metadata_records: DEFAULT_MAX_RECONCILE_METADATA, + max_expected_points: DEFAULT_MAX_RECONCILE_POINTS, + max_observed_points: DEFAULT_MAX_RECONCILE_POINTS, + max_scanned_points: DEFAULT_MAX_RECONCILE_SCANNED_POINTS, + max_mismatch_details: DEFAULT_MAX_RECONCILE_DETAILS, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReconcileLimit { + Batches, + MetadataRecords, + ExpectedPoints, + ObservedPoints, + ScannedPoints, +} + +#[derive(Debug)] +pub enum ShadowReconcileError { + LimitExceeded { + limit: ReconcileLimit, + maximum: usize, + }, + InvalidIdentity(IngressIdentity), + DuplicateSourceSequence { + source_id: u128, + sequence: u64, + }, + DuplicateCommitId(u128), + MaximumTimestamp { + series_id: u64, + }, + Store(crate::Error), +} + +impl fmt::Display for ShadowReconcileError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LimitExceeded { limit, maximum } => { + write!( + formatter, + "reconciliation {limit:?} limit exceeds {maximum}" + ) + } + Self::InvalidIdentity(identity) => write!( + formatter, + "invalid reconciliation identity for source {} sequence {}", + identity.source_id, identity.sequence + ), + Self::DuplicateSourceSequence { + source_id, + sequence, + } => write!( + formatter, + "duplicate reconciliation source {source_id} sequence {sequence}" + ), + Self::DuplicateCommitId(commit_id) => { + write!(formatter, "duplicate reconciliation commit id {commit_id}") + } + Self::MaximumTimestamp { series_id } => write!( + formatter, + "series {series_id} contains i64::MAX, which has no exclusive query end" + ), + Self::Store(error) => { + write!(formatter, "could not verify stored ingress bytes: {error}") + } + } + } +} + +impl Error for ShadowReconcileError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Store(error) => Some(error), + _ => None, + } + } +} + +impl From for ShadowReconcileError { + fn from(value: crate::Error) -> Self { + Self::Store(value) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CatalogObjectKind { + Entity, + Relation, + Series, + Run, + Plan, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CatalogMismatch { + Missing, + Different, +} + +/// Exact point identity. Floating-point values use their wire bits, so `-0.0` +/// and `0.0` do not compare as the same source row. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ShadowPointKey { + pub series_id: u64, + pub valid_time: i64, + pub valid_time_end: i64, + pub knowledge_time: i64, + pub change_time: i64, + pub run_id: u128, + pub value_bits: u64, + pub quality: u32, + pub flags: u32, +} + +impl From for ShadowPointKey { + fn from(point: Point) -> Self { + Self { + series_id: point.series_id, + valid_time: point.valid_time, + valid_time_end: point.valid_time_end, + knowledge_time: point.knowledge_time, + change_time: point.change_time, + run_id: point.run_id, + value_bits: point.value.to_bits(), + quality: point.quality, + flags: point.flags, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ShadowReconcileDetail { + MissingReceipt { + identity: IngressIdentity, + }, + ReceiptCommitConflict { + expected: IngressIdentity, + actual_commit_id: u128, + }, + ReceiptShapeMismatch { + identity: IngressIdentity, + expected_records: usize, + actual_records: usize, + expected_points: usize, + actual_points: usize, + }, + ReceiptPayloadMismatch { + identity: IngressIdentity, + }, + Catalog { + kind: CatalogObjectKind, + id: u128, + mismatch: CatalogMismatch, + }, + PointCount { + point: ShadowPointKey, + expected: usize, + actual: usize, + }, +} + +/// Stable counts plus a bounded sample of mismatches. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ShadowReconciliationReport { + pub expected_batches: usize, + pub matching_receipts: usize, + pub missing_receipts: usize, + pub conflicting_receipts: usize, + pub receipt_shape_mismatches: usize, + pub receipt_payload_mismatches: usize, + pub nondurable_receipts: usize, + pub expected_catalog_objects: usize, + pub matching_catalog_objects: usize, + pub missing_catalog_objects: usize, + pub different_catalog_objects: usize, + pub expected_points: usize, + /// Raw entries visited before timestamp filtering. + pub scanned_points: usize, + pub observed_points: usize, + pub matching_points: usize, + pub missing_points: usize, + pub unexpected_points: usize, + pub mismatch_groups: usize, + pub mismatch_details: Vec, + pub details_truncated: bool, +} + +impl ShadowReconciliationReport { + /// True when all expected receipts and data match. Durability is separate. + #[must_use] + pub const fn content_matches(&self) -> bool { + self.missing_receipts == 0 + && self.conflicting_receipts == 0 + && self.receipt_shape_mismatches == 0 + && self.receipt_payload_mismatches == 0 + && self.missing_catalog_objects == 0 + && self.different_catalog_objects == 0 + && self.missing_points == 0 + && self.unexpected_points == 0 + } + + /// True when content matches and every found receipt has a sync proof. + #[must_use] + pub const fn ready_to_release_source_copy(&self) -> bool { + self.content_matches() && self.nondurable_receipts == 0 + } +} + +#[derive(Clone, Copy)] +struct SeriesSpan { + start: i64, + end: i64, +} + +/// Compares a bounded source window with checked FTWDB state without writes. +pub fn reconcile_shadow_batches( + database: &Database, + expected: &[CommitBatchRequest], + limits: ShadowReconcileLimits, +) -> Result { + check_limit(expected.len(), limits.max_batches, ReconcileLimit::Batches)?; + + let mut source_sequences = BTreeSet::new(); + let mut commit_ids = BTreeSet::new(); + let mut metadata_records = 0_usize; + let mut expected_points = 0_usize; + for batch in expected { + let identity = IngressIdentity::new(batch.source_id, batch.sequence, batch.commit_id); + if batch.source_id == 0 { + return Err(ShadowReconcileError::InvalidIdentity(identity)); + } + if !source_sequences.insert((batch.source_id, batch.sequence)) { + return Err(ShadowReconcileError::DuplicateSourceSequence { + source_id: batch.source_id, + sequence: batch.sequence, + }); + } + if !commit_ids.insert(batch.commit_id) { + return Err(ShadowReconcileError::DuplicateCommitId(batch.commit_id)); + } + metadata_records = metadata_records.checked_add(metadata_count(batch)).ok_or( + ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::MetadataRecords, + maximum: limits.max_metadata_records, + }, + )?; + expected_points = expected_points.checked_add(batch.points.len()).ok_or( + ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ExpectedPoints, + maximum: limits.max_expected_points, + }, + )?; + } + check_limit( + metadata_records, + limits.max_metadata_records, + ReconcileLimit::MetadataRecords, + )?; + check_limit( + expected_points, + limits.max_expected_points, + ReconcileLimit::ExpectedPoints, + )?; + + let mut report = ShadowReconciliationReport { + expected_batches: expected.len(), + expected_points, + ..ShadowReconciliationReport::default() + }; + reconcile_receipts(database, expected, limits, &mut report)?; + reconcile_catalog(database, expected, limits, &mut report); + reconcile_points(database, expected, limits, &mut report)?; + report.details_truncated = report.mismatch_details.len() < report.mismatch_groups; + Ok(report) +} + +fn reconcile_receipts( + database: &Database, + expected: &[CommitBatchRequest], + limits: ShadowReconcileLimits, + report: &mut ShadowReconciliationReport, +) -> Result<(), ShadowReconcileError> { + for batch in expected { + let identity = IngressIdentity::new(batch.source_id, batch.sequence, batch.commit_id); + let expected_records = metadata_count(batch) + usize::from(!batch.points.is_empty()); + let Some(receipt) = database.ingress_receipt(batch.source_id, batch.sequence) else { + report.missing_receipts += 1; + record_detail( + report, + limits, + ShadowReconcileDetail::MissingReceipt { identity }, + ); + continue; + }; + if !receipt.durable { + report.nondurable_receipts += 1; + } + let mut matches = true; + let commit_id_matches = receipt.identity.commit_id == batch.commit_id; + if !commit_id_matches { + matches = false; + report.conflicting_receipts += 1; + record_detail( + report, + limits, + ShadowReconcileDetail::ReceiptCommitConflict { + expected: identity, + actual_commit_id: receipt.identity.commit_id, + }, + ); + } + if receipt.records != expected_records || receipt.points != batch.points.len() { + matches = false; + report.receipt_shape_mismatches += 1; + record_detail( + report, + limits, + ShadowReconcileDetail::ReceiptShapeMismatch { + identity, + expected_records, + actual_records: receipt.records, + expected_points: batch.points.len(), + actual_points: receipt.points, + }, + ); + } + if commit_id_matches { + let transaction = crate::shadow_protocol::transaction_from_batch(batch.clone()); + let payload_matches = database + .verify_ingress_payload(identity, &transaction)? + .unwrap_or(false); + if !payload_matches { + matches = false; + report.receipt_payload_mismatches += 1; + record_detail( + report, + limits, + ShadowReconcileDetail::ReceiptPayloadMismatch { identity }, + ); + } + } + if matches { + report.matching_receipts += 1; + } + } + Ok(()) +} + +fn reconcile_catalog( + database: &Database, + expected: &[CommitBatchRequest], + limits: ShadowReconcileLimits, + report: &mut ShadowReconciliationReport, +) { + let mut entities = BTreeMap::new(); + let mut relations = BTreeMap::new(); + let mut series = BTreeMap::new(); + let mut runs = BTreeMap::new(); + let mut plans = BTreeMap::new(); + for batch in expected { + entities.extend(batch.entities.iter().map(|value| (value.id, value))); + relations.extend(batch.relations.iter().map(|value| (value.id, value))); + series.extend(batch.series.iter().map(|value| (value.id, value))); + runs.extend(batch.runs.iter().map(|value| (value.id, value))); + plans.extend(batch.plans.iter().map(|value| (value.id, value))); + } + report.expected_catalog_objects = + entities.len() + relations.len() + series.len() + runs.len() + plans.len(); + + macro_rules! compare_catalog { + ($values:expr, $lookup:ident, $kind:expr, $id:expr) => { + for (id, expected) in $values { + match database.catalog().$lookup(id) { + Some(actual) if actual == expected => report.matching_catalog_objects += 1, + Some(_) => { + report.different_catalog_objects += 1; + record_detail( + report, + limits, + ShadowReconcileDetail::Catalog { + kind: $kind, + id: $id(id), + mismatch: CatalogMismatch::Different, + }, + ); + } + None => { + report.missing_catalog_objects += 1; + record_detail( + report, + limits, + ShadowReconcileDetail::Catalog { + kind: $kind, + id: $id(id), + mismatch: CatalogMismatch::Missing, + }, + ); + } + } + } + }; + } + compare_catalog!( + entities, + entity, + CatalogObjectKind::Entity, + |id: crate::EntityId| id.0 + ); + compare_catalog!( + relations, + relation, + CatalogObjectKind::Relation, + |id: crate::RelationId| id.0 + ); + compare_catalog!(series, series, CatalogObjectKind::Series, |id: u64| { + u128::from(id) + }); + compare_catalog!(runs, run, CatalogObjectKind::Run, |id: crate::RunId| id.0); + compare_catalog!(plans, plan, CatalogObjectKind::Plan, |id: u128| id); +} + +fn reconcile_points( + database: &Database, + expected: &[CommitBatchRequest], + limits: ShadowReconcileLimits, + report: &mut ShadowReconciliationReport, +) -> Result<(), ShadowReconcileError> { + let mut expected_counts = BTreeMap::::new(); + let mut spans = BTreeMap::::new(); + for point in expected.iter().flat_map(|batch| &batch.points) { + let end = + point + .valid_time + .checked_add(1) + .ok_or(ShadowReconcileError::MaximumTimestamp { + series_id: point.series_id, + })?; + expected_counts + .entry((*point).into()) + .and_modify(|count| *count += 1) + .or_insert(1); + spans + .entry(point.series_id) + .and_modify(|span| { + span.start = span.start.min(point.valid_time); + span.end = span.end.max(end); + }) + .or_insert(SeriesSpan { + start: point.valid_time, + end, + }); + } + + let mut observed_counts = BTreeMap::::new(); + for (series_id, span) in spans { + database.visit_history( + series_id, + span.start, + span.end, + |count| { + report.scanned_points = report.scanned_points.checked_add(count).ok_or( + ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ScannedPoints, + maximum: limits.max_scanned_points, + }, + )?; + check_limit( + report.scanned_points, + limits.max_scanned_points, + ReconcileLimit::ScannedPoints, + ) + }, + |point| { + report.observed_points = report.observed_points.checked_add(1).ok_or( + ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ObservedPoints, + maximum: limits.max_observed_points, + }, + )?; + check_limit( + report.observed_points, + limits.max_observed_points, + ReconcileLimit::ObservedPoints, + )?; + observed_counts + .entry(point.into()) + .and_modify(|count| *count += 1) + .or_insert(1); + Ok(()) + }, + )?; + } + + let keys: BTreeSet<_> = expected_counts + .keys() + .chain(observed_counts.keys()) + .copied() + .collect(); + for point in keys { + let expected = expected_counts.get(&point).copied().unwrap_or(0); + let actual = observed_counts.get(&point).copied().unwrap_or(0); + report.matching_points += expected.min(actual); + report.missing_points += expected.saturating_sub(actual); + report.unexpected_points += actual.saturating_sub(expected); + if expected != actual { + record_detail( + report, + limits, + ShadowReconcileDetail::PointCount { + point, + expected, + actual, + }, + ); + } + } + Ok(()) +} + +fn metadata_count(batch: &CommitBatchRequest) -> usize { + batch.entities.len() + + batch.relations.len() + + batch.series.len() + + batch.runs.len() + + batch.plans.len() +} + +fn check_limit( + actual: usize, + maximum: usize, + limit: ReconcileLimit, +) -> Result<(), ShadowReconcileError> { + if actual > maximum { + Err(ShadowReconcileError::LimitExceeded { limit, maximum }) + } else { + Ok(()) + } +} + +fn record_detail( + report: &mut ShadowReconciliationReport, + limits: ShadowReconcileLimits, + detail: ShadowReconcileDetail, +) { + report.mismatch_groups += 1; + if report.mismatch_details.len() < limits.max_mismatch_details { + report.mismatch_details.push(detail); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shadow_protocol::CommitBatchRequest; + use crate::{ + Config, Durability, Entity, EntityId, RollupPolicy, Run, RunId, RunKind, RunStatus, + SeriesDefinition, SeriesSemantics, + }; + use tempfile::tempdir; + + fn batch(value: f64) -> CommitBatchRequest { + CommitBatchRequest { + source_id: 7, + sequence: 10, + commit_id: 99, + entities: vec![Entity { + id: EntityId(1), + kind: "site".into(), + name: "alpha".into(), + parent: None, + valid_from: 1, + valid_to: None, + properties: BTreeMap::new(), + }], + relations: Vec::new(), + series: vec![SeriesDefinition { + id: 2, + owner_entity: Some(EntityId(1)), + owner_relation: None, + name: "grid_power".into(), + physical_quantity: "power".into(), + canonical_unit: "W".into(), + semantics: SeriesSemantics::Gauge, + maximum_gap_micros: Some(5_000_000), + rollup_policy: RollupPolicy { + raw_retain_for_micros: None, + tiers: Vec::new(), + }, + }], + runs: vec![Run { + id: RunId(3), + kind: RunKind::Import, + status: RunStatus::Succeeded, + created_at: 2, + knowledge_time: 2, + workflow: "shadow".into(), + model: "source".into(), + model_version: "1".into(), + parent_run: None, + input_snapshot: None, + attributes: BTreeMap::new(), + }], + plans: Vec::new(), + points: vec![Point { + series_id: 2, + valid_time: 4, + valid_time_end: 4, + knowledge_time: 5, + change_time: 6, + run_id: 3, + value, + quality: 7, + flags: 8, + }], + } + } + + fn transaction(batch: &CommitBatchRequest) -> crate::Transaction { + crate::shadow_protocol::transaction_from_batch(batch.clone()) + } + + #[test] + fn exact_window_matches_receipt_catalog_and_point_bits() { + let directory = tempdir().unwrap(); + let path = directory.path().join("shadow.ftwdb"); + let expected = batch(-0.0); + let mut database = Database::open(&path).unwrap(); + database + .commit_ingress( + IngressIdentity::new(expected.source_id, expected.sequence, expected.commit_id), + transaction(&expected), + ) + .unwrap(); + + let report = reconcile_shadow_batches( + &database, + std::slice::from_ref(&expected), + ShadowReconcileLimits::default(), + ) + .unwrap(); + assert!(report.content_matches()); + assert!(report.ready_to_release_source_copy()); + assert_eq!((report.matching_receipts, report.matching_points), (1, 1)); + assert_eq!(report.expected_catalog_objects, 3); + assert_eq!(report.matching_catalog_objects, 3); + assert_eq!(report.scanned_points, 1); + assert!(report.mismatch_details.is_empty()); + } + + #[test] + fn exact_receipt_payload_detects_batches_with_swapped_points() { + let directory = tempdir().unwrap(); + let path = directory.path().join("shadow.ftwdb"); + let first = batch(10.0); + let mut second = batch(20.0); + second.sequence = 11; + second.commit_id = 100; + second.points[0].valid_time = 100; + second.points[0].valid_time_end = 100; + + let mut stored_first = second.clone(); + stored_first.sequence = first.sequence; + stored_first.commit_id = first.commit_id; + let mut stored_second = first.clone(); + stored_second.sequence = second.sequence; + stored_second.commit_id = second.commit_id; + + let mut database = Database::open(&path).unwrap(); + database + .commit_ingress( + IngressIdentity::new( + stored_first.source_id, + stored_first.sequence, + stored_first.commit_id, + ), + transaction(&stored_first), + ) + .unwrap(); + database + .commit_ingress( + IngressIdentity::new( + stored_second.source_id, + stored_second.sequence, + stored_second.commit_id, + ), + transaction(&stored_second), + ) + .unwrap(); + + let report = reconcile_shadow_batches( + &database, + &[first, second], + ShadowReconcileLimits::default(), + ) + .unwrap(); + assert!(!report.content_matches()); + assert_eq!(report.receipt_payload_mismatches, 2); + assert_eq!((report.missing_points, report.unexpected_points), (0, 0)); + } + + #[test] + fn reports_changed_identity_metadata_and_exact_point_bits() { + let directory = tempdir().unwrap(); + let path = directory.path().join("shadow.ftwdb"); + let stored = batch(0.0); + let mut database = Database::open(&path).unwrap(); + database + .commit_ingress( + IngressIdentity::new(stored.source_id, stored.sequence, stored.commit_id), + transaction(&stored), + ) + .unwrap(); + + let mut expected = batch(-0.0); + expected.commit_id += 1; + expected.entities[0].name = "changed".into(); + let report = reconcile_shadow_batches( + &database, + &[expected], + ShadowReconcileLimits { + max_mismatch_details: 2, + ..ShadowReconcileLimits::default() + }, + ) + .unwrap(); + assert!(!report.content_matches()); + assert_eq!(report.conflicting_receipts, 1); + assert_eq!(report.different_catalog_objects, 1); + assert_eq!((report.missing_points, report.unexpected_points), (1, 1)); + assert_eq!(report.mismatch_details.len(), 2); + assert!(report.details_truncated); + } + + #[test] + fn read_only_recovery_does_not_claim_receipt_durability() { + let directory = tempdir().unwrap(); + let path = directory.path().join("shadow.ftwdb"); + let expected = batch(1.0); + { + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Always, + ..Config::default() + }, + ) + .unwrap(); + database + .commit_ingress( + IngressIdentity::new(expected.source_id, expected.sequence, expected.commit_id), + transaction(&expected), + ) + .unwrap(); + } + let database = Database::open_read_only(&path).unwrap(); + let report = + reconcile_shadow_batches(&database, &[expected], ShadowReconcileLimits::default()) + .unwrap(); + assert!(report.content_matches()); + assert_eq!(report.nondurable_receipts, 1); + assert!(!report.ready_to_release_source_copy()); + } + + #[test] + fn scan_limit_counts_filtered_block_points_and_spans_seals_and_live_tail() { + let directory = tempdir().unwrap(); + let mut store = crate::Store::open(directory.path()).unwrap(); + let expected = batch(1.0); + store + .commit_ingress( + IngressIdentity::new(expected.source_id, expected.sequence, expected.commit_id), + transaction(&expected), + ) + .unwrap(); + let outside = [Point::actual(2, 0, 0.0), Point::actual(2, 8, 8.0)]; + let mut transaction = crate::Transaction::new(); + transaction.append_points(outside.to_vec()); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + // Only the expected point falls in the comparison window. All three + // block entries must still count against the decode budget. + let check = |store: &crate::Store, maximum| { + reconcile_shadow_batches( + store.database(), + std::slice::from_ref(&expected), + ShadowReconcileLimits { + max_scanned_points: maximum, + ..ShadowReconcileLimits::default() + }, + ) + }; + assert!(matches!( + check(&store, 2), + Err(ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ScannedPoints, + maximum: 2, + }) + )); + let report = check(&store, 3).unwrap(); + assert_eq!(report.scanned_points, 3); + assert_eq!(report.observed_points, 1); + assert!(report.content_matches()); + + for seal in [true, false] { + let mut extra = crate::Transaction::new(); + extra.append_points(vec![expected.points[0]]); + store.commit(extra).unwrap(); + if seal { + store.seal_and_reclaim().unwrap(); + } + } + assert!(matches!( + check(&store, 4), + Err(ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ScannedPoints, + maximum: 4, + }) + )); + let report = check(&store, 5).unwrap(); + assert_eq!(report.scanned_points, 5); + assert_eq!(report.observed_points, 3); + assert_eq!(report.unexpected_points, 2); + assert!(matches!( + reconcile_shadow_batches( + store.database(), + &[expected], + ShadowReconcileLimits { + max_observed_points: 1, + ..ShadowReconcileLimits::default() + } + ), + Err(ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ObservedPoints, + maximum: 1, + }) + )); + } + + #[test] + fn scan_limit_rejects_before_reading_an_oversize_block() { + use std::os::unix::fs::FileExt; + let directory = tempdir().unwrap(); + let mut database = Database::open(directory.path().join("raw.wlog")).unwrap(); + let expected = batch(1.0); + database + .commit_ingress( + IngressIdentity::new(expected.source_id, expected.sequence, expected.commit_id), + transaction(&expected), + ) + .unwrap(); + let segment_path = directory.path().join("raw.wseg"); + crate::Segment::create( + &segment_path, + &[expected.points[0], Point::actual(2, 8, 8.0)], + 2, + ) + .unwrap(); + let segment = crate::Segment::open(&segment_path).unwrap(); + let offset = segment.first_block_payload_offset().unwrap(); + database.attach_sealed_segments(vec![segment]); + let file = std::fs::OpenOptions::new() + .write(true) + .open(&segment_path) + .unwrap(); + file.write_all_at(&[0xff], offset).unwrap(); + assert!(matches!( + reconcile_shadow_batches( + &database, + std::slice::from_ref(&expected), + ShadowReconcileLimits { + max_scanned_points: 1, + ..ShadowReconcileLimits::default() + } + ), + Err(ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ScannedPoints, + maximum: 1 + }) + )); + assert!(matches!( + reconcile_shadow_batches(&database, &[expected], ShadowReconcileLimits::default()), + Err(ShadowReconcileError::Store(crate::Error::Corruption { .. })) + )); + } + + #[test] + fn rejects_duplicate_keys_and_work_above_limits() { + let directory = tempdir().unwrap(); + let mut database = Database::open(directory.path().join("shadow.ftwdb")).unwrap(); + let expected = batch(1.0); + assert!(matches!( + reconcile_shadow_batches( + &database, + &[expected.clone(), expected.clone()], + ShadowReconcileLimits::default() + ), + Err(ShadowReconcileError::DuplicateSourceSequence { .. }) + )); + assert!(matches!( + reconcile_shadow_batches( + &database, + std::slice::from_ref(&expected), + ShadowReconcileLimits { + max_expected_points: 0, + ..ShadowReconcileLimits::default() + } + ), + Err(ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ExpectedPoints, + maximum: 0, + }) + )); + + database + .commit_ingress( + IngressIdentity::new(expected.source_id, expected.sequence, expected.commit_id), + transaction(&expected), + ) + .unwrap(); + assert!(matches!( + reconcile_shadow_batches( + &database, + &[expected], + ShadowReconcileLimits { + max_scanned_points: 0, + ..ShadowReconcileLimits::default() + } + ), + Err(ShadowReconcileError::LimitExceeded { + limit: ReconcileLimit::ScannedPoints, + maximum: 0, + }) + )); + } +} diff --git a/src/shadow_runtime.rs b/src/shadow_runtime.rs new file mode 100644 index 0000000..22bb4c8 --- /dev/null +++ b/src/shadow_runtime.rs @@ -0,0 +1,2067 @@ +//! Bounded, single-writer runtime for local shadow ingestion. +//! +//! Producers submit identified transactions to a fixed-size queue. One worker +//! owns the [`Store`] or [`Database`], so commit order is the queue order and +//! callers never share a mutable storage handle. The runtime does not provide +//! a network protocol; an adapter can map its explicit submit and write +//! outcomes to its own wire format. +//! +//! Each write carries an [`IngressIdentity`]. FTWDB owns per-source ordering, +//! replay checks, conflicts, and durable watermarks, including after restart. +//! The runtime never acknowledges a replay from process-local state. + +use crate::{ + Commit, Database, Durability, Error, IngressIdentity, IngressWatermarks, Store, Transaction, +}; +use std::any::Any; +use std::collections::BTreeMap; +use std::fmt; +use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex, MutexGuard, TryLockError}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Settings for one shadow writer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ShadowRuntimeConfig { + /// Maximum number of operations waiting behind the active operation. + pub queue_capacity: usize, + /// Maximum total point count in writes waiting behind the active write. + /// Metadata records and flush operations consume no point budget. + pub max_queued_points: usize, + /// Optional periodic [`Store::maintain`] interval for store backends. + /// Database backends ignore background maintenance. + pub maintenance_interval: Option, + /// When set on a store backend, seal and reclaim once the active log + /// exceeds this many bytes after a maintenance tick. + pub seal_log_bytes_threshold: Option, + /// Limit a raw shadow store. Requires maintenance to be disabled and no + /// active rollups, so a write cannot publish extra files after this check. + pub storage_limits: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ShadowStorageLimits { + pub max_store_bytes: u64, + pub minimum_free_bytes: u64, +} + +impl ShadowStorageLimits { + fn check(&self, store: &Store, transaction: &Transaction) -> crate::Result<()> { + let additional = crate::storage::ingress_frame_bytes(transaction)?; + if store + .stored_bytes()? + .checked_add(additional) + .is_none_or(|bytes| bytes > self.max_store_bytes) + { + return Err(Error::ResourceLimit("store byte limit reached")); + } + let space = rustix::fs::statvfs(store.root()).map_err(std::io::Error::from)?; + let fragment = u128::from(space.f_frsize); + if fragment == 0 { + return Err(std::io::Error::other("filesystem reported a zero allocation size").into()); + } + let available = u128::from(space.f_bavail) * fragment; + let allocated = u128::from(additional).div_ceil(fragment) * fragment; + if available < u128::from(self.minimum_free_bytes) + allocated { + return Err(Error::ResourceLimit("free disk reserve reached")); + } + Ok(()) + } +} + +impl Default for ShadowRuntimeConfig { + fn default() -> Self { + Self { + queue_capacity: 64, + max_queued_points: 1_048_576, + maintenance_interval: None, + seal_log_bytes_threshold: None, + storage_limits: None, + } + } +} + +/// One ordered, idempotent storage request. +#[derive(Clone, Debug)] +pub struct ShadowWrite { + identity: IngressIdentity, + transaction: Transaction, +} + +impl ShadowWrite { + /// Builds a write and applies its durable FTWDB ingress identity. + #[must_use] + pub fn identified(identity: IngressIdentity, mut transaction: Transaction) -> Self { + transaction.with_ingress_identity(identity); + Self { + identity, + transaction, + } + } + + /// Builds a write from a transaction that already has an ingress identity. + pub fn from_identified(transaction: Transaction) -> Result { + let Some(identity) = transaction.ingress_identity() else { + return Err(UnidentifiedWrite { + transaction: Box::new(transaction), + }); + }; + Ok(Self { + identity, + transaction, + }) + } + + #[must_use] + pub const fn commit_id(&self) -> u128 { + self.identity.commit_id + } + + #[must_use] + pub const fn sequence(&self) -> u64 { + self.identity.sequence + } + + #[must_use] + pub const fn source_id(&self) -> u128 { + self.identity.source_id + } + + #[must_use] + pub const fn identity(&self) -> IngressIdentity { + self.identity + } +} + +/// A transaction without the commit ID required by the shadow runtime. +#[derive(Debug)] +pub struct UnidentifiedWrite { + pub transaction: Box, +} + +impl UnidentifiedWrite { + #[must_use] + pub fn into_transaction(self) -> Transaction { + *self.transaction + } +} + +impl fmt::Display for UnidentifiedWrite { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("shadow writes require an ingress identity") + } +} + +impl std::error::Error for UnidentifiedWrite {} + +/// The result of an acknowledged sequence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ShadowAck { + pub identity: IngressIdentity, + pub accepted_through: Option, + pub durable_through: Option, + pub commit: Commit, +} + +/// A write that reached the worker but could not be acknowledged. +#[derive(Debug)] +pub enum ShadowWriteFailure { + /// FTWDB rejected this request before it could change durable state. The + /// same sequence may be corrected and retried. + Rejected(Error), + /// FTWDB returned an error that may have happened after a durable raw-log + /// append. The runtime stops making storage calls. + Writer(Error), + /// An earlier write failed, so this write never reached FTWDB. + Poisoned { + cause: String, + }, + /// The storage implementation panicked. The panic stays in the worker and + /// the runtime rejects later writes. + WriterPanicked { + cause: String, + }, + WorkerStopped, +} + +impl fmt::Display for ShadowWriteFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Rejected(error) => write!(formatter, "shadow write was rejected: {error}"), + Self::Writer(error) => write!(formatter, "shadow writer failed: {error}"), + Self::Poisoned { cause } => { + write!(formatter, "shadow writer is poisoned: {cause}") + } + Self::WriterPanicked { cause } => { + write!(formatter, "shadow writer panicked: {cause}") + } + Self::WorkerStopped => formatter.write_str("shadow writer stopped before replying"), + } + } +} + +impl std::error::Error for ShadowWriteFailure { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Rejected(error) | Self::Writer(error) => Some(error), + _ => None, + } + } +} + +/// A receipt for one accepted queue entry. +#[derive(Debug)] +pub struct ShadowReceipt { + pub identity: IngressIdentity, + receiver: Receiver>, +} + +impl ShadowReceipt { + pub fn wait(self) -> Result { + self.receiver + .recv() + .unwrap_or(Err(ShadowWriteFailure::WorkerStopped)) + } + + pub fn wait_timeout( + self, + timeout: Duration, + ) -> Result, AckWaitError> { + match self.receiver.recv_timeout(timeout) { + Ok(result) => Ok(result), + Err(mpsc::RecvTimeoutError::Timeout) => Err(AckWaitError::Timeout), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(AckWaitError::WorkerStopped), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AckWaitError { + Timeout, + WorkerStopped, +} + +impl fmt::Display for AckWaitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Timeout => "timed out waiting for the shadow acknowledgement", + Self::WorkerStopped => "shadow writer stopped before replying", + }) + } +} + +impl std::error::Error for AckWaitError {} + +/// The result of an ordered FTWDB flush. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ShadowFlushAck { + pub source_id: u128, + pub through_sequence: u64, + pub accepted_through: Option, + pub durable_through: Option, +} + +#[derive(Debug)] +pub struct ShadowFlushReceipt { + receiver: Receiver>, +} + +impl ShadowFlushReceipt { + pub fn wait(self) -> Result { + self.receiver + .recv() + .unwrap_or(Err(ShadowFlushFailure::WorkerStopped)) + } + + pub fn wait_timeout( + self, + timeout: Duration, + ) -> Result, AckWaitError> { + match self.receiver.recv_timeout(timeout) { + Ok(result) => Ok(result), + Err(mpsc::RecvTimeoutError::Timeout) => Err(AckWaitError::Timeout), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(AckWaitError::WorkerStopped), + } + } +} + +#[derive(Debug)] +pub enum ShadowFlushFailure { + NotAccepted { + source_id: u128, + through_sequence: u64, + accepted_through: Option, + }, + Rejected(Error), + Writer(Error), + Poisoned { + cause: String, + }, + WriterPanicked { + cause: String, + }, + WorkerStopped, +} + +impl fmt::Display for ShadowFlushFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotAccepted { + source_id, + through_sequence, + accepted_through, + } => write!( + formatter, + "shadow source {source_id:032x} has accepted through {accepted_through:?}, not {through_sequence}" + ), + Self::Rejected(error) => write!(formatter, "shadow flush was rejected: {error}"), + Self::Writer(error) => write!(formatter, "shadow flush failed: {error}"), + Self::Poisoned { cause } => { + write!(formatter, "shadow writer is poisoned: {cause}") + } + Self::WriterPanicked { cause } => { + write!(formatter, "shadow writer panicked during flush: {cause}") + } + Self::WorkerStopped => formatter.write_str("shadow writer stopped before flushing"), + } + } +} + +impl std::error::Error for ShadowFlushFailure { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Rejected(error) | Self::Writer(error) => Some(error), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FlushSubmitError { + Overloaded, + DeadlineExceeded, + Closed, + Poisoned { cause: String }, +} + +impl fmt::Display for FlushSubmitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Overloaded => formatter.write_str("shadow writer queue is full"), + Self::DeadlineExceeded => formatter.write_str("shadow writer queue deadline expired"), + Self::Closed => formatter.write_str("shadow writer is closed"), + Self::Poisoned { cause } => { + write!(formatter, "shadow writer is poisoned: {cause}") + } + } + } +} + +impl std::error::Error for FlushSubmitError {} + +/// Why a write did not enter the bounded queue. +#[derive(Debug)] +pub enum SubmitError { + Overloaded(Box), + PointBudgetExhausted(Box), + DeadlineExceeded(Box), + Closed(Box), + Poisoned { + write: Box, + cause: String, + }, +} + +impl SubmitError { + #[must_use] + pub fn into_write(self) -> ShadowWrite { + match self { + Self::Overloaded(write) + | Self::PointBudgetExhausted(write) + | Self::DeadlineExceeded(write) + | Self::Closed(write) + | Self::Poisoned { write, .. } => *write, + } + } +} + +impl fmt::Display for SubmitError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Overloaded(_) => formatter.write_str("shadow writer queue is full"), + Self::PointBudgetExhausted(_) => { + formatter.write_str("shadow writer queued-point limit is full") + } + Self::DeadlineExceeded(_) => { + formatter.write_str("shadow writer queue deadline expired") + } + Self::Closed(_) => formatter.write_str("shadow writer is closed"), + Self::Poisoned { cause, .. } => { + write!(formatter, "shadow writer is poisoned: {cause}") + } + } + } +} + +impl std::error::Error for SubmitError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShadowRuntimeState { + Running, + Poisoned, + Closing, + Closed, +} + +/// A point-in-time view of writer health. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ShadowHealth { + pub state: ShadowRuntimeState, + pub queue_capacity: usize, + pub max_queued_points: usize, + /// Operations waiting in the queue. The active storage call is excluded. + pub queued: usize, + /// Points in waiting writes. Points in the active write are excluded. + pub queued_points: usize, + pub accepted: u64, + pub acknowledged: u64, + pub failed: u64, + /// Latest authoritative storage watermarks for sources observed by this + /// runtime. Sources not yet seen by this process are absent. + pub source_watermarks: BTreeMap, + /// Latest fatal writer or close error. Rejected client input increments + /// `failed` but does not mark a healthy writer as degraded. + pub last_error: Option, + /// A rejected write hit a storage budget. Existing receipts remain usable. + pub resource_limit: Option, + pub database_bytes: u64, + pub database_points: u64, + pub database_commits: u64, + pub recovered_tail_bytes: u64, + pub durability: Durability, + pub last_ack_durable: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct StoreOpsSnapshot { + bytes: u64, + points: u64, + commits: u64, + recovered_tail_bytes: u64, + durability: Durability, + last_ack_durable: bool, +} + +struct HealthState { + state: ShadowRuntimeState, + accepted: u64, + acknowledged: u64, + failed: u64, + source_watermarks: BTreeMap, + last_error: Option, + resource_limit: Option, + store: StoreOpsSnapshot, +} + +struct Shared { + health: Mutex, + send_gate: Mutex<()>, + queue_usage: Mutex, + queue_capacity: usize, + max_queued_points: usize, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct QueueUsage { + queued: usize, + points: usize, +} + +impl QueueUsage { + fn reserve( + &mut self, + queue_capacity: usize, + max_queued_points: usize, + points: usize, + ) -> Result<(), QueueReservationError> { + let next_queued = self + .queued + .checked_add(1) + .ok_or(QueueReservationError::Entries)?; + if next_queued > queue_capacity { + return Err(QueueReservationError::Entries); + } + let next_points = self + .points + .checked_add(points) + .ok_or(QueueReservationError::Points)?; + if next_points > max_queued_points { + return Err(QueueReservationError::Points); + } + self.queued = next_queued; + self.points = next_points; + Ok(()) + } + + fn release(&mut self, points: usize) { + debug_assert!(self.queued > 0); + debug_assert!(self.points >= points); + self.queued = self.queued.saturating_sub(1); + self.points = self.points.saturating_sub(points); + } +} + +/// Cloneable producer side of a shadow runtime. +#[derive(Clone)] +pub struct ShadowSubmitter { + sender: SyncSender, + shared: Arc, +} + +impl ShadowSubmitter { + /// Tries once without waiting for queue space. + pub fn try_submit(&self, write: ShadowWrite) -> Result { + self.try_submit_inner(write) + } + + /// Retries until the write enters the queue or the deadline passes. + pub fn submit_until( + &self, + mut write: ShadowWrite, + deadline: Instant, + ) -> Result { + loop { + match self.try_submit_inner(write) { + Ok(receipt) => return Ok(receipt), + Err( + SubmitError::Overloaded(returned) | SubmitError::PointBudgetExhausted(returned), + ) => { + write = *returned; + let now = Instant::now(); + if now >= deadline { + return Err(SubmitError::DeadlineExceeded(Box::new(write))); + } + thread::sleep( + deadline + .saturating_duration_since(now) + .min(Duration::from_millis(1)), + ); + } + Err(other) => return Err(other), + } + } + } + + /// Places a flush after all operations already accepted into the queue. + pub fn try_flush( + &self, + source_id: u128, + through_sequence: u64, + ) -> Result { + let _gate = match self.shared.send_gate.try_lock() { + Ok(gate) => gate, + Err(TryLockError::WouldBlock) => return Err(FlushSubmitError::Overloaded), + Err(TryLockError::Poisoned(error)) => error.into_inner(), + }; + let (state, last_error) = { + let health = lock(&self.shared.health); + (health.state, health.last_error.clone()) + }; + match state { + ShadowRuntimeState::Running => {} + ShadowRuntimeState::Poisoned => { + let cause = last_error.unwrap_or_else(|| "unknown writer failure".to_owned()); + return Err(FlushSubmitError::Poisoned { cause }); + } + ShadowRuntimeState::Closing | ShadowRuntimeState::Closed => { + return Err(FlushSubmitError::Closed); + } + } + if reserve_queue_slot(&self.shared, 0).is_err() { + return Err(FlushSubmitError::Overloaded); + } + let (reply, receiver) = mpsc::sync_channel(1); + match self.sender.try_send(Command::Flush { + source_id, + through_sequence, + reply, + }) { + Ok(()) => Ok(ShadowFlushReceipt { receiver }), + Err(TrySendError::Full(Command::Flush { .. })) => { + release_queue_slot(&self.shared, 0); + Err(FlushSubmitError::Overloaded) + } + Err(TrySendError::Disconnected(Command::Flush { .. })) => { + release_queue_slot(&self.shared, 0); + Err(FlushSubmitError::Closed) + } + Err( + TrySendError::Full(Command::Write { .. } | Command::Shutdown { .. }) + | TrySendError::Disconnected(Command::Write { .. } | Command::Shutdown { .. }), + ) => unreachable!(), + } + } + + /// Retries an ordered flush until it enters the queue or its deadline passes. + pub fn flush_until( + &self, + source_id: u128, + through_sequence: u64, + deadline: Instant, + ) -> Result { + loop { + match self.try_flush(source_id, through_sequence) { + Ok(receipt) => return Ok(receipt), + Err(FlushSubmitError::Overloaded) => { + let now = Instant::now(); + if now >= deadline { + return Err(FlushSubmitError::DeadlineExceeded); + } + thread::sleep( + deadline + .saturating_duration_since(now) + .min(Duration::from_millis(1)), + ); + } + Err(other) => return Err(other), + } + } + } + + #[must_use] + pub fn health(&self) -> ShadowHealth { + health_snapshot(&self.shared) + } + + fn try_submit_inner(&self, write: ShadowWrite) -> Result { + let _gate = match self.shared.send_gate.try_lock() { + Ok(gate) => gate, + Err(TryLockError::WouldBlock) => { + return Err(SubmitError::Overloaded(Box::new(write))); + } + Err(TryLockError::Poisoned(error)) => error.into_inner(), + }; + + let (state, last_error) = { + let health = lock(&self.shared.health); + (health.state, health.last_error.clone()) + }; + match state { + ShadowRuntimeState::Running => {} + ShadowRuntimeState::Poisoned => { + let cause = last_error.unwrap_or_else(|| "unknown writer failure".to_owned()); + return Err(SubmitError::Poisoned { + write: Box::new(write), + cause, + }); + } + ShadowRuntimeState::Closing | ShadowRuntimeState::Closed => { + return Err(SubmitError::Closed(Box::new(write))); + } + } + + let queued_points = write.transaction.point_count(); + match reserve_queue_slot(&self.shared, queued_points) { + Ok(()) => {} + Err(QueueReservationError::Entries) => { + return Err(SubmitError::Overloaded(Box::new(write))); + } + Err(QueueReservationError::Points) => { + return Err(SubmitError::PointBudgetExhausted(Box::new(write))); + } + } + let identity = write.identity; + let (reply, receiver) = mpsc::sync_channel(1); + lock(&self.shared.health).accepted += 1; + match self.sender.try_send(Command::Write { write, reply }) { + Ok(()) => Ok(ShadowReceipt { identity, receiver }), + Err(TrySendError::Full(Command::Write { write, .. })) => { + release_queue_slot(&self.shared, queued_points); + lock(&self.shared.health).accepted -= 1; + Err(SubmitError::Overloaded(Box::new(write))) + } + Err(TrySendError::Disconnected(Command::Write { write, .. })) => { + release_queue_slot(&self.shared, queued_points); + lock(&self.shared.health).accepted -= 1; + Err(SubmitError::Closed(Box::new(write))) + } + Err( + TrySendError::Full(Command::Flush { .. } | Command::Shutdown { .. }) + | TrySendError::Disconnected(Command::Flush { .. } | Command::Shutdown { .. }), + ) => unreachable!(), + } + } +} + +/// Owns the worker lifecycle. Producers should hold [`ShadowSubmitter`] clones. +pub struct ShadowRuntime { + submitter: ShadowSubmitter, + join: Option>, +} + +impl ShadowRuntime { + pub fn start_store( + store: Store, + config: ShadowRuntimeConfig, + ) -> Result { + if store.is_read_only() { + return Err(ShadowStartError::ReadOnlyBackend); + } + if config.storage_limits.is_some() + && (store.active_rollups().next().is_some() + || config.maintenance_interval.is_some() + || config.seal_log_bytes_threshold.is_some()) + { + return Err(ShadowStartError::InvalidStorageLimits); + } + Self::start_backend( + Box::new(StoreWriter { + store, + limits: config.storage_limits, + }), + config, + ) + } + + pub fn start_database( + database: Database, + config: ShadowRuntimeConfig, + ) -> Result { + if database.is_read_only() { + return Err(ShadowStartError::ReadOnlyBackend); + } + if config.storage_limits.is_some() { + return Err(ShadowStartError::InvalidStorageLimits); + } + Self::start_backend(Box::new(database), config) + } + + fn start_backend( + backend: Box, + config: ShadowRuntimeConfig, + ) -> Result { + if config.queue_capacity == 0 { + return Err(ShadowStartError::ZeroQueueCapacity); + } + + let source_watermarks = backend.all_ingress_watermarks(); + let store = backend.store_snapshot().unwrap_or_default(); + let (sender, receiver) = mpsc::sync_channel(config.queue_capacity); + let shared = Arc::new(Shared { + health: Mutex::new(HealthState { + state: ShadowRuntimeState::Running, + accepted: 0, + acknowledged: 0, + failed: 0, + source_watermarks, + last_error: None, + resource_limit: None, + store, + }), + send_gate: Mutex::new(()), + queue_usage: Mutex::new(QueueUsage::default()), + queue_capacity: config.queue_capacity, + max_queued_points: config.max_queued_points, + }); + let worker_shared = Arc::clone(&shared); + let join = thread::Builder::new() + .name("ftwdb-shadow-writer".to_owned()) + .spawn(move || worker_loop(backend, receiver, worker_shared, config)) + .map_err(ShadowStartError::Spawn)?; + Ok(Self { + submitter: ShadowSubmitter { sender, shared }, + join: Some(join), + }) + } + + #[must_use] + pub fn submitter(&self) -> ShadowSubmitter { + self.submitter.clone() + } + + #[must_use] + pub fn health(&self) -> ShadowHealth { + self.submitter.health() + } + + pub fn shutdown(mut self) -> Result { + self.shutdown_inner() + } + + fn shutdown_inner(&mut self) -> Result { + let Some(join) = self.join.take() else { + return Ok(ShutdownReport { + health: self.health(), + }); + }; + + let (reply, receiver) = mpsc::sync_channel(1); + let send_result = { + let _gate = lock(&self.submitter.shared.send_gate); + let mut health = lock(&self.submitter.shared.health); + if health.state == ShadowRuntimeState::Running { + health.state = ShadowRuntimeState::Closing; + } + drop(health); + self.submitter.sender.send(Command::Shutdown { reply }) + }; + + if send_result.is_err() { + let _ = join.join(); + let report = ShutdownReport { + health: self.health(), + }; + return Err(ShutdownError::WorkerStopped(Box::new(report))); + } + + let worker_result = receiver.recv(); + let join_result = join.join(); + if let Err(panic) = join_result { + let report = ShutdownReport { + health: self.health(), + }; + return Err(ShutdownError::WorkerPanicked { + cause: panic_message(panic), + report: Box::new(report), + }); + } + worker_result.unwrap_or_else(|_| { + Err(ShutdownError::WorkerStopped(Box::new(ShutdownReport { + health: self.health(), + }))) + }) + } +} + +impl Drop for ShadowRuntime { + fn drop(&mut self) { + let _ = self.shutdown_inner(); + } +} + +#[derive(Debug)] +pub enum ShadowStartError { + ZeroQueueCapacity, + ReadOnlyBackend, + InvalidStorageLimits, + Spawn(std::io::Error), +} + +impl fmt::Display for ShadowStartError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZeroQueueCapacity => { + formatter.write_str("shadow queue capacity must be positive") + } + Self::ReadOnlyBackend => formatter.write_str("shadow writer requires writable storage"), + Self::InvalidStorageLimits => formatter.write_str( + "storage limits require a raw Store with no active rollups or background maintenance", + ), + Self::Spawn(error) => write!(formatter, "could not start shadow writer: {error}"), + } + } +} + +impl std::error::Error for ShadowStartError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Spawn(error) => Some(error), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ShutdownReport { + pub health: ShadowHealth, +} + +#[derive(Debug)] +pub enum ShutdownError { + WriterPoisoned { + cause: String, + report: Box, + }, + Close { + error: Error, + report: Box, + }, + WriterPanicked { + cause: String, + report: Box, + }, + WorkerPanicked { + cause: String, + report: Box, + }, + WorkerStopped(Box), +} + +impl ShutdownError { + #[must_use] + pub fn report(&self) -> &ShutdownReport { + match self { + Self::WriterPoisoned { report, .. } + | Self::Close { report, .. } + | Self::WriterPanicked { report, .. } + | Self::WorkerPanicked { report, .. } + | Self::WorkerStopped(report) => report, + } + } +} + +impl fmt::Display for ShutdownError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WriterPoisoned { cause, .. } => { + write!( + formatter, + "shadow writer closed after a write error: {cause}" + ) + } + Self::Close { error, .. } => write!(formatter, "shadow close failed: {error}"), + Self::WriterPanicked { cause, .. } => { + write!(formatter, "shadow writer closed after a panic: {cause}") + } + Self::WorkerPanicked { cause, .. } => { + write!(formatter, "shadow worker panicked: {cause}") + } + Self::WorkerStopped(_) => formatter.write_str("shadow worker stopped during shutdown"), + } + } +} + +impl std::error::Error for ShutdownError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Close { error, .. } => Some(error), + _ => None, + } + } +} + +enum Command { + Write { + write: ShadowWrite, + reply: SyncSender>, + }, + Flush { + source_id: u128, + through_sequence: u64, + reply: SyncSender>, + }, + Shutdown { + reply: SyncSender>, + }, +} + +trait WriterBackend: Send + 'static { + fn commit_ingress( + &mut self, + identity: IngressIdentity, + transaction: Transaction, + ) -> crate::Result; + fn ingress_watermarks(&self, source_id: u128) -> IngressWatermarks; + fn all_ingress_watermarks(&self) -> BTreeMap; + fn flush(&mut self) -> crate::Result<()>; + fn close(self: Box) -> crate::Result>; + fn background_maintenance( + &mut self, + now_micros: i64, + config: &ShadowRuntimeConfig, + ) -> crate::Result<()> { + let _ = (now_micros, config); + Ok(()) + } + fn store_snapshot(&self) -> crate::Result { + Ok(StoreOpsSnapshot::default()) + } +} + +struct StoreWriter { + store: Store, + limits: Option, +} + +impl WriterBackend for StoreWriter { + fn commit_ingress( + &mut self, + identity: IngressIdentity, + transaction: Transaction, + ) -> crate::Result { + // Let storage verify exact retry bytes even when no new data fits. + // A known key can only return its receipt or a conflict, never append. + if self + .store + .ingress_receipt(identity.source_id, identity.sequence) + .is_none() + && let Some(limits) = self.limits + { + limits.check(&self.store, &transaction)?; + } + self.store.commit_ingress(identity, transaction) + } + + fn ingress_watermarks(&self, source_id: u128) -> IngressWatermarks { + self.store.ingress_watermarks(source_id) + } + + fn all_ingress_watermarks(&self) -> BTreeMap { + self.store.all_ingress_watermarks() + } + + fn flush(&mut self) -> crate::Result<()> { + self.store.flush() + } + + fn close(mut self: Box) -> crate::Result> { + self.store.flush()?; + Ok(self.store.all_ingress_watermarks()) + } + + fn background_maintenance( + &mut self, + now_micros: i64, + config: &ShadowRuntimeConfig, + ) -> crate::Result<()> { + self.store.maintain(now_micros)?; + if let Some(threshold) = config.seal_log_bytes_threshold + && self.store.database().stats()?.file_bytes > threshold + { + self.store.seal_and_reclaim()?; + } + Ok(()) + } + + fn store_snapshot(&self) -> crate::Result { + let stats = self.store.database().stats()?; + Ok(StoreOpsSnapshot { + bytes: self.store.stored_bytes()?, + points: stats.points, + commits: stats.commits, + recovered_tail_bytes: stats.recovered_tail_bytes, + durability: self.store.database().durability(), + last_ack_durable: false, + }) + } +} + +impl WriterBackend for Database { + fn commit_ingress( + &mut self, + identity: IngressIdentity, + transaction: Transaction, + ) -> crate::Result { + Database::commit_ingress(self, identity, transaction) + } + + fn ingress_watermarks(&self, source_id: u128) -> IngressWatermarks { + Database::ingress_watermarks(self, source_id) + } + + fn all_ingress_watermarks(&self) -> BTreeMap { + Database::all_ingress_watermarks(self) + } + + fn flush(&mut self) -> crate::Result<()> { + Database::flush(self) + } + + fn close(mut self: Box) -> crate::Result> { + Database::flush(&mut self)?; + Ok(Database::all_ingress_watermarks(&self)) + } + + fn store_snapshot(&self) -> crate::Result { + let stats = self.stats()?; + Ok(StoreOpsSnapshot { + bytes: stats.file_bytes, + points: stats.points, + commits: stats.commits, + recovered_tail_bytes: stats.recovered_tail_bytes, + durability: self.durability(), + last_ack_durable: false, + }) + } +} + +fn worker_loop( + mut backend: Box, + receiver: Receiver, + shared: Arc, + config: ShadowRuntimeConfig, +) { + let mut poison: Option = None; + let mut last_maintenance = None::; + while let Ok(command) = receiver.recv() { + match command { + Command::Write { write, reply } => { + let points = write.transaction.point_count(); + release_queue_slot(&shared, points); + let result = process_write(&mut backend, write, &shared, &mut poison); + if result.is_ok() { + maybe_run_background_maintenance( + &mut backend, + &config, + &shared, + &mut last_maintenance, + &mut poison, + ); + } + let _ = reply.send(result); + } + Command::Flush { + source_id, + through_sequence, + reply, + } => { + release_queue_slot(&shared, 0); + let result = process_flush( + &mut backend, + source_id, + through_sequence, + &shared, + &mut poison, + ); + let _ = reply.send(result); + } + Command::Shutdown { reply } => { + let result = close_worker(backend, &shared, poison); + let _ = reply.send(result); + return; + } + } + } + lock(&shared.health).state = ShadowRuntimeState::Closed; +} + +enum WorkerPoison { + Error(String), + Panic(String), +} + +fn writer_error_requires_poison(error: &Error) -> bool { + matches!( + error, + Error::Io(_) + | Error::InvalidHeader + | Error::UnsupportedVersion(_) + | Error::Corruption { .. } + | Error::Poisoned + | Error::SnapshotPublication { .. } + ) +} + +fn observe_source( + backend: &dyn WriterBackend, + shared: &Shared, + source_id: u128, +) -> IngressWatermarks { + let watermarks = backend.ingress_watermarks(source_id); + lock(&shared.health) + .source_watermarks + .insert(source_id, watermarks); + watermarks +} + +fn refresh_store_snapshot(backend: &dyn WriterBackend, shared: &Shared, last_ack_durable: bool) { + if let Ok(mut snapshot) = backend.store_snapshot() { + snapshot.last_ack_durable = last_ack_durable; + lock(&shared.health).store = snapshot; + } else { + lock(&shared.health).store.last_ack_durable = last_ack_durable; + } +} + +fn maybe_run_background_maintenance( + backend: &mut Box, + config: &ShadowRuntimeConfig, + shared: &Shared, + last_maintenance: &mut Option, + poison: &mut Option, +) { + let Some(interval) = config.maintenance_interval else { + return; + }; + let now = Instant::now(); + if last_maintenance.is_some_and(|previous| now.duration_since(previous) < interval) { + return; + } + let now_micros = i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros(), + ) + .unwrap_or(i64::MAX); + match backend.background_maintenance(now_micros, config) { + Ok(()) => *last_maintenance = Some(now), + Err(error) if writer_error_requires_poison(&error) => { + let cause = error.to_string(); + *poison = Some(WorkerPoison::Error(cause.clone())); + let mut health = lock(&shared.health); + health.state = ShadowRuntimeState::Poisoned; + health.last_error = Some(cause); + } + Err(_) => *last_maintenance = Some(now), + } +} + +fn process_write( + backend: &mut Box, + write: ShadowWrite, + shared: &Shared, + poison: &mut Option, +) -> Result { + if let Some(poison) = poison { + lock(&shared.health).failed += 1; + return Err(match poison { + WorkerPoison::Error(cause) => ShadowWriteFailure::Poisoned { + cause: cause.clone(), + }, + WorkerPoison::Panic(cause) => ShadowWriteFailure::WriterPanicked { + cause: cause.clone(), + }, + }); + } + + let identity = write.identity; + let commit = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + backend.commit_ingress(identity, write.transaction) + })); + match commit { + Ok(Ok(commit)) => { + let watermarks = observe_source(&**backend, shared, identity.source_id); + refresh_store_snapshot(&**backend, shared, commit.durable); + let mut health = lock(&shared.health); + health.acknowledged += 1; + if !commit.deduplicated { + health.resource_limit = None; + } + Ok(ShadowAck { + identity, + accepted_through: watermarks.accepted_through, + durable_through: watermarks.durable_through, + commit, + }) + } + Ok(Err(error)) => { + observe_source(&**backend, shared, identity.source_id); + if !writer_error_requires_poison(&error) { + let mut health = lock(&shared.health); + health.failed += 1; + if let Error::ResourceLimit(reason) = &error { + health.resource_limit = Some((*reason).to_owned()); + } + return Err(ShadowWriteFailure::Rejected(error)); + } + let cause = error.to_string(); + *poison = Some(WorkerPoison::Error(cause.clone())); + let mut health = lock(&shared.health); + health.state = ShadowRuntimeState::Poisoned; + health.failed += 1; + health.last_error = Some(cause); + Err(ShadowWriteFailure::Writer(error)) + } + Err(panic) => { + let cause = panic_message(panic); + *poison = Some(WorkerPoison::Panic(cause.clone())); + let mut health = lock(&shared.health); + health.state = ShadowRuntimeState::Poisoned; + health.failed += 1; + health.last_error = Some(cause.clone()); + Err(ShadowWriteFailure::WriterPanicked { cause }) + } + } +} + +fn process_flush( + backend: &mut Box, + source_id: u128, + through_sequence: u64, + shared: &Shared, + poison: &mut Option, +) -> Result { + if let Some(poison) = poison { + lock(&shared.health).failed += 1; + return Err(match poison { + WorkerPoison::Error(cause) => ShadowFlushFailure::Poisoned { + cause: cause.clone(), + }, + WorkerPoison::Panic(cause) => ShadowFlushFailure::WriterPanicked { + cause: cause.clone(), + }, + }); + } + + let before = observe_source(&**backend, shared, source_id); + if before + .accepted_through + .is_none_or(|accepted| accepted < through_sequence) + { + lock(&shared.health).failed += 1; + return Err(ShadowFlushFailure::NotAccepted { + source_id, + through_sequence, + accepted_through: before.accepted_through, + }); + } + + let flush = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| backend.flush())); + match flush { + Ok(Ok(())) => { + let source_ids: Vec<_> = lock(&shared.health) + .source_watermarks + .keys() + .copied() + .collect(); + for observed_source_id in source_ids { + observe_source(&**backend, shared, observed_source_id); + } + let watermarks = backend.ingress_watermarks(source_id); + refresh_store_snapshot(&**backend, shared, true); + Ok(ShadowFlushAck { + source_id, + through_sequence, + accepted_through: watermarks.accepted_through, + durable_through: watermarks.durable_through, + }) + } + Ok(Err(error)) => { + if !writer_error_requires_poison(&error) { + let mut health = lock(&shared.health); + health.failed += 1; + return Err(ShadowFlushFailure::Rejected(error)); + } + let cause = error.to_string(); + *poison = Some(WorkerPoison::Error(cause.clone())); + let mut health = lock(&shared.health); + health.state = ShadowRuntimeState::Poisoned; + health.failed += 1; + health.last_error = Some(cause); + Err(ShadowFlushFailure::Writer(error)) + } + Err(panic) => { + let cause = panic_message(panic); + *poison = Some(WorkerPoison::Panic(cause.clone())); + let mut health = lock(&shared.health); + health.state = ShadowRuntimeState::Poisoned; + health.failed += 1; + health.last_error = Some(cause.clone()); + Err(ShadowFlushFailure::WriterPanicked { cause }) + } + } +} + +fn close_worker( + backend: Box, + shared: &Shared, + poison: Option, +) -> Result { + if let Some(poison) = poison { + lock(&shared.health).state = ShadowRuntimeState::Closed; + let report = ShutdownReport { + health: health_snapshot(shared), + }; + return Err(match poison { + WorkerPoison::Error(cause) => ShutdownError::WriterPoisoned { + cause, + report: Box::new(report), + }, + WorkerPoison::Panic(cause) => ShutdownError::WriterPanicked { + cause, + report: Box::new(report), + }, + }); + } + + let close = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| backend.close())); + match close { + Ok(Ok(source_watermarks)) => { + let mut health = lock(&shared.health); + health.source_watermarks = source_watermarks; + health.state = ShadowRuntimeState::Closed; + drop(health); + Ok(ShutdownReport { + health: health_snapshot(shared), + }) + } + Ok(Err(error)) => { + let cause = error.to_string(); + let mut health = lock(&shared.health); + health.state = ShadowRuntimeState::Closed; + health.failed += 1; + health.last_error = Some(cause); + drop(health); + let report = ShutdownReport { + health: health_snapshot(shared), + }; + Err(ShutdownError::Close { + error, + report: Box::new(report), + }) + } + Err(panic) => { + let cause = panic_message(panic); + let mut health = lock(&shared.health); + health.state = ShadowRuntimeState::Closed; + health.failed += 1; + health.last_error = Some(cause.clone()); + drop(health); + let report = ShutdownReport { + health: health_snapshot(shared), + }; + Err(ShutdownError::WriterPanicked { + cause, + report: Box::new(report), + }) + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum QueueReservationError { + Entries, + Points, +} + +fn reserve_queue_slot(shared: &Shared, points: usize) -> Result<(), QueueReservationError> { + let mut usage = lock(&shared.queue_usage); + usage.reserve(shared.queue_capacity, shared.max_queued_points, points) +} + +fn release_queue_slot(shared: &Shared, points: usize) { + lock(&shared.queue_usage).release(points); +} + +fn health_snapshot(shared: &Shared) -> ShadowHealth { + let health = lock(&shared.health); + let usage = *lock(&shared.queue_usage); + ShadowHealth { + state: health.state, + queue_capacity: shared.queue_capacity, + max_queued_points: shared.max_queued_points, + queued: usage.queued, + queued_points: usage.points, + accepted: health.accepted, + acknowledged: health.acknowledged, + failed: health.failed, + source_watermarks: health.source_watermarks.clone(), + last_error: health.last_error.clone(), + resource_limit: health.resource_limit.clone(), + database_bytes: health.store.bytes, + database_points: health.store.points, + database_commits: health.store.commits, + recovered_tail_bytes: health.store.recovered_tail_bytes, + durability: health.store.durability, + last_ack_durable: health.store.last_ack_durable, + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|error| error.into_inner()) +} + +fn panic_message(panic: Box) -> String { + if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_owned() + } else if let Some(message) = panic.downcast_ref::() { + message.clone() + } else { + "non-string panic".to_owned() + } +} + +#[cfg(test)] +mod tests { + use super::{ + FlushSubmitError, QueueReservationError, QueueUsage, ShadowFlushFailure, ShadowRuntime, + ShadowRuntimeConfig, ShadowRuntimeState, ShadowWrite, ShadowWriteFailure, SubmitError, + WriterBackend, + }; + use crate::{ + Commit, Config, Database, Durability, Entity, EntityId, IngressIdentity, IngressWatermarks, + Point, Properties, RollupPolicy, SeriesDefinition, SeriesSemantics, Store, Transaction, + }; + use std::sync::{Arc, Condvar, Mutex}; + use std::time::{Duration, Instant}; + + #[derive(Default)] + struct FakeState { + commit_ids: Vec, + entered: bool, + released: bool, + reject_next: bool, + fail_next: bool, + closed: bool, + watermarks: std::collections::BTreeMap, + } + + struct FakeWriter { + state: Arc<(Mutex, Condvar)>, + block: bool, + } + + impl WriterBackend for FakeWriter { + fn commit_ingress( + &mut self, + identity: IngressIdentity, + transaction: Transaction, + ) -> crate::Result { + let (state, changed) = &*self.state; + let mut state = state.lock().unwrap(); + state.entered = true; + changed.notify_all(); + while self.block && !state.released { + state = changed.wait(state).unwrap(); + } + if state.reject_next { + state.reject_next = false; + return Err(crate::Error::InvalidModel( + "injected invalid model".to_owned(), + )); + } + if state.fail_next { + state.fail_next = false; + return Err(crate::Error::Io(std::io::Error::other( + "injected writer error", + ))); + } + state.commit_ids.push(identity.commit_id); + state.watermarks.insert( + identity.source_id, + IngressWatermarks { + accepted_through: Some(identity.sequence), + durable_through: Some(identity.sequence), + }, + ); + Ok(Commit { + frame_offset: state.commit_ids.len() as u64, + points: transaction.point_count(), + records: transaction.record_count(), + bytes_written: 1, + durable: true, + deduplicated: false, + }) + } + + fn ingress_watermarks(&self, source_id: u128) -> IngressWatermarks { + self.state + .0 + .lock() + .unwrap() + .watermarks + .get(&source_id) + .copied() + .unwrap_or_default() + } + + fn all_ingress_watermarks(&self) -> std::collections::BTreeMap { + self.state.0.lock().unwrap().watermarks.clone() + } + + fn flush(&mut self) -> crate::Result<()> { + Ok(()) + } + + fn close( + self: Box, + ) -> crate::Result> { + let mut state = self.state.0.lock().unwrap(); + state.closed = true; + Ok(state.watermarks.clone()) + } + } + + fn fake_runtime( + capacity: usize, + block: bool, + fail_next: bool, + ) -> (ShadowRuntime, Arc<(Mutex, Condvar)>) { + fake_runtime_with_config( + ShadowRuntimeConfig { + queue_capacity: capacity, + ..ShadowRuntimeConfig::default() + }, + block, + fail_next, + ) + } + + fn fake_runtime_with_config( + config: ShadowRuntimeConfig, + block: bool, + fail_next: bool, + ) -> (ShadowRuntime, Arc<(Mutex, Condvar)>) { + let state = Arc::new(( + Mutex::new(FakeState { + fail_next, + ..FakeState::default() + }), + Condvar::new(), + )); + let runtime = ShadowRuntime::start_backend( + Box::new(FakeWriter { + state: Arc::clone(&state), + block, + }), + config, + ) + .unwrap(); + (runtime, state) + } + + fn write(sequence: u64, commit_id: u128) -> ShadowWrite { + write_points(sequence, commit_id, 1) + } + + fn write_points(sequence: u64, commit_id: u128, points: usize) -> ShadowWrite { + write_points_for_source(1, sequence, commit_id, points) + } + + fn write_points_for_source( + source_id: u128, + sequence: u64, + commit_id: u128, + points: usize, + ) -> ShadowWrite { + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(1, sequence as i64, 1.0); points]); + ShadowWrite::identified( + IngressIdentity::new(source_id, sequence, commit_id), + transaction, + ) + } + + #[test] + fn queue_capacity_is_a_hard_bound() { + let (runtime, state) = fake_runtime(1, true, false); + let submitter = runtime.submitter(); + let active = submitter.try_submit(write(0, 10)).unwrap(); + let (lock, changed) = &*state; + let mut state = lock.lock().unwrap(); + while !state.entered { + state = changed.wait(state).unwrap(); + } + drop(state); + + let queued = submitter.try_submit(write(1, 11)).unwrap(); + assert!(matches!( + submitter.try_submit(write(2, 12)), + Err(SubmitError::Overloaded(_)) + )); + assert_eq!(submitter.health().queued, 1); + assert_eq!(submitter.health().queued_points, 1); + + let mut state = lock.lock().unwrap(); + state.released = true; + changed.notify_all(); + drop(state); + active.wait().unwrap(); + queued.wait().unwrap(); + runtime.shutdown().unwrap(); + } + + #[test] + fn read_only_database_cannot_start_a_writer_runtime() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("read-only-runtime.wlog"); + Database::open(&path).unwrap().close().unwrap(); + let database = Database::open_read_only(path).unwrap(); + assert!(matches!( + ShadowRuntime::start_database(database, ShadowRuntimeConfig::default()), + Err(super::ShadowStartError::ReadOnlyBackend) + )); + } + + #[test] + fn read_only_store_cannot_start_a_writer_runtime() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("read-only-runtime-store"); + Store::open(&path).unwrap().close().unwrap(); + let store = Store::open_read_only(path).unwrap(); + assert!(matches!( + ShadowRuntime::start_store(store, ShadowRuntimeConfig::default()), + Err(super::ShadowStartError::ReadOnlyBackend) + )); + } + + #[test] + fn queued_point_limit_is_a_hard_bound_and_excludes_active_write() { + let (runtime, state) = fake_runtime_with_config( + ShadowRuntimeConfig { + queue_capacity: 3, + max_queued_points: 2, + ..ShadowRuntimeConfig::default() + }, + true, + false, + ); + let submitter = runtime.submitter(); + let active = submitter.try_submit(write(0, 10)).unwrap(); + let (lock, changed) = &*state; + let mut state = lock.lock().unwrap(); + while !state.entered { + state = changed.wait(state).unwrap(); + } + drop(state); + + let queued = submitter.try_submit(write_points(1, 11, 2)).unwrap(); + assert!(matches!( + submitter.try_submit(write(2, 12)), + Err(SubmitError::PointBudgetExhausted(_)) + )); + let health = submitter.health(); + assert_eq!(health.max_queued_points, 2); + assert_eq!(health.queued, 1); + assert_eq!(health.queued_points, 2); + + let mut state = lock.lock().unwrap(); + state.released = true; + changed.notify_all(); + drop(state); + active.wait().unwrap(); + queued.wait().unwrap(); + runtime.shutdown().unwrap(); + } + + #[test] + fn queue_reservation_overflow_changes_neither_counter() { + let mut entries_full = QueueUsage { + queued: usize::MAX, + points: 7, + }; + assert_eq!( + entries_full.reserve(usize::MAX, usize::MAX, 1), + Err(QueueReservationError::Entries) + ); + assert_eq!( + entries_full, + QueueUsage { + queued: usize::MAX, + points: 7, + } + ); + + let mut points_full = QueueUsage { + queued: 0, + points: usize::MAX, + }; + assert_eq!( + points_full.reserve(usize::MAX, usize::MAX, 1), + Err(QueueReservationError::Points) + ); + assert_eq!( + points_full, + QueueUsage { + queued: 0, + points: usize::MAX, + } + ); + } + + #[test] + fn writer_preserves_sequence_and_queue_order() { + let (runtime, state) = fake_runtime(4, false, false); + let submitter = runtime.submitter(); + let receipts: Vec<_> = (40..44) + .map(|sequence| { + submitter + .submit_until( + write(sequence, 100 + u128::from(sequence)), + Instant::now() + Duration::from_secs(1), + ) + .unwrap() + }) + .collect(); + for (offset, receipt) in receipts.into_iter().enumerate() { + assert_eq!( + receipt.wait().unwrap().identity.sequence, + 40 + offset as u64 + ); + } + let report = runtime.shutdown().unwrap(); + assert_eq!( + report.health.source_watermarks.get(&1), + Some(&IngressWatermarks { + accepted_through: Some(43), + durable_through: Some(43), + }) + ); + assert_eq!(state.0.lock().unwrap().commit_ids, vec![140, 141, 142, 143]); + } + + #[test] + fn duplicate_sequence_always_reaches_the_writer_backend() { + let (runtime, state) = fake_runtime(2, false, false); + let submitter = runtime.submitter(); + submitter.try_submit(write(0, 7)).unwrap().wait().unwrap(); + submitter.try_submit(write(0, 7)).unwrap().wait().unwrap(); + assert_eq!(state.0.lock().unwrap().commit_ids, vec![7, 7]); + runtime.shutdown().unwrap(); + } + + #[test] + fn ftwdb_exact_ingress_replay_is_deduplicated() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("shadow.wlog"); + let mut database = Database::open(&path).unwrap(); + initialize_catalog(&mut database); + let runtime = + ShadowRuntime::start_database(database, ShadowRuntimeConfig::default()).unwrap(); + let submitter = runtime.submitter(); + let first = submitter.try_submit(write(0, 77)).unwrap().wait().unwrap(); + let replay = submitter.try_submit(write(0, 77)).unwrap().wait().unwrap(); + assert!(!first.commit.deduplicated); + assert!(replay.commit.deduplicated); + runtime.shutdown().unwrap(); + + let restarted = ShadowRuntime::start_database( + Database::open(&path).unwrap(), + ShadowRuntimeConfig::default(), + ) + .unwrap(); + assert_eq!( + restarted.health().source_watermarks.get(&1), + Some(&IngressWatermarks { + accepted_through: Some(0), + durable_through: Some(0), + }) + ); + restarted.shutdown().unwrap(); + + let database = Database::open_read_only(path).unwrap(); + assert_eq!(database.stats().unwrap().points, 1); + } + + #[test] + fn ingress_conflict_is_nonfatal_and_next_sequence_succeeds() { + let directory = tempfile::tempdir().unwrap(); + let mut database = Database::open(directory.path().join("conflict.wlog")).unwrap(); + initialize_catalog(&mut database); + let runtime = + ShadowRuntime::start_database(database, ShadowRuntimeConfig::default()).unwrap(); + let submitter = runtime.submitter(); + submitter.try_submit(write(0, 77)).unwrap().wait().unwrap(); + + let mut changed = Transaction::new(); + changed.append_points(vec![Point::actual(1, 0, 2.0)]); + let conflict = ShadowWrite::identified(IngressIdentity::new(1, 0, 77), changed); + assert!(matches!( + submitter.try_submit(conflict).unwrap().wait(), + Err(ShadowWriteFailure::Rejected( + crate::Error::IngressSourceSequenceConflict { + source_id: 1, + sequence: 0, + } + )) + )); + submitter.try_submit(write(1, 78)).unwrap().wait().unwrap(); + assert_eq!(submitter.health().state, ShadowRuntimeState::Running); + assert!(submitter.health().last_ack_durable); + assert!(submitter.health().database_points >= 1); + runtime.shutdown().unwrap(); + } + + #[test] + fn ftwdb_batch_rejection_does_not_stop_a_corrected_retry() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("shadow.wlog"); + let mut database = Database::open_with( + &path, + Config { + max_batch_points: 1, + ..Config::default() + }, + ) + .unwrap(); + initialize_catalog(&mut database); + let runtime = + ShadowRuntime::start_database(database, ShadowRuntimeConfig::default()).unwrap(); + let submitter = runtime.submitter(); + + let rejected = submitter.try_submit(write_points(5, 91, 2)).unwrap().wait(); + assert!(matches!( + rejected, + Err(ShadowWriteFailure::Rejected( + crate::Error::BatchTooLarge { .. } + )) + )); + assert_eq!(submitter.health().state, ShadowRuntimeState::Running); + submitter.try_submit(write(5, 91)).unwrap().wait().unwrap(); + runtime.shutdown().unwrap(); + + assert_eq!( + Database::open_read_only(path) + .unwrap() + .stats() + .unwrap() + .points, + 1 + ); + } + + #[test] + fn writer_error_poisons_runtime_and_contains_later_writes() { + let (runtime, state) = fake_runtime(2, false, true); + let submitter = runtime.submitter(); + let first = submitter.try_submit(write(0, 1)).unwrap(); + let second = submitter.try_submit(write(0, 2)).unwrap(); + assert!(matches!(first.wait(), Err(ShadowWriteFailure::Writer(_)))); + assert!(matches!( + second.wait(), + Err(ShadowWriteFailure::Poisoned { .. }) + )); + assert_eq!(state.0.lock().unwrap().commit_ids, Vec::::new()); + assert_eq!(submitter.health().state, ShadowRuntimeState::Poisoned); + assert!(matches!( + submitter.try_submit(write(1, 3)), + Err(SubmitError::Poisoned { .. }) + )); + assert!(matches!( + submitter.try_flush(1, 0), + Err(FlushSubmitError::Poisoned { .. }) + )); + assert!(runtime.shutdown().is_err()); + } + + #[test] + fn request_error_allows_a_corrected_retry_and_next_write() { + let (runtime, state) = fake_runtime(2, false, false); + state.0.lock().unwrap().reject_next = true; + let submitter = runtime.submitter(); + let rejected = submitter.try_submit(write(20, 1)).unwrap().wait(); + assert!(matches!( + rejected, + Err(ShadowWriteFailure::Rejected(crate::Error::InvalidModel(_))) + )); + assert_eq!(submitter.health().state, ShadowRuntimeState::Running); + assert_eq!(submitter.health().last_error, None); + + submitter.try_submit(write(20, 2)).unwrap().wait().unwrap(); + submitter.try_submit(write(21, 3)).unwrap().wait().unwrap(); + assert_eq!(state.0.lock().unwrap().commit_ids, vec![2, 3]); + runtime.shutdown().unwrap(); + } + + #[test] + fn clean_shutdown_flushes_manual_durability_and_closes_submissions() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("shadow.wlog"); + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + initialize_catalog(&mut database); + let runtime = + ShadowRuntime::start_database(database, ShadowRuntimeConfig::default()).unwrap(); + let submitter = runtime.submitter(); + let ack = submitter.try_submit(write(0, 88)).unwrap().wait().unwrap(); + assert_eq!(ack.durable_through, None); + let report = runtime.shutdown().unwrap(); + assert_eq!(report.health.state, ShadowRuntimeState::Closed); + assert_eq!( + report + .health + .source_watermarks + .get(&1) + .unwrap() + .durable_through, + Some(0) + ); + assert!(matches!( + submitter.try_submit(write(1, 89)), + Err(SubmitError::Closed(_)) + )); + assert_eq!( + Database::open_read_only(path) + .unwrap() + .stats() + .unwrap() + .points, + 1 + ); + } + + #[test] + fn source_flush_checks_through_and_refreshes_all_observed_sources() { + let directory = tempfile::tempdir().unwrap(); + let mut database = Database::open_with( + directory.path().join("two-sources.wlog"), + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + initialize_catalog(&mut database); + let runtime = + ShadowRuntime::start_database(database, ShadowRuntimeConfig::default()).unwrap(); + let submitter = runtime.submitter(); + submitter + .try_submit(write_points_for_source(1, 40, 140, 1)) + .unwrap() + .wait() + .unwrap(); + submitter + .try_submit(write_points_for_source(2, 900, 2900, 1)) + .unwrap() + .wait() + .unwrap(); + + assert!(matches!( + submitter.try_flush(1, 41).unwrap().wait(), + Err(ShadowFlushFailure::NotAccepted { + source_id: 1, + through_sequence: 41, + accepted_through: Some(40), + }) + )); + let flushed = submitter.try_flush(1, 40).unwrap().wait().unwrap(); + assert_eq!(flushed.durable_through, Some(40)); + let health = submitter.health(); + assert_eq!( + health.source_watermarks.get(&1).unwrap().durable_through, + Some(40) + ); + assert_eq!( + health.source_watermarks.get(&2).unwrap().durable_through, + Some(900) + ); + runtime.shutdown().unwrap(); + } + + fn initialize_catalog(database: &mut Database) { + let mut transaction = Transaction::new(); + transaction + .upsert_entity(Entity { + id: EntityId(1), + kind: "site".to_owned(), + name: "test".to_owned(), + parent: None, + valid_from: 0, + valid_to: None, + properties: Properties::new(), + }) + .define_series(SeriesDefinition { + id: 1, + owner_entity: Some(EntityId(1)), + owner_relation: None, + name: "shadow".to_owned(), + physical_quantity: "power".to_owned(), + canonical_unit: "W".to_owned(), + semantics: SeriesSemantics::Gauge, + maximum_gap_micros: None, + rollup_policy: RollupPolicy { + raw_retain_for_micros: None, + tiers: Vec::new(), + }, + }); + database.commit(transaction).unwrap(); + } +} diff --git a/src/shadow_server.rs b/src/shadow_server.rs new file mode 100644 index 0000000..9de69d3 --- /dev/null +++ b/src/shadow_server.rs @@ -0,0 +1,1504 @@ +//! Local Unix socket server for the FTWDB shadow protocol. +//! +//! Version one serves one client at a time. Every accepted stream has bounded +//! read and write times, so a stalled peer cannot hold the listener forever. +//! HELLO binds each connection to one ingress source. The durable store checks +//! source, sequence, commit ID, and transaction bytes. + +use crate::shadow_protocol::{ + self, Ack, AckKind, CommitBatchRequest, ErrorCode, ErrorResponse, HealthResponse, HealthStatus, + HelloResponse, Request, Response, SyncPolicy, WireMessage, +}; +use crate::shadow_runtime::{ + AckWaitError, FlushSubmitError, ShadowFlushFailure, ShadowRuntimeState, ShadowSubmitter, + ShadowWrite, ShadowWriteFailure, SubmitError, +}; +use crate::{Durability, Error, IngressIdentity}; +use std::fmt; +use std::fs::{self, DirBuilder, FileType}; +use std::io::{self, Read}; +use std::mem::MaybeUninit; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{DirBuilderExt, FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +static SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// Limits for the local sidecar endpoint. +#[derive(Clone, Debug)] +pub struct ShadowServerConfig { + pub socket_path: PathBuf, + /// Effective user ID allowed to use the socket. + pub allowed_peer_uid: u32, + pub io_timeout: Duration, + pub acknowledgement_timeout: Duration, + pub accept_poll_interval: Duration, +} + +impl ShadowServerConfig { + #[must_use] + pub fn new(socket_path: impl Into) -> Self { + Self { + socket_path: socket_path.into(), + allowed_peer_uid: rustix::process::geteuid().as_raw(), + io_timeout: Duration::from_secs(2), + acknowledgement_timeout: Duration::from_secs(5), + accept_poll_interval: Duration::from_millis(20), + } + } +} + +/// Cloneable stop flag for tests and clean service shutdown. +#[derive(Clone, Debug, Default)] +pub struct ShadowStopToken(Arc); + +impl ShadowStopToken { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn stop(&self) { + self.0.store(true, Ordering::Release); + } + + #[must_use] + pub fn is_stopped(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ShadowServerReport { + pub accepted_clients: u64, + pub peer_auth_failures: u64, + pub client_errors: u64, + pub overload_count: u64, + pub protocol_error_count: u64, + pub database_bytes: u64, + pub database_points: u64, + pub database_commits: u64, + pub recovered_tail_bytes: u64, + pub sync_policy: SyncPolicy, + pub last_ack_durable: bool, +} + +#[derive(Debug, Default)] +struct ServerCounters { + overload: AtomicU64, + protocol_error: AtomicU64, +} + +#[derive(Debug)] +pub enum ShadowServerError { + MissingSocketParent, + UnsafeSocketParent(PathBuf), + ExistingPathIsNotSocket(PathBuf), + SocketInUse(PathBuf), + CouldNotProveSocketStale { path: PathBuf, error: io::Error }, + PeerCredentials(io::Error), + Io(io::Error), +} + +impl fmt::Display for ShadowServerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingSocketParent => { + formatter.write_str("socket path needs a parent directory") + } + Self::UnsafeSocketParent(path) => write!( + formatter, + "socket parent is not a real directory: {}", + path.display() + ), + Self::ExistingPathIsNotSocket(path) => write!( + formatter, + "refusing to replace a non-socket path: {}", + path.display() + ), + Self::SocketInUse(path) => { + write!(formatter, "a listener already owns {}", path.display()) + } + Self::CouldNotProveSocketStale { path, error } => write!( + formatter, + "could not prove that {} is stale: {error}", + path.display() + ), + Self::PeerCredentials(error) => { + write!(formatter, "could not read Unix peer credentials: {error}") + } + Self::Io(error) => write!(formatter, "shadow server I/O error: {error}"), + } + } +} + +impl std::error::Error for ShadowServerError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::CouldNotProveSocketStale { error, .. } + | Self::PeerCredentials(error) + | Self::Io(error) => Some(error), + _ => None, + } + } +} + +impl From for ShadowServerError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +/// Serves local clients until `stop` is set. +pub fn serve( + config: &ShadowServerConfig, + submitter: ShadowSubmitter, + stop: &ShadowStopToken, +) -> Result { + validate_config(config)?; + let bound = BoundSocket::bind(&config.socket_path)?; + bound.listener.set_nonblocking(true)?; + let counters = ServerCounters::default(); + let mut report = ShadowServerReport::default(); + while !stop.is_stopped() { + match bound.listener.accept() { + Ok((mut stream, _)) => { + report.accepted_clients = report.accepted_clients.saturating_add(1); + // The listener is nonblocking so stop polling stays bounded. + // Accepted streams must block under their own frame deadline. + stream.set_nonblocking(false)?; + let peer_uid = + peer_effective_uid(&stream).map_err(ShadowServerError::PeerCredentials)?; + if peer_uid != config.allowed_peer_uid { + report.peer_auth_failures = report.peer_auth_failures.saturating_add(1); + continue; + } + if serve_connection(&mut stream, &submitter, config, stop, &counters).is_err() { + report.client_errors = report.client_errors.saturating_add(1); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(config.accept_poll_interval); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(ShadowServerError::Io(error)), + } + } + let health = submitter.health(); + report.overload_count = counters.overload.load(Ordering::Relaxed); + report.protocol_error_count = counters.protocol_error.load(Ordering::Relaxed); + report.database_bytes = health.database_bytes; + report.database_points = health.database_points; + report.database_commits = health.database_commits; + report.recovered_tail_bytes = health.recovered_tail_bytes; + report.sync_policy = sync_policy_from_durability(health.durability); + report.last_ack_durable = health.last_ack_durable; + Ok(report) +} + +/// Returns the effective UID captured by the kernel for this connected peer. +/// The check does not trust socket-file mode alone: a process that received an +/// open descriptor still has to run as the configured service user. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn peer_effective_uid(stream: &UnixStream) -> io::Result { + let mut credentials = MaybeUninit::::zeroed(); + let mut length = libc::socklen_t::try_from(std::mem::size_of::()) + .expect("ucred size fits socklen_t"); + // SAFETY: `credentials` points to writable storage of `length` bytes, and + // the stream owns a valid socket descriptor for the whole call. + let result = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + credentials.as_mut_ptr().cast(), + &mut length, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + if usize::try_from(length).ok() != Some(std::mem::size_of::()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "kernel returned a truncated peer credential", + )); + } + // SAFETY: getsockopt succeeded and reported the full `ucred` size. + Ok(unsafe { credentials.assume_init() }.uid) +} + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" +))] +fn peer_effective_uid(stream: &UnixStream) -> io::Result { + let mut uid = MaybeUninit::::uninit(); + let mut gid = MaybeUninit::::uninit(); + // SAFETY: the stream owns a valid socket descriptor, and both output + // pointers refer to initialized storage when getpeereid returns success. + let result = + unsafe { libc::getpeereid(stream.as_raw_fd(), uid.as_mut_ptr(), gid.as_mut_ptr()) }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: getpeereid initialized both outputs on success. + Ok(unsafe { uid.assume_init() }) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "tvos", + target_os = "watchos", + target_os = "visionos", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd" +)))] +fn peer_effective_uid(_stream: &UnixStream) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "peer credentials are not implemented on this Unix target", + )) +} + +fn validate_config(config: &ShadowServerConfig) -> Result<(), ShadowServerError> { + if config.io_timeout.is_zero() + || config.acknowledgement_timeout.is_zero() + || config.accept_poll_interval.is_zero() + { + return Err(ShadowServerError::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "shadow server timeouts must be positive", + ))); + } + Ok(()) +} + +fn serve_connection( + stream: &mut UnixStream, + submitter: &ShadowSubmitter, + config: &ShadowServerConfig, + stop: &ShadowStopToken, + counters: &ServerCounters, +) -> Result<(), ()> { + let mut source_id = None; + + loop { + if stop.is_stopped() { + return Ok(()); + } + let message = match read_frame_before(stream, config.io_timeout) { + Ok(message) => message, + Err(shadow_protocol::ProtocolError::Truncated { actual: 0, .. }) => { + // EOF before the next frame starts is a normal client close. + // A partial header or body still takes the error path below. + return Ok(()); + } + Err(error) => { + if stop.is_stopped() { + return Ok(()); + } + counters.protocol_error.fetch_add(1, Ordering::Relaxed); + let code = match error { + shadow_protocol::ProtocolError::UnsupportedVersion(_) => ErrorCode::Unsupported, + _ => ErrorCode::InvalidRequest, + }; + let _ = write_error(stream, code, false); + return Err(()); + } + }; + + let response = match message { + WireMessage::Request(Request::Hello(hello)) if source_id.is_none() => { + if hello.source_id == 0 { + stable_error(ErrorCode::InvalidRequest, false) + } else { + source_id = Some(hello.source_id); + WireMessage::Response(Response::Hello(HelloResponse { + selected_version: shadow_protocol::PROTOCOL_VERSION, + session_id: next_session_id(), + server_time_micros: unix_time_micros(), + })) + } + } + WireMessage::Request(Request::Hello(_)) => { + stable_error(ErrorCode::InvalidRequest, false) + } + WireMessage::Request(_) if source_id.is_none() => { + stable_error(ErrorCode::InvalidRequest, false) + } + WireMessage::Request(Request::CommitBatch(batch)) => { + if source_id != Some(batch.source_id) { + stable_error(ErrorCode::InvalidRequest, false) + } else { + handle_commit(batch, submitter, config) + } + } + WireMessage::Request(Request::Flush(flush)) => { + if source_id != Some(flush.source_id) { + stable_error(ErrorCode::InvalidRequest, false) + } else { + handle_flush(flush.source_id, flush.through_sequence, submitter, config) + } + } + WireMessage::Request(Request::Health(request)) => handle_health( + request.nonce, + source_id.expect("HELLO set a source"), + submitter, + counters, + ), + WireMessage::Response(_) => stable_error(ErrorCode::InvalidRequest, false), + }; + + if matches!( + &response, + WireMessage::Response(Response::Error(ErrorResponse { + code: ErrorCode::Overloaded, + .. + })) + ) { + counters.overload.fetch_add(1, Ordering::Relaxed); + } + + if stream.set_write_timeout(Some(config.io_timeout)).is_err() + || shadow_protocol::write_to(stream, &response).is_err() + { + return Err(()); + } + } +} + +/// Reads one whole frame under one deadline. A socket timeout on each read is +/// not enough: a peer could otherwise send one byte per timeout and occupy the +/// only connection slot for hours. +fn read_frame_before( + stream: &mut UnixStream, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + .checked_add(timeout) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "frame deadline overflows"))?; + shadow_protocol::read_from(&mut DeadlineReader { stream, deadline }) +} + +struct DeadlineReader<'a> { + stream: &'a mut UnixStream, + deadline: Instant, +} + +impl Read for DeadlineReader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let remaining = self + .deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "frame deadline expired"))?; + let read = match self.stream.set_read_timeout(Some(remaining)) { + Ok(()) => self.stream.read(buffer), + // macOS can reject SO_RCVTIMEO with EINVAL after the peer has + // already closed. A nonblocking read can classify that close + // without risking an unbounded wait if the timeout itself was bad. + Err(error) if error.kind() == io::ErrorKind::InvalidInput => { + self.stream.set_nonblocking(true)?; + let read = self.stream.read(buffer); + self.stream.set_nonblocking(false)?; + read + } + Err(error) => return Err(error), + }; + match read { + Ok(read) => Ok(read), + Err(error) if error.kind() == io::ErrorKind::ConnectionReset => Ok(0), + Err(error) => Err(error), + } + } +} + +fn handle_commit( + batch: CommitBatchRequest, + submitter: &ShadowSubmitter, + config: &ShadowServerConfig, +) -> WireMessage { + let source_id = batch.source_id; + let sequence = batch.sequence; + let commit_id = batch.commit_id; + let mut transaction = shadow_protocol::transaction_from_batch(batch); + transaction.with_ingress_identity(IngressIdentity::new(source_id, sequence, commit_id)); + let write = ShadowWrite::from_identified(transaction) + .expect("ingress identity always supplies a commit ID"); + let receipt = match submitter.try_submit(write) { + Ok(receipt) => receipt, + Err(error) => return map_submit_error(error), + }; + let acknowledgement = match receipt.wait_timeout(config.acknowledgement_timeout) { + Ok(Ok(acknowledgement)) => acknowledgement, + Ok(Err(error)) => return map_write_failure(error), + Err(error) => return map_wait_error(error), + }; + + let commit = acknowledgement.commit; + WireMessage::Response(Response::Ack(Ack { + kind: AckKind::CommitBatch, + source_id, + sequence, + commit_id, + accepted_through_sequence: acknowledgement.accepted_through, + durable_through_sequence: acknowledgement.durable_through, + durable: commit.durable, + deduplicated: commit.deduplicated, + frame_offset: commit.frame_offset, + records: u32::try_from(commit.records).unwrap_or(u32::MAX), + points: u32::try_from(commit.points).unwrap_or(u32::MAX), + bytes_written: commit.bytes_written, + })) +} + +fn handle_flush( + source_id: u128, + through_sequence: u64, + submitter: &ShadowSubmitter, + config: &ShadowServerConfig, +) -> WireMessage { + let receipt = match submitter.try_flush(source_id, through_sequence) { + Ok(receipt) => receipt, + Err(error) => return map_flush_submit_error(error), + }; + let acknowledgement = match receipt.wait_timeout(config.acknowledgement_timeout) { + Ok(Ok(acknowledgement)) => acknowledgement, + Ok(Err(error)) => return map_flush_failure(error), + Err(error) => return map_wait_error(error), + }; + if acknowledgement + .durable_through + .is_none_or(|watermark| watermark < through_sequence) + { + return stable_error(ErrorCode::Internal, true); + } + WireMessage::Response(Response::Ack(Ack { + kind: AckKind::Flush, + source_id, + sequence: through_sequence, + commit_id: 0, + accepted_through_sequence: acknowledgement.accepted_through, + durable_through_sequence: acknowledgement.durable_through, + durable: true, + deduplicated: false, + frame_offset: 0, + records: 0, + points: 0, + bytes_written: 0, + })) +} + +fn handle_health( + nonce: u64, + source_id: u128, + submitter: &ShadowSubmitter, + counters: &ServerCounters, +) -> WireMessage { + let health = submitter.health(); + let watermarks = health + .source_watermarks + .get(&source_id) + .copied() + .unwrap_or_default(); + let status = match health.state { + ShadowRuntimeState::Running if health.resource_limit.is_some() => HealthStatus::Degraded, + ShadowRuntimeState::Running => HealthStatus::Healthy, + ShadowRuntimeState::Closing => HealthStatus::Degraded, + ShadowRuntimeState::Poisoned | ShadowRuntimeState::Closed => HealthStatus::Unavailable, + }; + WireMessage::Response(Response::Health(HealthResponse { + nonce, + source_id, + status, + queue_entries: u32::try_from(health.queued).unwrap_or(u32::MAX), + accepted_through_sequence: watermarks.accepted_through, + durable_through_sequence: watermarks.durable_through, + overload_count: counters.overload.load(Ordering::Relaxed), + protocol_error_count: counters.protocol_error.load(Ordering::Relaxed), + database_bytes: health.database_bytes, + database_points: health.database_points, + database_commits: health.database_commits, + recovered_tail_bytes: health.recovered_tail_bytes, + sync_policy: sync_policy_from_durability(health.durability), + last_ack_durable: health.last_ack_durable, + })) +} + +fn sync_policy_from_durability(durability: Durability) -> SyncPolicy { + match durability { + Durability::Always => SyncPolicy::Always, + Durability::Manual => SyncPolicy::Manual, + Durability::EveryBytes(bytes) => SyncPolicy::EveryBytes(bytes), + } +} + +fn map_submit_error(error: SubmitError) -> WireMessage { + match error { + SubmitError::Overloaded(_) | SubmitError::PointBudgetExhausted(_) => { + stable_error(ErrorCode::Overloaded, true) + } + SubmitError::DeadlineExceeded(_) => stable_error(ErrorCode::Overloaded, true), + SubmitError::Closed(_) | SubmitError::Poisoned { .. } => { + stable_error(ErrorCode::Internal, true) + } + } +} + +fn map_write_failure(error: ShadowWriteFailure) -> WireMessage { + match error { + ShadowWriteFailure::Rejected(error) => map_store_error(&error), + ShadowWriteFailure::Writer(_) + | ShadowWriteFailure::Poisoned { .. } + | ShadowWriteFailure::WriterPanicked { .. } + | ShadowWriteFailure::WorkerStopped => stable_error(ErrorCode::Internal, true), + } +} + +fn map_store_error(error: &Error) -> WireMessage { + match error { + Error::ResourceLimit(reason) => WireMessage::Response(Response::Error(ErrorResponse { + code: ErrorCode::Overloaded, + retryable: true, + message: (*reason).to_owned(), + })), + Error::IngressSourceSequenceConflict { .. } | Error::IngressCommitIdConflict { .. } => { + stable_error(ErrorCode::IdempotencyConflict, false) + } + Error::IngressSequenceNotIncreasing { .. } + | Error::BatchTooLarge { .. } + | Error::InvalidArgument(_) + | Error::InvalidModel(_) + | Error::Serialization(_) => stable_error(ErrorCode::InvalidRequest, false), + _ => stable_error(ErrorCode::Internal, true), + } +} + +fn map_flush_submit_error(error: FlushSubmitError) -> WireMessage { + match error { + FlushSubmitError::Overloaded | FlushSubmitError::DeadlineExceeded => { + stable_error(ErrorCode::Overloaded, true) + } + FlushSubmitError::Closed | FlushSubmitError::Poisoned { .. } => { + stable_error(ErrorCode::Internal, true) + } + } +} + +fn map_flush_failure(error: ShadowFlushFailure) -> WireMessage { + match error { + ShadowFlushFailure::NotAccepted { .. } => stable_error(ErrorCode::InvalidRequest, true), + ShadowFlushFailure::Rejected(error) => map_store_error(&error), + ShadowFlushFailure::Writer(_) + | ShadowFlushFailure::Poisoned { .. } + | ShadowFlushFailure::WriterPanicked { .. } + | ShadowFlushFailure::WorkerStopped => stable_error(ErrorCode::Internal, true), + } +} + +fn map_wait_error(_error: AckWaitError) -> WireMessage { + stable_error(ErrorCode::Internal, true) +} + +fn stable_error(code: ErrorCode, retryable: bool) -> WireMessage { + let message = match code { + ErrorCode::InvalidRequest => "invalid request", + ErrorCode::Overloaded => "shadow writer overloaded", + ErrorCode::Internal => "shadow writer unavailable", + ErrorCode::Unsupported => "unsupported protocol version", + ErrorCode::IdempotencyConflict => "idempotency conflict", + }; + WireMessage::Response(Response::Error(ErrorResponse { + code, + retryable, + message: message.to_owned(), + })) +} + +fn write_error(stream: &mut UnixStream, code: ErrorCode, retryable: bool) -> Result<(), ()> { + shadow_protocol::write_to(stream, &stable_error(code, retryable)).map_err(|_| ()) +} + +fn next_session_id() -> [u8; 16] { + let count = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); + let now = unix_time_micros() as u64; + let mut id = [0; 16]; + id[..8].copy_from_slice(&now.to_be_bytes()); + id[8..].copy_from_slice(&(count ^ u64::from(std::process::id())).to_be_bytes()); + id +} + +fn unix_time_micros() -> i64 { + let micros = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros(); + i64::try_from(micros).unwrap_or(i64::MAX) +} + +struct BoundSocket { + listener: UnixListener, + _guard: SocketGuard, +} + +impl BoundSocket { + fn bind(path: &Path) -> Result { + prepare_parent(path)?; + remove_proven_stale_socket(path)?; + let listener = UnixListener::bind(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + let metadata = fs::symlink_metadata(path)?; + Ok(Self { + listener, + _guard: SocketGuard { + path: path.to_owned(), + device: metadata.dev(), + inode: metadata.ino(), + }, + }) + } +} + +struct SocketGuard { + path: PathBuf, + device: u64, + inode: u64, +} + +impl Drop for SocketGuard { + fn drop(&mut self) { + let Ok(metadata) = fs::symlink_metadata(&self.path) else { + return; + }; + if metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode + { + let _ = fs::remove_file(&self.path); + } + } +} + +fn prepare_parent(path: &Path) -> Result<(), ShadowServerError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or(ShadowServerError::MissingSocketParent)?; + match fs::symlink_metadata(parent) { + Ok(metadata) => check_private_socket_parent(parent, &metadata), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + create_private_socket_ancestors(parent) + } + Err(error) => Err(ShadowServerError::Io(error)), + } +} + +fn owned_by_effective_user(metadata: &fs::Metadata) -> bool { + metadata.uid() == rustix::process::geteuid().as_raw() +} + +/// The socket directory itself must be owner-only. A 0755 parent lets another +/// local user reach the inode during the bind-to-chmod window. +fn check_private_socket_parent( + path: &Path, + metadata: &fs::Metadata, +) -> Result<(), ShadowServerError> { + if metadata.file_type().is_dir() + && owned_by_effective_user(metadata) + && metadata.permissions().mode() & 0o777 == 0o700 + { + Ok(()) + } else { + Err(ShadowServerError::UnsafeSocketParent(path.to_owned())) + } +} + +/// An existing ancestor may be 0755 (home, `/var/lib`) but must not be +/// group- or world-writable. Creating a 0700 child under `/tmp` is a +/// classic symlink race. +fn check_existing_ancestor(path: &Path, metadata: &fs::Metadata) -> Result<(), ShadowServerError> { + if metadata.file_type().is_dir() + && owned_by_effective_user(metadata) + && metadata.permissions().mode() & 0o022 == 0 + { + Ok(()) + } else { + Err(ShadowServerError::UnsafeSocketParent(path.to_owned())) + } +} + +fn create_private_socket_ancestors(path: &Path) -> Result<(), ShadowServerError> { + let mut missing = vec![path.to_path_buf()]; + let mut cursor = path; + loop { + let Some(parent) = cursor + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + else { + let cwd = Path::new("."); + check_existing_ancestor(cwd, &fs::symlink_metadata(cwd)?)?; + break; + }; + match fs::symlink_metadata(parent) { + Ok(metadata) => { + check_existing_ancestor(parent, &metadata)?; + break; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + missing.push(parent.to_path_buf()); + cursor = parent; + } + Err(error) => return Err(ShadowServerError::Io(error)), + } + } + + missing.reverse(); + for directory in missing { + let mut builder = DirBuilder::new(); + builder.mode(0o700).create(&directory)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + check_private_socket_parent(&directory, &fs::symlink_metadata(&directory)?)?; + } + Ok(()) +} + +fn remove_proven_stale_socket(path: &Path) -> Result<(), ShadowServerError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(ShadowServerError::Io(error)), + }; + if !is_socket(metadata.file_type()) { + return Err(ShadowServerError::ExistingPathIsNotSocket(path.to_owned())); + } + match UnixStream::connect(path) { + Ok(_) => Err(ShadowServerError::SocketInUse(path.to_owned())), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound + ) => + { + let current = fs::symlink_metadata(path)?; + if !current.file_type().is_socket() + || current.dev() != metadata.dev() + || current.ino() != metadata.ino() + { + return Err(ShadowServerError::CouldNotProveSocketStale { + path: path.to_owned(), + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "socket path changed during the stale check", + ), + }); + } + fs::remove_file(path)?; + Ok(()) + } + Err(error) => Err(ShadowServerError::CouldNotProveSocketStale { + path: path.to_owned(), + error, + }), + } +} + +fn is_socket(file_type: FileType) -> bool { + file_type.is_socket() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shadow_protocol::{FlushRequest, HelloRequest, Response}; + use crate::shadow_runtime::{ShadowRuntime, ShadowRuntimeConfig}; + use crate::{Entity, EntityId, Store}; + use std::fs::File; + + fn runtime() -> (tempfile::TempDir, ShadowRuntime) { + let directory = tempfile::tempdir().unwrap(); + let store = Store::open(directory.path().join("store")).unwrap(); + let runtime = ShadowRuntime::start_store( + store, + ShadowRuntimeConfig { + queue_capacity: 4, + max_queued_points: 64, + ..ShadowRuntimeConfig::default() + }, + ) + .unwrap(); + (directory, runtime) + } + + fn private_socket_path(directory: &tempfile::TempDir) -> PathBuf { + let parent = directory.path().join("run"); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap(); + parent.join("shadow.sock") + } + + #[test] + fn reads_the_effective_uid_of_a_connected_peer() { + let (client, server) = UnixStream::pair().unwrap(); + let expected = rustix::process::geteuid().as_raw(); + assert_eq!(peer_effective_uid(&client).unwrap(), expected); + assert_eq!(peer_effective_uid(&server).unwrap(), expected); + } + + #[test] + fn hello_is_required_before_health() { + let (_directory, runtime) = runtime(); + let submitter = runtime.submitter(); + let config = ShadowServerConfig::new("/tmp/unused-ftwdb-shadow.sock"); + let (mut client, mut server) = UnixStream::pair().unwrap(); + let join = thread::spawn(move || { + serve_connection( + &mut server, + &submitter, + &config, + &ShadowStopToken::new(), + &ServerCounters::default(), + ) + }); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Health(shadow_protocol::HealthRequest { nonce: 7 })), + ) + .unwrap(); + let response = shadow_protocol::read_from(&mut client).unwrap(); + assert!(matches!( + response, + WireMessage::Response(Response::Error(ErrorResponse { + code: ErrorCode::InvalidRequest, + .. + })) + )); + drop(client); + assert!(join.join().unwrap().is_ok()); + runtime.shutdown().unwrap(); + } + + #[test] + fn hello_then_health_succeeds() { + let (_directory, runtime) = runtime(); + let submitter = runtime.submitter(); + let config = ShadowServerConfig::new("/tmp/unused-ftwdb-shadow.sock"); + let (mut client, mut server) = UnixStream::pair().unwrap(); + let join = thread::spawn(move || { + serve_connection( + &mut server, + &submitter, + &config, + &ShadowStopToken::new(), + &ServerCounters::default(), + ) + }); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Hello(HelloRequest { + source_id: 17, + node_id: "box-test".to_owned(), + client_version: "test".to_owned(), + capabilities: 0, + })), + ) + .unwrap(); + assert!(matches!( + shadow_protocol::read_from(&mut client).unwrap(), + WireMessage::Response(Response::Hello(_)) + )); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Health(shadow_protocol::HealthRequest { + nonce: 11, + })), + ) + .unwrap(); + let WireMessage::Response(Response::Health(health)) = + shadow_protocol::read_from(&mut client).unwrap() + else { + panic!("expected a health response"); + }; + assert_eq!(health.nonce, 11); + assert_eq!(health.source_id, 17); + assert_eq!(health.status, HealthStatus::Healthy); + assert_eq!(health.queue_entries, 0); + assert_eq!(health.overload_count, 0); + assert_eq!(health.protocol_error_count, 0); + assert!(health.database_bytes > 0); + assert_eq!(health.database_points, 0); + assert_eq!(health.database_commits, 0); + assert_eq!(health.recovered_tail_bytes, 0); + assert_eq!(health.sync_policy, SyncPolicy::Always); + assert!(!health.last_ack_durable); + drop(client); + assert!(join.join().unwrap().is_ok()); + runtime.shutdown().unwrap(); + } + + #[test] + fn frame_boundary_eof_is_clean_but_body_eof_is_an_error() { + use std::io::Write; + + let config = ShadowServerConfig::new("/tmp/unused-ftwdb-shadow.sock"); + + let (client, mut server) = UnixStream::pair().unwrap(); + drop(client); + let boundary = read_frame_before(&mut server, config.io_timeout); + assert!( + matches!( + boundary, + Err(shadow_protocol::ProtocolError::Truncated { actual: 0, .. }) + ), + "unexpected boundary result: {boundary:?}" + ); + + let frame = shadow_protocol::encode(&WireMessage::Request(Request::Hello(HelloRequest { + source_id: 17, + node_id: "partial-client".to_owned(), + client_version: "test".to_owned(), + capabilities: 0, + }))) + .unwrap(); + let (mut client, mut server) = UnixStream::pair().unwrap(); + client.write_all(&frame[..12]).unwrap(); + drop(client); + let partial = read_frame_before(&mut server, config.io_timeout); + assert!( + matches!( + partial, + Err(shadow_protocol::ProtocolError::Truncated { actual: 12, .. }) + ), + "unexpected partial-body result: {partial:?}" + ); + } + + #[test] + fn one_deadline_bounds_the_whole_frame() { + use std::io::Write; + + let message = WireMessage::Request(Request::Hello(HelloRequest { + source_id: 17, + node_id: "slow-client".to_owned(), + client_version: "test".to_owned(), + capabilities: 0, + })); + let frame = shadow_protocol::encode(&message).unwrap(); + let (mut client, mut server) = UnixStream::pair().unwrap(); + let writer = thread::spawn(move || { + for byte in frame { + if client.write_all(&[byte]).is_err() { + break; + } + thread::sleep(Duration::from_millis(20)); + } + }); + + let started = Instant::now(); + let result = read_frame_before(&mut server, Duration::from_millis(60)); + let elapsed = started.elapsed(); + drop(server); + writer.join().unwrap(); + + assert!(matches!( + result, + Err(shadow_protocol::ProtocolError::Io(error)) + if matches!(error.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock) + )); + assert!(elapsed < Duration::from_millis(500)); + } + + #[test] + fn stop_token_ends_an_active_connection_within_the_read_deadline() { + let (_directory, runtime) = runtime(); + let submitter = runtime.submitter(); + let mut config = ShadowServerConfig::new("/tmp/unused-ftwdb-shadow.sock"); + config.io_timeout = Duration::from_millis(50); + let stop = ShadowStopToken::new(); + let server_stop = stop.clone(); + let (mut client, mut server) = UnixStream::pair().unwrap(); + let join = thread::spawn(move || { + serve_connection( + &mut server, + &submitter, + &config, + &server_stop, + &ServerCounters::default(), + ) + }); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Hello(HelloRequest { + source_id: 17, + node_id: "stop-test".to_owned(), + client_version: "test".to_owned(), + capabilities: 0, + })), + ) + .unwrap(); + assert!(matches!( + shadow_protocol::read_from(&mut client).unwrap(), + WireMessage::Response(Response::Hello(_)) + )); + + let started = Instant::now(); + stop.stop(); + assert!(join.join().unwrap().is_ok()); + assert!(started.elapsed() < Duration::from_millis(500)); + runtime.shutdown().unwrap(); + } + + #[test] + fn commit_replay_and_conflict_use_durable_ingress_identity() { + let (_directory, runtime) = runtime(); + let submitter = runtime.submitter(); + let config = ShadowServerConfig::new("/tmp/unused-ftwdb-shadow.sock"); + let (mut client, mut server) = UnixStream::pair().unwrap(); + let join = thread::spawn(move || { + serve_connection( + &mut server, + &submitter, + &config, + &ShadowStopToken::new(), + &ServerCounters::default(), + ) + }); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Hello(HelloRequest { + source_id: 17, + node_id: "box-test".to_owned(), + client_version: "test".to_owned(), + capabilities: 0, + })), + ) + .unwrap(); + let _ = shadow_protocol::read_from(&mut client).unwrap(); + + let mut batch = CommitBatchRequest { + source_id: 17, + sequence: 5, + commit_id: 99, + entities: vec![Entity { + id: EntityId(1), + kind: "site".to_owned(), + name: "test-site".to_owned(), + parent: None, + valid_from: 0, + valid_to: None, + properties: Default::default(), + }], + relations: Vec::new(), + series: Vec::new(), + runs: Vec::new(), + plans: Vec::new(), + points: Vec::new(), + }; + let request = WireMessage::Request(Request::CommitBatch(batch.clone())); + shadow_protocol::write_to(&mut client, &request).unwrap(); + assert!(matches!( + shadow_protocol::read_from(&mut client).unwrap(), + WireMessage::Response(Response::Ack(Ack { + source_id: 17, + sequence: 5, + durable: true, + deduplicated: false, + .. + })) + )); + + shadow_protocol::write_to(&mut client, &request).unwrap(); + assert!(matches!( + shadow_protocol::read_from(&mut client).unwrap(), + WireMessage::Response(Response::Ack(Ack { + source_id: 17, + sequence: 5, + durable: true, + deduplicated: true, + .. + })) + )); + + batch.entities[0].name = "changed".to_owned(); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::CommitBatch(batch)), + ) + .unwrap(); + assert!(matches!( + shadow_protocol::read_from(&mut client).unwrap(), + WireMessage::Response(Response::Error(ErrorResponse { + code: ErrorCode::IdempotencyConflict, + retryable: false, + .. + })) + )); + + drop(client); + assert!(join.join().unwrap().is_ok()); + runtime.shutdown().unwrap(); + } + + #[test] + fn bind_refuses_to_replace_regular_file() { + let directory = tempfile::tempdir().unwrap(); + let path = private_socket_path(&directory); + File::create(&path).unwrap(); + let error = match BoundSocket::bind(&path) { + Ok(_) => panic!("bind unexpectedly replaced the file"), + Err(error) => error, + }; + assert!(matches!( + error, + ShadowServerError::ExistingPathIsNotSocket(found) if found == path + )); + assert!(path.is_file()); + } + + #[test] + fn bind_rejects_group_writable_existing_parent() { + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join("shared-run"); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o770)).unwrap(); + let path = parent.join("shadow.sock"); + let error = match BoundSocket::bind(&path) { + Ok(_) => panic!("bind unexpectedly used a shared parent"), + Err(error) => error, + }; + assert!(matches!( + error, + ShadowServerError::UnsafeSocketParent(found) if found == parent + )); + assert!(!path.exists()); + } + + #[test] + fn bind_rejects_world_accessible_existing_parent() { + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join("open-run"); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap(); + let path = parent.join("shadow.sock"); + let error = match BoundSocket::bind(&path) { + Ok(_) => panic!("bind unexpectedly used a world-traversable parent"), + Err(error) => error, + }; + assert!(matches!( + error, + ShadowServerError::UnsafeSocketParent(found) if found == parent + )); + assert!(!path.exists()); + } + + #[test] + fn bind_refuses_to_create_under_a_world_writable_ancestor() { + let directory = tempfile::tempdir().unwrap(); + let world = directory.path().join("world"); + fs::create_dir(&world).unwrap(); + fs::set_permissions(&world, fs::Permissions::from_mode(0o777)).unwrap(); + let path = world.join("run/shadow.sock"); + let error = match BoundSocket::bind(&path) { + Ok(_) => panic!("bind unexpectedly created a socket under a world-writable directory"), + Err(error) => error, + }; + assert!(matches!(error, ShadowServerError::UnsafeSocketParent(_))); + assert!(!path.exists()); + assert!(!world.join("run").exists()); + } + + #[test] + fn hello_binds_source_id_and_rejects_a_different_source() { + let (_directory, runtime) = runtime(); + let submitter = runtime.submitter(); + let config = ShadowServerConfig::new("/tmp/unused-ftwdb-shadow.sock"); + let (mut client, mut server) = UnixStream::pair().unwrap(); + let join = thread::spawn(move || { + serve_connection( + &mut server, + &submitter, + &config, + &ShadowStopToken::new(), + &ServerCounters::default(), + ) + }); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Hello(HelloRequest { + source_id: 17, + node_id: "box-test".to_owned(), + client_version: "test".to_owned(), + capabilities: 0, + })), + ) + .unwrap(); + assert!(matches!( + shadow_protocol::read_from(&mut client).unwrap(), + WireMessage::Response(Response::Hello(_)) + )); + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Flush(FlushRequest { + source_id: 18, + through_sequence: 1, + })), + ) + .unwrap(); + match shadow_protocol::read_from(&mut client).unwrap() { + WireMessage::Response(Response::Error(ErrorResponse { + code: ErrorCode::InvalidRequest, + retryable: false, + message, + })) => assert_eq!(message, "invalid request"), + other => panic!("expected a stable invalid-request error, got {other:?}"), + } + drop(client); + assert!(join.join().unwrap().is_ok()); + runtime.shutdown().unwrap(); + } + + #[test] + fn bind_refuses_a_live_socket() { + let directory = tempfile::tempdir().unwrap(); + let path = private_socket_path(&directory); + let listener = UnixListener::bind(&path).unwrap(); + let error = match BoundSocket::bind(&path) { + Ok(_) => panic!("bind unexpectedly replaced the live socket"), + Err(error) => error, + }; + assert!(matches!( + error, + ShadowServerError::SocketInUse(found) if found == path + )); + drop(listener); + } + + #[test] + fn bind_replaces_a_proven_stale_socket() { + let directory = tempfile::tempdir().unwrap(); + let path = private_socket_path(&directory); + let listener = UnixListener::bind(&path).unwrap(); + drop(listener); + let bound = BoundSocket::bind(&path).unwrap(); + assert!(path.exists()); + drop(bound); + assert!(!path.exists()); + } + + #[test] + fn bound_socket_has_private_mode_and_is_removed_on_drop() { + let directory = tempfile::tempdir().unwrap(); + let parent = directory.path().join("run"); + let path = parent.join("shadow.sock"); + let bound = BoundSocket::bind(&path).unwrap(); + assert_eq!( + fs::metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + drop(bound); + assert!(!path.exists()); + } + + #[test] + fn malformed_client_does_not_stop_the_listener() { + use std::io::Write; + + let directory = tempfile::tempdir().unwrap(); + let store = Store::open(directory.path().join("store")).unwrap(); + let runtime = ShadowRuntime::start_store( + store, + ShadowRuntimeConfig { + queue_capacity: 4, + max_queued_points: 64, + ..ShadowRuntimeConfig::default() + }, + ) + .unwrap(); + let socket_path = directory.path().join("run/shadow.sock"); + let mut config = ShadowServerConfig::new(&socket_path); + config.io_timeout = Duration::from_secs(2); + config.acknowledgement_timeout = Duration::from_millis(250); + config.accept_poll_interval = Duration::from_millis(2); + let stop = ShadowStopToken::new(); + let server_stop = stop.clone(); + let submitter = runtime.submitter(); + let mut join = Some(thread::spawn(move || { + serve(&config, submitter, &server_stop) + })); + + for _ in 0..100 { + if socket_path.exists() { + break; + } + if join.as_ref().unwrap().is_finished() { + let result = join.take().unwrap().join().unwrap(); + runtime.shutdown().unwrap(); + panic!("shadow server stopped before bind: {result:?}"); + } + thread::sleep(Duration::from_millis(2)); + } + if !socket_path.exists() { + stop.stop(); + let result = join.take().unwrap().join().unwrap(); + runtime.shutdown().unwrap(); + panic!("shadow socket did not appear; server result: {result:?}"); + } + let connect_deadline = Instant::now() + Duration::from_secs(1); + let mut malformed = loop { + match UnixStream::connect(&socket_path) { + Ok(client) => break client, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound + ) && Instant::now() < connect_deadline => + { + thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("listener did not accept its first client: {error}"), + } + }; + malformed.write_all(b"bad").unwrap(); + drop(malformed); + + let reconnect_deadline = Instant::now() + Duration::from_secs(1); + let mut client = loop { + match UnixStream::connect(&socket_path) { + Ok(client) => break client, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound + ) && Instant::now() < reconnect_deadline => + { + thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("listener did not recover from malformed client: {error}"), + } + }; + shadow_protocol::write_to( + &mut client, + &WireMessage::Request(Request::Hello(HelloRequest { + source_id: 18, + node_id: "box-test".to_owned(), + client_version: "test".to_owned(), + capabilities: 0, + })), + ) + .unwrap(); + assert!(matches!( + shadow_protocol::read_from(&mut client).unwrap(), + WireMessage::Response(Response::Hello(_)) + )); + stop.stop(); + drop(client); + let report = join.take().unwrap().join().unwrap().unwrap(); + assert_eq!(report.accepted_clients, 2); + assert_eq!(report.client_errors, 1); + assert_eq!(report.protocol_error_count, 1); + assert_eq!(report.overload_count, 0); + assert_eq!(report.sync_policy, SyncPolicy::Always); + assert!(!report.last_ack_durable); + assert!(!socket_path.exists()); + runtime.shutdown().unwrap(); + } + + #[test] + fn listener_rejects_a_peer_with_the_wrong_effective_uid() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::open(directory.path().join("store")).unwrap(); + let runtime = ShadowRuntime::start_store( + store, + ShadowRuntimeConfig { + queue_capacity: 4, + max_queued_points: 64, + ..ShadowRuntimeConfig::default() + }, + ) + .unwrap(); + let socket_path = directory.path().join("run/shadow.sock"); + let mut config = ShadowServerConfig::new(&socket_path); + let current_uid = rustix::process::geteuid().as_raw(); + config.allowed_peer_uid = if current_uid == u32::MAX { + 0 + } else { + current_uid + 1 + }; + config.io_timeout = Duration::from_millis(100); + config.accept_poll_interval = Duration::from_millis(2); + let stop = ShadowStopToken::new(); + let server_stop = stop.clone(); + let submitter = runtime.submitter(); + let join = thread::spawn(move || serve(&config, submitter, &server_stop)); + + for _ in 0..100 { + if socket_path.exists() || join.is_finished() { + break; + } + thread::sleep(Duration::from_millis(2)); + } + if !socket_path.exists() { + stop.stop(); + let result = join.join().unwrap(); + runtime.shutdown().unwrap(); + panic!("shadow socket did not appear; server result: {result:?}"); + } + + let connect_deadline = Instant::now() + Duration::from_secs(1); + let mut client = loop { + match UnixStream::connect(&socket_path) { + Ok(client) => break client, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound + ) && Instant::now() < connect_deadline => + { + thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("listener did not accept unauthorized client: {error}"), + } + }; + client + .set_read_timeout(Some(Duration::from_millis(250))) + .unwrap(); + let mut byte = [0_u8; 1]; + match client.read(&mut byte) { + Ok(0) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe + ) => {} + result => panic!("unauthorized connection remained open: {result:?}"), + } + + stop.stop(); + let report = join.join().unwrap().unwrap(); + assert_eq!(report.accepted_clients, 1); + assert_eq!(report.peer_auth_failures, 1); + assert_eq!(report.client_errors, 0); + runtime.shutdown().unwrap(); + } +} diff --git a/src/snapshot.rs b/src/snapshot.rs index 83765bd..2584531 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -3,7 +3,7 @@ use crate::{Error, Result}; use crc32fast::Hasher; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; -use std::os::unix::fs::MetadataExt; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -126,6 +126,94 @@ pub(crate) fn snapshot_file_prefix_digest( }) } +/// Snapshot digest over `relative_paths`, hashing `prefix_bytes` of an already +/// open `prefix_relative` file and the full contents of every other selected +/// path. Salvage uses this so a torn live tail does not enter the checksum +/// while sealed segment bytes still do. +pub(crate) fn snapshot_digest_with_open_prefix( + root: &Path, + relative_paths: &[String], + prefix_relative: &str, + prefix_file: &mut File, + prefix_bytes: u64, +) -> Result { + if !relative_paths.iter().any(|path| path == prefix_relative) { + return Err(Error::InvalidArgument( + "snapshot prefix path must be one of the selected files", + )); + } + validate_relative_path(prefix_relative)?; + let metadata = prefix_file.metadata()?; + if !metadata.file_type().is_file() || metadata.len() < prefix_bytes { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "snapshot prefix is no longer present in the source file", + ))); + } + + let root_metadata = std::fs::symlink_metadata(root)?; + if relative_paths.len() > 1 && !root_metadata.file_type().is_dir() { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "snapshot root is not a directory", + ))); + } + + let mut previous: Option<&str> = None; + let mut hasher = Hasher::new(); + hasher.update(SNAPSHOT_CRC32_DOMAIN); + let mut bytes = 0_u64; + + for relative in relative_paths { + validate_relative_path(relative)?; + if previous.is_some_and(|previous| previous >= relative.as_str()) { + return Err(Error::InvalidArgument( + "snapshot paths must be unique and sorted", + )); + } + previous = Some(relative); + + let path_bytes = relative.as_bytes(); + let path_len = u64::try_from(path_bytes.len()) + .map_err(|_| Error::Serialization("snapshot path is too long".to_owned()))?; + let mut selected = None; + let file_len = if relative == prefix_relative { + prefix_bytes + } else { + let file = open_snapshot_file(root, relative)?; + let file_len = file.metadata()?.len(); + selected = Some(file); + file_len + }; + bytes = bytes + .checked_add(file_len) + .ok_or_else(|| Error::Serialization("snapshot byte count exceeds u64".to_owned()))?; + + hasher.update(&path_len.to_le_bytes()); + hasher.update(path_bytes); + hasher.update(&file_len.to_le_bytes()); + if relative == prefix_relative { + prefix_file.seek(SeekFrom::Start(0))?; + hash_exact_bytes(prefix_file, prefix_bytes, relative, &mut hasher)?; + } else { + let mut file = selected.expect("opened selected snapshot file"); + hash_exact_bytes(&mut file, file_len, relative, &mut hasher)?; + if file.read(&mut [0_u8; 1])? != 0 { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("snapshot file grew while reading {relative}"), + ))); + } + } + } + + Ok(SnapshotDigest { + files: relative_paths.len(), + bytes, + crc32: hasher.finalize(), + }) +} + fn hash_exact_bytes( file: &mut File, mut remaining: u64, @@ -233,8 +321,11 @@ impl StagedDirectory { ".{name}.{operation}-{}-{timestamp}-{counter}-{attempt}", std::process::id() )); - match std::fs::create_dir(&path) { + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700); + match builder.create(&path) { Ok(()) => { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))?; return Ok(Self { path, published: false, @@ -472,9 +563,29 @@ pub(crate) fn inject_checksum_mismatch(digest: SnapshotDigest) -> SnapshotDigest #[cfg(test)] mod tests { use super::{StagedDirectory, snapshot_digest}; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::sync::{Arc, Barrier}; use tempfile::tempdir; + #[test] + fn staged_snapshot_directory_is_owner_only() { + let directory = tempdir().unwrap(); + let destination = directory.path().join("target"); + let stage = StagedDirectory::create(&destination, "test").unwrap(); + assert_eq!( + std::fs::symlink_metadata(stage.path()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::symlink_metadata(stage.path()).unwrap().uid(), + rustix::process::geteuid().as_raw() + ); + } + #[test] fn digest_ignores_unselected_files_and_changes_with_paths_lengths_or_bytes() { let directory = tempdir().unwrap(); diff --git a/src/storage.rs b/src/storage.rs index 5e8229d..c64f9c0 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -2,15 +2,16 @@ use crate::FixedGaugeRollup; use crate::catalog::Catalog; use crate::error::{Error, Result}; use crate::segment::{Segment, SegmentStats}; -use crate::transaction::{Record, Transaction}; +use crate::transaction::{IngressIdentity, Record, Transaction}; use crc32fast::hash; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::fs::{File, OpenOptions, TryLockError}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::os::unix::fs::MetadataExt; -use std::path::Path; +use std::io::{BufReader, Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; const DATABASE_MAGIC: &[u8; 8] = b"FTWDB001"; const DATABASE_VERSION: u16 = 1; @@ -33,6 +34,29 @@ const FRAME_KIND_TRANSACTION: u16 = 1; /// this feature exists to close. const FRAME_KIND_IDENTIFIED_TRANSACTION: u16 = 2; const COMMIT_ID_BYTES: usize = 16; +/// An ordered ingress transaction. +/// +/// Its payload starts with `source_id: u128`, `sequence: u64`, and +/// `commit_id: u128`, all little-endian, followed by the canonical kind-1 +/// transaction payload. One frame checksum therefore covers both identity +/// and data. Kinds 0 through 2 remain unchanged. +const FRAME_KIND_INGRESS_TRANSACTION: u16 = 3; +/// Marks the durable prefix that a later manifest generation sealed into an +/// immutable raw segment. Recovery drops live-index points accumulated before +/// a checkpoint whose generation is published, so reopen stays bounded by the +/// unsealed tail even when reclaim has not yet rewritten `active.wlog`. +const FRAME_KIND_SEAL_CHECKPOINT: u16 = 4; +/// Compact identity receipts after WAL reclaim. Payload is postcard-encoded +/// receipt metadata plus exact retry bytes; recovery does not rebuild point +/// records into the live query index. +const FRAME_KIND_IDENTITY_INDEX: u16 = 5; +const IDENTITY_INDEX_MAGIC_V2: &[u8; 8] = b"WIDX0002"; +/// A single retained transaction may already use the configured transaction +/// limit. Kind 5 needs a small amount of receipt metadata around those exact +/// bytes, while the writer splits multiple receipts across frames. +const IDENTITY_INDEX_ENTRY_OVERHEAD_BYTES: usize = 1024; +const INGRESS_IDENTITY_BYTES: usize = 16 + 8 + 16; +const SEAL_CHECKPOINT_BYTES: usize = 16; const TRANSACTION_MAGIC: &[u8; 4] = b"WTXN"; const TRANSACTION_VERSION: u16 = 1; const TRANSACTION_HEADER_BYTES: usize = 12; @@ -82,10 +106,11 @@ impl Point { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Durability { /// Sync every committed batch before returning. Safest and hardest on /// flash media. + #[default] Always, /// Sync when at least this many frame bytes have accumulated. Recent /// acknowledged batches may be lost on power failure. @@ -120,11 +145,175 @@ pub struct Commit { pub durable: bool, /// True when the transaction's [`Transaction::with_commit_id`] identifier /// was already committed, so this call wrote nothing: the original - /// commit's records and points are stored exactly once. `points`, - /// `records`, and `bytes_written` are zero for such a replayed commit. + /// commit's records and points are stored exactly once. Legacy identified + /// replays return zero counts. Ordered ingress replays return the original + /// frame offset, counts, and byte count as a durable receipt. pub deduplicated: bool, } +/// Durable progress for one ordered ingress source. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IngressWatermarks { + /// Highest sequence present in the current process view. + pub accepted_through: Option, + /// Highest sequence covered by a successful sync. + pub durable_through: Option, +} + +/// Read-only proof that one ordered ingress frame exists in the current log. +/// +/// `durable` reflects the current handle's proven durable watermark. A +/// read-only opener cannot prove that a prior writer synced a recovered frame, +/// so it reports `false` until a writable opener has synced that prefix. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct IngressReceipt { + pub identity: IngressIdentity, + pub frame_offset: u64, + pub records: usize, + pub points: usize, + pub bytes_written: u64, + pub durable: bool, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct IngressKey { + source_id: u128, + sequence: u64, +} + +impl From for IngressKey { + fn from(identity: IngressIdentity) -> Self { + Self { + source_id: identity.source_id, + sequence: identity.sequence, + } + } +} + +#[derive(Clone, Debug)] +struct StoredIngressReceipt { + identity: IngressIdentity, + canonical_payload_offset: u64, + canonical_payload_len: u32, + canonical_payload_crc32: u32, + /// Exact canonical bytes retained by a compact identity index. Live + /// receipts instead point at their original frame in `active.wlog`. + compact_payload: Option>, + commit: Commit, +} + +#[derive(Clone, Debug)] +struct StoredIdentifiedReceipt { + payload_offset: u64, + payload_len: u32, + payload_crc32: u32, + /// Exact identified-frame payload retained by a compact identity index. + compact_payload: Option>, + commit: Commit, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CompactIdentifiedReceipt { + commit_id: u128, + payload_len: u32, + payload_crc32: u32, + points: u64, + records: u64, + /// Empty only when reading an index written by the first kind-5 format, + /// which stored CRC metadata but no bytes. Such receipts stay known but + /// fail closed on retry instead of treating CRC equality as exact proof. + #[serde(default)] + payload: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct CompactIngressReceipt { + source_id: u128, + sequence: u64, + commit_id: u128, + canonical_payload_len: u32, + canonical_payload_crc32: u32, + points: u64, + records: u64, + /// See [`CompactIdentifiedReceipt::payload`]. + #[serde(default)] + canonical_payload: Vec, + #[serde(default)] + frame_offset: u64, + #[serde(default)] + bytes_written: u64, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +struct CompactIdentityIndex { + identified: Vec, + ingress: Vec, +} + +/// Old writers created this kind-5 payload before they retained exact retry +/// bytes. Keep a decoder so those stores still open; their known identifiers +/// fail closed on retry because this format cannot prove byte equality. +#[derive(Deserialize)] +struct LegacyCompactIdentifiedReceipt { + commit_id: u128, + payload_len: u32, + payload_crc32: u32, + points: u64, + records: u64, +} + +#[derive(Deserialize)] +struct LegacyCompactIngressReceipt { + source_id: u128, + sequence: u64, + commit_id: u128, + canonical_payload_len: u32, + canonical_payload_crc32: u32, + points: u64, + records: u64, +} + +#[derive(Deserialize)] +struct LegacyCompactIdentityIndex { + identified: Vec, + ingress: Vec, +} + +impl From for CompactIdentityIndex { + fn from(index: LegacyCompactIdentityIndex) -> Self { + Self { + identified: index + .identified + .into_iter() + .map(|receipt| CompactIdentifiedReceipt { + commit_id: receipt.commit_id, + payload_len: receipt.payload_len, + payload_crc32: receipt.payload_crc32, + points: receipt.points, + records: receipt.records, + payload: Vec::new(), + }) + .collect(), + ingress: index + .ingress + .into_iter() + .map(|receipt| CompactIngressReceipt { + source_id: receipt.source_id, + sequence: receipt.sequence, + commit_id: receipt.commit_id, + canonical_payload_len: receipt.canonical_payload_len, + canonical_payload_crc32: receipt.canonical_payload_crc32, + points: receipt.points, + records: receipt.records, + canonical_payload: Vec::new(), + frame_offset: 0, + bytes_written: 0, + }) + .collect(), + } + } +} + /// Why the final bytes of a log were recovered. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum RecoveredTail { @@ -146,15 +335,21 @@ pub enum SalvageStopReason { UnsupportedFrameVersion, FrameHeaderChecksumMismatch, InvalidLegacyFrameSize, + InvalidLegacyPoint, TransactionFrameTooLarge, IdentifiedTransactionTooShort, + IngressTransactionTooShort, UnknownFrameKind, IncompleteFramePayload, PayloadChecksumMismatch, DuplicateCommitId, + DuplicateIngressSequence, + InvalidIngressSequence, InvalidTransaction, TransactionPointCountTooLarge, InvalidCatalogTransaction, + SealCheckpointInvalid, + IdentityIndexInvalid, } impl fmt::Display for SalvageStopReason { @@ -166,15 +361,21 @@ impl fmt::Display for SalvageStopReason { Self::UnsupportedFrameVersion => "unsupported-frame-version", Self::FrameHeaderChecksumMismatch => "frame-header-checksum-mismatch", Self::InvalidLegacyFrameSize => "invalid-legacy-frame-size", + Self::InvalidLegacyPoint => "invalid-legacy-point", Self::TransactionFrameTooLarge => "transaction-frame-too-large", Self::IdentifiedTransactionTooShort => "identified-transaction-too-short", + Self::IngressTransactionTooShort => "ingress-transaction-too-short", Self::UnknownFrameKind => "unknown-frame-kind", Self::IncompleteFramePayload => "incomplete-frame-payload", Self::PayloadChecksumMismatch => "payload-checksum-mismatch", Self::DuplicateCommitId => "duplicate-commit-id", + Self::DuplicateIngressSequence => "duplicate-ingress-sequence", + Self::InvalidIngressSequence => "invalid-ingress-sequence", Self::InvalidTransaction => "invalid-transaction", Self::TransactionPointCountTooLarge => "transaction-point-count-too-large", Self::InvalidCatalogTransaction => "invalid-catalog-transaction", + Self::SealCheckpointInvalid => "seal-checkpoint-invalid", + Self::IdentityIndexInvalid => "identity-index-invalid", }; f.write_str(name) } @@ -212,10 +413,18 @@ pub struct PlanOutcome { /// A single-writer embedded database. pub struct Database { + path: PathBuf, file: File, config: Config, read_only: bool, index: HashMap>, + /// Immutable raw segments published by the store. Queries merge these + /// with the live tail; they are never rebuilt into `index`. + sealed: Vec, + sealed_points: u64, + /// True when recovery dropped sealed frames from the live index because + /// a published checkpoint is still sitting in an unreclaimed log. + pending_reclaim: bool, catalog: Catalog, /// Every client-supplied commit identifier in the log, rebuilt on open by /// `scan_and_recover` so idempotency survives a crash or reopen. Growth is @@ -223,6 +432,17 @@ pub struct Database { /// log — acceptable because the whole log is already replayed into memory /// by design (the README acknowledges that ceiling). commit_ids: HashSet, + /// Payload receipts for legacy [`Transaction::with_commit_id`] frames. + /// A replay compares encoded bytes (and re-reads the stored frame) so a + /// reused identifier with different records cannot silently deduplicate. + identified_receipts: HashMap, + /// One receipt per ordered ingress frame. Live receipts read canonical + /// transaction bytes from the log. Reclaim retains those exact bytes in + /// kind 5 so CRC equality never becomes the retry decision. + ingress_receipts: HashMap, + ingress_commit_ids: HashMap, + ingress_last_sequences: HashMap, + ingress_durable_sequences: HashMap, commits: u64, points: u64, catalog_records: u64, @@ -268,7 +488,7 @@ impl Database { } pub fn open_with(path: impl AsRef, config: Config) -> Result { - Self::open_mode(path.as_ref(), config, false) + Self::open_mode(path.as_ref(), config, false, &HashSet::new()) } /// Opens an existing database without any possibility of mutating it. @@ -281,20 +501,35 @@ impl Database { /// still blocks (and is blocked by) read-only opens, preventing reads of a /// mid-write file. pub fn open_read_only(path: impl AsRef) -> Result { - Self::open_mode(path.as_ref(), Config::default(), true) + Self::open_mode(path.as_ref(), Config::default(), true, &HashSet::new()) + } + + pub(crate) fn open_with_published_seals( + path: impl AsRef, + config: Config, + published_seals: &HashSet, + ) -> Result { + Self::open_mode(path.as_ref(), config, false, published_seals) } - fn open_mode(path: &Path, config: Config, read_only: bool) -> Result { + pub(crate) fn open_read_only_with_published_seals( + path: impl AsRef, + published_seals: &HashSet, + ) -> Result { + Self::open_mode(path.as_ref(), Config::default(), true, published_seals) + } + + fn open_mode( + path: &Path, + config: Config, + read_only: bool, + published_seals: &HashSet, + ) -> Result { validate_config(config)?; let mut file = if read_only { open_regular_file_read_only(path)? } else { - OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(path)? + open_regular_file_read_write(path)? }; // Take an advisory lock before recovery so a writer and a reader can @@ -336,19 +571,46 @@ impl Database { sync_parent_directory(path)?; } - let scan = scan_and_recover( + let mut scan = scan_and_recover( &mut file, config.max_batch_points, config.max_transaction_bytes, read_only, + published_seals, )?; + let ingress_durable_sequences = if read_only { + // A complete frame proves only that the kernel can read it. The + // prior writer may have used Manual or EveryBytes durability and + // died before syncing, so a read-only opener cannot tell a client + // that it is safe to discard its source copy. + HashMap::new() + } else { + // Recovery can see complete frames left in the page cache by a + // process crash. Sync the recovered prefix before publishing a + // durable watermark or replay receipt. This also makes a tail + // truncation durable before the writer accepts more frames. + sync_database_file(&file)?; + for receipt in scan.ingress_receipts.values_mut() { + receipt.commit.durable = true; + } + scan.ingress_last_sequences.clone() + }; Ok(Self { + path: path.to_path_buf(), file, config, read_only, index: scan.index, + sealed: Vec::new(), + sealed_points: 0, + pending_reclaim: scan.pending_reclaim, catalog: scan.catalog, commit_ids: scan.commit_ids, + identified_receipts: scan.identified_receipts, + ingress_receipts: scan.ingress_receipts, + ingress_commit_ids: scan.ingress_commit_ids, + ingress_last_sequences: scan.ingress_last_sequences, + ingress_durable_sequences, commits: scan.commits, points: scan.points, catalog_records: scan.catalog_records, @@ -446,9 +708,12 @@ impl Database { return Err(error); } }; + if durable { + self.mark_ingress_durable(); + } for point in points { - self.index.entry(point.series_id).or_default().push(*point); + insert_indexed_point(&mut self.index, *point); } self.commits += 1; self.points += points.len() as u64; @@ -466,12 +731,12 @@ impl Database { /// Atomically commits catalog changes and point batches in one frame. /// /// When the transaction carries a [`Transaction::with_commit_id`] - /// identifier that is already in the log, nothing is validated or - /// written and the returned commit reports - /// [`Commit::deduplicated`]: the identifier is checked before any other - /// work, so a retried commit can never duplicate points. The check keys - /// on the identifier alone — a retry that reuses an identifier with - /// different records is also answered from the original commit. An empty + /// identifier that is already in the log, the encoded payload is checked + /// against the original frame. An exact retry writes nothing and reports + /// [`Commit::deduplicated`]. Reusing the identifier with different + /// records returns [`Error::IngressCommitIdConflict`]. Prefer + /// [`Self::commit_ingress`] at a production writer boundary so retries + /// carry a source cursor and cannot drop a mutated payload. An empty /// transaction writes no frame, so its identifier (if any) is not /// recorded; there is nothing a replay could duplicate. pub fn commit(&mut self, transaction: Transaction) -> Result { @@ -481,17 +746,30 @@ impl Database { if self.poisoned { return Err(Error::Poisoned); } - if let Some(commit_id) = transaction.commit_id - && self.commit_ids.contains(&commit_id) - { - return Ok(Commit { - frame_offset: self.file.seek(SeekFrom::End(0))?, - points: 0, - records: 0, - bytes_written: 0, - durable: self.bytes_since_sync == 0, - deduplicated: true, - }); + if let Some(identity) = transaction.ingress_identity { + return self.commit_ordered_ingress(identity, transaction); + } + if let Some(commit_id) = transaction.commit_id { + if self.ingress_commit_ids.contains_key(&commit_id) { + return Err(Error::IngressCommitIdConflict { commit_id }); + } + if self.commit_ids.contains(&commit_id) { + let payload = encode_transaction(&transaction)?; + if let Some(receipt) = self.identified_receipts.get(&commit_id).cloned() { + if !self.identified_payload_matches(receipt, &payload)? { + return Err(Error::IngressCommitIdConflict { commit_id }); + } + return Ok(Commit { + frame_offset: self.file.seek(SeekFrom::End(0))?, + points: 0, + records: 0, + bytes_written: 0, + durable: self.bytes_since_sync == 0, + deduplicated: true, + }); + } + return Err(Error::IngressCommitIdConflict { commit_id }); + } } if transaction.is_empty() { return Ok(Commit { @@ -511,7 +789,7 @@ impl Database { }); } - let candidate_catalog = self.catalog.validate_and_apply(&transaction.records)?; + let candidate_catalog = self.catalog.apply_records(&transaction.records)?; let payload = encode_transaction(&transaction)?; if payload.len() > self.config.max_transaction_bytes { return Err(Error::InvalidModel(format!( @@ -538,13 +816,32 @@ impl Database { for record in &transaction.records { if let Record::Points(points) = record { for point in points { - self.index.entry(point.series_id).or_default().push(*point); + insert_indexed_point(&mut self.index, *point); } } } - self.catalog = candidate_catalog; + if let Some(catalog) = candidate_catalog { + self.catalog = catalog; + } if let Some(commit_id) = transaction.commit_id { self.commit_ids.insert(commit_id); + self.identified_receipts.insert( + commit_id, + StoredIdentifiedReceipt { + payload_offset: frame_offset + FRAME_HEADER_BYTES as u64, + payload_len, + payload_crc32: hash(&payload), + compact_payload: None, + commit: Commit { + frame_offset, + points: point_count, + records: transaction.record_count(), + bytes_written, + durable, + deduplicated: false, + }, + }, + ); } self.commits += 1; self.points += point_count as u64; @@ -566,6 +863,381 @@ impl Database { }) } + /// Commits one ordered producer transaction with two durable retry keys. + /// + /// FTWDB stores `identity` and the canonical transaction bytes in one + /// checksummed frame. An exact replay returns the original frame receipt + /// with `deduplicated: true` and writes nothing. Reusing either key with + /// different bytes returns a nonfatal ingress conflict. After a source's + /// first commit, only a strictly greater source cursor is accepted. The + /// cursor may contain gaps; source reconciliation detects missing data. + pub fn commit_ingress( + &mut self, + identity: IngressIdentity, + mut transaction: Transaction, + ) -> Result { + transaction.with_ingress_identity(identity); + self.commit(transaction) + } + + fn commit_ordered_ingress( + &mut self, + identity: IngressIdentity, + transaction: Transaction, + ) -> Result { + if identity.source_id == 0 { + return Err(Error::InvalidArgument("ingress source id zero is reserved")); + } + let canonical = encode_canonical_transaction(&transaction)?; + let key = IngressKey::from(identity); + + if let Some(receipt) = self.ingress_receipts.get(&key).cloned() { + if receipt.identity.commit_id != identity.commit_id + || !self.ingress_payload_matches(receipt.clone(), &canonical)? + { + return Err(Error::IngressSourceSequenceConflict { + source_id: identity.source_id, + sequence: identity.sequence, + }); + } + let mut replay = receipt.commit; + replay.deduplicated = true; + // The source watermark, not an empty dirty-byte counter, is the + // durability proof. A read-only recovery has no such proof. + replay.durable = self + .ingress_durable_sequences + .get(&identity.source_id) + .is_some_and(|sequence| *sequence >= identity.sequence); + return Ok(replay); + } + + if self.commit_ids.contains(&identity.commit_id) { + return Err(Error::IngressCommitIdConflict { + commit_id: identity.commit_id, + }); + } + + if let Some(last) = self + .ingress_last_sequences + .get(&identity.source_id) + .copied() + && identity.sequence <= last + { + return Err(Error::IngressSequenceNotIncreasing { + source_id: identity.source_id, + previous: last, + actual: identity.sequence, + }); + } + + let point_count = transaction.point_count(); + if point_count > self.config.max_batch_points { + return Err(Error::BatchTooLarge { + points: point_count, + maximum: self.config.max_batch_points, + }); + } + let payload_len = INGRESS_IDENTITY_BYTES + .checked_add(canonical.len()) + .ok_or_else(|| { + Error::Serialization("ingress transaction length overflows".to_owned()) + })?; + if payload_len > self.config.max_transaction_bytes { + return Err(Error::InvalidModel(format!( + "transaction has {payload_len} encoded bytes; maximum is {}", + self.config.max_transaction_bytes + ))); + } + + let candidate_catalog = self.catalog.apply_records(&transaction.records)?; + let payload_len_u32 = u32::try_from(payload_len) + .map_err(|_| Error::Serialization("transaction exceeds u32 length".to_owned()))?; + let canonical_len_u32 = u32::try_from(canonical.len()) + .map_err(|_| Error::Serialization("transaction exceeds u32 length".to_owned()))?; + let record_count = u32::try_from(transaction.record_count()) + .map_err(|_| Error::Serialization("too many transaction records".to_owned()))?; + let mut payload = Vec::with_capacity(payload_len); + payload.extend_from_slice(&identity.source_id.to_le_bytes()); + payload.extend_from_slice(&identity.sequence.to_le_bytes()); + payload.extend_from_slice(&identity.commit_id.to_le_bytes()); + payload.extend_from_slice(&canonical); + let frame_header = encode_frame_header( + FRAME_KIND_INGRESS_TRANSACTION, + record_count, + payload_len_u32, + hash(&payload), + ); + let bytes_written = (FRAME_HEADER_BYTES + payload.len()) as u64; + let frame_offset = self.file.seek(SeekFrom::End(0))?; + let durable = self.write_frame(&frame_header, &payload, bytes_written)?; + + for record in &transaction.records { + if let Record::Points(points) = record { + for point in points { + insert_indexed_point(&mut self.index, *point); + } + } + } + if let Some(catalog) = candidate_catalog { + self.catalog = catalog; + } + self.commits += 1; + self.points += point_count as u64; + let metadata_records = transaction.record_count() + - transaction + .records + .iter() + .filter(|record| matches!(record, Record::Points(_))) + .count(); + self.catalog_records += metadata_records as u64; + + let commit = Commit { + frame_offset, + points: point_count, + records: transaction.record_count(), + bytes_written, + durable, + deduplicated: false, + }; + let receipt = StoredIngressReceipt { + identity, + canonical_payload_offset: frame_offset + + FRAME_HEADER_BYTES as u64 + + INGRESS_IDENTITY_BYTES as u64, + canonical_payload_len: canonical_len_u32, + canonical_payload_crc32: hash(&canonical), + compact_payload: None, + commit, + }; + self.commit_ids.insert(identity.commit_id); + self.ingress_receipts.insert(key, receipt); + self.ingress_commit_ids.insert(identity.commit_id, key); + self.ingress_last_sequences + .insert(identity.source_id, identity.sequence); + if durable { + self.ingress_durable_sequences + .insert(identity.source_id, identity.sequence); + } + Ok(commit) + } + + fn identified_payload_matches( + &mut self, + receipt: StoredIdentifiedReceipt, + candidate: &[u8], + ) -> Result { + let result = self.identified_payload_matches_at(receipt, candidate); + if matches!(result, Err(Error::Io(_) | Error::Corruption { .. })) { + self.poisoned = true; + } + result + } + + fn identified_payload_matches_at( + &self, + receipt: StoredIdentifiedReceipt, + candidate: &[u8], + ) -> Result { + if candidate.len() != receipt.payload_len as usize + || hash(candidate) != receipt.payload_crc32 + { + return Ok(false); + } + if receipt.payload_offset == 0 { + // An old compact index may lack exact bytes. Treat it as a known + // identifier that conflicts on retry; CRC equality alone cannot + // prove that the transaction is the same. + return Ok(receipt.compact_payload.as_deref() == Some(candidate)); + } + let expected_payload_offset = receipt + .commit + .frame_offset + .checked_add(FRAME_HEADER_BYTES as u64) + .ok_or_else(|| Error::Serialization("identified replay offset overflows".to_owned()))?; + if receipt.payload_offset != expected_payload_offset { + return corruption( + receipt.commit.frame_offset, + "stored identified receipt offset is invalid", + ); + } + let record_count = u32::try_from(receipt.commit.records).map_err(|_| { + Error::Serialization("identified replay record count overflows".to_owned()) + })?; + let expected_header = encode_frame_header( + FRAME_KIND_IDENTIFIED_TRANSACTION, + record_count, + receipt.payload_len, + receipt.payload_crc32, + ); + let mut stored_header = [0_u8; FRAME_HEADER_BYTES]; + self.file + .read_exact_at(&mut stored_header, receipt.commit.frame_offset)?; + if stored_header != expected_header { + return corruption( + receipt.commit.frame_offset, + "stored identified frame header changed after open", + ); + } + + let mut stored = [0_u8; 8 * 1024]; + let mut candidate_offset = 0_usize; + while candidate_offset < candidate.len() { + let chunk_len = stored.len().min(candidate.len() - candidate_offset); + let file_offset = receipt + .payload_offset + .checked_add(u64::try_from(candidate_offset).map_err(|_| { + Error::Serialization("identified replay offset overflows".to_owned()) + })?) + .ok_or_else(|| { + Error::Serialization("identified replay offset overflows".to_owned()) + })?; + self.file + .read_exact_at(&mut stored[..chunk_len], file_offset)?; + if stored[..chunk_len] != candidate[candidate_offset..candidate_offset + chunk_len] { + return corruption( + receipt.commit.frame_offset, + "stored identified payload changed after open", + ); + } + candidate_offset += chunk_len; + } + Ok(true) + } + + fn ingress_payload_matches( + &mut self, + receipt: StoredIngressReceipt, + candidate: &[u8], + ) -> Result { + let result = self.ingress_payload_matches_at(receipt, candidate); + if matches!(result, Err(Error::Io(_) | Error::Corruption { .. })) { + // The in-memory catalog and index may no longer match the log. + // Stop all later writes until a fresh scan proves a sound prefix. + self.poisoned = true; + } + result + } + + fn ingress_payload_matches_at( + &self, + receipt: StoredIngressReceipt, + candidate: &[u8], + ) -> Result { + if candidate.len() != receipt.canonical_payload_len as usize + || hash(candidate) != receipt.canonical_payload_crc32 + { + return Ok(false); + } + if receipt.canonical_payload_offset == 0 { + return Ok(receipt.compact_payload.as_deref() == Some(candidate)); + } + let expected_canonical_offset = receipt + .commit + .frame_offset + .checked_add((FRAME_HEADER_BYTES + INGRESS_IDENTITY_BYTES) as u64) + .ok_or_else(|| Error::Serialization("ingress replay offset overflows".to_owned()))?; + if receipt.canonical_payload_offset != expected_canonical_offset { + return corruption( + receipt.commit.frame_offset, + "stored ingress receipt offset is invalid", + ); + } + let mut identity_bytes = [0_u8; INGRESS_IDENTITY_BYTES]; + identity_bytes[..16].copy_from_slice(&receipt.identity.source_id.to_le_bytes()); + identity_bytes[16..24].copy_from_slice(&receipt.identity.sequence.to_le_bytes()); + identity_bytes[24..40].copy_from_slice(&receipt.identity.commit_id.to_le_bytes()); + let mut expected_payload_checksum = crc32fast::Hasher::new(); + expected_payload_checksum.update(&identity_bytes); + expected_payload_checksum.update(candidate); + let payload_len = u32::try_from(INGRESS_IDENTITY_BYTES + candidate.len()) + .map_err(|_| Error::Serialization("ingress replay length overflows".to_owned()))?; + let record_count = u32::try_from(receipt.commit.records).map_err(|_| { + Error::Serialization("ingress replay record count overflows".to_owned()) + })?; + let expected_header = encode_frame_header( + FRAME_KIND_INGRESS_TRANSACTION, + record_count, + payload_len, + expected_payload_checksum.finalize(), + ); + + let mut stored_header = [0_u8; FRAME_HEADER_BYTES]; + self.file + .read_exact_at(&mut stored_header, receipt.commit.frame_offset)?; + if stored_header != expected_header { + return corruption( + receipt.commit.frame_offset, + "stored ingress frame header changed after open", + ); + } + + let identity_offset = receipt + .commit + .frame_offset + .checked_add(FRAME_HEADER_BYTES as u64) + .ok_or_else(|| Error::Serialization("ingress replay offset overflows".to_owned()))?; + let mut stored_identity = [0_u8; INGRESS_IDENTITY_BYTES]; + self.file + .read_exact_at(&mut stored_identity, identity_offset)?; + if stored_identity != identity_bytes { + return corruption( + receipt.commit.frame_offset, + "stored ingress identity changed after open", + ); + } + + let mut stored = [0_u8; 8 * 1024]; + let mut stored_checksum = crc32fast::Hasher::new(); + let mut equal = true; + let mut candidate_offset = 0_usize; + while candidate_offset < candidate.len() { + let chunk_len = stored.len().min(candidate.len() - candidate_offset); + let file_offset = receipt + .canonical_payload_offset + .checked_add(u64::try_from(candidate_offset).map_err(|_| { + Error::Serialization("ingress replay offset overflows".to_owned()) + })?) + .ok_or_else(|| { + Error::Serialization("ingress replay offset overflows".to_owned()) + })?; + self.file + .read_exact_at(&mut stored[..chunk_len], file_offset)?; + stored_checksum.update(&stored[..chunk_len]); + equal &= + stored[..chunk_len] == candidate[candidate_offset..candidate_offset + chunk_len]; + candidate_offset += chunk_len; + } + if stored_checksum.finalize() != receipt.canonical_payload_crc32 { + return corruption( + receipt.commit.frame_offset, + "stored ingress transaction checksum changed after open", + ); + } + Ok(equal) + } + + /// Checks one expected ingress transaction against its exact stored + /// canonical bytes without changing the file cursor or writing the store. + pub(crate) fn verify_ingress_payload( + &self, + identity: IngressIdentity, + transaction: &Transaction, + ) -> Result> { + let Some(receipt) = self + .ingress_receipts + .get(&IngressKey::from(identity)) + .cloned() + else { + return Ok(None); + }; + if receipt.identity != identity { + return Ok(Some(false)); + } + let canonical = encode_canonical_transaction(transaction)?; + self.ingress_payload_matches_at(receipt, &canonical) + .map(Some) + } + /// Whether a [`Transaction::with_commit_id`] identifier has been applied /// by this handle — recovered from the log on open or committed since. #[must_use] @@ -573,6 +1245,49 @@ impl Database { self.commit_ids.contains(&commit_id) } + /// Returns accepted and synced progress for one ordered source. + #[must_use] + pub fn ingress_watermarks(&self, source_id: u128) -> IngressWatermarks { + IngressWatermarks { + accepted_through: self.ingress_last_sequences.get(&source_id).copied(), + durable_through: self.ingress_durable_sequences.get(&source_id).copied(), + } + } + + /// Returns the stored identity and frame receipt for one source sequence. + /// + /// This does not reread the frame payload. Open-time recovery already + /// checked it; integrity checks and exact retry perform the stronger byte + /// validation when required. + #[must_use] + pub fn ingress_receipt(&self, source_id: u128, sequence: u64) -> Option { + let stored = self.ingress_receipts.get(&IngressKey { + source_id, + sequence, + })?; + Some(IngressReceipt { + identity: stored.identity, + frame_offset: stored.commit.frame_offset, + records: stored.commit.records, + points: stored.commit.points, + bytes_written: stored.commit.bytes_written, + durable: self + .ingress_durable_sequences + .get(&source_id) + .is_some_and(|durable| *durable >= sequence), + }) + } + + /// Returns every known ingress source in stable source-ID order. + #[must_use] + pub fn all_ingress_watermarks(&self) -> BTreeMap { + self.ingress_last_sequences + .keys() + .copied() + .map(|source_id| (source_id, self.ingress_watermarks(source_id))) + .collect() + } + fn write_frame( &mut self, frame_header: &[u8; FRAME_HEADER_BYTES], @@ -595,7 +1310,12 @@ impl Database { Ok(should_sync) })(); match write_result { - Ok(durable) => Ok(durable), + Ok(durable) => { + if durable { + self.mark_ingress_durable(); + } + Ok(durable) + } Err(error) => { self.poisoned = true; Err(error) @@ -603,6 +1323,11 @@ impl Database { } } + fn mark_ingress_durable(&mut self) { + self.ingress_durable_sequences + .clone_from(&self.ingress_last_sequences); + } + /// Makes all prior appends durable according to the operating system. pub fn flush(&mut self) -> Result<()> { if self.read_only { @@ -622,6 +1347,7 @@ impl Database { return Err(Error::Io(error)); } self.bytes_since_sync = 0; + self.mark_ingress_durable(); Ok(()) } @@ -645,53 +1371,140 @@ impl Database { } /// Returns every revision in deterministic temporal order. - #[must_use] - pub fn query_history(&self, series_id: u64, start: i64, end: i64) -> Vec { - let mut result: Vec<_> = self - .index - .get(&series_id) - .into_iter() - .flatten() - .filter(|point| point.valid_time >= start && point.valid_time < end) - .copied() - .collect(); + /// + /// Sealed raw segments are merged with the live tail. A sealed-segment + /// read error is fail-closed: the call returns [`Error::Corruption`] + /// rather than a silent partial history. + pub fn query_history(&self, series_id: u64, start: i64, end: i64) -> Result> { + let mut result = self.collect_raw_points(series_id, start, end)?; result.sort_by_key(|point| (point.valid_time, point.knowledge_time, point.change_time)); - result + Ok(result) } - /// Returns the winning revision for each valid timestamp. - #[must_use] - pub fn query_latest(&self, series_id: u64, start: i64, end: i64) -> Vec { - self.query_with_cutoffs(series_id, start, end, None, None) + /// Visits raw history without collecting or sorting the whole result. + /// The caller reserves scan work before each sealed block or live slice. + pub(crate) fn visit_history>( + &self, + series_id: u64, + start: i64, + end: i64, + mut reserve: impl FnMut(usize) -> std::result::Result<(), E>, + mut visit: impl FnMut(Point) -> std::result::Result<(), E>, + ) -> std::result::Result<(), E> { + for segment in &self.sealed { + segment.visit_query(series_id, start, end, &mut reserve, &mut visit)?; + } + let live = series_time_slice(self.series_points(series_id), start, end); + reserve(live.len())?; + for point in live { + visit(*point)?; + } + Ok(()) + } + + /// Live-index revisions only. Sealed history is not included. + pub(crate) fn series_points(&self, series_id: u64) -> &[Point] { + self.index.get(&series_id).map_or(&[], Vec::as_slice) } - /// Replays what was visible at one historical instant. #[must_use] - pub fn query_as_of(&self, series_id: u64, start: i64, end: i64, as_of: i64) -> Vec { - self.query_with_cutoffs(series_id, start, end, Some(as_of), Some(as_of)) + pub fn live_index_len(&self) -> usize { + self.index.values().map(Vec::len).sum() } - /// Returns the latest correction within one forecast/optimization run. #[must_use] - pub fn query_run(&self, series_id: u64, run_id: u128, start: i64, end: i64) -> Vec { - let mut winners = BTreeMap::::new(); - for point in self.index.get(&series_id).into_iter().flatten() { - if point.run_id != run_id || point.valid_time < start || point.valid_time >= end { - continue; + pub const fn sealed_point_count(&self) -> u64 { + self.sealed_points + } + + #[must_use] + pub(crate) const fn pending_reclaim(&self) -> bool { + self.pending_reclaim + } + + pub(crate) fn series_revision_count(&self, series_id: u64) -> usize { + let live = self.series_points(series_id).len(); + let sealed = self + .sealed + .iter() + .map(|segment| segment.series_point_count(series_id) as usize) + .sum::(); + live.saturating_add(sealed) + } + + pub(crate) fn series_valid_bounds(&self, series_id: u64) -> Option<(i64, i64)> { + let mut min_time = i64::MAX; + let mut max_time = i64::MIN; + if let Some((first, last)) = self + .series_points(series_id) + .first() + .zip(self.series_points(series_id).last()) + { + min_time = min_time.min(first.valid_time); + max_time = max_time.max(last.valid_time); + } + for segment in &self.sealed { + if let Some((start, end)) = segment.series_bounds(series_id) { + min_time = min_time.min(start); + max_time = max_time.max(end); } - match winners.get(&point.valid_time) { - Some(current) if current.change_time > point.change_time => {} - _ => { - winners.insert(point.valid_time, *point); - } + } + (min_time <= max_time).then_some((min_time, max_time)) + } + + fn collect_raw_points(&self, series_id: u64, start: i64, end: i64) -> Result> { + // Segment order follows seal order. Read those older revisions before + // the live tail so the stable sort below keeps append order when two + // revisions have identical bitemporal keys. `winning_revisions` uses + // the later item as the tie-breaker, matching the documented + // `(knowledge_time, change_time, append_order)` rule. + let mut points = Vec::new(); + for segment in &self.sealed { + if !segment.overlaps(series_id, start, end) { + continue; } + points.extend(segment.query(series_id, start, end)?); } - winners.into_values().collect() + points.extend_from_slice(series_time_slice(self.series_points(series_id), start, end)); + points.sort_by_key(|point| (point.valid_time, point.knowledge_time, point.change_time)); + Ok(points) + } + + /// Returns the winning revision for each valid timestamp. + pub fn query_latest(&self, series_id: u64, start: i64, end: i64) -> Result> { + self.query_with_cutoffs(series_id, start, end, None, None) + } + + /// Replays what was visible at one historical instant. + pub fn query_as_of( + &self, + series_id: u64, + start: i64, + end: i64, + as_of: i64, + ) -> Result> { + self.query_with_cutoffs(series_id, start, end, Some(as_of), Some(as_of)) + } + + /// Returns the latest correction within one forecast/optimization run. + pub fn query_run( + &self, + series_id: u64, + run_id: u128, + start: i64, + end: i64, + ) -> Result> { + Ok(winning_revisions( + &self.collect_raw_points(series_id, start, end)?, + start, + end, + |point| point.run_id == run_id, + |candidate, current| candidate.change_time >= current.change_time, + )) } /// Exact-time plan-versus-actual alignment. Higher-level resampling uses /// persistent rollups before calling this primitive. - #[must_use] pub fn compare_plan_to_actual( &self, planned_series_id: u64, @@ -699,15 +1512,15 @@ impl Database { run_id: u128, start: i64, end: i64, - ) -> Vec { + ) -> Result> { let mut aligned = BTreeMap::, Option)>::new(); - for point in self.query_run(planned_series_id, run_id, start, end) { + for point in self.query_run(planned_series_id, run_id, start, end)? { aligned.entry(point.valid_time).or_default().0 = Some(point); } - for point in self.query_run(actual_series_id, 0, start, end) { + for point in self.query_run(actual_series_id, 0, start, end)? { aligned.entry(point.valid_time).or_default().1 = Some(point); } - aligned + Ok(aligned .into_iter() .map(|(valid_time, (planned, actual))| PlanOutcome { valid_time, @@ -717,12 +1530,11 @@ impl Database { planned, actual, }) - .collect() + .collect()) } /// Separates forecast issue-time and correction-time cutoffs for strict /// point-in-time backtests. - #[must_use] pub fn query_with_cutoffs( &self, series_id: u64, @@ -730,27 +1542,20 @@ impl Database { end: i64, maximum_knowledge_time: Option, maximum_change_time: Option, - ) -> Vec { - let mut winners = BTreeMap::::new(); - for point in self.index.get(&series_id).into_iter().flatten() { - if point.valid_time < start || point.valid_time >= end { - continue; - } - if maximum_knowledge_time.is_some_and(|cutoff| point.knowledge_time > cutoff) - || maximum_change_time.is_some_and(|cutoff| point.change_time > cutoff) - { - continue; - } - - let candidate_key = (point.knowledge_time, point.change_time); - match winners.get(&point.valid_time) { - Some(current) if (current.knowledge_time, current.change_time) > candidate_key => {} - _ => { - winners.insert(point.valid_time, *point); - } - } - } - winners.into_values().collect() + ) -> Result> { + Ok(winning_revisions( + &self.collect_raw_points(series_id, start, end)?, + start, + end, + |point| { + maximum_knowledge_time.is_none_or(|cutoff| point.knowledge_time <= cutoff) + && maximum_change_time.is_none_or(|cutoff| point.change_time <= cutoff) + }, + |candidate, current| { + (candidate.knowledge_time, candidate.change_time) + >= (current.knowledge_time, current.change_time) + }, + )) } /// Materializes fixed UTC gauge buckets from the winning revisions in a @@ -769,7 +1574,7 @@ impl Database { max_gap_micros: i64, ) -> Result { FixedGaugeRollup::build( - &self.query_latest(series_id, start, end), + &self.query_latest(series_id, start, end)?, resolution_micros, max_gap_micros, ) @@ -787,6 +1592,211 @@ impl Database { Segment::create(path, &points, block_points) } + pub(crate) fn attach_sealed_segments(&mut self, segments: Vec) { + self.sealed_points = segments.iter().map(|segment| segment.stats().points).sum(); + self.sealed = segments; + } + + pub(crate) fn live_points_snapshot(&self) -> Vec { + self.index.values().flatten().copied().collect() + } + + pub(crate) fn clear_live_index(&mut self) { + self.index.clear(); + self.points = 0; + } + + pub(crate) fn write_seal_checkpoint(&mut self, generation: u64, points: u64) -> Result<()> { + if self.read_only { + return Err(Error::ReadOnly); + } + if self.poisoned { + return Err(Error::Poisoned); + } + let mut payload = [0_u8; SEAL_CHECKPOINT_BYTES]; + payload[..8].copy_from_slice(&generation.to_le_bytes()); + payload[8..16].copy_from_slice(&points.to_le_bytes()); + let payload_len = u32::try_from(payload.len()) + .map_err(|_| Error::Serialization("seal checkpoint exceeds u32 length".to_owned()))?; + let header = + encode_frame_header(FRAME_KIND_SEAL_CHECKPOINT, 0, payload_len, hash(&payload)); + let bytes_written = (FRAME_HEADER_BYTES + payload.len()) as u64; + self.file.seek(SeekFrom::End(0))?; + self.write_frame(&header, &payload, bytes_written)?; + self.flush()?; + self.commits += 1; + Ok(()) + } + + fn compact_identity_index(&self) -> Result { + let mut identified = Vec::with_capacity(self.identified_receipts.len()); + for (commit_id, receipt) in &self.identified_receipts { + let payload = if receipt.payload_offset == 0 { + receipt + .compact_payload + .as_deref() + .map(<[u8]>::to_vec) + .unwrap_or_default() + } else { + let mut payload = vec![0_u8; receipt.payload_len as usize]; + self.file + .read_exact_at(&mut payload, receipt.payload_offset)?; + if !self.identified_payload_matches_at(receipt.clone(), &payload)? { + return corruption( + receipt.commit.frame_offset, + "stored identified payload changed before reclaim", + ); + } + payload + }; + identified.push(CompactIdentifiedReceipt { + commit_id: *commit_id, + payload_len: receipt.payload_len, + payload_crc32: receipt.payload_crc32, + points: receipt.commit.points as u64, + records: receipt.commit.records as u64, + payload, + }); + } + identified.sort_by_key(|receipt| receipt.commit_id); + + let mut ingress = Vec::with_capacity(self.ingress_receipts.len()); + for receipt in self.ingress_receipts.values() { + let canonical_payload = if receipt.canonical_payload_offset == 0 { + receipt + .compact_payload + .as_deref() + .map(<[u8]>::to_vec) + .unwrap_or_default() + } else { + let mut payload = vec![0_u8; receipt.canonical_payload_len as usize]; + self.file + .read_exact_at(&mut payload, receipt.canonical_payload_offset)?; + if !self.ingress_payload_matches_at(receipt.clone(), &payload)? { + return corruption( + receipt.commit.frame_offset, + "stored ingress payload changed before reclaim", + ); + } + payload + }; + ingress.push(CompactIngressReceipt { + source_id: receipt.identity.source_id, + sequence: receipt.identity.sequence, + commit_id: receipt.identity.commit_id, + canonical_payload_len: receipt.canonical_payload_len, + canonical_payload_crc32: receipt.canonical_payload_crc32, + points: receipt.commit.points as u64, + records: receipt.commit.records as u64, + canonical_payload, + frame_offset: receipt.commit.frame_offset, + bytes_written: receipt.commit.bytes_written, + }); + } + // Recovery advances one cursor per source while it reads this list. + // HashMap iteration has no stable order, so sort before serializing. + ingress.sort_by_key(|receipt| (receipt.source_id, receipt.sequence)); + + Ok(CompactIdentityIndex { + identified, + ingress, + }) + } + + /// Rewrites `active.wlog` to catalog + identity receipts + the live tail. + /// + /// The exclusive lock moves with the new inode: the compact file is locked + /// before it is renamed over the live name, so a concurrent opener never + /// sees an unlocked `active.wlog`. + pub(crate) fn reclaim_live_log(&mut self) -> Result<()> { + if self.read_only { + return Err(Error::ReadOnly); + } + if self.poisoned { + return Err(Error::Poisoned); + } + self.flush()?; + let identity_index = self.compact_identity_index()?; + let compact_path = compact_log_path(&self.path)?; + if let Err(error) = write_compact_log( + &compact_path, + &self.catalog, + &identity_index, + &self.index, + self.config.max_batch_points, + self.config.max_transaction_bytes, + ) { + let _ = std::fs::remove_file(&compact_path); + return Err(error); + } + + let mut new_file = open_regular_file_read_write(&compact_path)?; + match new_file.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => { + let _ = std::fs::remove_file(&compact_path); + return Err(Error::Locked { path: compact_path }); + } + Err(TryLockError::Error(error)) => { + let _ = std::fs::remove_file(&compact_path); + return Err(Error::Io(error)); + } + } + let mut scan = match scan_and_recover( + &mut new_file, + self.config.max_batch_points, + self.config.max_transaction_bytes, + false, + &HashSet::new(), + ) { + Ok(scan) => scan, + Err(error) => { + drop(new_file); + let _ = std::fs::remove_file(&compact_path); + return Err(error); + } + }; + if let Err(error) = sync_database_file(&new_file) { + self.poisoned = true; + drop(new_file); + let _ = std::fs::remove_file(&compact_path); + return Err(Error::Io(error)); + } + for receipt in scan.ingress_receipts.values_mut() { + receipt.commit.durable = true; + } + + if let Err(error) = std::fs::rename(&compact_path, &self.path) { + self.poisoned = true; + drop(new_file); + let _ = std::fs::remove_file(&compact_path); + return Err(Error::Io(error)); + } + if let Err(error) = sync_parent_directory(&self.path) { + self.poisoned = true; + return Err(error); + } + + let old = std::mem::replace(&mut self.file, new_file); + drop(old); + self.index = scan.index; + self.catalog = scan.catalog; + self.commit_ids = scan.commit_ids; + self.identified_receipts = scan.identified_receipts; + self.ingress_receipts = scan.ingress_receipts; + self.ingress_commit_ids = scan.ingress_commit_ids; + self.ingress_last_sequences = scan.ingress_last_sequences; + self.ingress_durable_sequences = self.ingress_last_sequences.clone(); + self.commits = scan.commits; + self.points = scan.points; + self.catalog_records = scan.catalog_records; + self.recovered_tail_bytes = 0; + self.recovered_tail = RecoveredTail::None; + self.bytes_since_sync = 0; + self.pending_reclaim = false; + Ok(()) + } + pub fn stats(&self) -> Result { // A read-only open leaves a torn tail on disk, so its physical length // is reduced by the simulated truncation to report the same logical @@ -798,26 +1808,47 @@ impl Database { physical_bytes }; Ok(Stats { - points: self.points, + points: self.points.saturating_add(self.sealed_points), commits: self.commits, - series: self.index.len(), + series: { + let mut ids: HashSet = self.index.keys().copied().collect(); + for segment in &self.sealed { + ids.extend(segment.series_ids()); + } + ids.len() + }, catalog_records: self.catalog_records, file_bytes, recovered_tail_bytes: self.recovered_tail_bytes, recovered_tail: self.recovered_tail, }) } + + #[must_use] + pub const fn durability(&self) -> Durability { + self.config.durability + } } fn validate_config(config: Config) -> Result<()> { if config.max_batch_points == 0 { return Err(Error::InvalidConfig("max_batch_points must be positive")); } + if config.max_batch_points > u32::MAX as usize { + return Err(Error::InvalidConfig( + "max_batch_points exceeds the on-disk u32 count", + )); + } if config.max_transaction_bytes < TRANSACTION_HEADER_BYTES + RECORD_HEADER_BYTES { return Err(Error::InvalidConfig( "max_transaction_bytes is too small for one record", )); } + if config.max_transaction_bytes > u32::MAX as usize { + return Err(Error::InvalidConfig( + "max_transaction_bytes exceeds the on-disk u32 length", + )); + } if matches!(config.durability, Durability::EveryBytes(0)) { return Err(Error::InvalidConfig( "EveryBytes durability threshold must be positive", @@ -826,6 +1857,391 @@ fn validate_config(config: Config) -> Result<()> { Ok(()) } +fn insert_indexed_point(index: &mut HashMap>, point: Point) { + let series = index.entry(point.series_id).or_default(); + match series.last() { + Some(last) if last.valid_time > point.valid_time => { + let at = series.partition_point(|existing| existing.valid_time <= point.valid_time); + series.insert(at, point); + } + _ => series.push(point), + } +} + +fn series_time_slice(points: &[Point], start: i64, end: i64) -> &[Point] { + let lo = points.partition_point(|point| point.valid_time < start); + let hi = lo + points[lo..].partition_point(|point| point.valid_time < end); + &points[lo..hi] +} + +fn winning_revisions( + points: &[Point], + start: i64, + end: i64, + keep: impl Fn(&Point) -> bool, + prefer: impl Fn(&Point, &Point) -> bool, +) -> Vec { + let mut winners: Vec = Vec::new(); + for point in series_time_slice(points, start, end) { + if !keep(point) { + continue; + } + match winners.last_mut() { + Some(current) if current.valid_time == point.valid_time => { + if prefer(point, current) { + *current = *point; + } + } + _ => winners.push(*point), + } + } + winners +} + +fn compact_log_path(active: &Path) -> Result { + let parent = parent_directory(active); + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Ok(parent.join(format!( + ".active.wlog.reclaim-{}-{nonce}", + std::process::id() + ))) +} + +fn write_compact_log( + path: &Path, + catalog: &Catalog, + identity_index: &CompactIdentityIndex, + live_index: &HashMap>, + max_batch_points: usize, + max_transaction_bytes: usize, +) -> Result<()> { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(path)?; + write_database_header(&mut file)?; + + let catalog_records = catalog.snapshot_records()?; + if !catalog_records.is_empty() { + let mut transaction = Transaction::new(); + transaction.records = catalog_records; + write_standalone_transaction(&mut file, &transaction, max_transaction_bytes)?; + } + + if !identity_index.identified.is_empty() || !identity_index.ingress.is_empty() { + write_identity_index_frames(&mut file, identity_index, max_transaction_bytes)?; + } + + let tail: Vec = live_index.values().flatten().copied().collect(); + if tail.len() > max_batch_points { + return Err(Error::BatchTooLarge { + points: tail.len(), + maximum: max_batch_points, + }); + } + if !tail.is_empty() { + let mut transaction = Transaction::new(); + transaction.records.push(Record::Points(tail)); + write_standalone_transaction(&mut file, &transaction, max_transaction_bytes)?; + } + + file.sync_all()?; + Ok(()) +} + +fn identity_index_frame_limit(max_transaction_bytes: usize) -> usize { + max_transaction_bytes + .saturating_add(IDENTITY_INDEX_ENTRY_OVERHEAD_BYTES) + .min(u32::MAX as usize) +} + +fn write_identity_index_frames( + file: &mut File, + index: &CompactIdentityIndex, + max_transaction_bytes: usize, +) -> Result<()> { + let limit = identity_index_frame_limit(max_transaction_bytes); + let mut chunk = CompactIdentityIndex::default(); + let mut estimated_bytes = 0_usize; + + for receipt in &index.identified { + let singleton = CompactIdentityIndex { + identified: vec![receipt.clone()], + ingress: Vec::new(), + }; + let bytes = encoded_identity_index(&singleton)?.len(); + if bytes > limit { + return Err(Error::Serialization( + "one identified receipt exceeds the identity-index frame limit".to_owned(), + )); + } + if estimated_bytes > 0 && estimated_bytes.saturating_add(bytes) > limit { + write_identity_index_frame(file, &chunk, limit)?; + chunk = CompactIdentityIndex::default(); + estimated_bytes = 0; + } + chunk.identified.push(receipt.clone()); + estimated_bytes = estimated_bytes.saturating_add(bytes); + } + for receipt in &index.ingress { + let singleton = CompactIdentityIndex { + identified: Vec::new(), + ingress: vec![receipt.clone()], + }; + let bytes = encoded_identity_index(&singleton)?.len(); + if bytes > limit { + return Err(Error::Serialization( + "one ingress receipt exceeds the identity-index frame limit".to_owned(), + )); + } + if estimated_bytes > 0 && estimated_bytes.saturating_add(bytes) > limit { + write_identity_index_frame(file, &chunk, limit)?; + chunk = CompactIdentityIndex::default(); + estimated_bytes = 0; + } + chunk.ingress.push(receipt.clone()); + estimated_bytes = estimated_bytes.saturating_add(bytes); + } + if !chunk.identified.is_empty() || !chunk.ingress.is_empty() { + write_identity_index_frame(file, &chunk, limit)?; + } + Ok(()) +} + +fn encoded_identity_index(index: &CompactIdentityIndex) -> Result> { + let encoded = postcard::to_stdvec(index) + .map_err(|error| Error::Serialization(format!("identity index encode failed: {error}")))?; + let mut payload = Vec::with_capacity(IDENTITY_INDEX_MAGIC_V2.len() + encoded.len()); + payload.extend_from_slice(IDENTITY_INDEX_MAGIC_V2); + payload.extend_from_slice(&encoded); + Ok(payload) +} + +fn write_identity_index_frame( + file: &mut File, + index: &CompactIdentityIndex, + limit: usize, +) -> Result<()> { + let payload = encoded_identity_index(index)?; + if payload.len() > limit { + return Err(Error::Serialization( + "identity index chunk exceeds its frame limit".to_owned(), + )); + } + let payload_len = u32::try_from(payload.len()) + .map_err(|_| Error::Serialization("identity index exceeds u32 length".to_owned()))?; + let header = encode_frame_header(FRAME_KIND_IDENTITY_INDEX, 0, payload_len, hash(&payload)); + file.write_all(&header)?; + file.write_all(&payload)?; + Ok(()) +} + +fn write_standalone_transaction( + file: &mut File, + transaction: &Transaction, + max_transaction_bytes: usize, +) -> Result<()> { + let payload = encode_transaction(transaction)?; + if payload.len() > max_transaction_bytes { + return Err(Error::InvalidModel(format!( + "transaction has {} encoded bytes; maximum is {max_transaction_bytes}", + payload.len() + ))); + } + let payload_len = u32::try_from(payload.len()) + .map_err(|_| Error::Serialization("transaction exceeds u32 length".to_owned()))?; + let record_count = u32::try_from(transaction.record_count()) + .map_err(|_| Error::Serialization("too many transaction records".to_owned()))?; + let header = encode_frame_header( + FRAME_KIND_TRANSACTION, + record_count, + payload_len, + hash(&payload), + ); + file.write_all(&header)?; + file.write_all(&payload)?; + Ok(()) +} + +fn apply_identity_index( + payload: &[u8], + offset: u64, + commit_ids: &mut HashSet, + identified_receipts: &mut HashMap, + ingress_receipts: &mut HashMap, + ingress_commit_ids: &mut HashMap, + ingress_last_sequences: &mut HashMap, +) -> Result<()> { + let index = decode_identity_index(payload, offset)?; + for receipt in index.identified { + if receipt.payload_len < (COMMIT_ID_BYTES + TRANSACTION_HEADER_BYTES) as u32 { + return corruption(offset, "identified receipt payload is too short"); + } + let points = usize::try_from(receipt.points).map_err(|_| Error::Corruption { + offset, + reason: "identity index point count overflows usize".to_owned(), + })?; + let records = usize::try_from(receipt.records).map_err(|_| Error::Corruption { + offset, + reason: "identity index record count overflows usize".to_owned(), + })?; + let compact_payload = if receipt.payload.is_empty() { + None + } else { + if receipt.payload.len() != receipt.payload_len as usize + || hash(&receipt.payload) != receipt.payload_crc32 + || receipt.payload.len() < COMMIT_ID_BYTES + || u128::from_le_bytes(receipt.payload[..COMMIT_ID_BYTES].try_into().unwrap()) + != receipt.commit_id + { + return corruption(offset, "identified receipt bytes do not match their index"); + } + validate_compact_transaction( + &receipt.payload[COMMIT_ID_BYTES..], + records, + points, + offset, + )?; + Some(Arc::<[u8]>::from(receipt.payload)) + }; + if !commit_ids.insert(receipt.commit_id) { + return corruption(offset, "duplicate commit identifier"); + } + identified_receipts.insert( + receipt.commit_id, + StoredIdentifiedReceipt { + payload_offset: 0, + payload_len: receipt.payload_len, + payload_crc32: receipt.payload_crc32, + compact_payload, + commit: Commit { + frame_offset: offset, + points, + records, + bytes_written: 0, + durable: false, + deduplicated: false, + }, + }, + ); + } + for receipt in index.ingress { + if receipt.canonical_payload_len < TRANSACTION_HEADER_BYTES as u32 { + return corruption(offset, "ingress receipt payload is too short"); + } + let points = usize::try_from(receipt.points).map_err(|_| Error::Corruption { + offset, + reason: "identity index point count overflows usize".to_owned(), + })?; + let records = usize::try_from(receipt.records).map_err(|_| Error::Corruption { + offset, + reason: "identity index record count overflows usize".to_owned(), + })?; + let compact_payload = if receipt.canonical_payload.is_empty() { + None + } else { + if receipt.canonical_payload.len() != receipt.canonical_payload_len as usize + || hash(&receipt.canonical_payload) != receipt.canonical_payload_crc32 + { + return corruption(offset, "ingress receipt bytes do not match their index"); + } + validate_compact_transaction(&receipt.canonical_payload, records, points, offset)?; + Some(Arc::<[u8]>::from(receipt.canonical_payload)) + }; + let identity = IngressIdentity { + source_id: receipt.source_id, + sequence: receipt.sequence, + commit_id: receipt.commit_id, + }; + if identity.source_id == 0 { + return corruption(offset, "ingress source id zero is reserved"); + } + let key = IngressKey::from(identity); + if ingress_receipts.contains_key(&key) { + return corruption(offset, "duplicate ingress source sequence"); + } + if !commit_ids.insert(identity.commit_id) { + return corruption(offset, "duplicate commit identifier"); + } + if let Some(last) = ingress_last_sequences.get(&identity.source_id).copied() + && identity.sequence <= last + { + return corruption(offset, "ingress source cursor is not strictly increasing"); + } + ingress_receipts.insert( + key, + StoredIngressReceipt { + identity, + canonical_payload_offset: 0, + canonical_payload_len: receipt.canonical_payload_len, + canonical_payload_crc32: receipt.canonical_payload_crc32, + compact_payload, + commit: Commit { + frame_offset: receipt.frame_offset, + points, + records, + bytes_written: receipt.bytes_written, + durable: false, + deduplicated: false, + }, + }, + ); + ingress_commit_ids.insert(identity.commit_id, key); + ingress_last_sequences.insert(identity.source_id, identity.sequence); + } + Ok(()) +} + +fn decode_identity_index(payload: &[u8], offset: u64) -> Result { + if let Some(encoded) = payload.strip_prefix(IDENTITY_INDEX_MAGIC_V2) { + return postcard::from_bytes(encoded).map_err(|error| Error::Corruption { + offset, + reason: format!("identity index v2 decode failed: {error}"), + }); + } + postcard::from_bytes::(payload) + .map(Into::into) + .map_err(|error| Error::Corruption { + offset, + reason: format!("legacy identity index decode failed: {error}"), + }) +} + +fn validate_compact_transaction( + payload: &[u8], + expected_records: usize, + expected_points: usize, + offset: u64, +) -> Result<()> { + let records = decode_transaction(payload, expected_records, offset)?; + let points = records + .iter() + .map(|record| match record { + Record::Points(points) => points.len(), + _ => 0, + }) + .try_fold(0_usize, usize::checked_add) + .ok_or_else(|| Error::Corruption { + offset, + reason: "identity index point count overflows".to_owned(), + })?; + if points != expected_points { + return corruption(offset, "identity index point count does not match payload"); + } + for record in &records { + if let Record::Points(points) = record + && crate::catalog::validate_point_intervals(points).is_err() + { + return corruption(offset, "identity index contains invalid point values"); + } + } + Ok(()) +} + fn write_database_header(file: &mut File) -> Result<()> { let mut header = [0_u8; DATABASE_HEADER_BYTES]; header[..8].copy_from_slice(DATABASE_MAGIC); @@ -843,6 +2259,10 @@ struct Scan { index: HashMap>, catalog: Catalog, commit_ids: HashSet, + identified_receipts: HashMap, + ingress_receipts: HashMap, + ingress_commit_ids: HashMap, + ingress_last_sequences: HashMap, commits: u64, points: u64, catalog_records: u64, @@ -850,6 +2270,7 @@ struct Scan { recovered_tail: RecoveredTail, validated_bytes: u64, salvage_stop_reason: Option, + pending_reclaim: bool, } enum ScanMode { @@ -865,6 +2286,7 @@ fn scan_and_recover( max_batch_points: usize, max_transaction_bytes: usize, simulate_recovery: bool, + published_seals: &HashSet, ) -> Result { scan_log( file, @@ -873,6 +2295,7 @@ fn scan_and_recover( ScanMode::Recover { simulate: simulate_recovery, }, + published_seals, ) } @@ -881,10 +2304,13 @@ fn scan_log( max_batch_points: usize, max_transaction_bytes: usize, mode: ScanMode, + published_seals: &HashSet, ) -> Result { - let mut database_header = [0_u8; DATABASE_HEADER_BYTES]; + let original_len = file.metadata()?.len(); file.seek(SeekFrom::Start(0))?; - file.read_exact(&mut database_header)?; + let mut reader = BufReader::with_capacity(1024 * 1024, file); + let mut database_header = [0_u8; DATABASE_HEADER_BYTES]; + reader.read_exact(&mut database_header)?; if &database_header[..8] != DATABASE_MAGIC { return Err(Error::InvalidHeader); } @@ -893,21 +2319,26 @@ fn scan_log( return Err(Error::UnsupportedVersion(version)); } let expected_checksum = u32::from_le_bytes(database_header[12..16].try_into().unwrap()); - if hash(&database_header[..12]) != expected_checksum { + if hash(&database_header[..12]) != expected_checksum || database_header[10..12] != [0, 0] { return Err(Error::InvalidHeader); } - let original_len = file.metadata()?.len(); let mut offset = DATABASE_HEADER_BYTES as u64; let mut index = HashMap::>::new(); let mut catalog = Catalog::default(); let mut commit_ids = HashSet::::new(); + let mut identified_receipts = HashMap::::new(); + let mut ingress_receipts = HashMap::::new(); + let mut ingress_commit_ids = HashMap::::new(); + let mut ingress_last_sequences = HashMap::::new(); let mut commits = 0_u64; let mut points = 0_u64; let mut catalog_records = 0_u64; let mut recovered_tail_bytes = 0_u64; let mut recovered_tail = RecoveredTail::None; let mut salvage_stop_reason = None; + let mut pending_reclaim = false; + let mut payload = Vec::new(); macro_rules! stop_or_corruption { ($reason:expr, $message:expr) => { @@ -926,7 +2357,8 @@ fn scan_log( recovered_tail_bytes = remaining; recovered_tail = RecoveredTail::IncompleteHeader; if !simulate { - truncate_recovered_tail(file, offset)?; + reader.seek(SeekFrom::Start(offset))?; + truncate_recovered_tail(reader.get_mut(), offset)?; } } else { salvage_stop_reason = Some(SalvageStopReason::IncompleteFrameHeader); @@ -935,8 +2367,7 @@ fn scan_log( } let mut frame_header = [0_u8; FRAME_HEADER_BYTES]; - file.seek(SeekFrom::Start(offset))?; - file.read_exact(&mut frame_header)?; + reader.read_exact(&mut frame_header)?; if &frame_header[..4] != FRAME_MAGIC { stop_or_corruption!(SalvageStopReason::InvalidFrameMagic, "invalid frame magic"); } @@ -974,6 +2405,7 @@ fn scan_log( } } else if frame_kind == FRAME_KIND_TRANSACTION || frame_kind == FRAME_KIND_IDENTIFIED_TRANSACTION + || frame_kind == FRAME_KIND_INGRESS_TRANSACTION { if payload_len > max_transaction_bytes { stop_or_corruption!( @@ -987,6 +2419,33 @@ fn scan_log( "identified transaction frame is too short" ); } + if frame_kind == FRAME_KIND_INGRESS_TRANSACTION && payload_len < INGRESS_IDENTITY_BYTES + { + stop_or_corruption!( + SalvageStopReason::IngressTransactionTooShort, + "ingress transaction frame is too short" + ); + } + } else if frame_kind == FRAME_KIND_SEAL_CHECKPOINT { + if item_count != 0 || payload_len != SEAL_CHECKPOINT_BYTES { + stop_or_corruption!( + SalvageStopReason::SealCheckpointInvalid, + "seal checkpoint header has an item count or wrong payload length" + ); + } + } else if frame_kind == FRAME_KIND_IDENTITY_INDEX { + if item_count != 0 { + stop_or_corruption!( + SalvageStopReason::IdentityIndexInvalid, + "identity index header has an item count" + ); + } + if payload_len > identity_index_frame_limit(max_transaction_bytes) { + stop_or_corruption!( + SalvageStopReason::TransactionFrameTooLarge, + "identity index frame exceeds configured maximum" + ); + } } else { stop_or_corruption!(SalvageStopReason::UnknownFrameKind, "unknown frame kind"); } @@ -997,7 +2456,8 @@ fn scan_log( recovered_tail_bytes = remaining; recovered_tail = RecoveredTail::IncompletePayload; if !simulate { - truncate_recovered_tail(file, offset)?; + reader.seek(SeekFrom::Start(offset))?; + truncate_recovered_tail(reader.get_mut(), offset)?; } } else { salvage_stop_reason = Some(SalvageStopReason::IncompleteFramePayload); @@ -1005,8 +2465,9 @@ fn scan_log( break; } - let mut payload = vec![0_u8; payload_len]; - file.read_exact(&mut payload)?; + payload.clear(); + payload.resize(payload_len, 0); + reader.read_exact(&mut payload)?; if hash(&payload) != payload_checksum { let reason = if remaining == frame_len { "payload checksum mismatch in complete final frame" @@ -1017,12 +2478,55 @@ fn scan_log( } if frame_kind == FRAME_KIND_LEGACY_POINTS { - for raw in payload.chunks_exact(POINT_BYTES) { - let point = decode_point(raw); + let recovered: Vec<_> = payload + .chunks_exact(POINT_BYTES) + .map(decode_point) + .collect(); + if crate::catalog::validate_point_intervals(&recovered).is_err() { + stop_or_corruption!( + SalvageStopReason::InvalidLegacyPoint, + "legacy point frame violates point invariants" + ); + } + for point in recovered { index.entry(point.series_id).or_default().push(point); } points += item_count as u64; + } else if frame_kind == FRAME_KIND_SEAL_CHECKPOINT { + let generation = u64::from_le_bytes(payload[..8].try_into().unwrap()); + let sealed_points = u64::from_le_bytes(payload[8..16].try_into().unwrap()); + if generation == 0 || sealed_points != points { + stop_or_corruption!( + SalvageStopReason::SealCheckpointInvalid, + "seal checkpoint generation or point count is invalid" + ); + } + if published_seals.contains(&generation) { + index.clear(); + points = 0; + pending_reclaim = true; + } + } else if frame_kind == FRAME_KIND_IDENTITY_INDEX { + match apply_identity_index( + &payload, + offset, + &mut commit_ids, + &mut identified_receipts, + &mut ingress_receipts, + &mut ingress_commit_ids, + &mut ingress_last_sequences, + ) { + Ok(()) => {} + Err(error) if matches!(mode, ScanMode::Salvage) => { + let _ = error; + salvage_stop_reason = Some(SalvageStopReason::IdentityIndexInvalid); + break; + } + Err(error) => return Err(error), + } } else { + let mut ingress_identity = None; + let mut identified_commit_id = None; let transaction_payload = if frame_kind == FRAME_KIND_IDENTIFIED_TRANSACTION { let commit_id = u128::from_le_bytes(payload[..COMMIT_ID_BYTES].try_into().unwrap()); // The writer refuses to append a frame whose identifier is @@ -1035,7 +2539,43 @@ fn scan_log( "duplicate commit identifier" ); } + identified_commit_id = Some(commit_id); &payload[COMMIT_ID_BYTES..] + } else if frame_kind == FRAME_KIND_INGRESS_TRANSACTION { + let identity = IngressIdentity { + source_id: u128::from_le_bytes(payload[..16].try_into().unwrap()), + sequence: u64::from_le_bytes(payload[16..24].try_into().unwrap()), + commit_id: u128::from_le_bytes(payload[24..40].try_into().unwrap()), + }; + if identity.source_id == 0 { + stop_or_corruption!( + SalvageStopReason::InvalidIngressSequence, + "ingress source id zero is reserved" + ); + } + let key = IngressKey::from(identity); + if ingress_receipts.contains_key(&key) { + stop_or_corruption!( + SalvageStopReason::DuplicateIngressSequence, + "duplicate ingress source sequence" + ); + } + if !commit_ids.insert(identity.commit_id) { + stop_or_corruption!( + SalvageStopReason::DuplicateCommitId, + "duplicate commit identifier" + ); + } + if let Some(last) = ingress_last_sequences.get(&identity.source_id).copied() + && identity.sequence <= last + { + stop_or_corruption!( + SalvageStopReason::InvalidIngressSequence, + "ingress source cursor is not strictly increasing" + ); + } + ingress_identity = Some(identity); + &payload[INGRESS_IDENTITY_BYTES..] } else { &payload[..] }; @@ -1080,6 +2620,53 @@ fn scan_log( _ => catalog_records += 1, } } + if let Some(commit_id) = identified_commit_id { + identified_receipts.insert( + commit_id, + StoredIdentifiedReceipt { + payload_offset: offset + FRAME_HEADER_BYTES as u64, + payload_len: u32::try_from(payload.len()).unwrap(), + payload_crc32: payload_checksum, + compact_payload: None, + commit: Commit { + frame_offset: offset, + points: recovered_point_count, + records: records.len(), + bytes_written: frame_len, + durable: false, + deduplicated: false, + }, + }, + ); + } + if let Some(identity) = ingress_identity { + let key = IngressKey::from(identity); + let receipt = StoredIngressReceipt { + identity, + canonical_payload_offset: offset + + FRAME_HEADER_BYTES as u64 + + INGRESS_IDENTITY_BYTES as u64, + canonical_payload_len: u32::try_from(transaction_payload.len()).unwrap(), + canonical_payload_crc32: hash(transaction_payload), + compact_payload: None, + commit: Commit { + frame_offset: offset, + points: recovered_point_count, + records: records.len(), + bytes_written: frame_len, + // A scan proves framing and checksums, not persistence + // across power loss. A writable open syncs the full + // recovered prefix and upgrades these receipts before + // exposing the handle; a read-only open leaves them + // conservative. + durable: false, + deduplicated: false, + }, + }; + ingress_receipts.insert(key, receipt); + ingress_commit_ids.insert(identity.commit_id, key); + ingress_last_sequences.insert(identity.source_id, identity.sequence); + } } commits += 1; offset += frame_len; @@ -1089,10 +2676,18 @@ fn scan_log( salvage_stop_reason = Some(SalvageStopReason::CleanEof); } + for series in index.values_mut() { + series.sort_by_key(|point| point.valid_time); + } + Ok(Scan { index, catalog, commit_ids, + identified_receipts, + ingress_receipts, + ingress_commit_ids, + ingress_last_sequences, commits, points, catalog_records, @@ -1100,6 +2695,7 @@ fn scan_log( recovered_tail, validated_bytes: offset, salvage_stop_reason, + pending_reclaim, }) } @@ -1158,7 +2754,11 @@ impl ReadOnlyFileIdentity { } impl SalvageSource { - pub(crate) fn open(root_path: &Path, file_name: &str) -> Result { + pub(crate) fn open( + root_path: &Path, + file_name: &str, + published_seals: &HashSet, + ) -> Result { use rustix::fs::{Mode, OFlags, open, openat}; let root_descriptor = open( @@ -1227,6 +2827,7 @@ impl SalvageSource { Config::default().max_batch_points, Config::default().max_transaction_bytes, ScanMode::Salvage, + published_seals, )?; Ok(Self { file, @@ -1259,7 +2860,10 @@ impl SalvageSource { }); #[cfg(test)] if mutate { - let mut writer = OpenOptions::new().read(true).write(true).open(&self.path)?; + let mut writer = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&self.path)?; writer.seek(SeekFrom::End(-1))?; let mut byte = [0_u8; 1]; writer.read_exact(&mut byte)?; @@ -1289,6 +2893,63 @@ impl SalvageSource { } } +/// Opens or creates one regular file without following a final symlink or +/// blocking on a FIFO or device. Existing paths use the same identity check +/// as [`open_regular_file_read_only`]; a missing path is created as `0600`. +fn open_regular_file_read_write(path: &Path) -> Result { + use rustix::fs::{Mode, OFlags, open}; + + match std::fs::symlink_metadata(path) { + Ok(path_metadata) => { + if !path_metadata.file_type().is_file() { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("writable path is not a regular file: {}", path.display()), + ))); + } + let descriptor = open( + path, + OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|error| Error::Io(error.into()))?; + let file = File::from(descriptor); + let opened_metadata = file.metadata()?; + if !opened_metadata.file_type().is_file() + || opened_metadata.dev() != path_metadata.dev() + || opened_metadata.ino() != path_metadata.ino() + { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("writable path changed while opening: {}", path.display()), + ))); + } + Ok(file) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let descriptor = open( + path, + OFlags::RDWR + | OFlags::CLOEXEC + | OFlags::NOFOLLOW + | OFlags::CREATE + | OFlags::NONBLOCK, + Mode::RUSR | Mode::WUSR, + ) + .map_err(|error| Error::Io(error.into()))?; + let file = File::from(descriptor); + if !file.metadata()?.file_type().is_file() { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("writable path is not a regular file: {}", path.display()), + ))); + } + Ok(file) + } + Err(error) => Err(Error::Io(error)), + } +} + /// Opens one existing regular file without following a final symlink or /// blocking on a FIFO or device. The identity check closes the metadata/open /// race for the final path component. @@ -1378,8 +3039,6 @@ fn encode_frame_header( } fn encode_transaction(transaction: &Transaction) -> Result> { - let record_count = u32::try_from(transaction.record_count()) - .map_err(|_| Error::Serialization("too many transaction records".to_owned()))?; let mut payload = Vec::new(); // An identified transaction (frame kind 2) is the 16-byte little-endian // commit identifier followed by the unchanged kind-1 payload, keeping the @@ -1387,6 +3046,19 @@ fn encode_transaction(transaction: &Transaction) -> Result> { if let Some(commit_id) = transaction.commit_id { payload.extend_from_slice(&commit_id.to_le_bytes()); } + payload.extend_from_slice(&encode_canonical_transaction(transaction)?); + Ok(payload) +} + +pub(crate) fn ingress_frame_bytes(transaction: &Transaction) -> Result { + let payload = encode_canonical_transaction(transaction)?; + Ok((FRAME_HEADER_BYTES + INGRESS_IDENTITY_BYTES + payload.len()) as u64) +} + +fn encode_canonical_transaction(transaction: &Transaction) -> Result> { + let record_count = u32::try_from(transaction.record_count()) + .map_err(|_| Error::Serialization("too many transaction records".to_owned()))?; + let mut payload = Vec::new(); payload.extend_from_slice(TRANSACTION_MAGIC); payload.extend_from_slice(&TRANSACTION_VERSION.to_le_bytes()); payload.extend_from_slice(&0_u16.to_le_bytes()); @@ -1436,6 +3108,9 @@ fn decode_transaction(payload: &[u8], expected_records: usize, offset: u64) -> R if version != TRANSACTION_VERSION { return corruption(offset, "unsupported transaction version"); } + if payload[6..8] != [0, 0] { + return corruption(offset, "transaction reserved flags are non-zero"); + } let record_count = u32::from_le_bytes(payload[8..12].try_into().unwrap()) as usize; if record_count != expected_records { return corruption(offset, "transaction record count mismatch"); @@ -1462,6 +3137,9 @@ fn decode_transaction(payload: &[u8], expected_records: usize, offset: u64) -> R if record_version != 1 { return corruption(offset, "unsupported transaction record version"); } + if payload[cursor + 2..cursor + 4] != [0, 0] { + return corruption(offset, "transaction record reserved flags are non-zero"); + } let body_len = u32::from_le_bytes(payload[cursor + 4..cursor + 8].try_into().unwrap()) as usize; let body_end = header_end @@ -1567,13 +3245,15 @@ mod tests { SalvageSource, SalvageStopReason, ScanMode, encode_frame_header, encode_transaction, fail_next_sync, scan_log, }; + use crate::transaction::IngressIdentity; use crate::{ Entity, EntityId, Error, Plan, PlanStatus, RollupPolicy, Run, RunId, RunKind, RunStatus, SeriesDefinition, SeriesSemantics, Transaction, }; - use std::collections::BTreeMap; + use std::collections::{BTreeMap, HashSet}; use std::fs::OpenOptions; use std::io::{Read, Seek, SeekFrom, Write}; + use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; fn point(valid_time: i64, knowledge_time: i64, change_time: i64, value: f64) -> Point { @@ -1681,6 +3361,140 @@ mod tests { file.sync_all().unwrap(); } + #[test] + fn newly_created_database_file_is_owner_only() { + let directory = tempdir().unwrap(); + let path = directory.path().join("private.ftwdb"); + Database::open(&path).unwrap().close().unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn rejects_non_zero_reserved_database_transaction_and_record_flags() { + let directory = tempdir().unwrap(); + + let database_path = directory.path().join("database-flags.ftwdb"); + header_only(&database_path); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open(&database_path) + .unwrap(); + let mut header = [0_u8; DATABASE_HEADER_BYTES]; + file.read_exact(&mut header).unwrap(); + header[10] = 1; + let checksum = crc32fast::hash(&header[..12]); + header[12..16].copy_from_slice(&checksum.to_le_bytes()); + file.seek(SeekFrom::Start(0)).unwrap(); + file.write_all(&header).unwrap(); + file.sync_all().unwrap(); + drop(file); + assert!(matches!( + Database::open(&database_path), + Err(Error::InvalidHeader) + )); + + let transaction_path = directory.path().join("transaction-flags.ftwdb"); + header_only(&transaction_path); + let mut payload = encode_transaction(&Transaction::new()).unwrap(); + payload[6] = 1; + append_raw_frame(&transaction_path, FRAME_KIND_TRANSACTION, 0, &payload); + assert_eq!( + salvage_scan(&transaction_path, Config::default().max_batch_points).salvage_stop_reason, + Some(SalvageStopReason::InvalidTransaction) + ); + + let record_path = directory.path().join("record-flags.ftwdb"); + header_only(&record_path); + let mut transaction = Transaction::new(); + transaction.upsert_entity(home()); + let mut payload = encode_transaction(&transaction).unwrap(); + payload[super::TRANSACTION_HEADER_BYTES + 2] = 1; + append_raw_frame(&record_path, FRAME_KIND_TRANSACTION, 1, &payload); + assert_eq!( + salvage_scan(&record_path, Config::default().max_batch_points).salvage_stop_reason, + Some(SalvageStopReason::InvalidTransaction) + ); + } + + #[test] + fn salvage_rejects_invalid_legacy_points_and_control_frame_counts() { + let directory = tempdir().unwrap(); + + let legacy_path = directory.path().join("invalid-legacy-point.ftwdb"); + header_only(&legacy_path); + let mut payload = Vec::new(); + super::encode_point(Point::actual(1, 1, f64::INFINITY), &mut payload); + append_raw_frame(&legacy_path, super::FRAME_KIND_LEGACY_POINTS, 1, &payload); + assert_eq!( + salvage_scan(&legacy_path, Config::default().max_batch_points).salvage_stop_reason, + Some(SalvageStopReason::InvalidLegacyPoint) + ); + + let checkpoint_path = directory.path().join("invalid-checkpoint.ftwdb"); + header_only(&checkpoint_path); + let mut checkpoint = [0_u8; super::SEAL_CHECKPOINT_BYTES]; + checkpoint[..8].copy_from_slice(&1_u64.to_le_bytes()); + append_raw_frame( + &checkpoint_path, + super::FRAME_KIND_SEAL_CHECKPOINT, + 1, + &checkpoint, + ); + assert_eq!( + salvage_scan(&checkpoint_path, Config::default().max_batch_points).salvage_stop_reason, + Some(SalvageStopReason::SealCheckpointInvalid) + ); + + let checkpoint_points_path = directory.path().join("invalid-checkpoint-points.ftwdb"); + header_only(&checkpoint_points_path); + checkpoint[8..16].copy_from_slice(&1_u64.to_le_bytes()); + append_raw_frame( + &checkpoint_points_path, + super::FRAME_KIND_SEAL_CHECKPOINT, + 0, + &checkpoint, + ); + assert_eq!( + salvage_scan(&checkpoint_points_path, Config::default().max_batch_points,) + .salvage_stop_reason, + Some(SalvageStopReason::SealCheckpointInvalid) + ); + + let index_path = directory.path().join("invalid-index-count.ftwdb"); + header_only(&index_path); + let index = postcard::to_stdvec(&super::CompactIdentityIndex::default()).unwrap(); + append_raw_frame(&index_path, super::FRAME_KIND_IDENTITY_INDEX, 1, &index); + assert_eq!( + salvage_scan(&index_path, Config::default().max_batch_points).salvage_stop_reason, + Some(SalvageStopReason::IdentityIndexInvalid) + ); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn config_rejects_limits_that_the_file_format_cannot_encode() { + let directory = tempdir().unwrap(); + for config in [ + Config { + max_batch_points: u32::MAX as usize + 1, + ..Config::default() + }, + Config { + max_transaction_bytes: u32::MAX as usize + 1, + ..Config::default() + }, + ] { + assert!(matches!( + Database::open_with(directory.path().join("limit.ftwdb"), config), + Err(Error::InvalidConfig(_)) + )); + } + } + fn salvage_scan(path: &std::path::Path, max_batch_points: usize) -> super::Scan { let mut file = OpenOptions::new().read(true).open(path).unwrap(); scan_log( @@ -1688,6 +3502,7 @@ mod tests { max_batch_points, Config::default().max_transaction_bytes, ScanMode::Salvage, + &HashSet::new(), ) .unwrap() } @@ -1979,7 +3794,7 @@ mod tests { std::fs::create_dir(&source_root).unwrap(); let active = source_root.join("active.wlog"); legacy_log(&active, 1); - let source = SalvageSource::open(&source_root, "active.wlog").unwrap(); + let source = SalvageSource::open(&source_root, "active.wlog", &HashSet::new()).unwrap(); let mut writer = OpenOptions::new().write(true).open(&active).unwrap(); writer.seek(SeekFrom::End(-1)).unwrap(); @@ -2042,10 +3857,33 @@ mod tests { let database = Database::open(&path).unwrap(); assert_eq!(database.stats().unwrap().points, 3); - let latest = database.query_latest(7, 0, 1_000); + let latest = database.query_latest(7, 0, 1_000).unwrap(); assert_eq!(latest.len(), 2); assert_eq!(latest[0].value, 3.0); - assert_eq!(database.query_as_of(7, 0, 1_000, 15)[0].value, 1.0); + assert_eq!(database.query_as_of(7, 0, 1_000, 15).unwrap()[0].value, 1.0); + } + + #[test] + fn recovered_late_valid_times_remain_visible_to_range_queries() { + let directory = tempdir().unwrap(); + let path = directory.path().join("late-valid-time.ftwdb"); + { + let mut database = Database::open(&path).unwrap(); + database + .append(&[point(100, 10, 11, 1.0), point(200, 10, 11, 2.0)]) + .unwrap(); + database.append(&[point(50, 20, 21, 3.0)]).unwrap(); + assert_eq!(database.query_history(7, 0, 75).unwrap().len(), 1); + database.close().unwrap(); + } + + let database = Database::open(&path).unwrap(); + let early = database.query_history(7, 0, 75).unwrap(); + assert_eq!(early.len(), 1); + assert_eq!(early[0].valid_time, 50); + assert_eq!(early[0].value, 3.0); + assert_eq!(database.query_history(7, 0, 150).unwrap().len(), 2); + assert_eq!(database.query_latest(7, 0, 1_000).unwrap().len(), 3); } #[test] @@ -2084,6 +3922,46 @@ mod tests { assert_eq!(append_error.to_string(), commit_error.to_string()); } + #[test] + fn append_and_commit_reject_non_finite_values_before_writing() { + let directory = tempdir().unwrap(); + let path = directory.path().join("non-finite.ftwdb"); + let mut database = Database::open(&path).unwrap(); + let before = database.stats().unwrap().file_bytes; + + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut invalid = point(100, 10, 11, value); + invalid.valid_time_end = invalid.valid_time; + match database.append(&[invalid]) { + Err(Error::InvalidModel(reason)) => { + assert_eq!(reason, "point value must be finite"); + } + other => panic!("expected finite-value rejection, got {other:?}"), + } + } + assert_eq!(database.stats().unwrap().file_bytes, before); + assert_eq!(database.stats().unwrap().points, 0); + + let mut catalog = Transaction::new(); + catalog + .upsert_entity(home()) + .define_series(power_series()) + .upsert_run(optimization_run()); + database.commit(catalog).unwrap(); + let mut committed = Transaction::new(); + let mut invalid = point(100, 10, 11, f64::NAN); + invalid.series_id = 7; + invalid.run_id = 9; + committed.append_points(vec![invalid]); + match database.commit(committed) { + Err(Error::InvalidModel(reason)) => { + assert_eq!(reason, "point value must be finite"); + } + other => panic!("expected finite-value rejection, got {other:?}"), + } + assert!(database.append(&[point(1, 1, 1, 1.0)]).is_ok()); + } + #[test] fn append_remains_a_catalog_less_fast_path() { let directory = tempdir().unwrap(); @@ -2101,7 +3979,7 @@ mod tests { // The legacy frame recovers unchanged on reopen. let database = Database::open(&path).unwrap(); assert_eq!(database.stats().unwrap().points, 1); - assert_eq!(database.query_latest(7, 0, 1_000).len(), 1); + assert_eq!(database.query_latest(7, 0, 1_000).unwrap().len(), 1); } #[test] @@ -2219,7 +4097,7 @@ mod tests { super::RecoveredTail::IncompletePayload ); assert_eq!(stats.file_bytes, first_length); - assert_eq!(database.query_latest(7, 0, 10).len(), 1); + assert_eq!(database.query_latest(7, 0, 10).unwrap().len(), 1); database.close().unwrap(); assert_eq!(std::fs::read(&path).unwrap(), bytes_before); } @@ -2256,7 +4134,7 @@ mod tests { Err(Error::ReadOnly) )); assert!(matches!(database.flush(), Err(Error::ReadOnly))); - assert_eq!(database.query_latest(7, 0, 10).len(), 1); + assert_eq!(database.query_latest(7, 0, 10).unwrap().len(), 1); database.close().unwrap(); } @@ -2313,7 +4191,7 @@ mod tests { file.set_len(full_length - 10).unwrap(); let database = Database::open(&path).unwrap(); - assert_eq!(database.query_latest(7, 0, 10).len(), 1); + assert_eq!(database.query_latest(7, 0, 10).unwrap().len(), 1); let stats = database.stats().unwrap(); assert_eq!(stats.file_bytes, first_length); assert!(stats.recovered_tail_bytes > 0); @@ -2346,7 +4224,7 @@ mod tests { assert_eq!(stats.file_bytes, first_length); assert_eq!(stats.recovered_tail_bytes, 7); assert_eq!(stats.recovered_tail, super::RecoveredTail::IncompleteHeader); - assert_eq!(database.query_latest(7, 0, 10).len(), 1); + assert_eq!(database.query_latest(7, 0, 10).unwrap().len(), 1); database.close().unwrap(); assert_eq!(std::fs::read(&path).unwrap(), bytes_before); @@ -2355,7 +4233,7 @@ mod tests { assert_eq!(stats.file_bytes, first_length); assert_eq!(stats.recovered_tail_bytes, 7); assert_eq!(stats.recovered_tail, super::RecoveredTail::IncompleteHeader); - assert_eq!(database.query_latest(7, 0, 10).len(), 1); + assert_eq!(database.query_latest(7, 0, 10).unwrap().len(), 1); assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1); } @@ -2548,8 +4426,8 @@ mod tests { assert_eq!(database.catalog().series(7), Some(&power_series())); assert_eq!(database.catalog().run(RunId(9)), Some(&optimization_run())); assert_eq!(database.catalog().plan(11), Some(&plan())); - assert_eq!(database.query_run(7, 9, 0, 1_000), vec![planned]); - let comparison = database.compare_plan_to_actual(7, 7, 9, 0, 1_000); + assert_eq!(database.query_run(7, 9, 0, 1_000).unwrap(), vec![planned]); + let comparison = database.compare_plan_to_actual(7, 7, 9, 0, 1_000).unwrap(); assert_eq!(comparison.len(), 1); assert_eq!(comparison[0].difference, Some(-200.0)); assert_eq!(database.stats().unwrap().catalog_records, 4); @@ -2589,7 +4467,7 @@ mod tests { assert_eq!(commit.records, 0); assert_eq!(commit.bytes_written, 0); assert_eq!(database.stats().unwrap().points, 1); - assert_eq!(database.query_history(7, 0, 1_000).len(), 1); + assert_eq!(database.query_history(7, 0, 1_000).unwrap().len(), 1); // A different identifier is an independent commit; retrying it within // the same session is deduplicated without a reopen. @@ -2600,8 +4478,721 @@ mod tests { assert!(!database.commit(second.clone()).unwrap().deduplicated); assert!(database.commit(second).unwrap().deduplicated); assert_eq!(database.stats().unwrap().points, 2); - assert_eq!(database.query_history(7, 0, 1_000).len(), 2); + assert_eq!(database.query_history(7, 0, 1_000).unwrap().len(), 2); + database.close().unwrap(); + } + + #[test] + fn identified_commit_rejects_a_reused_id_with_different_bytes() { + let directory = tempdir().unwrap(); + let path = directory.path().join("identified-conflict.ftwdb"); + let mut first_point = point(100, 10, 10, 1.0); + first_point.run_id = 0; + let mut second_point = point(200, 20, 20, 2.0); + second_point.run_id = 0; + let mut database = Database::open(&path).unwrap(); + let mut catalog = Transaction::new(); + catalog.upsert_entity(home()).define_series(power_series()); + database.commit(catalog).unwrap(); + + let mut first = Transaction::new(); + first.append_points(vec![first_point]).with_commit_id(42); + assert!(!database.commit(first).unwrap().deduplicated); + + let mut mutated = Transaction::new(); + mutated.append_points(vec![second_point]).with_commit_id(42); + assert!(matches!( + database.commit(mutated), + Err(Error::IngressCommitIdConflict { commit_id: 42 }) + )); + assert_eq!(database.stats().unwrap().points, 1); + assert_eq!(database.query_history(7, 0, 1_000).unwrap().len(), 1); + + let mut exact = Transaction::new(); + exact.append_points(vec![first_point]).with_commit_id(42); + assert!(database.commit(exact).unwrap().deduplicated); + + database.close().unwrap(); + let mut reopened = Database::open(&path).unwrap(); + let mut mutated_after_reopen = Transaction::new(); + mutated_after_reopen + .append_points(vec![second_point]) + .with_commit_id(42); + assert!(matches!( + reopened.commit(mutated_after_reopen), + Err(Error::IngressCommitIdConflict { commit_id: 42 }) + )); + let mut exact_after_reopen = Transaction::new(); + exact_after_reopen + .append_points(vec![first_point]) + .with_commit_id(42); + assert!(reopened.commit(exact_after_reopen).unwrap().deduplicated); + assert_eq!(reopened.stats().unwrap().points, 1); + } + + #[test] + fn compact_receipts_compare_exact_bytes_even_when_crc32_collides() { + let directory = tempdir().unwrap(); + let database = Database::open(directory.path().join("collision.ftwdb")).unwrap(); + let original = b"plumless"; + let collision = b"buckeroo"; + assert_eq!(original.len(), collision.len()); + assert_eq!(crc32fast::hash(original), crc32fast::hash(collision)); + + let receipt = super::StoredIdentifiedReceipt { + payload_offset: 0, + payload_len: original.len() as u32, + payload_crc32: crc32fast::hash(original), + compact_payload: Some(std::sync::Arc::from(original.as_slice())), + commit: super::Commit { + frame_offset: 0, + points: 0, + records: 0, + bytes_written: 0, + durable: true, + deduplicated: false, + }, + }; + + assert!( + !database + .identified_payload_matches_at(receipt, collision) + .unwrap() + ); + } + + #[test] + fn reclaim_sorts_ingress_receipts_and_preserves_exact_replay_receipts() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ordered-compact-index.ftwdb"); + let mut database = Database::open(&path).unwrap(); + let identities = [ + IngressIdentity::new(2, 4, 204), + IngressIdentity::new(1, 10, 110), + IngressIdentity::new(1, 12, 112), + IngressIdentity::new(2, 9, 209), + IngressIdentity::new(1, 20, 120), + ]; + let mut originals = Vec::new(); + for identity in identities { + originals.push(( + identity, + database + .commit_ingress(identity, Transaction::new()) + .unwrap(), + )); + } + + database.reclaim_live_log().unwrap(); + for (identity, original) in &originals { + let replay = database + .commit_ingress(*identity, Transaction::new()) + .unwrap(); + assert!(replay.deduplicated); + assert_eq!(replay.frame_offset, original.frame_offset); + assert_eq!(replay.bytes_written, original.bytes_written); + } + database.close().unwrap(); + + let mut reopened = Database::open(&path).unwrap(); + for (identity, original) in originals { + let replay = reopened + .commit_ingress(identity, Transaction::new()) + .unwrap(); + assert!(replay.deduplicated); + assert_eq!(replay.frame_offset, original.frame_offset); + assert_eq!(replay.bytes_written, original.bytes_written); + } + } + + #[test] + fn reclaim_splits_large_exact_identity_indexes_into_bounded_frames() { + let directory = tempdir().unwrap(); + let path = directory.path().join("chunked-compact-index.ftwdb"); + let config = Config { + max_transaction_bytes: 64, + ..Config::default() + }; + let mut database = Database::open_with(&path, config).unwrap(); + let identities: Vec<_> = (0_u64..80) + .map(|sequence| { + IngressIdentity::new(9, sequence, u128::from(sequence).saturating_add(1_000)) + }) + .collect(); + for identity in &identities { + database + .commit_ingress(*identity, Transaction::new()) + .unwrap(); + } + database.reclaim_live_log().unwrap(); database.close().unwrap(); + + let mut reopened = Database::open_with(&path, config).unwrap(); + for identity in identities { + assert!( + reopened + .commit_ingress(identity, Transaction::new()) + .unwrap() + .deduplicated + ); + } + } + + #[test] + fn legacy_compact_identity_index_decodes_without_exact_payloads() { + #[derive(serde::Serialize)] + struct LegacyIdentified { + commit_id: u128, + payload_len: u32, + payload_crc32: u32, + points: u64, + records: u64, + } + #[derive(serde::Serialize)] + struct LegacyIngress { + source_id: u128, + sequence: u64, + commit_id: u128, + canonical_payload_len: u32, + canonical_payload_crc32: u32, + points: u64, + records: u64, + } + #[derive(serde::Serialize)] + struct LegacyIndex { + identified: Vec, + ingress: Vec, + } + + let encoded = postcard::to_stdvec(&LegacyIndex { + identified: vec![LegacyIdentified { + commit_id: 1, + payload_len: 28, + payload_crc32: 2, + points: 3, + records: 4, + }], + ingress: vec![LegacyIngress { + source_id: 5, + sequence: 6, + commit_id: 7, + canonical_payload_len: 12, + canonical_payload_crc32: 8, + points: 9, + records: 0, + }], + }) + .unwrap(); + let decoded = super::decode_identity_index(&encoded, 0).unwrap(); + assert!(decoded.identified[0].payload.is_empty()); + assert!(decoded.ingress[0].canonical_payload.is_empty()); + assert_eq!(decoded.ingress[0].frame_offset, 0); + assert_eq!(decoded.ingress[0].bytes_written, 0); + + let directory = tempdir().unwrap(); + let path = directory.path().join("legacy-index.ftwdb"); + header_only(&path); + let mut transaction = Transaction::new(); + transaction.with_commit_id(77); + let payload = encode_transaction(&transaction).unwrap(); + let legacy = postcard::to_stdvec(&LegacyIndex { + identified: vec![LegacyIdentified { + commit_id: 77, + payload_len: payload.len() as u32, + payload_crc32: crc32fast::hash(&payload), + points: 0, + records: 0, + }], + ingress: Vec::new(), + }) + .unwrap(); + append_raw_frame(&path, super::FRAME_KIND_IDENTITY_INDEX, 0, &legacy); + + let mut database = Database::open(&path).unwrap(); + assert!(database.contains_commit_id(77)); + assert!(matches!( + database.commit(transaction), + Err(Error::IngressCommitIdConflict { commit_id: 77 }) + )); + } + + #[test] + fn writable_open_rejects_symlink_fifo_and_directory_without_blocking() { + for kind in ["symlink", "fifo", "directory"] { + let directory = tempdir().unwrap(); + let path = directory.path().join("not-a-regular-file.ftwdb"); + match kind { + "symlink" => std::os::unix::fs::symlink("outside", &path).unwrap(), + "fifo" => create_fifo(&path), + "directory" => std::fs::create_dir(&path).unwrap(), + _ => unreachable!(), + } + match Database::open(&path) { + Ok(_) => panic!("{kind}: writable open must reject a non-file"), + Err(Error::Io(io_error)) => { + assert!( + io_error.to_string().contains("not a regular file"), + "{kind}: {io_error}" + ); + } + Err(other) => panic!("{kind}: expected I/O rejection, got {other}"), + } + } + } + + #[test] + fn writable_open_creates_a_private_regular_file() { + let directory = tempdir().unwrap(); + let path = directory.path().join("created.ftwdb"); + let database = Database::open(&path).unwrap(); + drop(database); + let metadata = std::fs::symlink_metadata(&path).unwrap(); + assert!(metadata.file_type().is_file()); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + + #[cfg(target_os = "linux")] + fn create_fifo(path: &std::path::Path) { + rustix::fs::mkfifoat( + rustix::fs::CWD, + path, + rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, + ) + .unwrap(); + } + + #[cfg(target_os = "macos")] + fn create_fifo(path: &std::path::Path) { + use std::os::unix::ffi::OsStrExt; + + let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: `path` is a live NUL-terminated string and mode has no + // platform-dependent bits beyond user read/write permissions. + let result = unsafe { libc::mkfifo(path.as_ptr(), 0o600) }; + if result != 0 { + panic!("mkfifo failed: {}", std::io::Error::last_os_error()); + } + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + fn create_fifo(_path: &std::path::Path) { + panic!("FIFO open tests require Linux or macOS"); + } + + #[test] + fn ingress_replay_after_reopen_returns_the_original_receipt() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-replay.ftwdb"); + let identity = IngressIdentity::new(11, 400, 9001); + let mut telemetry = point(100, 10, 10, 1.0); + telemetry.run_id = 0; + let build = || { + let mut transaction = Transaction::new(); + transaction.append_points(vec![telemetry]); + transaction + }; + + let original = { + let mut database = Database::open(&path).unwrap(); + let mut catalog = Transaction::new(); + catalog.upsert_entity(home()).define_series(power_series()); + database.commit(catalog).unwrap(); + let receipt = database.commit_ingress(identity, build()).unwrap(); + assert!(!receipt.deduplicated); + database.close().unwrap(); + receipt + }; + + let mut database = Database::open(&path).unwrap(); + let replay = database.commit_ingress(identity, build()).unwrap(); + assert!(replay.deduplicated); + assert_eq!(replay.frame_offset, original.frame_offset); + assert_eq!(replay.points, original.points); + assert_eq!(replay.records, original.records); + assert_eq!(replay.bytes_written, original.bytes_written); + assert!(replay.durable); + assert_eq!(database.stats().unwrap().points, 1); + assert_eq!(database.query_history(7, 0, 1_000).unwrap().len(), 1); + } + + #[test] + fn ingress_identity_conflicts_are_exact_and_nonfatal() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-conflict.ftwdb"); + let mut database = Database::open(&path).unwrap(); + let mut catalog = Transaction::new(); + catalog.upsert_entity(home()).define_series(power_series()); + database.commit(catalog).unwrap(); + + let mut first = Transaction::new(); + first.append_points(vec![Point::actual(7, 100, 1.0)]); + database + .commit_ingress(IngressIdentity::new(1, 8, 80), first) + .unwrap(); + + let mut changed = Transaction::new(); + changed.append_points(vec![Point::actual(7, 100, 2.0)]); + assert!(matches!( + database.commit_ingress(IngressIdentity::new(1, 8, 80), changed), + Err(Error::IngressSourceSequenceConflict { + source_id: 1, + sequence: 8 + }) + )); + + let mut reused_commit_id = Transaction::new(); + reused_commit_id.append_points(vec![Point::actual(7, 200, 3.0)]); + assert!(matches!( + database.commit_ingress(IngressIdentity::new(2, 1, 80), reused_commit_id), + Err(Error::IngressCommitIdConflict { commit_id: 80 }) + )); + + let mut next = Transaction::new(); + next.append_points(vec![Point::actual(7, 200, 3.0)]); + assert!( + !database + .commit_ingress(IngressIdentity::new(1, 9, 81), next) + .unwrap() + .deduplicated + ); + assert_eq!(database.stats().unwrap().points, 2); + } + + #[test] + fn ingress_replay_detects_storage_changes_and_poisons_later_writes() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-mutated-after-open.ftwdb"); + let identity = IngressIdentity::new(6, 20, 200); + let mut database = Database::open(&path).unwrap(); + database + .commit_ingress(identity, Transaction::new()) + .unwrap(); + + let receipt = database + .ingress_receipts + .get(&super::IngressKey::from(identity)) + .unwrap(); + let offset = receipt.canonical_payload_offset; + let frame_offset = receipt.commit.frame_offset; + let mut mutator = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + mutator.seek(SeekFrom::Start(offset)).unwrap(); + let mut byte = [0_u8; 1]; + mutator.read_exact(&mut byte).unwrap(); + mutator.seek(SeekFrom::Start(offset)).unwrap(); + mutator.write_all(&[byte[0] ^ 0xff]).unwrap(); + mutator.sync_data().unwrap(); + + assert!(matches!( + database.commit_ingress(identity, Transaction::new()), + Err(Error::Corruption { offset, .. }) if offset == frame_offset + )); + assert!(matches!( + database.commit_ingress(IngressIdentity::new(6, 21, 201), Transaction::new()), + Err(Error::Poisoned) + )); + } + + #[test] + fn ingress_replay_detects_identity_changes_after_open() { + let directory = tempdir().unwrap(); + let path = directory + .path() + .join("ingress-identity-mutated-after-open.ftwdb"); + let identity = IngressIdentity::new(7, 30, 300); + let mut database = Database::open(&path).unwrap(); + let commit = database + .commit_ingress(identity, Transaction::new()) + .unwrap(); + + let identity_offset = commit.frame_offset + super::FRAME_HEADER_BYTES as u64; + let mut mutator = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + mutator.seek(SeekFrom::Start(identity_offset)).unwrap(); + let mut byte = [0_u8; 1]; + mutator.read_exact(&mut byte).unwrap(); + mutator.seek(SeekFrom::Start(identity_offset)).unwrap(); + mutator.write_all(&[byte[0] ^ 0xff]).unwrap(); + mutator.sync_data().unwrap(); + + assert!(matches!( + database.commit_ingress(identity, Transaction::new()), + Err(Error::Corruption { offset, .. }) if offset == commit.frame_offset + )); + assert!(matches!( + database.commit_ingress(IngressIdentity::new(7, 31, 301), Transaction::new()), + Err(Error::Poisoned) + )); + } + + #[test] + fn ingress_cursor_allows_gaps_and_rejects_regression_after_reopen() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-sequence.ftwdb"); + { + let mut database = Database::open(&path).unwrap(); + database + .commit_ingress(IngressIdentity::new(7, 50, 500), Transaction::new()) + .unwrap(); + database.close().unwrap(); + } + + let mut database = Database::open(&path).unwrap(); + assert!( + !database + .commit_ingress(IngressIdentity::new(7, 52, 502), Transaction::new()) + .unwrap() + .deduplicated + ); + database.close().unwrap(); + + let mut database = Database::open(&path).unwrap(); + assert!( + database + .commit_ingress(IngressIdentity::new(7, 52, 502), Transaction::new()) + .unwrap() + .deduplicated + ); + assert!(matches!( + database.commit_ingress(IngressIdentity::new(7, 51, 501), Transaction::new()), + Err(Error::IngressSequenceNotIncreasing { + source_id: 7, + previous: 52, + actual: 51 + }) + )); + assert_eq!(database.stats().unwrap().commits, 2); + } + + #[test] + fn ingress_watermarks_are_per_source_and_advance_on_flush() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-watermarks.ftwdb"); + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + + database + .commit_ingress(IngressIdentity::new(1, 10, 100), Transaction::new()) + .unwrap(); + database + .commit_ingress(IngressIdentity::new(2, 50, 200), Transaction::new()) + .unwrap(); + assert_eq!( + database.ingress_watermarks(1), + super::IngressWatermarks { + accepted_through: Some(10), + durable_through: None, + } + ); + assert_eq!( + database.ingress_watermarks(2), + super::IngressWatermarks { + accepted_through: Some(50), + durable_through: None, + } + ); + + database.flush().unwrap(); + assert_eq!(database.ingress_watermarks(1).durable_through, Some(10)); + assert_eq!(database.ingress_watermarks(2).durable_through, Some(50)); + + database + .commit_ingress(IngressIdentity::new(1, 11, 101), Transaction::new()) + .unwrap(); + assert_eq!( + database.ingress_watermarks(1), + super::IngressWatermarks { + accepted_through: Some(11), + durable_through: Some(10), + } + ); + database.close().unwrap(); + + let database = Database::open_read_only(&path).unwrap(); + assert_eq!( + database.ingress_watermarks(1), + super::IngressWatermarks { + accepted_through: Some(11), + durable_through: None, + } + ); + assert_eq!( + database.ingress_watermarks(2), + super::IngressWatermarks { + accepted_through: Some(50), + durable_through: None, + } + ); + } + + #[test] + fn append_sync_advances_ingress_durable_watermarks() { + let directory = tempdir().unwrap(); + let path = directory.path().join("append-sync-watermarks.ftwdb"); + let mut database = Database::open_with( + &path, + Config { + durability: Durability::EveryBytes(2_048), + ..Config::default() + }, + ) + .unwrap(); + + database + .commit_ingress(IngressIdentity::new(3, 7, 70), Transaction::new()) + .unwrap(); + assert_eq!( + database.ingress_watermarks(3), + super::IngressWatermarks { + accepted_through: Some(7), + durable_through: None, + } + ); + + let points: Vec<_> = (0..64) + .map(|index| point(index, 1, 1, index as f64)) + .collect(); + let commit = database.append(&points).unwrap(); + assert!(commit.durable); + assert_eq!(database.ingress_watermarks(3).durable_through, Some(7)); + } + + #[test] + fn writable_reopen_syncs_recovered_ingress_before_claiming_durability() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-reopen-durability.ftwdb"); + let identity = IngressIdentity::new(4, 70, 700); + + { + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + let commit = database + .commit_ingress(identity, Transaction::new()) + .unwrap(); + assert!(!commit.durable); + assert_eq!(database.ingress_watermarks(4).durable_through, None); + // Dropping models a process exit that did not call flush. + } + + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + assert_eq!(database.ingress_watermarks(4).durable_through, Some(70)); + let replay = database + .commit_ingress(identity, Transaction::new()) + .unwrap(); + assert!(replay.durable); + assert!(replay.deduplicated); + } + + #[test] + fn writable_reopen_does_not_publish_durability_when_startup_sync_fails() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-reopen-sync-failure.ftwdb"); + { + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + database + .commit_ingress(IngressIdentity::new(5, 80, 800), Transaction::new()) + .unwrap(); + } + + fail_next_sync(std::io::ErrorKind::Other); + assert!(matches!( + Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + } + ), + Err(Error::Io(error)) if error.kind() == std::io::ErrorKind::Other + )); + } + + #[test] + fn ingress_rejects_zero_source_without_poisoning_the_writer() { + let directory = tempdir().unwrap(); + let path = directory.path().join("ingress-zero-source.ftwdb"); + let mut database = Database::open(&path).unwrap(); + assert!(matches!( + database.commit_ingress(IngressIdentity::new(0, 1, 1), Transaction::new()), + Err(Error::InvalidArgument("ingress source id zero is reserved")) + )); + assert!( + !database + .commit_ingress(IngressIdentity::new(1, 1, 1), Transaction::new()) + .unwrap() + .deduplicated + ); + } + + #[test] + fn torn_ingress_frame_forgets_identity_sequence_and_data_together() { + let directory = tempdir().unwrap(); + let path = directory.path().join("torn-ingress.ftwdb"); + let identity = IngressIdentity::new(3, 90, 900); + { + let mut database = Database::open(&path).unwrap(); + let mut catalog = Transaction::new(); + catalog.upsert_entity(home()).define_series(power_series()); + database.commit(catalog).unwrap(); + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(7, 100, 1.0)]); + database.commit_ingress(identity, transaction).unwrap(); + database.close().unwrap(); + } + let full_length = std::fs::metadata(&path).unwrap().len(); + let file = OpenOptions::new().write(true).open(&path).unwrap(); + file.set_len(full_length - 7).unwrap(); + drop(file); + + let mut database = Database::open(&path).unwrap(); + assert_eq!(database.stats().unwrap().points, 0); + let mut retry = Transaction::new(); + retry.append_points(vec![Point::actual(7, 100, 1.0)]); + assert!( + !database + .commit_ingress(identity, retry.clone()) + .unwrap() + .deduplicated + ); + assert!( + database + .commit_ingress(identity, retry) + .unwrap() + .deduplicated + ); + assert_eq!(database.stats().unwrap().points, 1); } #[test] @@ -2626,7 +5217,7 @@ mod tests { assert!(!commit.deduplicated); assert_eq!(commit.points, 1); assert_eq!(database.stats().unwrap().points, 2); - assert_eq!(database.query_history(7, 0, 1_000).len(), 2); + assert_eq!(database.query_history(7, 0, 1_000).unwrap().len(), 2); } #[test] @@ -2660,7 +5251,7 @@ mod tests { retry.append_points(vec![telemetry]).with_commit_id(77); assert!(!database.commit(retry).unwrap().deduplicated); assert_eq!(database.stats().unwrap().points, 1); - assert_eq!(database.query_history(7, 0, 1_000).len(), 1); + assert_eq!(database.query_history(7, 0, 1_000).unwrap().len(), 1); } #[test] @@ -2749,6 +5340,6 @@ mod tests { let recovered = Database::open(&path).unwrap(); assert_eq!(recovered.stats().unwrap().file_bytes, before); assert_eq!(recovered.catalog().stats().entities, 0); - assert!(recovered.query_latest(7, 0, 1_000).is_empty()); + assert!(recovered.query_latest(7, 0, 1_000).unwrap().is_empty()); } } diff --git a/src/store.rs b/src/store.rs index df05288..e82f3e6 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,18 +1,20 @@ -use crate::manifest::{self, Manifest, RollupDescriptor}; +use crate::manifest::{self, Manifest, RawSegmentDescriptor, RollupDescriptor}; use crate::rollup::calendar_bucket_bounds; use crate::snapshot::{ PublicationStep, StagedDirectory, inject_checksum_mismatch, publication_checkpoint, - snapshot_digest, snapshot_file_prefix_digest, + snapshot_digest, snapshot_digest_with_open_prefix, snapshot_file_prefix_digest, }; use crate::storage::{SalvageSource, sync_directory, sync_parent_directory}; -use crate::transaction::Record; +use crate::transaction::{IngressIdentity, Record}; use crate::{ - CalendarGaugeRollup, Commit, Config, Database, Error, FixedGaugeRollup, GaugeBucket, Point, - Result, RollupResolution, RollupSegment, SeriesSemantics, Transaction, + CalendarGaugeRollup, Commit, Config, Database, Error, FixedGaugeRollup, GaugeBucket, + IngressWatermarks, Point, Result, RollupResolution, RollupSegment, Segment, SeriesDefinition, + SeriesSemantics, Transaction, }; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::io::{Read, Seek, SeekFrom}; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::sync::RwLock; use std::time::{SystemTime, UNIX_EPOCH}; @@ -20,7 +22,23 @@ use std::time::{SystemTime, UNIX_EPOCH}; const ACTIVE_LOG: &str = "active.wlog"; const MANIFEST_DIRECTORY: &str = "manifests"; const ROLLUP_DIRECTORY: &str = "rollups"; +const SEGMENT_DIRECTORY: &str = "segments"; +const SEAL_BLOCK_POINTS: usize = 16_384; const UTC_DAY_MICROS: i64 = 86_400_000_000; +/// Process-local verified rollup files. Queries may temporarily hold more than +/// this when a single range covers a larger working set; idle cache is trimmed +/// back so open no longer preloads every generation into RAM. +const MAX_CACHED_ROLLUP_SEGMENTS: usize = 1_024; + +#[cfg(test)] +std::thread_local! { + static FAIL_AFTER_SEAL_PUBLISH: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn fail_next_seal_reclaim() { + FAIL_AFTER_SEAL_PUBLISH.with(|flag| flag.set(true)); +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RollupSource { @@ -53,6 +71,16 @@ pub struct MaintenanceReport { pub retention_gates: Vec, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SealReport { + pub manifest_generation: u64, + pub segment_file: String, + pub sealed_points: u64, + pub live_points: u64, + pub segment_bytes: u64, + pub log_bytes: u64, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct IntegrityReport { pub manifest_generation: u64, @@ -91,6 +119,13 @@ pub struct RestoreReport { pub destination_snapshot_crc32: u32, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SalvageOptions { + /// When true, orphan `.wseg` files not named by the recovered manifest + /// are ignored instead of failing salvage. + pub drop_orphan_segments: bool, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SalvageStatus { Clean, @@ -146,12 +181,18 @@ pub struct Store { root: PathBuf, rollup_directory: PathBuf, manifest_directory: PathBuf, + segment_directory: PathBuf, database: Database, manifest: Manifest, rollup_cache: RwLock>, poisoned: bool, read_only: bool, stale_rollup_files: usize, + /// Last materialized `series_points(id).len()` for each gauge series. + /// Empty after open, so the first maintain may still scan; later calls + /// skip `query_latest` for series whose revision vector is unchanged. + materialized_series_revisions: HashMap, + last_maintain_now_micros: Option, } impl Store { @@ -161,22 +202,36 @@ impl Store { pub fn open_with(path: impl AsRef, config: Config) -> Result { let root = path.as_ref().to_path_buf(); - let root_created = !root.exists(); - std::fs::create_dir_all(&root)?; + let root_created = match std::fs::symlink_metadata(&root) { + Ok(_) => false, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir_all(&root)?; + true + } + Err(error) => return Err(Error::Io(error)), + }; + require_real_directory(&root)?; + if root_created { + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?; + } let manifest_directory = root.join(MANIFEST_DIRECTORY); let rollup_directory = root.join(ROLLUP_DIRECTORY); - std::fs::create_dir_all(&manifest_directory)?; - std::fs::create_dir_all(&rollup_directory)?; + let segment_directory = root.join(SEGMENT_DIRECTORY); + create_or_require_real_directory(&manifest_directory)?; + create_or_require_real_directory(&rollup_directory)?; + create_or_require_real_directory(&segment_directory)?; // Make the manifests/ and rollups/ entries durable in the root, then - // make a freshly created root's own entry durable in its parent — - // the same order segment publication uses: contents first, then the - // directory entry that names them. `Database::open_with` below syncs - // the root again after it creates the active log, so the log's entry - // is covered even though the file does not exist yet here. + // make the root's own entry durable in its parent — the same order + // segment publication uses: contents first, then the directory entry + // that names them. Always sync the parent, even when the root already + // exists: the sidecar creates the directory before open, and a prior + // open can crash after mkdir but before this fsync. Skipping it would + // let later Always commits acknowledge durability for a store whose + // parent dirent can vanish on power loss. `Database::open_with` below + // syncs the root again after it creates the active log, so the log's + // entry is covered even though the file does not exist yet here. sync_directory(&root)?; - if root_created { - sync_parent_directory(&root)?; - } + sync_parent_directory(&root)?; // The exclusive advisory lock that `Database::open_with` takes on the // active log also guards the whole store directory: every mutation — @@ -185,23 +240,32 @@ impl Store { // `Error::Locked` before it can republish manifests or rewrite // rollups. Backups copy (never hard-link) the active log, so opening // a published backup does not contend with the source's lock. - let database = Database::open_with(root.join(ACTIVE_LOG), config)?; let manifest = Manifest::load(&manifest_directory)?; + let published_seals = published_seal_generations(&manifest); + let database = + Database::open_with_published_seals(root.join(ACTIVE_LOG), config, &published_seals)?; let mut store = Self { root, rollup_directory, manifest_directory, + segment_directory, database, manifest, rollup_cache: RwLock::new(HashMap::new()), poisoned: false, read_only: false, stale_rollup_files: 0, + materialized_series_revisions: HashMap::new(), + last_maintain_now_micros: None, }; + store.attach_published_segments()?; store.verify_and_reconcile_manifest()?; // Reclaims superseded manifests/segments and any segment orphaned by // a crash between `RollupSegment::create` and manifest publication. store.remove_unreferenced_files(); + if store.database.pending_reclaim() { + store.database.reclaim_live_log()?; + } Ok(store) } @@ -221,22 +285,32 @@ impl Store { let root = path.as_ref().to_path_buf(); let manifest_directory = root.join(MANIFEST_DIRECTORY); let rollup_directory = root.join(ROLLUP_DIRECTORY); + let segment_directory = root.join(SEGMENT_DIRECTORY); require_real_directory(&root)?; require_real_directory(&manifest_directory)?; require_real_directory(&rollup_directory)?; - let database = Database::open_read_only(root.join(ACTIVE_LOG))?; let manifest = Manifest::load(&manifest_directory)?; + if !manifest.segments.is_empty() { + require_real_directory(&segment_directory)?; + } + let published_seals = published_seal_generations(&manifest); + let database = + Database::open_read_only_with_published_seals(root.join(ACTIVE_LOG), &published_seals)?; let mut store = Self { root, rollup_directory, manifest_directory, + segment_directory, database, manifest, rollup_cache: RwLock::new(HashMap::new()), poisoned: false, read_only: true, stale_rollup_files: 0, + materialized_series_revisions: HashMap::new(), + last_maintain_now_micros: None, }; + store.attach_published_segments()?; store.verify_manifest_read_only()?; Ok(store) } @@ -246,11 +320,20 @@ impl Store { &self.database } + pub fn stored_bytes(&self) -> Result { + directory_bytes(&self.root) + } + #[must_use] pub const fn manifest_generation(&self) -> u64 { self.manifest.generation } + #[must_use] + pub const fn is_read_only(&self) -> bool { + self.read_only + } + pub fn active_rollups(&self) -> impl Iterator { self.manifest.rollups.iter().filter(|rollup| rollup.active) } @@ -258,10 +341,13 @@ impl Store { /// Commits catalog and data records atomically, then durably advances the /// rollup manifest if new points affect or supersede materialized state. /// - /// A transaction tagged with [`Transaction::with_commit_id`] makes a - /// retry of this multi-step sequence safe. The identifier is checked - /// inside [`Database::commit`] before the raw frame is written, so a - /// replayed commit stores nothing and reports [`Commit::deduplicated`]. + /// A transaction tagged with [`Transaction::with_commit_id`] makes an + /// exact retry of this multi-step sequence safe. The identifier and + /// payload are checked inside [`Database::commit`] before the raw frame + /// is written, so a matching replay stores nothing and reports + /// [`Commit::deduplicated`]. Prefer [`Self::commit_ingress`] for + /// production writers. A reused identifier with different records + /// conflicts instead of silently dropping the mutation. /// The failure mode this closes: the raw commit becomes durable, then /// manifest advancement fails and poisons this store (or the process /// crashes), so the caller saw an error for data that is permanently in @@ -274,16 +360,21 @@ impl Store { /// not rewritten, and rollup provenance is already reconciled. pub fn commit(&mut self, transaction: Transaction) -> Result { self.ensure_writable()?; - let committed_points: Vec = transaction - .records - .iter() - .filter_map(|record| match record { - Record::Points(points) => Some(points.as_slice()), - _ => None, - }) - .flatten() - .copied() - .collect(); + let has_active_rollups = self.manifest.rollups.iter().any(|rollup| rollup.active); + let committed_points: Vec = if has_active_rollups { + transaction + .records + .iter() + .filter_map(|record| match record { + Record::Points(points) => Some(points.as_slice()), + _ => None, + }) + .flatten() + .copied() + .collect() + } else { + Vec::new() + }; let mut commit = self.database.commit(transaction)?; if commit.deduplicated { return Ok(commit); @@ -299,6 +390,53 @@ impl Store { Ok(commit) } + /// Commits one ordered producer transaction through the raw log and the + /// same rollup invalidation path as [`Store::commit`]. Exact retries keep + /// the original raw frame receipt and skip manifest work. + pub fn commit_ingress( + &mut self, + identity: IngressIdentity, + mut transaction: Transaction, + ) -> Result { + transaction.with_ingress_identity(identity); + self.commit(transaction) + } + + /// Returns accepted and durable progress for one ordered ingress source. + #[must_use] + pub fn ingress_watermarks(&self, source_id: u128) -> IngressWatermarks { + self.database.ingress_watermarks(source_id) + } + + /// Returns the read-only frame receipt for one ordered source sequence. + #[must_use] + pub fn ingress_receipt(&self, source_id: u128, sequence: u64) -> Option { + self.database.ingress_receipt(source_id, sequence) + } + + /// Compares source-side shadow batches with this store without writing. + /// + /// A read-only store can prove content but cannot prove that a prior + /// writer synced a recovered receipt. Pair this report with the live + /// sidecar's durable watermark when deciding whether the source copy may + /// be released. + pub fn reconcile_shadow_batches( + &self, + expected: &[crate::shadow_protocol::CommitBatchRequest], + limits: crate::shadow_reconcile::ShadowReconcileLimits, + ) -> std::result::Result< + crate::shadow_reconcile::ShadowReconciliationReport, + crate::shadow_reconcile::ShadowReconcileError, + > { + crate::shadow_reconcile::reconcile_shadow_batches(&self.database, expected, limits) + } + + /// Returns every known ingress source in stable source-ID order. + #[must_use] + pub fn all_ingress_watermarks(&self) -> std::collections::BTreeMap { + self.database.all_ingress_watermarks() + } + /// Compatibility append for a previously initialized catalog. New code /// should prefer a mixed `Transaction` so metadata and values are atomic. /// Like [`Database::append`], only catalog-independent point invariants @@ -317,8 +455,9 @@ impl Store { Ok(commit) } - /// Builds every completed configured gauge bucket and atomically publishes - /// one manifest generation after all new segment files are durable. + /// Materializes completed configured gauge buckets that are dirty or newly + /// closable, then atomically publishes one manifest generation when + /// descriptors or segment files change. pub fn maintain(&mut self, now_micros: i64) -> Result { self.ensure_writable()?; // A durable rollup may never get ahead of the raw source it summarizes. @@ -330,6 +469,17 @@ impl Store { .series_definitions() .cloned() .collect(); + if self.can_skip_maintain_scan(now_micros, stats.points, &definitions)? { + self.remember_materialized_revisions(&definitions); + self.last_maintain_now_micros = Some(now_micros); + return Ok(MaintenanceReport { + manifest_generation: self.manifest.generation, + rollup_files_written: 0, + rollup_buckets_written: 0, + rollup_bytes_written: 0, + retention_gates: self.retention_gates(now_micros)?, + }); + } let next_generation = self.manifest.generation.saturating_add(1); let mut next = self.manifest.clone(); let mut files_written = 0_usize; @@ -337,35 +487,67 @@ impl Store { let mut bytes_written = 0_u64; let mut changed = false; - for definition in definitions { + for definition in &definitions { if definition.semantics != SeriesSemantics::Gauge { continue; } - let points = self - .database - .query_latest(definition.id, i64::MIN, i64::MAX); + if deactivate_expired_rollups(&mut next, definition, now_micros) { + changed = true; + } + if self.can_skip_series_latest_query(definition, now_micros, &next.rollups)? { + if stamp_active_series_source(&mut next, definition.id, stats.commits, stats.points) + { + changed = true; + } + continue; + } let max_gap = definition.maximum_gap_micros.unwrap_or(0); + let Some((earliest, latest)) = self.database.series_valid_bounds(definition.id) else { + continue; + }; for tier in &definition.rollup_policy.tiers { - let mut buckets = materialize(&points, &tier.resolution, max_gap)?; - buckets.retain(|bucket| bucket.end <= now_micros); let retention_cutoff = tier .retain_for_micros .map(|retention| now_micros.saturating_sub(retention)); - for rollup in &mut next.rollups { - if rollup.active - && rollup.series_id == definition.id - && rollup.resolution == tier.resolution - && retention_cutoff.is_some_and(|cutoff| rollup.end < cutoff) - { - rollup.active = false; - changed = true; - } - } - let shards = rollup_shards(&buckets, &tier.resolution, now_micros)?; - for shard in shards + let needed = needed_completed_shards( + definition.id, + &tier.resolution, + earliest, + latest, + now_micros, + &next.rollups, + stats.points, + )?; + let needed: Vec<_> = needed .into_iter() .filter(|shard| retention_cutoff.is_none_or(|cutoff| shard.end >= cutoff)) - { + .collect(); + if needed.is_empty() { + continue; + } + let query_start = needed + .iter() + .map(|shard| shard.start) + .min() + .unwrap_or(earliest) + .saturating_sub(max_gap); + let query_end = needed + .iter() + .map(|shard| shard.end) + .max() + .unwrap_or(latest.saturating_add(1)) + .saturating_add(max_gap.max(1)); + let points = self + .database + .query_latest(definition.id, query_start, query_end)?; + let mut buckets = materialize(&points, &tier.resolution, max_gap)?; + buckets.retain(|bucket| bucket.end <= now_micros); + let shards = rollup_shards(&buckets, &tier.resolution, now_micros)?; + for shard in shards.into_iter().filter(|shard| { + needed + .iter() + .any(|want| want.start == shard.start && want.end == shard.end) + }) { let already_current = next.rollups.iter().any(|rollup| { rollup.active && rollup.series_id == definition.id @@ -416,6 +598,8 @@ impl Store { next.generation = next_generation; self.publish_or_poison(next)?; } + self.remember_materialized_revisions(&definitions); + self.last_maintain_now_micros = Some(now_micros); let retention_gates = self.retention_gates(now_micros)?; Ok(MaintenanceReport { manifest_generation: self.manifest.generation, @@ -426,6 +610,69 @@ impl Store { }) } + /// Seals the live raw index into an immutable segment, publishes it, and + /// rewrites `active.wlog` to catalog + identity receipts. + /// + /// After this returns, open/recovery replays only the unsealed tail. + /// Sealed points stay queryable from the segment file. + pub fn seal_and_reclaim(&mut self) -> Result { + self.ensure_writable()?; + self.database.flush()?; + let live = self.database.live_points_snapshot(); + if live.is_empty() { + return Ok(SealReport { + manifest_generation: self.manifest.generation, + log_bytes: self.database.stats()?.file_bytes, + ..SealReport::default() + }); + } + let stats = self.database.stats()?; + let next_generation = self.manifest.generation.saturating_add(1); + let file = raw_segment_file_name(next_generation); + let min_valid_time = live.iter().map(|point| point.valid_time).min().unwrap(); + let max_valid_time = live.iter().map(|point| point.valid_time).max().unwrap(); + let segment_stats = + Segment::create(self.segment_directory.join(&file), &live, SEAL_BLOCK_POINTS)?; + let content_crc32 = Segment::open(self.segment_directory.join(&file))?.content_crc32()?; + self.database.write_seal_checkpoint( + next_generation, + u64::try_from(live.len()).unwrap_or(u64::MAX), + )?; + + let mut next = self.manifest.clone(); + next.generation = next_generation; + next.segments.push(RawSegmentDescriptor { + file: file.clone(), + generation: next_generation, + points: segment_stats.points, + source_commit: stats.commits, + source_points: stats.points, + min_valid_time, + max_valid_time, + content_crc32, + }); + self.publish_or_poison(next)?; + self.attach_published_segments()?; + + #[cfg(test)] + if FAIL_AFTER_SEAL_PUBLISH.with(std::cell::Cell::take) { + return Err(Error::Io(std::io::Error::other( + "injected seal reclaim failure", + ))); + } + + self.database.clear_live_index(); + self.database.reclaim_live_log()?; + Ok(SealReport { + manifest_generation: self.manifest.generation, + segment_file: file, + sealed_points: segment_stats.points, + live_points: self.database.live_index_len() as u64, + segment_bytes: segment_stats.stored_bytes, + log_bytes: self.database.stats()?.file_bytes, + }) + } + /// Uses a fully covering current materialization, otherwise computes the /// same aggregate state from the latest raw revisions. pub fn query_gauge( @@ -467,25 +714,7 @@ impl Store { .collect(); let coverage = coverage_plan(candidates, required_start, required_end); if !coverage.descriptors.is_empty() { - let mut cache = self.rollup_cache.write().map_err(|_| Error::Poisoned)?; - for descriptor in &coverage.descriptors { - if !cache.contains_key(&descriptor.file) { - cache.insert( - descriptor.file.clone(), - RollupSegment::open(self.rollup_directory.join(&descriptor.file))?, - ); - } - } - let mut buckets = Vec::new(); - for descriptor in coverage.descriptors { - buckets.extend( - cache - .get(&descriptor.file) - .expect("rollup was inserted") - .query(start, end), - ); - } - drop(cache); + let mut buckets = self.cached_rollup_buckets(&coverage.descriptors, start, end)?; for (gap_start, gap_end) in &coverage.gaps { buckets.extend(self.materialize_raw_range( series_id, @@ -532,10 +761,7 @@ impl Store { continue; }; let cutoff = now_micros.saturating_sub(retention); - let raw = self - .database - .query_history(definition.id, i64::MIN, i64::MAX); - let Some(oldest) = raw.iter().map(|point| point.valid_time).min() else { + let Some((oldest, _)) = self.database.series_valid_bounds(definition.id) else { gates.push(RetentionGate { series_id: definition.id, raw_before: cutoff, @@ -623,6 +849,11 @@ impl Store { stale_rollup_files: self.stale_rollup_files, ..IntegrityReport::default() }; + for descriptor in &self.manifest.segments { + let segment = Segment::open(self.segment_directory.join(&descriptor.file))?; + verify_raw_segment_descriptor(&segment, descriptor)?; + segment.verify_blocks()?; + } for descriptor in self.active_rollups() { let segment = RollupSegment::open(self.rollup_directory.join(&descriptor.file))?; let stats = segment.stats(); @@ -778,23 +1009,46 @@ impl Store { } /// Copies the longest raw-log prefix that validates from the first frame - /// into a new store. Derived manifests and rollups are never opened. + /// into a new store, together with sealed raw segments the recovered + /// manifest still names. Derived rollups are never opened. pub fn salvage_from( damaged_store: impl AsRef, destination: impl AsRef, ) -> Result { - let mut source = SalvageSource::open(damaged_store.as_ref(), ACTIVE_LOG)?; + Self::salvage_from_with_options(damaged_store, destination, SalvageOptions::default()) + } + + /// Like [`Self::salvage_from`], with optional recovery policy controls. + pub fn salvage_from_with_options( + damaged_store: impl AsRef, + destination: impl AsRef, + options: SalvageOptions, + ) -> Result { + let damaged_store = damaged_store.as_ref(); + let sealed = plan_sealed_salvage(damaged_store, options)?; + let published_seals = published_seal_generations(&sealed.manifest); + let mut source = SalvageSource::open(damaged_store, ACTIVE_LOG, &published_seals)?; let destination = destination.as_ref(); - let relative_paths = vec![ACTIVE_LOG.to_owned()]; - let source_digest = snapshot_file_prefix_digest( - &mut source.file, - ACTIVE_LOG, - source.recovered_prefix_bytes, - )?; + let relative_paths = salvage_snapshot_paths(&sealed); + let source_digest = if sealed.manifest.segments.is_empty() { + snapshot_file_prefix_digest( + &mut source.file, + ACTIVE_LOG, + source.recovered_prefix_bytes, + )? + } else { + snapshot_digest_with_open_prefix( + damaged_store, + &relative_paths, + ACTIVE_LOG, + &mut source.file, + source.recovered_prefix_bytes, + )? + }; source.ensure_unchanged()?; let staged = StagedDirectory::create(destination, "salvage")?; - write_salvage_stage(&mut source, staged.path())?; + write_salvage_stage(&mut source, damaged_store, staged.path(), &sealed)?; let stage_digest = inject_checksum_mismatch(snapshot_digest(staged.path(), &relative_paths)?); if stage_digest != source_digest { @@ -809,8 +1063,11 @@ impl Store { let stage = Self::open_read_only(staged.path())?; let stage_integrity = stage.check_integrity()?; stage.require_clean_restore_source(&stage_integrity)?; + let expected_points = source + .recovered_points + .saturating_add(sealed.sealed_points()); if stage_integrity.raw_commits != source.recovered_commits - || stage_integrity.raw_points != source.recovered_points + || stage_integrity.raw_points != expected_points { return Err(Error::Corruption { offset: 0, @@ -834,7 +1091,7 @@ impl Store { )); } if destination_integrity.raw_commits != source.recovered_commits - || destination_integrity.raw_points != source.recovered_points + || destination_integrity.raw_points != expected_points { return Err(Error::Corruption { offset: 0, @@ -885,6 +1142,71 @@ impl Store { &self.root } + fn can_skip_maintain_scan( + &self, + now_micros: i64, + source_points: u64, + definitions: &[SeriesDefinition], + ) -> Result { + if self + .manifest + .rollups + .iter() + .any(|rollup| rollup.active && rollup.source_points != source_points) + { + return Ok(false); + } + if has_retention_work(&self.manifest, definitions, now_micros) { + return Ok(false); + } + for definition in definitions { + if definition.semantics != SeriesSemantics::Gauge { + continue; + } + if !self.can_skip_series_latest_query(definition, now_micros, &self.manifest.rollups)? { + return Ok(false); + } + } + Ok(true) + } + + fn can_skip_series_latest_query( + &self, + definition: &SeriesDefinition, + now_micros: i64, + rollups: &[RollupDescriptor], + ) -> Result { + let known_unchanged = self.materialized_series_revisions.get(&definition.id) + == Some(&self.database.series_revision_count(definition.id)); + let no_new_completed_shard = match (known_unchanged, self.last_maintain_now_micros) { + (true, Some(prev_now)) => { + !series_gained_completed_shard(definition, prev_now, now_micros)? + } + _ => false, + }; + if no_new_completed_shard { + return Ok(true); + } + Ok(!series_has_missing_completed_shard( + definition, + self.database.series_valid_bounds(definition.id), + now_micros, + rollups, + )?) + } + + fn remember_materialized_revisions(&mut self, definitions: &[SeriesDefinition]) { + self.materialized_series_revisions.clear(); + for definition in definitions { + if definition.semantics == SeriesSemantics::Gauge { + self.materialized_series_revisions.insert( + definition.id, + self.database.series_revision_count(definition.id), + ); + } + } + } + fn advance_after_points(&mut self, points: &[Point]) -> Result<()> { let stats = self.database.stats()?; let mut next = self.manifest.clone(); @@ -937,7 +1259,7 @@ impl Store { let context_end = end.saturating_add(max_gap_micros.max(1)); let points = self .database - .query_latest(series_id, context_start, context_end); + .query_latest(series_id, context_start, context_end)?; let mut buckets = materialize(&points, resolution, max_gap_micros)?; buckets.retain(|bucket| bucket.start >= start && bucket.end <= end); Ok(buckets) @@ -949,6 +1271,12 @@ impl Store { self.active_rollups() .map(|descriptor| format!("{ROLLUP_DIRECTORY}/{}", descriptor.file)), ); + files.extend( + self.manifest + .segments + .iter() + .map(|descriptor| format!("{SEGMENT_DIRECTORY}/{}", descriptor.file)), + ); if self.manifest.generation > 0 { files.push(format!( "{MANIFEST_DIRECTORY}/MANIFEST.{:020}", @@ -960,6 +1288,21 @@ impl Store { files } + fn attach_published_segments(&mut self) -> Result<()> { + let mut opened = Vec::with_capacity(self.manifest.segments.len()); + for descriptor in &self.manifest.segments { + let path = self.segment_directory.join(&descriptor.file); + let segment = Segment::open(&path).map_err(|error| Error::Corruption { + offset: 0, + reason: format!("raw segment {} is unreadable: {error}", descriptor.file), + })?; + verify_raw_segment_descriptor(&segment, descriptor)?; + opened.push(segment); + } + self.database.attach_sealed_segments(opened); + Ok(()) + } + fn require_clean_restore_source(&self, report: &IntegrityReport) -> Result<()> { if report.stale_rollup_files > 0 { return Err(Error::Corruption { @@ -988,8 +1331,10 @@ impl Store { fn write_snapshot(&self, temporary: &Path) -> Result { let manifests = temporary.join(MANIFEST_DIRECTORY); let rollups = temporary.join(ROLLUP_DIRECTORY); + let segments = temporary.join(SEGMENT_DIRECTORY); std::fs::create_dir(&manifests)?; std::fs::create_dir(&rollups)?; + std::fs::create_dir(&segments)?; let mut report = BackupReport { manifest_generation: self.manifest.generation, ..BackupReport::default() @@ -1007,6 +1352,13 @@ impl Store { )?; report.record_link_or_copy(outcome); } + for descriptor in &self.manifest.segments { + let outcome = hard_link_or_copy( + &self.segment_directory.join(&descriptor.file), + &segments.join(&descriptor.file), + )?; + report.record_link_or_copy(outcome); + } if self.manifest.generation > 0 { let file = format!("MANIFEST.{:020}", self.manifest.generation); let outcome = @@ -1016,6 +1368,7 @@ impl Store { publication_checkpoint(PublicationStep::Sync)?; sync_directory(&manifests)?; sync_directory(&rollups)?; + sync_directory(&segments)?; sync_directory(temporary)?; report.bytes = directory_bytes(temporary)?; Ok(report) @@ -1052,11 +1405,6 @@ impl Store { // invalidation. Conservatively rebuild every stale descriptor. descriptor.active = false; changed = true; - } else { - self.rollup_cache - .write() - .map_err(|_| Error::Poisoned)? - .insert(descriptor.file.clone(), segment); } } if changed { @@ -1073,7 +1421,6 @@ impl Store { fn verify_manifest_read_only(&mut self) -> Result<()> { let stats = self.database.stats()?; let mut stale_rollup_files = 0_usize; - let mut cache = self.rollup_cache.write().map_err(|_| Error::Poisoned)?; for descriptor in self.manifest.rollups.iter().filter(|rollup| rollup.active) { if descriptor.source_points > stats.points { return Err(Error::Corruption { @@ -1095,11 +1442,8 @@ impl Store { } if descriptor.source_points < stats.points { stale_rollup_files += 1; - } else { - cache.insert(descriptor.file.clone(), segment); } } - drop(cache); self.stale_rollup_files = stale_rollup_files; Ok(()) } @@ -1153,9 +1497,15 @@ impl Store { let Ok(mut referenced) = manifest::referenced_rollup_files(&retained) else { return; }; + let Ok(mut referenced_segments) = manifest::referenced_segment_files(&retained) else { + return; + }; for rollup in &self.manifest.rollups { referenced.insert(rollup.file.clone()); } + for segment in &self.manifest.segments { + referenced_segments.insert(segment.file.clone()); + } let Ok(entries) = std::fs::read_dir(&self.rollup_directory) else { return; }; @@ -1173,6 +1523,21 @@ impl Store { if removed { let _ = sync_directory(&self.rollup_directory); } + let Ok(entries) = std::fs::read_dir(&self.segment_directory) else { + return; + }; + let mut removed_segments = false; + for entry in entries.flatten() { + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.ends_with(".wseg") && !referenced_segments.contains(&name) { + removed_segments |= std::fs::remove_file(entry.path()).is_ok(); + } + } + if removed_segments { + let _ = sync_directory(&self.segment_directory); + } } fn ensure_healthy(&self) -> Result<()> { @@ -1189,6 +1554,82 @@ impl Store { } self.ensure_healthy() } + + fn cached_rollup_buckets( + &self, + descriptors: &[&RollupDescriptor], + start: i64, + end: i64, + ) -> Result> { + let mut missing = Vec::new(); + { + let cache = self.rollup_cache.read().map_err(|_| Error::Poisoned)?; + if descriptors + .iter() + .all(|descriptor| cache.contains_key(&descriptor.file)) + { + return Ok(descriptors + .iter() + .flat_map(|descriptor| { + cache + .get(&descriptor.file) + .expect("checked") + .query(start, end) + }) + .collect()); + } + for descriptor in descriptors { + if !cache.contains_key(&descriptor.file) { + missing.push(descriptor.file.clone()); + } + } + } + + let mut opened = Vec::with_capacity(missing.len()); + for file in missing { + opened.push(( + file.clone(), + RollupSegment::open(self.rollup_directory.join(&file))?, + )); + } + + let mut cache = self.rollup_cache.write().map_err(|_| Error::Poisoned)?; + for (file, segment) in opened { + cache.entry(file).or_insert(segment); + } + let mut buckets = Vec::new(); + for descriptor in descriptors { + if let Some(segment) = cache.get(&descriptor.file) { + buckets.extend(segment.query(start, end)); + continue; + } + let segment = RollupSegment::open(self.rollup_directory.join(&descriptor.file))?; + buckets.extend(segment.query(start, end)); + cache.insert(descriptor.file.clone(), segment); + } + trim_rollup_cache(&mut cache, descriptors); + Ok(buckets) + } +} + +fn trim_rollup_cache(cache: &mut HashMap, keep: &[&RollupDescriptor]) { + if cache.len() <= MAX_CACHED_ROLLUP_SEGMENTS { + return; + } + let keep: HashSet<&str> = keep + .iter() + .map(|descriptor| descriptor.file.as_str()) + .collect(); + let overflow = cache.len() - MAX_CACHED_ROLLUP_SEGMENTS; + let evict: Vec = cache + .keys() + .filter(|file| !keep.contains(file.as_str())) + .take(overflow) + .cloned() + .collect(); + for file in evict { + cache.remove(&file); + } } fn materialize( @@ -1226,59 +1667,263 @@ struct RollupShard { buckets: Vec, } -fn rollup_shards( - buckets: &[GaugeBucket], - resolution: &RollupResolution, +fn has_retention_work( + manifest: &Manifest, + definitions: &[SeriesDefinition], now_micros: i64, -) -> Result> { - match resolution { - RollupResolution::FixedMicros(micros) if *micros > 0 => { - // The common 5m/30m/hour tiers get stable UTC-day files. An - // unusual resolution that does not divide a day gets one bucket - // per file rather than a moving, rewrite-heavy tail chunk. - let width = if *micros <= UTC_DAY_MICROS && UTC_DAY_MICROS % *micros == 0 { - UTC_DAY_MICROS - } else { - *micros - }; - let mut grouped = BTreeMap::>::new(); - for bucket in buckets { - let start = bucket.start.div_euclid(width) * width; - let end = start.saturating_add(width); - if end <= now_micros { - grouped.entry(start).or_default().push(*bucket); - } - } - Ok(grouped - .into_iter() - .map(|(start, buckets)| RollupShard { - start, - end: start.saturating_add(width), - buckets, +) -> bool { + definitions.iter().any(|definition| { + definition.semantics == SeriesSemantics::Gauge + && definition.rollup_policy.tiers.iter().any(|tier| { + let Some(retention) = tier.retain_for_micros else { + return false; + }; + let cutoff = now_micros.saturating_sub(retention); + manifest.rollups.iter().any(|rollup| { + rollup.active + && rollup.series_id == definition.id + && rollup.resolution == tier.resolution + && rollup.end < cutoff }) - .collect()) - } - RollupResolution::FixedMicros(_) => Err(Error::InvalidModel( - "fixed rollup resolution must be positive".to_owned(), - )), - RollupResolution::Calendar { .. } => Ok(buckets - .iter() - .filter(|bucket| bucket.end <= now_micros) - .map(|bucket| RollupShard { - start: bucket.start, - end: bucket.end, - buckets: vec![*bucket], }) - .collect()), - } + }) } -struct CoveragePlan<'a> { - descriptors: Vec<&'a RollupDescriptor>, - gaps: Vec<(i64, i64)>, +fn deactivate_expired_rollups( + next: &mut Manifest, + definition: &SeriesDefinition, + now_micros: i64, +) -> bool { + let mut changed = false; + for tier in &definition.rollup_policy.tiers { + let retention_cutoff = tier + .retain_for_micros + .map(|retention| now_micros.saturating_sub(retention)); + for rollup in &mut next.rollups { + if rollup.active + && rollup.series_id == definition.id + && rollup.resolution == tier.resolution + && retention_cutoff.is_some_and(|cutoff| rollup.end < cutoff) + { + rollup.active = false; + changed = true; + } + } + } + changed } -fn coverage_plan<'a>( +fn stamp_active_series_source( + next: &mut Manifest, + series_id: u64, + source_commit: u64, + source_points: u64, +) -> bool { + let mut changed = false; + for rollup in &mut next.rollups { + if rollup.active + && rollup.series_id == series_id + && (rollup.source_commit != source_commit || rollup.source_points != source_points) + { + rollup.source_commit = source_commit; + rollup.source_points = source_points; + changed = true; + } + } + changed +} + +fn series_gained_completed_shard( + definition: &SeriesDefinition, + prev_now: i64, + now_micros: i64, +) -> Result { + if now_micros <= prev_now { + return Ok(false); + } + for tier in &definition.rollup_policy.tiers { + if latest_completed_shard_end(now_micros, &tier.resolution)? + > latest_completed_shard_end(prev_now, &tier.resolution)? + { + return Ok(true); + } + } + Ok(false) +} + +fn series_has_missing_completed_shard( + definition: &SeriesDefinition, + bounds: Option<(i64, i64)>, + now_micros: i64, + rollups: &[RollupDescriptor], +) -> Result { + let Some((earliest, latest)) = bounds else { + return Ok(false); + }; + for tier in &definition.rollup_policy.tiers { + let needed = needed_completed_shards( + definition.id, + &tier.resolution, + earliest, + latest, + now_micros, + rollups, + u64::MAX, + )?; + if !needed.is_empty() { + return Ok(true); + } + } + Ok(false) +} + +struct ShardBounds { + start: i64, + end: i64, +} + +fn needed_completed_shards( + series_id: u64, + resolution: &RollupResolution, + earliest: i64, + latest: i64, + now_micros: i64, + rollups: &[RollupDescriptor], + current_points: u64, +) -> Result> { + let covered: HashSet<(i64, i64)> = rollups + .iter() + .filter(|rollup| { + rollup.active + && rollup.series_id == series_id + && &rollup.resolution == resolution + && (current_points == u64::MAX || rollup.source_points == current_points) + }) + .map(|rollup| (rollup.start, rollup.end)) + .collect(); + let mut needed = Vec::new(); + match resolution { + RollupResolution::FixedMicros(micros) => { + let width = fixed_shard_width(*micros)?; + let mut start = earliest.div_euclid(width) * width; + while start <= latest { + let end = start.saturating_add(width); + if end <= now_micros && !covered.contains(&(start, end)) { + needed.push(ShardBounds { start, end }); + } + if end <= start { + break; + } + start = end; + } + } + RollupResolution::Calendar { + unit, + iana_timezone, + } => { + let mut cursor = earliest; + while cursor <= latest { + let (start, end) = calendar_bucket_bounds(cursor, *unit, iana_timezone)?; + if end <= now_micros && !covered.contains(&(start, end)) { + needed.push(ShardBounds { start, end }); + } + if end <= cursor { + break; + } + cursor = end; + } + } + } + Ok(needed) +} + +fn latest_completed_shard_end( + now_micros: i64, + resolution: &RollupResolution, +) -> Result> { + match resolution { + RollupResolution::FixedMicros(micros) => { + let width = fixed_shard_width(*micros)?; + Ok(Some(now_micros.div_euclid(width) * width)) + } + RollupResolution::Calendar { + unit, + iana_timezone, + } => { + let (start, _) = calendar_bucket_bounds(now_micros, *unit, iana_timezone)?; + Ok(Some(start)) + } + } +} + +fn fixed_shard_width(micros: i64) -> Result { + if micros <= 0 { + return Err(Error::InvalidModel( + "fixed rollup resolution must be positive".to_owned(), + )); + } + Ok( + if micros <= UTC_DAY_MICROS && UTC_DAY_MICROS % micros == 0 { + UTC_DAY_MICROS + } else { + micros + }, + ) +} + +fn rollup_shards( + buckets: &[GaugeBucket], + resolution: &RollupResolution, + now_micros: i64, +) -> Result> { + match resolution { + RollupResolution::FixedMicros(micros) if *micros > 0 => { + // The common 5m/30m/hour tiers get stable UTC-day files. An + // unusual resolution that does not divide a day gets one bucket + // per file rather than a moving, rewrite-heavy tail chunk. + let width = if *micros <= UTC_DAY_MICROS && UTC_DAY_MICROS % *micros == 0 { + UTC_DAY_MICROS + } else { + *micros + }; + let mut grouped = BTreeMap::>::new(); + for bucket in buckets { + let start = bucket.start.div_euclid(width) * width; + let end = start.saturating_add(width); + if end <= now_micros { + grouped.entry(start).or_default().push(*bucket); + } + } + Ok(grouped + .into_iter() + .map(|(start, buckets)| RollupShard { + start, + end: start.saturating_add(width), + buckets, + }) + .collect()) + } + RollupResolution::FixedMicros(_) => Err(Error::InvalidModel( + "fixed rollup resolution must be positive".to_owned(), + )), + RollupResolution::Calendar { .. } => Ok(buckets + .iter() + .filter(|bucket| bucket.end <= now_micros) + .map(|bucket| RollupShard { + start: bucket.start, + end: bucket.end, + buckets: vec![*bucket], + }) + .collect()), + } +} + +struct CoveragePlan<'a> { + descriptors: Vec<&'a RollupDescriptor>, + gaps: Vec<(i64, i64)>, +} + +fn coverage_plan<'a>( mut candidates: Vec<&'a RollupDescriptor>, start: i64, end: i64, @@ -1355,6 +2000,168 @@ fn query_envelope(start: i64, end: i64, resolution: &RollupResolution) -> Result } } +fn published_seal_generations(manifest: &Manifest) -> HashSet { + manifest + .segments + .iter() + .map(|segment| segment.generation) + .collect() +} + +fn raw_segment_file_name(generation: u64) -> String { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("g{generation}-{nonce}.wseg") +} + +fn verify_raw_segment_descriptor( + segment: &Segment, + descriptor: &RawSegmentDescriptor, +) -> Result<()> { + if segment.stats().points != descriptor.points + || segment.valid_bounds() != Some((descriptor.min_valid_time, descriptor.max_valid_time)) + || segment.content_crc32()? != descriptor.content_crc32 + { + return Err(Error::Corruption { + offset: 0, + reason: format!( + "raw segment {} does not match its manifest count, time bounds, or content checksum", + descriptor.file + ), + }); + } + Ok(()) +} + +#[derive(Clone, Debug, Default)] +struct SealedSalvagePlan { + manifest: Manifest, +} + +impl SealedSalvagePlan { + fn sealed_points(&self) -> u64 { + self.manifest + .segments + .iter() + .map(|segment| segment.points) + .sum() + } +} + +fn salvage_snapshot_paths(sealed: &SealedSalvagePlan) -> Vec { + let mut files = vec![ACTIVE_LOG.to_owned()]; + files.extend( + sealed + .manifest + .segments + .iter() + .map(|descriptor| format!("{SEGMENT_DIRECTORY}/{}", descriptor.file)), + ); + files.sort(); + files.dedup(); + files +} + +fn list_wseg_names(root: &Path) -> Result> { + let segments = root.join(SEGMENT_DIRECTORY); + let entries = match std::fs::read_dir(&segments) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(Error::Io(error)), + }; + let mut names = Vec::new(); + for entry in entries { + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.ends_with(".wseg") { + names.push(name); + } + } + names.sort(); + Ok(names) +} + +/// Recovers sealed `.wseg` coverage from the highest valid manifest. Missing +/// or unreadable sealed files fail closed so salvage never publishes a store +/// that silently dropped historical raw. +fn plan_sealed_salvage(root: &Path, options: SalvageOptions) -> Result { + let on_disk = list_wseg_names(root)?; + let manifests = root.join(MANIFEST_DIRECTORY); + let loaded = if manifests.is_dir() { + match Manifest::load(&manifests) { + Ok(manifest) => manifest, + Err(_) if on_disk.is_empty() => Manifest::default(), + Err(error) => return Err(error), + } + } else if on_disk.is_empty() { + Manifest::default() + } else { + return Err(Error::Corruption { + offset: 0, + reason: "salvage found sealed raw segments but no readable manifest descriptors" + .to_owned(), + }); + }; + + if loaded.segments.is_empty() && !on_disk.is_empty() { + return Err(Error::Corruption { + offset: 0, + reason: "salvage found sealed raw segments but no manifest descriptors".to_owned(), + }); + } + + let referenced: HashSet<&str> = loaded + .segments + .iter() + .map(|segment| segment.file.as_str()) + .collect(); + for name in &on_disk { + if !referenced.contains(name.as_str()) { + if options.drop_orphan_segments { + continue; + } + return Err(Error::Corruption { + offset: 0, + reason: format!( + "salvage found sealed segment {name} not named by the recovered manifest" + ), + }); + } + } + + let segment_directory = root.join(SEGMENT_DIRECTORY); + for descriptor in &loaded.segments { + let path = segment_directory.join(&descriptor.file); + let segment = Segment::open(&path).map_err(|error| Error::Corruption { + offset: 0, + reason: format!( + "sealed raw segment {} is unreadable: {error}", + descriptor.file + ), + })?; + verify_raw_segment_descriptor(&segment, descriptor)?; + segment.verify_blocks().map_err(|error| Error::Corruption { + offset: 0, + reason: format!( + "sealed raw segment {} failed block verification: {error}", + descriptor.file + ), + })?; + } + + Ok(SealedSalvagePlan { + manifest: Manifest { + generation: loaded.generation, + rollups: Vec::new(), + segments: loaded.segments, + }, + }) +} + fn rollup_file_name( generation: u64, series_id: u64, @@ -1374,20 +2181,31 @@ fn rollup_file_name( fn copy_and_sync(source: &Path, destination: &Path) -> std::io::Result { let bytes = std::fs::copy(source, destination)?; + std::fs::set_permissions(destination, std::fs::Permissions::from_mode(0o600))?; std::fs::File::open(destination)?.sync_all()?; Ok(bytes) } -fn write_salvage_stage(source: &mut SalvageSource, temporary: &Path) -> Result<()> { +fn write_salvage_stage( + source: &mut SalvageSource, + source_root: &Path, + temporary: &Path, + sealed: &SealedSalvagePlan, +) -> Result<()> { let manifests = temporary.join(MANIFEST_DIRECTORY); let rollups = temporary.join(ROLLUP_DIRECTORY); + let segments = temporary.join(SEGMENT_DIRECTORY); std::fs::create_dir(&manifests)?; std::fs::create_dir(&rollups)?; + if !sealed.manifest.segments.is_empty() { + std::fs::create_dir(&segments)?; + } publication_checkpoint(PublicationStep::Copy)?; source.file.seek(SeekFrom::Start(0))?; let mut active = std::fs::OpenOptions::new() .create_new(true) .write(true) + .mode(0o600) .open(temporary.join(ACTIVE_LOG))?; let mut prefix = std::io::Read::by_ref(&mut source.file).take(source.recovered_prefix_bytes); let copied = std::io::copy(&mut prefix, &mut active)?; @@ -1397,10 +2215,26 @@ fn write_salvage_stage(source: &mut SalvageSource, temporary: &Path) -> Result<( }); } active.sync_all()?; + for descriptor in &sealed.manifest.segments { + hard_link_or_copy( + &source_root.join(SEGMENT_DIRECTORY).join(&descriptor.file), + &segments.join(&descriptor.file), + )?; + } + if !sealed.manifest.segments.is_empty() { + let mut salvage_manifest = sealed.manifest.clone(); + if salvage_manifest.generation == 0 { + salvage_manifest.generation = 1; + } + salvage_manifest.publish(&manifests)?; + } source.ensure_unchanged()?; publication_checkpoint(PublicationStep::Sync)?; sync_directory(&manifests)?; sync_directory(&rollups)?; + if !sealed.manifest.segments.is_empty() { + sync_directory(&segments)?; + } sync_directory(temporary)?; Ok(()) } @@ -1462,6 +2296,18 @@ where } fn hard_link_or_copy(source: &Path, destination: &Path) -> std::io::Result { + let mode = std::fs::symlink_metadata(source)?.permissions().mode(); + if mode & 0o077 != 0 { + // A shared inode keeps the source mode. Copying into a 0755 backup + // directory must not republish a 0644 rollup or manifest. + copy_and_sync(source, destination)?; + return Ok(LinkOrCopy::Copied { + link_error: std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "refusing to hard-link a group- or world-accessible inode", + ), + }); + } hard_link_or_copy_with( source, destination, @@ -1472,18 +2318,46 @@ fn hard_link_or_copy(source: &Path, destination: &Path) -> std::io::Result Result { let mut total = 0_u64; - for entry in std::fs::read_dir(path)? { - let entry = entry?; - let metadata = entry.metadata()?; - total = total.saturating_add(if metadata.is_dir() { - directory_bytes(&entry.path())? - } else { - metadata.len() - }); + let mut directories = vec![path.to_path_buf()]; + while let Some(directory) = directories.pop() { + require_real_directory(&directory)?; + for entry in std::fs::read_dir(&directory)? { + let entry = entry?; + let entry_path = entry.path(); + let metadata = std::fs::symlink_metadata(&entry_path)?; + if metadata.file_type().is_dir() { + directories.push(entry_path); + } else if metadata.file_type().is_file() { + total = total.checked_add(metadata.len()).ok_or_else(|| { + Error::Serialization("stored byte count exceeds u64".to_owned()) + })?; + } else { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "stored path is not a regular file or directory: {}", + entry_path.display() + ), + ))); + } + } } Ok(total) } +fn create_or_require_real_directory(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(_) => require_real_directory(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700).create(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + require_real_directory(path) + } + Err(error) => Err(Error::Io(error)), + } +} + fn require_real_directory(path: &Path) -> Result<()> { if std::fs::symlink_metadata(path)?.file_type().is_dir() { return Ok(()); @@ -1512,17 +2386,19 @@ fn snapshot_mismatch( #[cfg(test)] mod tests { use super::{ - BackupReport, LinkOrCopy, RollupSource, SalvageStatus, Store, hard_link_or_copy_with, + BackupReport, LinkOrCopy, RollupSource, SalvageOptions, SalvageStatus, Store, + fail_next_seal_reclaim, hard_link_or_copy, hard_link_or_copy_with, }; use crate::snapshot::{PublicationStep, StagedDirectory, fail_next_publication_step}; use crate::storage::mutate_salvage_source_after_identity_checks; use crate::{ CalendarUnit, Entity, EntityId, Point, RollupPolicy, RollupResolution, RollupTier, - SalvageStopReason, SeriesDefinition, SeriesSemantics, Transaction, + SalvageStopReason, Segment, SeriesDefinition, SeriesSemantics, Transaction, }; use std::collections::BTreeMap; use std::error::Error as _; use std::io::{Seek, SeekFrom, Write}; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::Path; use tempfile::tempdir; @@ -1542,6 +2418,53 @@ mod tests { assert!(matches!(result, LinkOrCopy::Linked)); } + #[test] + fn hard_link_copies_group_readable_source_as_private() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let destination = directory.path().join("destination"); + std::fs::write(&source, b"rollup-bytes").unwrap(); + std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o644)).unwrap(); + + match hard_link_or_copy(&source, &destination).unwrap() { + LinkOrCopy::Copied { link_error } => { + assert_eq!(link_error.kind(), std::io::ErrorKind::PermissionDenied); + } + LinkOrCopy::Linked => panic!("group-readable source must be copied"), + } + assert_eq!(std::fs::read(&destination).unwrap(), b"rollup-bytes"); + assert_eq!( + std::fs::metadata(&destination) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_ne!( + std::fs::metadata(&source).unwrap().ino(), + std::fs::metadata(&destination).unwrap().ino() + ); + } + + #[test] + fn hard_link_keeps_a_private_source_inode() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let destination = directory.path().join("destination"); + std::fs::write(&source, b"private").unwrap(); + std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o600)).unwrap(); + + assert!(matches!( + hard_link_or_copy(&source, &destination).unwrap(), + LinkOrCopy::Linked + )); + assert_eq!( + std::fs::metadata(&source).unwrap().ino(), + std::fs::metadata(&destination).unwrap().ino() + ); + } + #[test] fn copy_fallback_reports_the_hard_link_cause() { let result = hard_link_or_copy_with( @@ -1881,34 +2804,123 @@ mod tests { } #[test] - fn second_store_opener_fails_until_the_first_closes() { + fn open_creates_an_owner_only_root_and_active_log() { let directory = tempdir().unwrap(); - let first = Store::open(directory.path()).unwrap(); - match Store::open(directory.path()) { - Err(crate::Error::Locked { path }) => { - assert_eq!(path, directory.path().join("active.wlog")); - } - Err(other) => panic!("expected Error::Locked, got {other:?}"), - Ok(_) => panic!("expected Error::Locked, got a second open store"), + let root = directory.path().join("private-store"); + Store::open(&root).unwrap().close().unwrap(); + assert_eq!( + std::fs::symlink_metadata(&root) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(root.join("active.wlog")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + for directory in ["manifests", "rollups", "segments"] { + assert_eq!( + std::fs::symlink_metadata(root.join(directory)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); } - first.close().unwrap(); - let mut reopened = Store::open(directory.path()).unwrap(); - initialize(&mut reopened, Vec::new(), None); } #[test] - fn read_only_open_neither_reconciles_nor_sweeps() { + fn writable_open_rejects_symlinked_store_directories() { let directory = tempdir().unwrap(); - let resolution = RollupResolution::FixedMicros(5 * SECOND); - { - let mut store = Store::open(directory.path()).unwrap(); - initialize( - &mut store, - vec![RollupTier { - resolution: resolution.clone(), - retain_for_micros: None, - }], - None, + let outside = directory.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + + let linked_root = directory.path().join("linked-root"); + std::os::unix::fs::symlink(&outside, &linked_root).unwrap(); + assert!(Store::open(&linked_root).is_err()); + assert!(std::fs::read_dir(&outside).unwrap().next().is_none()); + + for child in ["manifests", "rollups", "segments"] { + let root = directory.path().join(format!("store-{child}")); + let child_outside = directory.path().join(format!("outside-{child}")); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(&child_outside).unwrap(); + std::os::unix::fs::symlink(&child_outside, root.join(child)).unwrap(); + + assert!(Store::open(&root).is_err(), "accepted symlinked {child}"); + assert!( + std::fs::read_dir(&child_outside).unwrap().next().is_none(), + "wrote through symlinked {child}" + ); + } + } + + #[test] + fn stored_bytes_rejects_symlinks_instead_of_following_them() { + let directory = tempdir().unwrap(); + let root = directory.path().join("store"); + let outside = directory.path().join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("large"), vec![0_u8; 4096]).unwrap(); + let store = Store::open(&root).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("linked-outside")).unwrap(); + + assert!(store.stored_bytes().is_err()); + } + + #[test] + fn open_of_a_precreated_root_still_publishes_the_parent_entry() { + // ftwdb-shadow creates the private store directory before Store::open. + // The parent fsync must still run; otherwise Always commits can be + // acknowledged for a directory whose parent dirent is not durable. + let directory = tempdir().unwrap(); + let root = directory.path().join("precreated"); + std::fs::create_dir(&root).unwrap(); + { + let mut store = Store::open(&root).unwrap(); + initialize(&mut store, Vec::new(), None); + store.close().unwrap(); + } + let store = Store::open_read_only(&root).unwrap(); + assert_eq!(store.database().stats().unwrap().catalog_records, 2); + } + + #[test] + fn second_store_opener_fails_until_the_first_closes() { + let directory = tempdir().unwrap(); + let first = Store::open(directory.path()).unwrap(); + match Store::open(directory.path()) { + Err(crate::Error::Locked { path }) => { + assert_eq!(path, directory.path().join("active.wlog")); + } + Err(other) => panic!("expected Error::Locked, got {other:?}"), + Ok(_) => panic!("expected Error::Locked, got a second open store"), + } + first.close().unwrap(); + let mut reopened = Store::open(directory.path()).unwrap(); + initialize(&mut reopened, Vec::new(), None); + } + + #[test] + fn read_only_open_neither_reconciles_nor_sweeps() { + let directory = tempdir().unwrap(); + let resolution = RollupResolution::FixedMicros(5 * SECOND); + { + let mut store = Store::open(directory.path()).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + None, ); let mut transaction = Transaction::new(); transaction.append_points(points()); @@ -2214,6 +3226,7 @@ mod tests { store .database() .query_history(1, 6 * SECOND, 6 * SECOND + 1) + .unwrap() .len(), 2 // the original sixth-second sample plus exactly one correction ); @@ -2252,7 +3265,7 @@ mod tests { assert!(!store.commit(other).unwrap().deduplicated); assert_eq!(store.database().stats().unwrap().points, 2); assert_eq!( - store.database().query_history(1, 0, DAY).len(), + store.database().query_history(1, 0, DAY).unwrap().len(), 2 // each identified commit's point appears exactly once ); } @@ -2313,6 +3326,165 @@ mod tests { ); } + #[test] + fn second_maintain_without_new_points_writes_no_rollup_files() { + let directory = tempdir().unwrap(); + let resolution = RollupResolution::FixedMicros(5 * SECOND); + let mut store = Store::open(directory.path()).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + None, + ); + let mut transaction = Transaction::new(); + transaction.append_points(points()); + store.commit(transaction).unwrap(); + let first = store.maintain(DAY).unwrap(); + assert_eq!(first.rollup_files_written, 1); + let generation = store.manifest_generation(); + + let second = store.maintain(DAY).unwrap(); + assert_eq!(second.rollup_files_written, 0); + assert_eq!(second.manifest_generation, generation); + assert_eq!(store.manifest_generation(), generation); + assert_eq!( + store + .query_gauge(1, 0, 20 * SECOND, &resolution) + .unwrap() + .source, + RollupSource::Materialized + ); + } + + #[test] + fn maintain_does_not_rewrite_unchanged_series_when_another_ingests() { + let directory = tempdir().unwrap(); + let resolution = RollupResolution::FixedMicros(5 * SECOND); + let mut store = Store::open(directory.path()).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + None, + ); + let mut transaction = Transaction::new(); + transaction.define_series(SeriesDefinition { + id: 2, + owner_entity: Some(EntityId(1)), + owner_relation: None, + name: "site_power".to_owned(), + physical_quantity: "power".to_owned(), + canonical_unit: "W".to_owned(), + semantics: SeriesSemantics::Gauge, + maximum_gap_micros: Some(2 * SECOND), + rollup_policy: RollupPolicy { + raw_retain_for_micros: None, + tiers: vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + }, + }); + store.commit(transaction).unwrap(); + + let mut transaction = Transaction::new(); + transaction.append_points(points()); + store.commit(transaction).unwrap(); + store.maintain(DAY).unwrap(); + let series_a_files: Vec<_> = store + .active_rollups() + .filter(|rollup| rollup.series_id == 1) + .map(|rollup| rollup.file.clone()) + .collect(); + assert_eq!(series_a_files.len(), 1); + + let mut transaction = Transaction::new(); + transaction.append_points( + (0..=20) + .map(|second| Point::actual(2, second * SECOND, second as f64)) + .collect::>(), + ); + store.commit(transaction).unwrap(); + assert_eq!( + store + .query_gauge(1, 0, 20 * SECOND, &resolution) + .unwrap() + .source, + RollupSource::Materialized + ); + + let report = store.maintain(DAY).unwrap(); + assert_eq!(report.rollup_files_written, 1); + let series_a_after: Vec<_> = store + .active_rollups() + .filter(|rollup| rollup.series_id == 1) + .map(|rollup| rollup.file.clone()) + .collect(); + assert_eq!(series_a_after, series_a_files); + let current_points = store.database().stats().unwrap().points; + assert!( + store + .active_rollups() + .filter(|rollup| rollup.series_id == 1) + .all(|rollup| rollup.source_points == current_points) + ); + assert_eq!( + store + .query_gauge(1, 0, 20 * SECOND, &resolution) + .unwrap() + .source, + RollupSource::Materialized + ); + assert_eq!( + store + .query_gauge(2, 0, 20 * SECOND, &resolution) + .unwrap() + .source, + RollupSource::Materialized + ); + } + + #[test] + fn later_maintain_closes_completed_shards_without_new_points() { + let directory = tempdir().unwrap(); + let resolution = RollupResolution::FixedMicros(5 * SECOND); + let mut store = Store::open(directory.path()).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + None, + ); + let mut transaction = Transaction::new(); + transaction.append_points(points()); + store.commit(transaction).unwrap(); + assert_eq!(store.maintain(20 * SECOND).unwrap().rollup_files_written, 0); + assert_eq!( + store + .query_gauge(1, 0, 20 * SECOND, &resolution) + .unwrap() + .source, + RollupSource::Raw + ); + + let report = store.maintain(DAY).unwrap(); + assert_eq!(report.rollup_files_written, 1); + assert_eq!( + store + .query_gauge(1, 0, 20 * SECOND, &resolution) + .unwrap() + .source, + RollupSource::Materialized + ); + } + #[test] fn manifest_generations_stay_bounded_across_commits() { let directory = tempdir().unwrap(); @@ -2586,6 +3758,56 @@ mod tests { assert_eq!(store.database().stats().unwrap().points, backup_points + 1); } + #[test] + fn backup_copies_a_group_readable_rollup_instead_of_hard_linking() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let destination = directory.path().join("backup"); + let mut store = Store::open(&source).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: RollupResolution::FixedMicros(5 * SECOND), + retain_for_micros: None, + }], + None, + ); + let mut transaction = Transaction::new(); + transaction.append_points(points()); + store.commit(transaction).unwrap(); + store.maintain(DAY).unwrap(); + let rollup = store + .active_rollups() + .next() + .expect("maintain wrote a rollup") + .file + .clone(); + let source_rollup = source.join("rollups").join(&rollup); + std::fs::set_permissions(&source_rollup, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let report = store.backup_to(&destination).unwrap(); + assert!(report.hard_link_fallbacks >= 1); + assert!( + report + .hard_link_fallback_error_kinds + .contains(&std::io::ErrorKind::PermissionDenied) + ); + + let destination_rollup = destination.join("rollups").join(&rollup); + assert_eq!( + std::fs::metadata(&destination_rollup) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_ne!( + std::fs::metadata(&source_rollup).unwrap().ino(), + std::fs::metadata(&destination_rollup).unwrap().ino() + ); + } + #[test] fn restore_preserves_the_selected_snapshot_and_is_independent() { let directory = tempdir().unwrap(); @@ -3276,4 +4498,645 @@ mod tests { ); } } + + #[test] + fn seal_reclaim_reopen_reads_a_point_only_from_the_sealed_segment() { + let directory = tempdir().unwrap(); + let sealed = Point::actual(1, 5 * SECOND, 5.0); + let log_bytes_before; + { + let mut store = Store::open(directory.path()).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![sealed]); + store.commit(transaction).unwrap(); + log_bytes_before = store.database().stats().unwrap().file_bytes; + let report = store.seal_and_reclaim().unwrap(); + assert_eq!(report.sealed_points, 1); + assert_eq!(report.live_points, 0); + assert!(report.log_bytes < log_bytes_before); + assert_eq!(store.database().live_index_len(), 0); + assert_eq!(store.database().sealed_point_count(), 1); + assert_eq!( + store + .database() + .query_latest(1, i64::MIN, i64::MAX) + .unwrap(), + vec![sealed] + ); + store.close().unwrap(); + } + + let store = Store::open(directory.path()).unwrap(); + assert_eq!(store.database().live_index_len(), 0); + assert_eq!(store.database().sealed_point_count(), 1); + assert_eq!( + store + .database() + .query_latest(1, i64::MIN, i64::MAX) + .unwrap(), + vec![sealed] + ); + assert_eq!( + store + .database() + .query_history(1, i64::MIN, i64::MAX) + .unwrap(), + vec![sealed] + ); + assert!(store.database().stats().unwrap().file_bytes < log_bytes_before); + } + + #[test] + fn crash_after_seal_publish_before_reclaim_keeps_winners() { + let directory = tempdir().unwrap(); + let first = Point::actual(1, 5 * SECOND, 5.0); + let correction = Point { + series_id: 1, + valid_time: 5 * SECOND, + valid_time_end: 5 * SECOND, + knowledge_time: 6 * SECOND, + change_time: 6 * SECOND, + run_id: 0, + value: 9.0, + quality: 0, + flags: 0, + }; + { + let mut store = Store::open(directory.path()).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![first, correction]); + store.commit(transaction).unwrap(); + fail_next_seal_reclaim(); + assert!(store.seal_and_reclaim().is_err()); + store.close().unwrap(); + } + + let store = Store::open(directory.path()).unwrap(); + assert_eq!(store.database().live_index_len(), 0); + assert_eq!( + store + .database() + .query_latest(1, i64::MIN, i64::MAX) + .unwrap(), + vec![correction] + ); + assert_eq!( + store + .database() + .query_history(1, i64::MIN, i64::MAX) + .unwrap(), + vec![first, correction] + ); + assert_eq!(store.database().stats().unwrap().points, 2); + } + + #[test] + fn range_query_spans_sealed_history_and_the_live_tail() { + let directory = tempdir().unwrap(); + let historical = Point::actual(1, 5 * SECOND, 5.0); + let tail = Point::actual(1, 15 * SECOND, 15.0); + let mut store = Store::open(directory.path()).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![historical]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + assert_eq!(store.database().live_index_len(), 0); + + let mut transaction = Transaction::new(); + transaction.append_points(vec![tail]); + store.commit(transaction).unwrap(); + assert_eq!(store.database().live_index_len(), 1); + assert_eq!( + store.database().query_latest(1, 0, 10 * SECOND).unwrap(), + vec![historical] + ); + assert_eq!( + store + .database() + .query_latest(1, 10 * SECOND, 20 * SECOND) + .unwrap(), + vec![tail] + ); + assert_eq!( + store.database().query_latest(1, 0, 20 * SECOND).unwrap(), + vec![historical, tail] + ); + } + + #[test] + fn live_tail_wins_an_equal_bitemporal_tie_after_seal() { + let directory = tempdir().unwrap(); + let first = Point::actual(1, 5 * SECOND, 1.0); + let correction = Point::actual(1, 5 * SECOND, 2.0); + + { + let mut store = Store::open(directory.path()).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![first]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + + let mut transaction = Transaction::new(); + transaction.append_points(vec![correction]); + store.commit(transaction).unwrap(); + assert_eq!( + store.database().query_history(1, 0, 10 * SECOND).unwrap(), + vec![first, correction] + ); + assert_eq!( + store.database().query_latest(1, 0, 10 * SECOND).unwrap(), + vec![correction] + ); + store.seal_and_reclaim().unwrap(); + assert_eq!( + store.database().query_latest(1, 0, 10 * SECOND).unwrap(), + vec![correction] + ); + store.close().unwrap(); + } + + let store = Store::open_read_only(directory.path()).unwrap(); + assert_eq!( + store.database().query_history(1, 0, 10 * SECOND).unwrap(), + vec![first, correction] + ); + assert_eq!( + store.database().query_latest(1, 0, 10 * SECOND).unwrap(), + vec![correction] + ); + } + + #[test] + fn maintain_after_seal_materializes_from_segments_not_the_live_index() { + let directory = tempdir().unwrap(); + let resolution = RollupResolution::FixedMicros(5 * SECOND); + let mut store = Store::open(directory.path()).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + None, + ); + let mut transaction = Transaction::new(); + transaction.append_points(points()); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + assert_eq!(store.database().live_index_len(), 0); + assert_eq!(store.database().sealed_point_count(), 21); + + let report = store.maintain(DAY).unwrap(); + assert_eq!(report.rollup_files_written, 1); + assert_eq!(store.database().live_index_len(), 0); + let persisted = store.query_gauge(1, 0, 20 * SECOND, &resolution).unwrap(); + assert_eq!(persisted.source, RollupSource::Materialized); + let raw = store + .database() + .rollup_gauge(1, 0, 20 * SECOND + 1, 5 * SECOND, 2 * SECOND) + .unwrap() + .range(0, 20 * SECOND); + assert_eq!(persisted.buckets, raw); + } + + #[test] + fn dirty_maintain_after_seal_queries_only_the_live_window() { + let directory = tempdir().unwrap(); + let resolution = RollupResolution::FixedMicros(5 * SECOND); + let mut store = Store::open(directory.path()).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + None, + ); + let mut transaction = Transaction::new(); + transaction.append_points(points()); + store.commit(transaction).unwrap(); + store.maintain(DAY).unwrap(); + let historical: Vec<_> = store + .active_rollups() + .map(|rollup| rollup.file.clone()) + .collect(); + store.seal_and_reclaim().unwrap(); + assert_eq!(store.database().live_index_len(), 0); + + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(1, DAY + 5 * SECOND, 21.0)]); + store.commit(transaction).unwrap(); + assert_eq!(store.database().live_index_len(), 1); + let report = store.maintain(2 * DAY).unwrap(); + assert_eq!(report.rollup_files_written, 1); + assert!( + historical + .iter() + .all(|file| store.active_rollups().any(|rollup| &rollup.file == file)) + ); + assert_eq!(store.database().live_index_len(), 1); + assert_eq!( + store + .query_gauge(1, 0, 2 * DAY, &resolution) + .unwrap() + .source, + RollupSource::Materialized + ); + } + + #[test] + fn identified_commit_still_deduplicates_after_seal_and_reclaim() { + let directory = tempdir().unwrap(); + let sample = Point::actual(1, 6 * SECOND, 6.0); + let mut store = Store::open(directory.path()).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![sample]).with_commit_id(7); + assert!(!store.commit(transaction).unwrap().deduplicated); + store.seal_and_reclaim().unwrap(); + assert_eq!(store.database().live_index_len(), 0); + + let mut retry = Transaction::new(); + retry.append_points(vec![sample]).with_commit_id(7); + assert!(store.commit(retry).unwrap().deduplicated); + assert_eq!(store.database().live_index_len(), 0); + assert_eq!( + store.database().query_history(1, 0, DAY).unwrap(), + vec![sample] + ); + } + + fn sealed_wseg_paths(root: &Path) -> Vec { + let mut paths: Vec<_> = std::fs::read_dir(root.join("segments")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("wseg")) + .collect(); + paths.sort(); + paths + } + + fn corrupt_first_sealed_block_payload(root: &Path) { + let wseg = sealed_wseg_paths(root)[0].clone(); + let segment = Segment::open(&wseg).unwrap(); + let payload_offset = segment + .first_block_payload_offset() + .expect("sealed segment must expose a block payload offset"); + drop(segment); + overwrite_byte(&wseg, payload_offset, 0xAA); + } + + #[test] + fn valid_replacement_segment_fails_manifest_binding_before_reads_or_recovery() { + for replacement in [ + Point::actual(1, SECOND, 99.0), + Point::actual(2, SECOND, 1.0), + Point::actual(1, 2 * SECOND, 1.0), + ] { + let directory = tempdir().unwrap(); + let root = directory.path().join("store"); + let mut store = Store::open(&root).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(1, SECOND, 1.0)]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + let replacement_path = directory.path().join("replacement.wseg"); + Segment::create(&replacement_path, &[replacement], 1).unwrap(); + Segment::open(&replacement_path) + .unwrap() + .verify_blocks() + .unwrap(); + std::fs::copy(replacement_path, &sealed_wseg_paths(&root)[0]).unwrap(); + assert!(matches!( + store.check_integrity(), + Err(crate::Error::Corruption { .. }) + )); + store.close().unwrap(); + assert!(matches!( + Store::open_read_only(&root), + Err(crate::Error::Corruption { .. }) + )); + assert!(matches!( + Store::open(&root), + Err(crate::Error::Corruption { .. }) + )); + let restored = directory.path().join("restored"); + assert!(Store::restore_from(&root, &restored).is_err()); + assert!(!restored.exists()); + let salvaged = directory.path().join("salvaged"); + assert!(Store::salvage_from(&root, &salvaged).is_err()); + assert!(!salvaged.exists()); + } + } + + #[test] + fn query_latest_returns_corruption_for_corrupt_sealed_block_payload() { + let directory = tempdir().unwrap(); + let root = directory.path().join("store"); + { + let mut store = Store::open(&root).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(1, SECOND, 1.0)]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + store.close().unwrap(); + } + let store = Store::open_read_only(&root).unwrap(); + corrupt_first_sealed_block_payload(&root); + assert!(matches!( + store.database().query_latest(1, 0, DAY), + Err(crate::Error::Corruption { .. }) + )); + } + + #[test] + fn check_integrity_fails_on_corrupt_sealed_block_payload() { + let directory = tempdir().unwrap(); + let root = directory.path().join("store"); + { + let mut store = Store::open(&root).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(1, SECOND, 1.0)]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + store.close().unwrap(); + } + let store = Store::open_read_only(&root).unwrap(); + corrupt_first_sealed_block_payload(&root); + assert!(matches!( + store.check_integrity(), + Err(crate::Error::Corruption { .. }) + )); + } + + #[test] + fn salvage_with_drop_orphan_segments_ignores_unreferenced_wseg() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("salvaged"); + { + let mut store = Store::open(&source).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(1, SECOND, 1.0)]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + store.close().unwrap(); + } + let wseg = sealed_wseg_paths(&source)[0].clone(); + std::fs::copy(&wseg, source.join("segments").join("orphan.wseg")).unwrap(); + assert!(Store::salvage_from(&source, &target).is_err()); + + let target2 = directory.path().join("salvaged2"); + let report = Store::salvage_from_with_options( + &source, + &target2, + SalvageOptions { + drop_orphan_segments: true, + }, + ) + .unwrap(); + assert_eq!(report.recovered_points, 1); + let salvaged = Store::open_read_only(&target2).unwrap(); + assert_eq!( + salvaged.database().query_history(1, 0, DAY).unwrap(), + vec![Point::actual(1, SECOND, 1.0)] + ); + } + + #[test] + fn salvage_recovers_a_point_that_exists_only_in_a_sealed_segment() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("salvaged"); + let sealed = Point::actual(1, SECOND, 1.0); + let live = Point::actual(1, 2 * SECOND, 2.0); + { + let mut store = Store::open(&source).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![sealed]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + assert_eq!(store.database().live_index_len(), 0); + let mut transaction = Transaction::new(); + transaction.append_points(vec![live]); + store.commit(transaction).unwrap(); + store.close().unwrap(); + } + let source_before = directory_snapshot(&source); + + let report = Store::salvage_from(&source, &target).unwrap(); + assert_eq!(report.status, SalvageStatus::Clean); + assert_eq!(report.stop_reason, SalvageStopReason::CleanEof); + assert_eq!(report.recovered_points, 2); + assert_eq!( + report.source_prefix_crc32, + report.destination_snapshot_crc32 + ); + + let salvaged = Store::open_read_only(&target).unwrap(); + assert_eq!(salvaged.database().sealed_point_count(), 1); + assert_eq!(salvaged.database().live_index_len(), 1); + assert_eq!( + salvaged.database().query_history(1, 0, DAY).unwrap(), + vec![sealed, live] + ); + assert_eq!( + salvaged.database().query_history(1, 0, SECOND + 1).unwrap(), + vec![sealed] + ); + drop(salvaged); + + let reopened = Store::open(&target).unwrap(); + assert_eq!( + reopened.database().query_history(1, 0, SECOND + 1).unwrap(), + vec![sealed] + ); + assert_eq!(directory_snapshot(&source), source_before); + } + + #[test] + fn salvage_recovers_a_torn_live_tail_with_sealed_history_intact() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("salvaged"); + let sealed = Point::actual(1, SECOND, 1.0); + let live = Point::actual(1, 3 * SECOND, 3.0); + { + let mut store = Store::open(&source).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![sealed]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + let mut transaction = Transaction::new(); + transaction.append_points(vec![live]); + store.commit(transaction).unwrap(); + store.close().unwrap(); + } + let active = source.join("active.wlog"); + let clean_bytes = std::fs::metadata(&active).unwrap().len(); + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&active) + .unwrap(); + file.write_all(b"partial").unwrap(); + file.sync_all().unwrap(); + drop(file); + let source_before = directory_snapshot(&source); + + let report = Store::salvage_from(&source, &target).unwrap(); + assert_eq!(report.status, SalvageStatus::Partial); + assert_eq!(report.stop_reason, SalvageStopReason::IncompleteFrameHeader); + assert_eq!(report.recovered_prefix_bytes, clean_bytes); + assert_eq!(report.discarded_bytes, 7); + assert_eq!(report.recovered_points, 2); + assert_eq!( + std::fs::metadata(target.join("active.wlog")).unwrap().len(), + clean_bytes + ); + + let salvaged = Store::open_read_only(&target).unwrap(); + assert_eq!( + salvaged.database().query_history(1, 0, DAY).unwrap(), + vec![sealed, live] + ); + assert_eq!(salvaged.database().sealed_point_count(), 1); + assert_eq!(directory_snapshot(&source), source_before); + } + + #[test] + fn salvage_then_maintain_query_gauge_matches_raw_winners() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let salvaged_path = directory.path().join("salvaged"); + let resolution = RollupResolution::FixedMicros(5 * SECOND); + { + let mut store = Store::open(&source).unwrap(); + initialize( + &mut store, + vec![RollupTier { + resolution: resolution.clone(), + retain_for_micros: None, + }], + None, + ); + let mut transaction = Transaction::new(); + transaction.append_points(points()); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + assert_eq!(store.database().live_index_len(), 0); + store.close().unwrap(); + } + + Store::salvage_from(&source, &salvaged_path).unwrap(); + let mut salvaged = Store::open(&salvaged_path).unwrap(); + assert!( + salvaged.active_rollups().next().is_none(), + "salvage must drop rollups so maintain has to rebuild them" + ); + let report = salvaged.maintain(DAY).unwrap(); + assert_eq!(report.rollup_files_written, 1); + let persisted = salvaged + .query_gauge(1, 0, 20 * SECOND, &resolution) + .unwrap(); + assert_eq!(persisted.source, RollupSource::Materialized); + let raw = salvaged + .database() + .rollup_gauge(1, 0, 20 * SECOND + 1, 5 * SECOND, 2 * SECOND) + .unwrap() + .range(0, 20 * SECOND); + assert_eq!(persisted.buckets, raw); + } + + #[test] + fn salvage_fails_closed_on_an_unreadable_or_unreferenced_sealed_segment() { + for kind in ["corrupt", "missing", "no-manifest"] { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("salvaged"); + { + let mut store = Store::open(&source).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![Point::actual(1, SECOND, 1.0)]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + store.close().unwrap(); + } + match kind { + "corrupt" => flip_last_byte(&sealed_wseg_paths(&source)[0]), + "missing" => std::fs::remove_file(&sealed_wseg_paths(&source)[0]).unwrap(), + "no-manifest" => { + for entry in std::fs::read_dir(source.join("manifests")).unwrap() { + std::fs::remove_file(entry.unwrap().path()).unwrap(); + } + } + _ => unreachable!(), + } + let source_before = directory_snapshot(&source); + let error = Store::salvage_from(&source, &target).unwrap_err(); + assert!( + matches!(error, crate::Error::Corruption { .. }), + "{kind} must fail closed, got {error}" + ); + assert!(!target.exists(), "{kind} published a target"); + assert!( + salvage_stages(directory.path(), "salvaged").is_empty(), + "{kind} left a stage" + ); + assert_eq!(directory_snapshot(&source), source_before); + } + } + + #[test] + fn backup_and_restore_preserve_sealed_segment_history() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + let backup = directory.path().join("backup"); + let restored = directory.path().join("restored"); + let sealed = Point::actual(1, SECOND, 1.0); + let live = Point::actual(1, 4 * SECOND, 4.0); + { + let mut store = Store::open(&source).unwrap(); + initialize(&mut store, Vec::new(), None); + let mut transaction = Transaction::new(); + transaction.append_points(vec![sealed]); + store.commit(transaction).unwrap(); + store.seal_and_reclaim().unwrap(); + let mut transaction = Transaction::new(); + transaction.append_points(vec![live]); + store.commit(transaction).unwrap(); + store.backup_to(&backup).unwrap(); + store.close().unwrap(); + } + + let report = Store::restore_from(&backup, &restored).unwrap(); + assert!(report.raw_points >= 2); + assert_eq!( + report.source_snapshot_crc32, + report.destination_snapshot_crc32 + ); + + let store = Store::open_read_only(&restored).unwrap(); + assert_eq!(store.database().sealed_point_count(), 1); + assert_eq!( + store.database().query_history(1, 0, DAY).unwrap(), + vec![sealed, live] + ); + assert_eq!( + store.database().query_history(1, 0, SECOND + 1).unwrap(), + vec![sealed] + ); + assert!(!sealed_wseg_paths(&restored).is_empty()); + assert!(!sealed_wseg_paths(&backup).is_empty()); + } } diff --git a/src/transaction.rs b/src/transaction.rs index eded297..d2d01ce 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -1,5 +1,29 @@ use crate::{Entity, Plan, Point, Relation, Run, SeriesDefinition}; +/// Durable identity for one ordered ingress transaction. +/// +/// `source_id` names one producer and `sequence` is that producer's opaque, +/// strictly increasing cursor. Gaps are valid. `commit_id` supplies a second, +/// globally unique retry key. FTWDB stores all three fields in the same +/// checksummed frame as the transaction. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct IngressIdentity { + pub source_id: u128, + pub sequence: u64, + pub commit_id: u128, +} + +impl IngressIdentity { + #[must_use] + pub const fn new(source_id: u128, sequence: u64, commit_id: u128) -> Self { + Self { + source_id, + sequence, + commit_id, + } + } +} + #[derive(Clone, Debug)] pub(crate) enum Record { Entity(Entity), @@ -15,6 +39,7 @@ pub(crate) enum Record { pub struct Transaction { pub(crate) records: Vec, pub(crate) commit_id: Option, + pub(crate) ingress_identity: Option, } impl Transaction { @@ -23,6 +48,7 @@ impl Transaction { Self { records: Vec::new(), commit_id: None, + ingress_identity: None, } } @@ -30,15 +56,18 @@ impl Transaction { /// /// The identifier is stored inside the same durable frame as the /// transaction's records, so it survives crashes exactly when the data - /// does. Committing a transaction whose identifier has already been - /// durably committed writes nothing and reports - /// [`Commit::deduplicated`](crate::Commit::deduplicated), which makes a - /// retry after "error or crash after the durable write" safe: the points - /// are stored exactly once. A `u128` fits a UUID; every value, including - /// zero, is a valid identifier. Transactions without an identifier keep - /// today's at-least-once behavior and are never deduplicated. + /// does. An exact retry of a committed identifier writes nothing and + /// reports [`Commit::deduplicated`](crate::Commit::deduplicated). Reusing + /// the identifier with different records is a conflict, not a silent + /// no-op. Prefer [`Self::with_ingress_identity`] or + /// [`crate::Database::commit_ingress`] at a production writer boundary — + /// those keys also carry a source cursor. A `u128` fits a UUID; every + /// value, including zero, is a valid identifier. Transactions without an + /// identifier keep today's at-least-once behavior and are never + /// deduplicated. pub fn with_commit_id(&mut self, commit_id: u128) -> &mut Self { self.commit_id = Some(commit_id); + self.ingress_identity = None; self } @@ -48,6 +77,24 @@ impl Transaction { self.commit_id } + /// Tags this transaction with a durable, ordered ingress identity. + /// + /// Prefer [`Database::commit_ingress`](crate::Database::commit_ingress) + /// or [`Store::commit_ingress`](crate::Store::commit_ingress) at an + /// ingress boundary. This builder exists for code that already passes a + /// complete [`Transaction`] to `commit`. + pub fn with_ingress_identity(&mut self, identity: IngressIdentity) -> &mut Self { + self.commit_id = Some(identity.commit_id); + self.ingress_identity = Some(identity); + self + } + + /// The ordered ingress identity set on this transaction, if any. + #[must_use] + pub const fn ingress_identity(&self) -> Option { + self.ingress_identity + } + pub fn upsert_entity(&mut self, entity: Entity) -> &mut Self { self.records.push(Record::Entity(entity)); self diff --git a/src/tsbs.rs b/src/tsbs.rs index 9aead1c..f3ef07e 100644 --- a/src/tsbs.rs +++ b/src/tsbs.rs @@ -380,6 +380,7 @@ mod tests { store .database() .query_latest(latitude.id, i64::MIN, i64::MAX) + .unwrap() .len(), 2 ); diff --git a/testdata/shadow-protocol-v1/README.md b/testdata/shadow-protocol-v1/README.md new file mode 100644 index 0000000..af853d1 --- /dev/null +++ b/testdata/shadow-protocol-v1/README.md @@ -0,0 +1,41 @@ +# FTW shadow protocol v1 fixtures + +These files freeze every v1 request and response frame. The acknowledgement +kind has separate commit and flush examples. Each file contains lowercase hex, +one frame, and one trailing newline. Decode the hex before passing it to a wire +codec. + +`SHA256SUMS` hashes the `.hex` files as stored, including their final newline. +The Rust test checks the manifest with a small test-only SHA-256 function. Go +can use its standard `crypto/sha256` package for the same check. + +Rust tests build the named message, require an exact byte match, decode the +fixture, and require the same typed value. A Go client must run the same four +checks against these files before it can claim v1 support: + +1. hex decoding succeeds; +2. the frame checksum and all limits pass; +3. decoding yields the fields built in `tests/shadow_protocol_v1.rs`; +4. encoding that value yields the exact fixture bytes. + +Do not replace a fixture after release. A byte change needs a new protocol +version and a new directory. The commit fixture covers all catalog record +kinds, every property tag, both rollup resolution forms, every point field, +optional values, negative power, UTC microseconds, a run, a plan, and a +hardware-style telemetry value. Existing unit tests freeze every enum tag and +cover the other enum values. + +`health-response.hex` includes trailing ops fields (overload and protocol-error +counts, database bytes/points/commits, recovered tail, sync policy, and +last-ack durable). Decoders must still accept the shorter v1 prefix that omits +those fields and treat missing counts as zero with sync policy `always`. + +The corpus uses big-endian wire values. Its shared IDs include: + +- source: `00112233445566778899aabbccddeeff`; +- sequence: `0102030405060708`; +- commit: `ffeeddccbbaa99887766554433221100`; +- series: `1122334455667788`. + +The source sequence is an opaque, strictly increasing cursor. It does not need +to rise by one. Exact retries must reuse source, sequence, commit ID, and bytes. diff --git a/testdata/shadow-protocol-v1/SHA256SUMS b/testdata/shadow-protocol-v1/SHA256SUMS new file mode 100644 index 0000000..e58fb34 --- /dev/null +++ b/testdata/shadow-protocol-v1/SHA256SUMS @@ -0,0 +1,9 @@ +3d17a2173006920c7a55f378149174e246279b4d38cbacb732f87a1f4dbb0c93 commit-ack-response.hex +2af383146ce0f510dd46ab3adc0b6f6fa5080f3b0f9c41f9a6bf4886a6ddac32 commit-batch-request.hex +d46652d1fa8b2391fcbd4a076dd5a8b63ef182628b7d6124eeb4755767d787fa error-response.hex +90b72deea2ce423f96be0dc6dbe605ceabb998023d7cf41e95cd6ea9a6c5d98a flush-ack-response.hex +bb9d868e82cbfeb38e5327ef52647ef0f23ed577e297b07ae9a179534e5dca89 flush-request.hex +fa222f89961bf859a2e67beb9d3c868d990fd6fc4474d2c37422a117c6b2289d health-request.hex +608bb2e49cef0b73a401cc85d7c8d3a276e71b4c6ae8ca2f71e41c7c4acc4796 health-response.hex +3e0d24dc7e0758feba275bf03a85ff0b8b774d21a37b1921e21adc58f9461917 hello-request.hex +0c0e480a7865d0609a9c36ee264d789f40a0ce9dc3a2182cefbe66b4706a804a hello-response.hex diff --git a/testdata/shadow-protocol-v1/commit-ack-response.hex b/testdata/shadow-protocol-v1/commit-ack-response.hex new file mode 100644 index 0000000..9054d88 --- /dev/null +++ b/testdata/shadow-protocol-v1/commit-ack-response.hex @@ -0,0 +1 @@ +4654575300018100000000550100112233445566778899aabbccddeeff0102030405060708ffeeddccbbaa99887766554433221100010102030405060708010102030405060708010011121314151617180000000600000001212223242526272825675c1c diff --git a/testdata/shadow-protocol-v1/commit-batch-request.hex b/testdata/shadow-protocol-v1/commit-batch-request.hex new file mode 100644 index 0000000..363ead8 --- /dev/null +++ b/testdata/shadow-protocol-v1/commit-batch-request.hex @@ -0,0 +1 @@ +4654575300010200000002c500112233445566778899aabbccddeeff0102030405060708ffeeddccbbaa9988776655443322110000000001102030405060708090a0b0c0d0e0f001000473697465000c465457207465737420626f780000063b99fbc272400100063bae1999d240000000050004626f6f6c01010005666c6f617403c0290000000000000003696e7402ffffffffffffffd600046e756c6c0000047465787404000b6772696420696d706f7274000000012030405060708090a0b0c0d0e0f0010200056665656473102030405060708090a0b0c0d0e0f001102030405060708090a0b0c0d0e0f00200063b99fbc272400000000001000570686173650400024c3100000001112233445566778801102030405060708090a0b0c0d0e0f00100000a677269645f706f7765720005706f776572000157010100000000004c4b400100000119a1c7400000000002010000000011e1a3000100001cae8c13e000020100104575726f70652f53746f636b686f6c6d000000000130405060708090a0b0c0d0e0f0010203020300063b99f5caaf0000063b99f8c59f8000096461792d616865616400086674772d706c616e0007323032362e30380100000000000000000000000000000001010000000000000000000000000000000200000001000674617269666604000353453400000001405060708090a0b0c0d0e0f00102030430405060708090a0b0c0d0e0f00102030300063b99fbc0900000063bae1997f0000000000011e1a300000462617365000000020008636f73745f73656b402880000000000000067065616b5f7740b194000000000001402880000000000001000000000000000000000000000000030000000100046d6f64650400046175746f00000001112233445566778800063b99fbc2724000063b9a0da4154000063b99f8c59f8000063b99f8d4e1c030405060708090a0b0c0d0e0f0010203c0934a000000000010203040506070808c897c4a diff --git a/testdata/shadow-protocol-v1/error-response.hex b/testdata/shadow-protocol-v1/error-response.hex new file mode 100644 index 0000000..b2d11c3 --- /dev/null +++ b/testdata/shadow-protocol-v1/error-response.hex @@ -0,0 +1 @@ +465457530001830000000018050000146964656d706f74656e63792d636f6e666c696374556bb115 diff --git a/testdata/shadow-protocol-v1/flush-ack-response.hex b/testdata/shadow-protocol-v1/flush-ack-response.hex new file mode 100644 index 0000000..bbe9895 --- /dev/null +++ b/testdata/shadow-protocol-v1/flush-ack-response.hex @@ -0,0 +1 @@ +4654575300018100000000550200112233445566778899aabbccddeeff010203040506070800000000000000000000000000000000010102030405060708010102030405060708010000000000000000000000000000000000000000000000000034c91dd6 diff --git a/testdata/shadow-protocol-v1/flush-request.hex b/testdata/shadow-protocol-v1/flush-request.hex new file mode 100644 index 0000000..61ccba7 --- /dev/null +++ b/testdata/shadow-protocol-v1/flush-request.hex @@ -0,0 +1 @@ +46545753000103000000001800112233445566778899aabbccddeeff01020304050607089b12f22b diff --git a/testdata/shadow-protocol-v1/health-request.hex b/testdata/shadow-protocol-v1/health-request.hex new file mode 100644 index 0000000..8755c55 --- /dev/null +++ b/testdata/shadow-protocol-v1/health-request.hex @@ -0,0 +1 @@ +46545753000104000000000811223344556677888a1435df diff --git a/testdata/shadow-protocol-v1/health-response.hex b/testdata/shadow-protocol-v1/health-response.hex new file mode 100644 index 0000000..749a067 --- /dev/null +++ b/testdata/shadow-protocol-v1/health-response.hex @@ -0,0 +1 @@ +465457530001820000000061112233445566778800112233445566778899aabbccddeeff02000000030101020304050607080000000000000000050000000000000007313233343536373800000000000000090000000000000004000000000000000001000000000000000000882d5bf7 diff --git a/testdata/shadow-protocol-v1/hello-request.hex b/testdata/shadow-protocol-v1/hello-request.hex new file mode 100644 index 0000000..02ce2a1 --- /dev/null +++ b/testdata/shadow-protocol-v1/hello-request.hex @@ -0,0 +1 @@ +46545753000101000000003200112233445566778899aabbccddeeff000a6674772d626f782d3031000c676f2d6674772f302e312e300102030405060708a53e34a8 diff --git a/testdata/shadow-protocol-v1/hello-response.hex b/testdata/shadow-protocol-v1/hello-response.hex new file mode 100644 index 0000000..4450d85 --- /dev/null +++ b/testdata/shadow-protocol-v1/hello-response.hex @@ -0,0 +1 @@ +46545753000180000000001a000100112233445566778899aabbccddeeff00063b99fbc272405bbddd7c diff --git a/tests/cli.rs b/tests/cli.rs index b315f02..14acf75 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -270,6 +270,7 @@ fn usage_errors_exit_two_on_stderr_without_creating_files() { "store", "--batch-points", ], + &["bench-real-fixture", "fixture.csv.gz", "store", "--ack-log"], &[ "bench-real-fixture", "fixture.csv.gz", diff --git a/tests/performance.rs b/tests/performance.rs new file mode 100644 index 0000000..bb88fac --- /dev/null +++ b/tests/performance.rs @@ -0,0 +1,137 @@ +//! Focused performance probes for ingest, reopen, and range-query scaling. +//! +//! Default sizes stay CI-friendly. Set `FTWDB_PERF_POINTS` for a larger local +//! soak (for example 200000). + +use ftwdb::{Config, Database, Durability, Point}; +use std::time::{Duration, Instant}; +use tempfile::tempdir; + +fn batch(series: u64, start: i64, count: usize) -> Vec { + (0..count) + .map(|index| Point::actual(series, start + index as i64 * 1_000_000, index as f64)) + .collect() +} + +fn env_points(default: usize) -> usize { + std::env::var("FTWDB_PERF_POINTS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} + +fn p_latency(samples: &mut [Duration], quantile: f64) -> Duration { + samples.sort_unstable(); + let index = ((samples.len() as f64 - 1.0) * quantile).round() as usize; + samples[index] +} + +#[test] +fn ingest_reopen_and_range_query_scale() { + let points = env_points(20_000); + let directory = tempdir().unwrap(); + let path = directory.path().join("perf.ftwdb"); + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + + let ingest_started = Instant::now(); + for chunk in 0..(points / 1_000) { + database + .append(&batch(1, chunk as i64 * 1_000_000_000, 1_000)) + .unwrap(); + } + database.flush().unwrap(); + let ingest = ingest_started.elapsed(); + + let mut latest_full = Vec::with_capacity(40); + let mut latest_tail = Vec::with_capacity(40); + let tail_start = (points as i64 - 1_000) * 1_000_000; + for _ in 0..40 { + let started = Instant::now(); + let full = database.query_latest(1, 0, i64::MAX).unwrap(); + latest_full.push(started.elapsed()); + assert_eq!(full.len(), points); + + let started = Instant::now(); + let tail = database.query_latest(1, tail_start, i64::MAX).unwrap(); + latest_tail.push(started.elapsed()); + assert_eq!(tail.len(), 1_000); + } + + drop(database); + let reopen_started = Instant::now(); + let reopened = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + let reopen = reopen_started.elapsed(); + assert_eq!(reopened.stats().unwrap().points, points as u64); + assert_eq!( + reopened + .query_latest(1, tail_start, i64::MAX) + .unwrap() + .len(), + 1_000 + ); + drop(reopened); + + eprintln!( + "perf_probe points={points} ingest={:.3}s ({:.0} points/s) reopen={:.3}s latest_full_p50={:?} latest_full_p95={:?} latest_tail_p50={:?} latest_tail_p95={:?}", + ingest.as_secs_f64(), + points as f64 / ingest.as_secs_f64(), + reopen.as_secs_f64(), + p_latency(&mut latest_full, 0.50), + p_latency(&mut latest_full, 0.95), + p_latency(&mut latest_tail, 0.50), + p_latency(&mut latest_tail, 0.95), + ); +} + +#[test] +fn many_small_frames_reopen_cost() { + let frames = env_points(2_000).min(5_000); + let directory = tempdir().unwrap(); + let path = directory.path().join("small-frames.ftwdb"); + let mut database = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + for index in 0..frames { + database + .append(&[Point::actual(1, index as i64 * 1_000_000, index as f64)]) + .unwrap(); + } + database.flush().unwrap(); + drop(database); + + let started = Instant::now(); + let reopened = Database::open_with( + &path, + Config { + durability: Durability::Manual, + ..Config::default() + }, + ) + .unwrap(); + let reopen = started.elapsed(); + assert_eq!(reopened.stats().unwrap().points, frames as u64); + eprintln!( + "small_frame_reopen frames={frames} elapsed={:.3}s ({:.0} frames/s)", + reopen.as_secs_f64(), + frames as f64 / reopen.as_secs_f64(), + ); +} diff --git a/tests/power_cut.rs b/tests/power_cut.rs index 4b4281e..0606706 100644 --- a/tests/power_cut.rs +++ b/tests/power_cut.rs @@ -205,7 +205,7 @@ fn always_recovers_every_acknowledged_batch_after_sigkill() { } }; let stats = reopened.stats().unwrap(); - let points = reopened.query_history(1, i64::MIN, i64::MAX); + let points = reopened.query_history(1, i64::MIN, i64::MAX).unwrap(); let recovered_batches = points.len() / batch_points; let complete_batches = points.len().is_multiple_of(batch_points); let sequence_is_exact = points.iter().enumerate().all(|(sequence, point)| { diff --git a/tests/properties.rs b/tests/properties.rs index 8c10ba9..9cb53a2 100644 --- a/tests/properties.rs +++ b/tests/properties.rs @@ -45,7 +45,7 @@ proptest! { let reopened = Database::open(&path).unwrap(); prop_assert_eq!( - reopened.query_history(series_id, i64::MIN, i64::MAX), + reopened.query_history(series_id, i64::MIN, i64::MAX).unwrap(), expected, ); } @@ -75,8 +75,8 @@ proptest! { file.set_len(full_length - bytes_removed as u64).unwrap(); let recovered = Database::open(&path).unwrap(); - prop_assert_eq!(recovered.query_latest(1, i64::MIN, i64::MAX), vec![first]); - prop_assert!(recovered.query_latest(2, i64::MIN, i64::MAX).is_empty()); + prop_assert_eq!(recovered.query_latest(1, i64::MIN, i64::MAX).unwrap(), vec![first]); + prop_assert!(recovered.query_latest(2, i64::MIN, i64::MAX).unwrap().is_empty()); prop_assert_eq!(recovered.stats().unwrap().file_bytes, first_length); } @@ -97,7 +97,7 @@ proptest! { .map(|(index, value)| point(3, index, *value)) .collect(); Segment::create(&path, &expected, block_points).unwrap(); - let mut segment = Segment::open(&path).unwrap(); + let segment = Segment::open(&path).unwrap(); prop_assert_eq!( segment.query(3, i64::MIN, i64::MAX).unwrap(), expected, diff --git a/tests/service_examples.rs b/tests/service_examples.rs new file mode 100644 index 0000000..ab18478 --- /dev/null +++ b/tests/service_examples.rs @@ -0,0 +1,99 @@ +const SYSTEMD_UNIT: &str = include_str!("../packaging/systemd/ftwdb-shadow.service"); +const LAUNCHD_PLIST: &str = include_str!("../packaging/launchd/com.sourceful.ftwdb-shadow.plist"); + +fn assert_has_line(text: &str, expected: &str) { + assert!( + text.lines().any(|line| line.trim() == expected), + "missing exact setting: {expected}" + ); +} + +fn assert_has_fragment(text: &str, expected: &str) { + assert!(text.contains(expected), "missing setting block: {expected}"); +} + +fn assert_has_no_network_listener(text: &str) { + let lower = text.to_ascii_lowercase(); + for forbidden in [ + "0.0.0.0", + "[::]", + "tcp://", + "udp://", + "listenstream=", + "listen_datagram", + "sockets", + "socktype", + "inetdcompatibility", + ] { + assert!( + !lower.contains(forbidden), + "managed service must not expose {forbidden}" + ); + } +} + +#[test] +fn systemd_unit_keeps_the_shadow_endpoint_private_and_stoppable() { + for setting in [ + "User=ftw", + "Group=ftw", + "UMask=0077", + "RuntimeDirectory=ftwdb-shadow", + "RuntimeDirectoryMode=0700", + "StateDirectory=ftwdb-shadow", + "StateDirectoryMode=0700", + "ExecStart=/usr/local/libexec/ftwdb-shadow /var/lib/ftwdb-shadow /run/ftwdb-shadow/ftwdb-shadow.sock", + "Restart=on-failure", + "KillSignal=SIGTERM", + "TimeoutStopSec=30s", + "NoNewPrivileges=yes", + "ProtectSystem=strict", + "RestrictAddressFamilies=AF_UNIX", + "IPAddressDeny=any", + ] { + assert_has_line(SYSTEMD_UNIT, setting); + } + + for forbidden in [ + "User=root", + "Group=root", + "DynamicUser=yes", + "Restart=always", + "/tmp/", + "/var/tmp/", + ] { + assert!( + !SYSTEMD_UNIT.contains(forbidden), + "unsafe systemd setting: {forbidden}" + ); + } + assert_has_no_network_listener(SYSTEMD_UNIT); +} + +#[test] +fn launchd_job_uses_one_fixed_user_and_unix_socket() { + for block in [ + "UserName\n ftw", + "GroupName\n ftw", + "/usr/local/libexec/ftwdb-shadow\n /var/db/ftwdb-shadow\n /var/run/ftwdb-shadow/ftwdb-shadow.sock", + "Umask\n 63", + "RunAtLoad\n ", + "SuccessfulExit\n ", + "ExitTimeOut\n 30", + ] { + assert_has_fragment(LAUNCHD_PLIST, block); + } + + for forbidden in [ + "root", + "/tmp/", + "/var/tmp/", + "NetworkState", + ] { + assert!( + !LAUNCHD_PLIST.contains(forbidden), + "unsafe launchd setting: {forbidden}" + ); + } + assert_has_no_network_listener(LAUNCHD_PLIST); +} diff --git a/tests/shadow_lifecycle.rs b/tests/shadow_lifecycle.rs new file mode 100644 index 0000000..ac13f6b --- /dev/null +++ b/tests/shadow_lifecycle.rs @@ -0,0 +1,107 @@ +#![cfg(unix)] + +use ftwdb::Store; +use std::io::Read; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +struct ChildGuard(Child); + +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn assert_clean_signal_shutdown(signal: libc::c_int) { + let directory = tempfile::tempdir().unwrap(); + let store_path = directory.path().join("store"); + let socket_path = directory.path().join("run/shadow.sock"); + let child = Command::new(env!("CARGO_BIN_EXE_ftwdb-shadow")) + .arg(&store_path) + .arg(&socket_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut child = ChildGuard(child); + + wait_for_socket(&mut child.0, &socket_path); + // SAFETY: the child PID came from a live Child handle and the signal is + // SIGINT or SIGTERM. + let result = unsafe { libc::kill(child.0.id() as libc::pid_t, signal) }; + assert_eq!( + result, + 0, + "could not signal child: {}", + std::io::Error::last_os_error() + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = child.0.try_wait().unwrap() { + break status; + } + assert!( + Instant::now() < deadline, + "shadow sidecar did not stop after signal" + ); + thread::sleep(Duration::from_millis(10)); + }; + assert!(status.success(), "shadow sidecar exited with {status}"); + let mut stderr = String::new(); + child + .0 + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .unwrap(); + assert!( + stderr.contains( + "ftwdb-shadow: stopped accepted_clients=0 peer_auth_failures=0 client_errors=0 overload_count=0 protocol_error_count=0" + ) + && stderr.contains("sync_policy=always last_ack_durable=false"), + "clean shutdown report was missing: {stderr}" + ); + assert!(!socket_path.exists(), "shadow socket was not cleaned up"); + Store::open(&store_path).expect("a clean shutdown must release and leave a readable store"); +} + +fn wait_for_socket(child: &mut Child, socket_path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if socket_path.exists() { + return; + } + if let Some(status) = child.try_wait().unwrap() { + let mut stderr = String::new(); + child + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .unwrap(); + panic!("shadow sidecar exited before binding its socket: {status}: {stderr}"); + } + assert!( + Instant::now() < deadline, + "shadow sidecar did not bind its socket" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn sigterm_causes_a_clean_shutdown() { + assert_clean_signal_shutdown(libc::SIGTERM); +} + +#[test] +fn sigint_causes_a_clean_shutdown() { + assert_clean_signal_shutdown(libc::SIGINT); +} diff --git a/tests/shadow_limits.rs b/tests/shadow_limits.rs new file mode 100644 index 0000000..f15dd45 --- /dev/null +++ b/tests/shadow_limits.rs @@ -0,0 +1,232 @@ +#![cfg(unix)] + +use ftwdb::shadow_protocol::{ + self, CommitBatchRequest, ErrorCode, HealthRequest, HealthStatus, HelloRequest, Request, + Response, WireMessage, +}; +use ftwdb::shadow_runtime::{ShadowRuntime, ShadowRuntimeConfig, ShadowStorageLimits}; +use ftwdb::{Entity, EntityId, IngressIdentity, Point, Store}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +struct Server(Child); + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn batch(sequence: u64) -> CommitBatchRequest { + CommitBatchRequest { + source_id: 7, + sequence, + commit_id: u128::from(sequence) + 100, + entities: vec![Entity { + id: EntityId(1), + kind: "site".into(), + name: "shadow limit test".into(), + parent: None, + valid_from: 0, + valid_to: None, + properties: Default::default(), + }], + relations: Vec::new(), + series: vec![ftwdb::SeriesDefinition { + id: 1, + owner_entity: Some(EntityId(1)), + owner_relation: None, + name: "grid_power".into(), + physical_quantity: "power".into(), + canonical_unit: "W".into(), + semantics: ftwdb::SeriesSemantics::Gauge, + maximum_gap_micros: None, + rollup_policy: ftwdb::RollupPolicy { + raw_retain_for_micros: None, + tiers: Vec::new(), + }, + }], + runs: Vec::new(), + plans: Vec::new(), + points: vec![Point::actual(1, sequence as i64, 50.0)], + } +} + +fn request(stream: &mut UnixStream, request: Request) -> Response { + shadow_protocol::write_to(stream, &WireMessage::Request(request)).unwrap(); + match shadow_protocol::read_from(stream).unwrap() { + WireMessage::Response(response) => response, + other => panic!("expected response, got {other:?}"), + } +} + +fn start(store: &Path, socket: &Path, max_bytes: u64, min_free: u64) -> (Server, UnixStream) { + let child = Command::new(env!("CARGO_BIN_EXE_ftwdb-shadow")) + .args([store, socket]) + .env("FTWDB_SHADOW_MAX_STORE_BYTES", max_bytes.to_string()) + .env("FTWDB_SHADOW_MIN_FREE_BYTES", min_free.to_string()) + .env_remove("FTWDB_SHADOW_MAINTAIN_SECS") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let mut server = Server(child); + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + if let Ok(stream) = UnixStream::connect(socket) { + break stream; + } + assert!(server.0.try_wait().unwrap().is_none(), "sidecar exited"); + assert!(Instant::now() < deadline, "sidecar did not bind"); + std::thread::sleep(Duration::from_millis(10)); + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + assert!(matches!( + request( + &mut stream, + Request::Hello(HelloRequest { + source_id: 7, + node_id: "limits".into(), + client_version: "test".into(), + capabilities: 0, + }) + ), + Response::Hello(_) + )); + (server, stream) +} + +#[test] +fn storage_limits_reject_before_append_but_keep_exact_receipts_after_restart() { + for disk_reserve in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("store"); + let socket = directory.path().join("run/shadow.sock"); + let first = batch(1); + let mut store = Store::open(&root).unwrap(); + let mut transaction = ftwdb::Transaction::new(); + transaction.upsert_entity(first.entities[0].clone()); + transaction.define_series(first.series[0].clone()); + transaction.append_points(first.points.clone()); + store + .commit_ingress( + IngressIdentity::new(first.source_id, first.sequence, first.commit_id), + transaction, + ) + .unwrap(); + let bytes = store.stored_bytes().unwrap(); + store.close().unwrap(); + let before = std::fs::read(root.join("active.wlog")).unwrap(); + let (server, mut stream) = start( + &root, + &socket, + if disk_reserve { u64::MAX } else { bytes }, + if disk_reserve { u64::MAX } else { 1 }, + ); + + let Response::Error(error) = request(&mut stream, Request::CommitBatch(batch(2))) else { + panic!("new write should exceed budget"); + }; + assert_eq!(error.code, ErrorCode::Overloaded); + assert!(error.retryable); + assert_eq!( + error.message, + if disk_reserve { + "free disk reserve reached" + } else { + "store byte limit reached" + } + ); + let Response::Health(health) = + request(&mut stream, Request::Health(HealthRequest { nonce: 1 })) + else { + panic!("expected health"); + }; + assert_eq!(health.status, HealthStatus::Degraded); + assert_eq!(health.overload_count, 1); + assert_eq!(health.database_points, 1); + assert_eq!(health.durable_through_sequence, Some(1)); + + let Response::Ack(ack) = request(&mut stream, Request::CommitBatch(first.clone())) else { + panic!("an exact retry must still return its receipt"); + }; + assert!(ack.durable && ack.deduplicated); + let mut conflict = first; + conflict.points[0].value = 51.0; + let Response::Error(error) = request(&mut stream, Request::CommitBatch(conflict)) else { + panic!("changed retry must fail"); + }; + assert_eq!(error.code, ErrorCode::IdempotencyConflict); + assert_eq!(std::fs::read(root.join("active.wlog")).unwrap(), before); + drop(stream); + drop(server); // SIGKILL: reopen must preserve the durable receipt. + + let (server, mut stream) = start(&root, &socket, u64::MAX, 1); + let Response::Ack(ack) = request(&mut stream, Request::CommitBatch(batch(1))) else { + panic!("retry failed after SIGKILL"); + }; + assert!(ack.durable && ack.deduplicated); + let Response::Ack(ack) = request(&mut stream, Request::CommitBatch(batch(2))) else { + panic!("write failed after budget was raised"); + }; + assert!(ack.durable && !ack.deduplicated); + drop(stream); + drop(server); + assert_eq!( + Store::open_read_only(&root) + .unwrap() + .database() + .stats() + .unwrap() + .points, + 2 + ); + } +} + +#[test] +fn bounded_runtime_rejects_maintenance_that_could_bypass_the_budget() { + let directory = tempfile::tempdir().unwrap(); + let store = Store::open(directory.path()).unwrap(); + let result = ShadowRuntime::start_store( + store, + ShadowRuntimeConfig { + storage_limits: Some(ShadowStorageLimits { + max_store_bytes: 1024, + minimum_free_bytes: 1, + }), + maintenance_interval: Some(Duration::from_secs(300)), + ..ShadowRuntimeConfig::default() + }, + ); + assert!(matches!( + result, + Err(ftwdb::shadow_runtime::ShadowStartError::InvalidStorageLimits) + )); +} + +#[test] +fn invalid_budget_never_opens_a_store() { + for value in ["0", "-1", "", "512MiB", "18446744073709551616"] { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("store"); + let output = Command::new(env!("CARGO_BIN_EXE_ftwdb-shadow")) + .arg(&root) + .arg(directory.path().join("run/shadow.sock")) + .env("FTWDB_SHADOW_MAX_STORE_BYTES", value) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(!root.exists()); + assert!(String::from_utf8_lossy(&output.stderr).contains("must be a positive byte count")); + } +} diff --git a/tests/shadow_protocol_v1.rs b/tests/shadow_protocol_v1.rs new file mode 100644 index 0000000..fdfc424 --- /dev/null +++ b/tests/shadow_protocol_v1.rs @@ -0,0 +1,415 @@ +use ftwdb::shadow_protocol::{ + self, Ack, AckKind, CommitBatchRequest, ErrorCode, ErrorResponse, FlushRequest, HealthRequest, + HealthResponse, HealthStatus, HelloRequest, HelloResponse, Request, Response, SyncPolicy, + WireMessage, +}; +use ftwdb::{ + CalendarUnit, Entity, EntityId, Plan, PlanStatus, Point, PropertyValue, Relation, RelationId, + RollupPolicy, RollupResolution, RollupTier, Run, RunId, RunKind, RunStatus, SeriesDefinition, + SeriesSemantics, +}; +use std::collections::BTreeMap; + +fn fixture_messages() -> Vec<(&'static str, WireMessage)> { + let source_id = 0x0011_2233_4455_6677_8899_aabb_ccdd_eeff; + let commit_id = 0xffee_ddcc_bbaa_9988_7766_5544_3322_1100; + let entity_id = EntityId(0x1020_3040_5060_7080_90a0_b0c0_d0e0_f001); + let target_id = EntityId(0x1020_3040_5060_7080_90a0_b0c0_d0e0_f002); + let relation_id = RelationId(0x2030_4050_6070_8090_a0b0_c0d0_e0f0_0102); + let run_id = RunId(0x3040_5060_7080_90a0_b0c0_d0e0_f001_0203); + + let properties = BTreeMap::from([ + ("bool".into(), PropertyValue::Bool(true)), + ("float".into(), PropertyValue::Float(-12.5)), + ("int".into(), PropertyValue::Integer(-42)), + ("null".into(), PropertyValue::Null), + ("text".into(), PropertyValue::Text("grid import".into())), + ]); + let batch = CommitBatchRequest { + source_id, + sequence: 0x0102_0304_0506_0708, + commit_id, + entities: vec![Entity { + id: entity_id, + kind: "site".into(), + name: "FTW test box".into(), + parent: None, + valid_from: 1_754_382_400_123_456, + valid_to: Some(1_754_468_800_123_456), + properties, + }], + relations: vec![Relation { + id: relation_id, + kind: "feeds".into(), + source: entity_id, + target: target_id, + valid_from: 1_754_382_400_123_456, + valid_to: None, + properties: BTreeMap::from([("phase".into(), PropertyValue::Text("L1".into()))]), + }], + series: vec![SeriesDefinition { + id: 0x1122_3344_5566_7788, + owner_entity: Some(entity_id), + owner_relation: None, + name: "grid_power".into(), + physical_quantity: "power".into(), + canonical_unit: "W".into(), + semantics: SeriesSemantics::Gauge, + maximum_gap_micros: Some(5_000_000), + rollup_policy: RollupPolicy { + raw_retain_for_micros: Some(1_209_600_000_000), + tiers: vec![ + RollupTier { + resolution: RollupResolution::FixedMicros(300_000_000), + retain_for_micros: Some(31_536_000_000_000), + }, + RollupTier { + resolution: RollupResolution::Calendar { + unit: CalendarUnit::Day, + iana_timezone: "Europe/Stockholm".into(), + }, + retain_for_micros: None, + }, + ], + }, + }], + runs: vec![Run { + id: run_id, + kind: RunKind::Optimization, + status: RunStatus::Succeeded, + created_at: 1_754_382_300_000_000, + knowledge_time: 1_754_382_350_000_000, + workflow: "day-ahead".into(), + model: "ftw-plan".into(), + model_version: "2026.08".into(), + parent_run: Some(RunId(1)), + input_snapshot: Some(RunId(2)), + attributes: BTreeMap::from([("tariff".into(), PropertyValue::Text("SE4".into()))]), + }], + plans: vec![Plan { + id: 0x4050_6070_8090_a0b0_c0d0_e0f0_0102_0304, + run_id, + status: PlanStatus::Deployed, + horizon_start: 1_754_382_400_000_000, + horizon_end: 1_754_468_800_000_000, + resolution_micros: 300_000_000, + scenario: "base".into(), + objective_terms: BTreeMap::from([ + ("cost_sek".into(), 12.25), + ("peak_w".into(), 4_500.0), + ]), + objective_value: Some(12.25), + supersedes: Some(3), + attributes: BTreeMap::from([("mode".into(), PropertyValue::Text("auto".into()))]), + }], + points: vec![Point { + series_id: 0x1122_3344_5566_7788, + valid_time: 1_754_382_400_123_456, + valid_time_end: 1_754_382_700_123_456, + knowledge_time: 1_754_382_350_000_000, + change_time: 1_754_382_351_000_000, + run_id: run_id.0, + value: -1_234.5, + quality: 0x1020_3040, + flags: 0x5060_7080, + }], + }; + + vec![ + ( + "hello-request.hex", + WireMessage::Request(Request::Hello(HelloRequest { + source_id, + node_id: "ftw-box-01".into(), + client_version: "go-ftw/0.1.0".into(), + capabilities: 0x0102_0304_0506_0708, + })), + ), + ( + "commit-batch-request.hex", + WireMessage::Request(Request::CommitBatch(batch)), + ), + ( + "flush-request.hex", + WireMessage::Request(Request::Flush(FlushRequest { + source_id, + through_sequence: 0x0102_0304_0506_0708, + })), + ), + ( + "health-request.hex", + WireMessage::Request(Request::Health(HealthRequest { + nonce: 0x1122_3344_5566_7788, + })), + ), + ( + "hello-response.hex", + WireMessage::Response(Response::Hello(HelloResponse { + selected_version: 1, + session_id: [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, + 0xdd, 0xee, 0xff, + ], + server_time_micros: 1_754_382_400_123_456, + })), + ), + ( + "commit-ack-response.hex", + WireMessage::Response(Response::Ack(Ack { + kind: AckKind::CommitBatch, + source_id, + sequence: 0x0102_0304_0506_0708, + commit_id, + accepted_through_sequence: Some(0x0102_0304_0506_0708), + durable_through_sequence: Some(0x0102_0304_0506_0708), + durable: true, + deduplicated: false, + frame_offset: 0x1112_1314_1516_1718, + records: 6, + points: 1, + bytes_written: 0x2122_2324_2526_2728, + })), + ), + ( + "flush-ack-response.hex", + WireMessage::Response(Response::Ack(Ack { + kind: AckKind::Flush, + source_id, + sequence: 0x0102_0304_0506_0708, + commit_id: 0, + accepted_through_sequence: Some(0x0102_0304_0506_0708), + durable_through_sequence: Some(0x0102_0304_0506_0708), + durable: true, + deduplicated: false, + frame_offset: 0, + records: 0, + points: 0, + bytes_written: 0, + })), + ), + ( + "health-response.hex", + WireMessage::Response(Response::Health(HealthResponse { + nonce: 0x1122_3344_5566_7788, + source_id, + status: HealthStatus::Degraded, + queue_entries: 3, + accepted_through_sequence: Some(0x0102_0304_0506_0708), + durable_through_sequence: None, + overload_count: 5, + protocol_error_count: 7, + database_bytes: 0x3132_3334_3536_3738, + database_points: 9, + database_commits: 4, + recovered_tail_bytes: 0, + sync_policy: SyncPolicy::Always, + last_ack_durable: false, + })), + ), + ( + "error-response.hex", + WireMessage::Response(Response::Error(ErrorResponse { + code: ErrorCode::IdempotencyConflict, + retryable: false, + message: "idempotency-conflict".into(), + })), + ), + ] +} + +fn decode_hex(text: &str) -> Vec { + let text = text.trim(); + assert_eq!(text.len() % 2, 0); + text.as_bytes() + .chunks_exact(2) + .map(|pair| { + let digits = std::str::from_utf8(pair).unwrap(); + u8::from_str_radix(digits, 16).unwrap() + }) + .collect() +} + +fn encode_hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn fixture_file(name: &str) -> &'static [u8] { + match name { + "hello-request.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/hello-request.hex") + } + "commit-batch-request.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/commit-batch-request.hex") + } + "flush-request.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/flush-request.hex") + } + "health-request.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/health-request.hex") + } + "hello-response.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/hello-response.hex") + } + "commit-ack-response.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/commit-ack-response.hex") + } + "flush-ack-response.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/flush-ack-response.hex") + } + "health-response.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/health-response.hex") + } + "error-response.hex" => { + include_bytes!("../testdata/shadow-protocol-v1/error-response.hex") + } + _ => panic!("fixture list contains an unknown file: {name}"), + } +} + +fn sha256(input: &[u8]) -> [u8; 32] { + const INITIAL: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + const ROUND: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + + let bit_length = u64::try_from(input.len()).unwrap().checked_mul(8).unwrap(); + let mut padded = input.to_vec(); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_length.to_be_bytes()); + + let mut state = INITIAL; + for chunk in padded.chunks_exact(64) { + let mut schedule = [0_u32; 64]; + for (word, bytes) in schedule[..16].iter_mut().zip(chunk.chunks_exact(4)) { + *word = u32::from_be_bytes(bytes.try_into().unwrap()); + } + for index in 16..64 { + let s0 = schedule[index - 15].rotate_right(7) + ^ schedule[index - 15].rotate_right(18) + ^ (schedule[index - 15] >> 3); + let s1 = schedule[index - 2].rotate_right(17) + ^ schedule[index - 2].rotate_right(19) + ^ (schedule[index - 2] >> 10); + schedule[index] = schedule[index - 16] + .wrapping_add(s0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = state; + for index in 0..64 { + let sum1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choice = (e & f) ^ ((!e) & g); + let first = h + .wrapping_add(sum1) + .wrapping_add(choice) + .wrapping_add(ROUND[index]) + .wrapping_add(schedule[index]); + let sum0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let second = sum0.wrapping_add(majority); + h = g; + g = f; + f = e; + e = d.wrapping_add(first); + d = c; + c = b; + b = a; + a = first.wrapping_add(second); + } + for (value, addition) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) { + *value = value.wrapping_add(addition); + } + } + + let mut digest = [0_u8; 32]; + for (output, value) in digest.chunks_exact_mut(4).zip(state) { + output.copy_from_slice(&value.to_be_bytes()); + } + digest +} + +#[test] +fn every_v1_message_matches_the_shared_frozen_bytes() { + for (name, message) in fixture_messages() { + let fixture = match name { + "hello-request.hex" => { + include_str!("../testdata/shadow-protocol-v1/hello-request.hex") + } + "commit-batch-request.hex" => { + include_str!("../testdata/shadow-protocol-v1/commit-batch-request.hex") + } + "flush-request.hex" => { + include_str!("../testdata/shadow-protocol-v1/flush-request.hex") + } + "health-request.hex" => { + include_str!("../testdata/shadow-protocol-v1/health-request.hex") + } + "hello-response.hex" => { + include_str!("../testdata/shadow-protocol-v1/hello-response.hex") + } + "commit-ack-response.hex" => { + include_str!("../testdata/shadow-protocol-v1/commit-ack-response.hex") + } + "flush-ack-response.hex" => { + include_str!("../testdata/shadow-protocol-v1/flush-ack-response.hex") + } + "health-response.hex" => { + include_str!("../testdata/shadow-protocol-v1/health-response.hex") + } + "error-response.hex" => { + include_str!("../testdata/shadow-protocol-v1/error-response.hex") + } + _ => panic!("fixture list contains an unknown file: {name}"), + }; + let frozen = decode_hex(fixture); + assert_eq!( + shadow_protocol::encode(&message).unwrap(), + frozen, + "encoder drifted for {name}" + ); + assert_eq!( + shadow_protocol::decode(&frozen).unwrap(), + message, + "decoder drifted for {name}" + ); + } +} + +#[test] +fn sha256_manifest_covers_every_fixture_file() { + assert_eq!( + encode_hex(&sha256(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let manifest = include_str!("../testdata/shadow-protocol-v1/SHA256SUMS"); + let mut covered = BTreeMap::new(); + for line in manifest.lines() { + let (expected, name) = line.split_once(" ").unwrap(); + assert!(covered.insert(name, expected).is_none()); + assert_eq!(encode_hex(&sha256(fixture_file(name))), expected, "{name}"); + } + let names: BTreeMap<_, _> = fixture_messages() + .into_iter() + .map(|(name, _)| (name, ())) + .collect(); + assert_eq!( + covered.keys().copied().collect::>(), + names.keys().copied().collect::>() + ); +} diff --git a/tests/shadow_reconcile_cli.rs b/tests/shadow_reconcile_cli.rs new file mode 100644 index 0000000..393d416 --- /dev/null +++ b/tests/shadow_reconcile_cli.rs @@ -0,0 +1,72 @@ +use ftwdb::shadow_protocol::{self, CommitBatchRequest, Request, WireMessage}; +use ftwdb::{Entity, EntityId, IngressIdentity, Store, Transaction}; +use std::collections::BTreeMap; +use std::process::Command; + +#[test] +fn offline_command_reports_matching_content_without_claiming_durability() { + let directory = tempfile::tempdir().unwrap(); + let store_path = directory.path().join("store"); + let frame_path = directory.path().join("0001.hex"); + let entity = Entity { + id: EntityId(1), + kind: "site".to_owned(), + name: "test site".to_owned(), + parent: None, + valid_from: 1, + valid_to: None, + properties: BTreeMap::new(), + }; + let batch = CommitBatchRequest { + source_id: 7, + sequence: 50, + commit_id: 500, + entities: vec![entity.clone()], + relations: Vec::new(), + series: Vec::new(), + runs: Vec::new(), + plans: Vec::new(), + points: Vec::new(), + }; + { + let mut store = Store::open(&store_path).unwrap(); + let mut transaction = Transaction::new(); + transaction.upsert_entity(entity); + store + .commit_ingress(IngressIdentity::new(7, 50, 500), transaction) + .unwrap(); + store.close().unwrap(); + } + let frame = + shadow_protocol::encode(&WireMessage::Request(Request::CommitBatch(batch))).unwrap(); + std::fs::write(&frame_path, encode_hex(&frame)).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_ftwdb-shadow-reconcile")) + .arg(&store_path) + .arg(&frame_path) + .output() + .unwrap(); + assert!( + output.status.success(), + "reconcile failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("\"content_matches\":true")); + assert!(stdout.contains("\"matching_receipts\":1")); + assert!(stdout.contains("\"matching_catalog_objects\":1")); + assert!(stdout.contains("\"receipt_payload_mismatches\":0")); + assert!(stdout.contains("\"scanned_points\":0")); + assert!(stdout.contains("\"read_only_durability_proof\":false")); +} + +fn encode_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2 + 1); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output.push('\n'); + output +}