Skip to content

Make on-disk storage the provider's backend, and cover it in CI - #348

Merged
bkontur merged 26 commits into
devfrom
bko-disk-default-ci
Aug 14, 2026
Merged

Make on-disk storage the provider's backend, and cover it in CI#348
bkontur merged 26 commits into
devfrom
bko-disk-default-ci

Conversation

@bkontur

@bkontur bkontur commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

The provider defaulted to in-memory storage only because that was the behaviour
before #33 added the flag, and the default was never revisited — a provider
started with no flags silently dropped everything on restart. CI had drifted the
same way: since #145 every provider in CI ran inmemory, leaving the on-disk
backend and the persistent nonce counter with no end-to-end coverage.

So the on-disk backend is now what the provider runs, and the in-memory
implementation is gone rather than kept alive as a second, untested store.

Changes

  • --storage-mode--storage-backend, StorageMode
    StorageBackendKind, whose values are engine names (rocksdb) and whose sole
    variant is the default. It stays an enum so another engine is a variant, not a
    reshape.
  • StorageBackendSpec (in provider-storage, next to the backends) owns
    construction and pairs the backend with a matching nonce store; command.rs
    is now cli.storage.spec().build()?. StorageBackendKind::spec(path) is the
    only place a CLI value becomes a spec — the flags and the test harness both go
    through it.
  • In-memory backend deleted. NullNonceStore is gone from the library too,
    but not from the tree: the counter tests keep a local ForgetfulStore, so a
    no-op store exists only where a test asks for one and production cannot reach
    for it by accident. Tests that want a real store call
    provider_storage::temp_rocksdb().
  • One integration harness. TestServer moved into tests/common, replacing
    three near-identical copies, and the api/fs/s3 suites are generated by a
    backend_tests! macro that takes a StorageBackendKind — so a failure reads
    <test>::rocksdb, and a second engine is one arm in spec() plus one line in
    the macro. disk_integration.rs is deleted: it was a partial copy of the api
    suite run against a disk-backed server, which is now what every suite does.
    Its one unique test (/mmr_proof) moved over.
  • CI: every provider starts through a new .github/actions/start-provider,
    which derives its data directory from the provider's label so two in one job
    cannot collide on one RocksDB. The zombienet job pins the backend explicitly;
    the other jobs pass no flag, so each run also asserts the binary's default.

Notes

  • Follows Add storage-cli tooling crate with stress-test upload #220 (storage-cli + stress-test upload): that PR should be rebased
    on this one and drop its standalone utils-integration-tests job. The
    stress-test step belongs in the zombienet smoke test, which already has a
    chain, a provider started through .github/actions/start-provider, and a
    //Bob bucket + agreement from just demo for the CLI to upload into. A
    separate job pays ~2.5 min of fixed setup for well under a minute of work.
  • Follow-up, not fixed here: the local loop now mixes a fresh chain with a
    provider that remembers. just start-chain gives new chain state every run
    while just start-provider keeps ./provider-data, and init_bucket returns
    early for a bucket it already has — so a second run's bucket 1 inherits the
    first run's MMR. Wants a way to wipe the directory (CI is unaffected: fresh
    container per job).
  • Follow-up, not fixed here: disk mode is only half-persistent.
    FsIndexManager and S3IndexManager have with_persistence(), but
    ProviderState::from_parts always builds the non-persistent variants, so L1
    drive/bucket metadata is still lost on restart.
  • Not covered: data surviving a provider process restart. The store-level
    case is in negotiate_integration::with_store_counter_persists_on_next; an
    end-to-end version would reopen a TestServer on the same path.

`--storage-mode` defaulted to `inmemory` only because that was the behaviour
before #33 introduced the flag: the provider called `Storage::new()`
unconditionally, disk was bolted on for one persistent Westend demo ("very
dummy implementation ... will be reworked later"), and defaulting to inmemory
kept every existing caller unchanged. #38 moved the flag into `StorageParams`
verbatim; the default itself was never re-decided.

It has been wrong for a while. The disk backend is now the production path —
RocksDB with four column families, a durable nonce counter, the fs/s3 index
layered on top, and its own module doc saying "for production use" — while an
operator who passes no storage flags still got a provider that silently drops
every byte on restart and can no longer answer challenges for buckets it still
holds agreements for.

- `StorageMode` derives `Default` with `#[default] Disk`, and the clap arg
  takes its default from it, so there is one source of truth.
- `just start-provider` / `register-then-start-provider` default to `MODE=disk`
  to match the binary.
- Document both backends in provider-node/README.md — the repo had no prose
  about the storage backend at all, so the old default was invisible.

`--storage-mode inmemory` stays as an explicit opt-in for throwaway runs.
Since #145 dropped the disk provider from the zombienet job (collateral of
trimming that job to a smoke test), every provider in CI ran `--storage-mode
inmemory`. Nothing exercised RocksDB, the fs index, or the persistent nonce
counter end to end, and nothing ever restarted a provider — so the backend the
binary now defaults to had no integration coverage at all.

Split the backends across the jobs that already exist instead of adding runs,
so both are stretched and no suite runs twice:

  e2e-integration-tests  no --storage-mode: asserts the binary default (disk)
  integration-tests      disk, plus a mid-job restart (persistence)
  sc-integration-tests   inmemory
  ui-integration-tests   inmemory

The zombienet job keeps one provider on :3333 because the L0 demo is what
registers //Alice on-chain and the L1 fs/s3 demos reuse that registration —
they resolve the provider from the justfile's PROVIDER_URL default, so a second
provider on another port would need its own registration to be useful.

The restart check snapshots /stats after the L0 demo, kills the process, waits
for it to actually exit (RocksDB holds a LOCK on the data dir), restarts on the
same --storage-path and asserts nodes/bytes are unchanged. It fails loudly if
the provider stored nothing, so it cannot pass vacuously. The fs/s3 demos then
run against the restarted instance, which also proves a recovered provider is
usable and not merely non-empty; they wait on /info readiness first, since
/health answers before the chain-derived state (nonce counter, registration) is
back.

Also: the four copies of the nohup provider block are now one composite action,
.github/actions/start-provider, which writes the keyfile, launches the node,
fails fast if it exits immediately, and waits for health. Log files are named
per provider, and the log artifacts glob /tmp/provider-*.log — the zombienet job
had been uploading /tmp/provider.log while the step wrote
/tmp/provider-inmemory.log, so its provider log was never collected.

Cost: no extra suite runs and no extra chain. The disk job adds one process
restart plus two readiness waits.
Cleanup pass over the previous two commits.

wait-for-provider-health now owns what "up" means: a `wait-for: health|ready`
input picks the endpoint and the jq predicate, so the restart's bespoke
readiness loop in integration-tests.yml is gone. It was a copy of that action's
poll loop differing only in path and filter, and the race it worked around
(/health answers before the nonce counter and on-chain registration are loaded)
exists on every cold start, not just this restart — any job can now opt into the
stronger wait. That also drops a redundant /health poll on the restart path:
/info cannot answer unless the listener is bound, so waiting for both was two
grids for one fact. Restart timeout goes to 20 attempts, since the chain is
already up by then; worst case on a red run falls by ~200s.

Smaller cuts:

- start-provider builds the optional flag with ${MODE:+--storage-mode "$MODE"}
  instead of an array plus a `${arr[@]+...}` splat and the comment explaining it.
- Call sites stop restating action defaults (seed, port, and storage-path where
  it is not the point) — 13 lines of `with:` that said what the action already
  says. storage-path stays on the disk pair, where path identity is the
  invariant under test.
- The restart check compares one `jq -c '{total_nodes, total_bytes}'` blob
  through one GITHUB_ENV var instead of two scalars through two, and drops
  `// 0` fallbacks that could not fire (StatsResponse has non-optional u64s and
  curl -sf already fails the step on a non-2xx). The pkill pattern is a variable
  rather than three copies of the same string.
- StorageMode drops the Default derive: StorageMode::default() had exactly one
  caller — the clap attribute — so `default_value_t = StorageMode::Disk` says it
  in one place instead of two. Variant docs go back to one line each; they are
  --help text, where clap already appends [default: disk], and the rationale
  lives in provider-node/README.md.
- Trimmed the prose added by the previous commits (README, workflow comments,
  action descriptions).
@bkontur
bkontur marked this pull request as ready for review August 13, 2026 16:48
Workflow `run:` steps get `sh -e {0}` in the CI container — only steps that
declare `shell:` (and composite-action steps, which must) get bash. The two
inline steps added with the restart check opened with `set -euo pipefail`, so
dash killed the first one before it ran:

    /__w/_temp/….sh: 1: set: Illegal option -o pipefail

Declare `shell: bash` on both rather than dropping to POSIX: -u and pipefail are
what make the snapshot fail loudly instead of comparing empty strings later.

Nothing else in the diff was affected — start-provider and
wait-for-provider-health already declare `shell: bash`, and both ran fine.
Removes the three zombienet steps that snapshotted /stats, killed the provider,
restarted it on the same data dir and compared — plus the plumbing that existed
only to serve them: the `wait-for: health|ready` input on
wait-for-provider-health (that action is now byte-identical to dev again) and
the `wait-attempts` passthrough on start-provider.

The zombienet job still runs on disk, so the L0 and L1 fs/s3 demos exercise the
backend the binary now defaults to; what is gone is the assertion that data
survives a process restart. Nothing else referenced the removed inputs.
The suites covered one backend each: api/fs/s3_integration ran in-memory only,
and disk_integration was a hand-renamed partial copy of the api suite
(disk_check_exists ↔ test_check_exists, and so on) covering ~30% of the API
surface and nothing from fs/s3. Two files to keep in sync, and disk coverage
that drifts behind whatever the api suite grows.

Parameterize instead. `common::backend_tests!` declares a test once and emits
`<name>::in_memory` and `<name>::disk`, so a failure names the backend it
happened on; `common::storage_for(backend)` builds the storage plus the
TempDir RocksDB needs kept alive. Each suite's TestServer takes a Backend and
holds `_dir: Option<TempDir>`.

71 tests now run on both backends (142 cases), up from 57 tests on one backend
each. disk_integration.rs is deleted: every scenario it had is covered by the
parameterized suites, except `/mmr_proof`, which it alone exercised — that one
is ported into api_integration.rs, so it now runs in-memory too.

Test bodies are unchanged apart from indentation and taking the server from
`TestServer::new(backend)`; `git diff -w` shows the real change.

Not parameterized: auth_integration (membership/signature logic, backend-blind)
and negotiate_integration, which already covers the one genuinely disk-specific
behaviour in `with_store_counter_persists_on_next` — the nonce counter surviving
a store reopen.
Three places knew how to turn "which storage mode" into a backend: the clap
enum, the match in command.rs, and (since the test parameterization) a
test-only Backend enum. The test copy had already drifted — every disk test
paired DiskStorage with NullNonceStore, so the pairing the binary actually
uses was exercised nowhere.

StorageBackendSpec in provider-storage owns construction:

    pub enum StorageBackendSpec { InMemory, Disk { path: PathBuf } }
    pub fn build(&self) -> Result<(Arc<dyn StorageBackend>, Arc<dyn NonceStore>), Error>

The path now lives on the variant that uses it, which is what a plain
`Disk(RocksDB)` on the CLI enum was reaching for — clap value-enums must be
unit variants, and `--storage-path` arrives as a separate flag anyway, so the
rich type belongs next to the backends rather than in the parse surface.

- command.rs drops its match for `cli.storage.spec().build()?`, and logs the
  spec via Display instead of a per-variant message.
- StorageParams::spec() is the only place --storage-path is read, making
  "disk-only" explicit rather than a silently ignored combination.
- Tests drop their Backend enum for the node's own cli::StorageMode and go
  through build(), so disk tests now run against the RocksDB-backed nonce store
  like the binary does.

StorageMode keeps its CLI shape and its --storage-mode values; only what it
maps to changed.
StorageMode said which *mode*, not which backend, and its variants named a
property (`Disk`) and mis-cased the other (`Inmemory`). Follow what the SDK
does for the same problem — sc_client_db::DatabaseSource carries
`RocksDb { path, cache_size }` / `ParityDb { path }`, and the CLI-facing
sc_cli::arg_enums::Database is the flat `RocksDb` / `ParityDb` / `Auto`:

    StorageMode { Inmemory, Disk }   ->  StorageBackendKind { InMemory, RocksDb }
    StorageBackendSpec::Disk { path } ->  StorageBackendSpec::RocksDb { path }

Naming the engine rather than the medium leaves an obvious place for a second
one (ParityDb, S3, …) instead of a second thing called "disk".

The CLI surface does not change: `#[value(name = "inmemory", alias =
"in-memory")]` keeps the string CI, the justfile and the docs already pass, and
`#[value(alias = "disk")]` keeps every existing `--storage-mode disk` working.
A test walks all four spellings so the aliases cannot rot.

Repo-side strings move to the canonical name — justfile MODE, the workflow's
`mode:`/`label:`, the composite action's docs, provider-node/README.md — so
the codebase says one thing while operators' scripts keep working.
`#[value(rename_all = "lower")]` on the enum is what sc_cli::arg_enums::Database
uses to publish `rocksdb`/`paritydb`, and it gives the same result here without
per-variant names: `rocksdb` and `inmemory`. Clap's default would be kebab-case
(`rocks-db`, `in-memory`), which is the only reason the previous commit reached
for explicit names.

No back-compat alias for `disk`: the repo passes the canonical values
everywhere, and the test now asserts `disk`, `rocks-db` and `in-memory` are
rejected so the surface stays exactly the two engine names.
Cleanup pass over the parameterization.

The three suites each carried their own TestServer: fs and s3 were byte-identical
and api differed only in which ProviderState constructor it called, so the
storage_for 3-tuple, the `_dir: Option<TempDir>` field and its comment were
written out three times. TestServer and TestBackend now live in tests/common;
each suite keeps a two-line `new` naming its identity, and eight imports per
suite go away. Test bodies are untouched.

TestBackend also replaces the cli::StorageBackendKind selector the tests were
importing. The kind is the arg parser's surface — its value is the strings
`rocksdb`/`inmemory`, which only the binary's callers use — and taking it forced
storage_for to re-implement the kind→spec mapping that StorageParams::spec
already owns. sc-client-db does the same thing: its test helper
(`new_test_with_tx_storage_source`) takes DatabaseSource, not
sc_cli::arg_enums::Database.

test_member_pair now derives once per test thread. `//Alice` is a dev phrase, so
every call ran PBKDF2 2048 times — ~37ms in a debug build, two or three times
per case across 142 cases. That was a bigger cost than everything the RocksDB
half of the parameterization added.

Also:
- negotiate_integration builds its disk nonce store through
  StorageBackendSpec::build instead of pairing DiskStorage + nonce_store by
  hand — the one call site left doing what build() exists to prevent.
- The backend_tests! attribute capture is gone: no call site used a real
  attribute, and a captured `#[ignore]` would have landed on the private helper
  rather than the generated tests, silently doing nothing.
- The CLI value test asserts on the built spec, which covers the mapping and
  --storage-path in one go, and drops both the mem::discriminant dance and the
  now-redundant inmemory_is_opt_in.
- Trimmed the prose these commits added: the lib crate no longer documents the
  CLI's clap constraints, and the reviewer-facing justifications are gone.
Drops the cross-reference to sc_cli::arg_enums::Database, the two comments that
restated their own asserts, and the mode name from the --storage-path help,
which reads for any persistent engine.
The flag was the last thing still calling it a mode: the enum is
StorageBackendKind, the configured form is StorageBackendSpec, and the values
are engine names. No alias — the repo passes the new spelling everywhere.

Renames with it: StorageParams::storage_backend, the start-provider action's
`backend` input, and the justfile's BACKEND parameter.
@bkontur
bkontur enabled auto-merge August 13, 2026 22:20
@danielbui12

danielbui12 commented Aug 14, 2026

Copy link
Copy Markdown
Member

I remember that we did have both storage backends in CI, but then we decided to prune one of them. now we add it back?
I dont think putting in-memory tests in the CI is necessary, because we definitely will not use it in prod, and it would cause twice more time for the CI checks

bkontur and others added 6 commits August 14, 2026 10:04
Removes the InMemory variant from StorageBackendKind and StorageBackendSpec and
everything that selected it: the build()/Display arms, the `inmemory` CLI value,
TestBackend::in_memory, the macro's second arm, the sc/ui jobs' `backend:`
input, the justfile's conditional --storage-path, and the README paragraph. A
provider that forgets its data cannot answer challenges for buckets it still
holds agreements for, so it was never an option an operator should have.

Both enums stay as the extension point, and the prose around them stays generic:
adding an engine means a variant carrying its own configuration, an arm in
build(), an arm in the test macro — no doc rewrites.

Also fixes the two CI failures on 459f92b, both in backend/mod.rs:

- clippy type_complexity on build()'s return (the repo runs -D warnings).
  Factored into `OpenedBackend`, which is what the lint suggests and leaves
  callers' tuple destructuring untouched.
- Patch coverage 61%: Display was reachable only from command.rs at runtime, so
  5 of 13 new lines never executed. It now has a test in its own crate, which
  also pins what build() exists for — persist a nonce, drop both halves, reopen
  the same directory, watermark still there.

The suites now run 71 cases instead of 142, each against a real database.
in_memory.rs and NullNonceStore stay: no longer reachable through the spec, but
auth/negotiate/chain_state/coordinators and clients/storage construct them
directly.
It has no production caller now that the spec only builds RocksDB — every
remaining use is a #[cfg(test)] module or a tests/ suite. Rather than delete it
and push ~40 fast RAM-backed tests onto real databases, gate it behind a
`test-helpers` feature (off by default) and have provider-node and
clients/storage switch it on in dev-dependencies only. Same shape sc-client-db
uses for its test-only constructors.

A release build of the provider now cannot contain a backend an operator has no
way to select. NullNonceStore stays ungated: NonceCounter::new and
ChainStateCoordinator use it in production as the no-op store.

Also shows BACKEND alongside STORAGE_PATH in the justfile examples.
Drops both gated `pub use ... Storage` lines. The module is the export, so the
cfg lives in one place — `pub mod in_memory` — instead of being repeated on a
re-export in backend/mod.rs and another in lib.rs.

Callers now say provider_storage::backend::in_memory::Storage, which also reads
as what it is at every use site. The test-helpers feature carries no comment:
the cfg on the module says where it applies.
Neither had a production caller once the spec built only RocksDB: in_memory.rs
was reachable only from tests, and NullNonceStore only from the two constructors
that existed for it — NonceCounter::new and ChainState::default, both documented
as the in-memory path. Keeping a second StorageBackend implementation meant 601
lines and 19 trait methods maintained in parallel with the one that ships, and
this branch already showed what that costs: every disk test had been paired with
NullNonceStore, so the pairing the binary uses was exercised nowhere.

Gone:
- crates/providers/storage/src/backend/in_memory.rs and NullNonceStore
- the test-helpers feature that briefly gated the first of those
- NonceCounter::new (with_store is the constructor) and ChainState::default
  (with_nonce_store is the builder)

Tests now build the real thing. Each fixture opens a RocksDB on a TempDir and
keeps the guard for as long as the state lives — a `_dir` field on the server
structs, an extra tuple element out of the coordinator fixtures. Two exceptions,
both where the server outlives any guard a caller could hold: clients/storage's
start_test_provider persists its directory, and negotiate_integration keeps a
ForgetfulStore for the NonceCounter unit tests, which exercise the atomic and
not persistence — a no-op store is a test double, so it lives with the tests.
…dirs

Removing the in-memory backend left seven files opening a TempDir, building a
RocksDB spec and threading the guard around by hand — provider-node's src tests,
four test suites, the coordinator fixtures and clients/storage's helper.

One function does it now:

    let (storage, nonce_store, _dir) = temp_rocksdb();

Keep the guard for as long as the backend is in use; dropping it takes the
database with it. The suites that hold a server keep it in a `_dir` field, and
clients/storage — whose server outlives any guard a caller could hold — calls
`dir.keep()` at the one site where that is true.

It lives behind `test-helpers` (off by default, pulls tempfile only when on), so
a release build of the provider does not carry it. provider-node and
clients/storage switch it on in dev-dependencies.

Also drops the deprecated TempDir::into_path() — tempfile renamed it to keep(),
and -D warnings would have caught it.
@bkontur

bkontur commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

I remember that we did have both storage backends in CI, but then we decided to prune one of them. now we add it back? I dont think putting in-memory tests in the CI is necessary, because we definitely will not use it in prod, and it would cause twice more time for the CI checks

@danielbui12 thank you, agree, you right, I just removed InMemory support and the code around, for test that storage does not matter can use temp_rocskdb or for provider-node coverage tests we are exercising the StorageBackendKind (in case we add more support like pqrust or anything), please take a look now

The test-helpers entry landed above the existing plain one instead of replacing
it, and a duplicate key stops cargo metadata dead — which took out check-fmt,
Cargo check and cargo-deny before any of them got to do their job.
Three things the rewrite missed, all in tests:

- coordinators/challenge.rs destructured test_state_with_data() into two
  elements; `mut challenge` is why the scripted pass skipped this one.
- chain_state_integration built ChainState with `{ nonce_store, ..Default }`,
  which was the long way of saying with_nonce_store — now the only way.
- common/ is compiled into every test binary, so the backend_tests re-export is
  unused in the suites that build their own servers, and -D warnings counts it.
clippy caught the sibling of the last fix: the macro definition is unused in the
suites that never call it, same as its re-export and the helpers around it. All
three are the same per-crate-compilation effect the file header already
describes, so they belong in that allow rather than scattered over the lines
that happen to trip first.
Behind `Arc<dyn StorageBackend>` init_bucket returns a Result the coordinator
fixtures were dropping, which -D warnings rejects. Three sites now expect(),
which is what a fixture wants: a backend that will not initialise fails the
test.

temp_rocksdb's return gets a `TempBackend` alias — clippy's type_complexity
rejected the same tuple-of-trait-object-Arcs shape on `build()`. The tuple
stays flat, so no call site changes.
@bkontur bkontur changed the title Make disk the default storage backend, and cover both backends in CI Make on-disk storage the provider's backend, and cover it in CI Aug 14, 2026
`StorageBackendKind::spec(path)` is now the only place a CLI value becomes a
`StorageBackendSpec`; `StorageParams::spec()` and the integration harness both
call it, so a second engine is one arm in one file rather than three matches
that have to agree.

The rest is what the in-memory backend left behind: `--storage-path` claimed to
be ignored by a backend that no longer exists, `backend/mod.rs`'s module doc
still introduced the trait through `Storage` (a broken intra-doc link now), and
the photos design doc pointed at `backend/in_memory.rs`.

Also, so a leaked scratch directory can be found: `temp_rocksdb` names them with
`TEMP_DIR_PREFIX`, which is what `clients/storage` leaves behind when it keeps
one for a server that outlives the guard. In CI, `start-provider` derives the
data directory from the label, so two providers in one job cannot silently take
the same one — RocksDB only lets the first in.

The extra `unused_imports`/`unused_macros` allows in the shared test module go
back to a bare `dead_code`: dead code is still name-resolved, so the helpers no
suite calls keep their imports live. README gains the fsync caveat that
`DiskNonceStore` already documents, since persistence is now the only mode.
…the macro

`unused_macros`/`unused_imports` are back, but on `backend_tests!` and its
re-export rather than the whole module: the suites that never parameterize by
backend compile `common/mod.rs` too, and a `pub(crate) use` does not count as a
use in a crate that never expands the macro. Blanketing the module hid stale
imports in the rest of the file, which is what the previous commit was after.

`negotiate_integration`'s `serve` now takes the directory, so the field is a
`TempDir` instead of an `Option` set by mutation after construction, and the ten
call sites hand theirs over instead of holding a binding alive by hand. The
three lines that publish a bootstrapped counter became
`publish_nonce_counter`, and `chain_state_integration` gets `counter_for` --
each of its tests bootstraps differently, so only the constructor is shared.
Its `test_chain_state` also moves out from between two `use` lines.

`test_delete_happy_path` asserts `leaf_count` again: `disk_delete_before`
checked it before that suite was folded in, and nothing else covers it.

`register-then-start-provider` registers first. `start-provider` runs in the
foreground and never returns, so `register-provider` was unreachable;
registration is a chain-only extrinsic, so the order is free to swap.
@bkontur
bkontur disabled auto-merge August 14, 2026 13:28
@bkontur
bkontur merged commit 9a117b1 into dev Aug 14, 2026
42 checks passed
@bkontur
bkontur deleted the bko-disk-default-ci branch August 14, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants