Status: implemented (2026-08-03)
Storage backends are selected at compile time. corium-store gates
PostgresBlobStore, TursoBlobStore, and S3BlobStore behind the
postgres / turso / s3 features, and that gate propagates upward through
corium-transactor, corium-ffi, corium-python, and corium-cli. Every
layer repeats the same #[cfg] fan-out:
StoreSpec and NodeStore (crates/corium-transactor/src/backend.rs) each
carry one feature-gated variant per backend, and LogBackend::for_spec
repeats the list a third time to decide which backends get a root-backed log.DiscoveredStoreSpec and DiscoveredStore
(crates/corium-store/src/discovery.rs) repeat it again, twice, for the
read-only client path — and DiscoveredStore re-implements five delegating
methods purely to dispatch the enum.corium_ffi::compiled_storage_backends reports the build's feature set as a
string list so language clients can find out what their own binary can do.The cost lands hardest on the language clients. Because a native artifact can
only talk to a backend it was compiled with, the Python client ships four
wheels per platform — corium, corium-turso, corium-postgres,
corium-s3 — each containing a complete copy of the engine, differing only in
one storage driver. clients/python/src/corium/_api.py then imports every
installed variant, asks the transactor which backend it advertises, and picks
the module whose _storage_backends() claims it. Installing two backends
means two full engine copies in one interpreter. The coming Java client would
have to repeat all of it.
Compile-time selection also means a third party cannot add a backend without
forking: there is no supported way to reach BlobStore/RootStore from
outside the workspace's feature graph.
Make a storage backend a unit that can be built, shipped, and loaded separately from the engine, so that:
Non-goal: removing compiled-in backends. Static linking must remain available
for the transactor (single-binary deploys) and is mandatory on platforms
without dlopen (wasm, iOS).
Platforms without dlopen are an accepted limitation, not a problem to
solve. iOS gets one statically linked backend — Turso, which is the only one
that makes sense on a device; S3 and PostgreSQL direct-storage are
server-adjacent configurations that a mobile client should reach through a
transactor anyway. wasm keeps the filesystem/memory stores it has today. This
is why the dual rlib + cdylib delivery in §1 is a deliberate feature of the
design rather than transitional baggage: the static path is load-bearing for
real targets and is not scheduled to go away.
Rust has no stable ABI. Two separately compiled artifacts — the corium
wheel and a corium-turso wheel — cannot exchange a Box<dyn BlobStore>,
even when built from the same source at the same commit, unless they are
compiled together by one cargo invocation. Whatever transport carries the
plugin (a dlopened .so, a Python capsule handed between two extension
modules, a JNI-loaded library), the values crossing the boundary must be
#[repr(C)] with extern "C" function pointers.
That constraint drives the rest of the design, including the parts that look awkward (cursor-based listing, an explicit log-sink callback, a plugin-owned runtime).
Helper crates do not remove this constraint — they generate and verify the
repr(C) boundary for you. See §3 for the abi_stable / async-ffi
evaluation.
Ship one fat wheel with all-storage. Zero design work; solves the Python
artifact-selection problem outright. It does not solve external backends, and
it forces every user to carry the AWS SDK, tokio-postgres, and Turso. This is
the baseline any plugin design has to beat — it is a legitimate interim step
and worth taking if the plugin work slips.
Out-of-process backend (gRPC sidecar). No unsafe code, language-agnostic plugins, clean isolation. Rejected as the primary mechanism because it adds a network hop to every blob read on the peer's direct-storage path — which exists precisely to avoid a hop through the transactor — and because it introduces process lifecycle management into client libraries. Worth keeping as a future option for exotic backends where per-op latency does not matter.
WebAssembly component plugins. Attractive on safety (the host crate needs
no unsafe of its own; wasmtime carries it). Rejected because the backends
we care about are exactly the ones that are hardest to run in wasm: the AWS
SDK, a Postgres wire driver with TLS, and an embedded SQLite fork. Revisit if
wasi:http and wasi:sockets mature.
Move postgres_store.rs, turso_store.rs, and s3_store.rs out of
corium-store into corium-store-postgres, corium-store-turso, and
corium-store-s3. Each is built as both an rlib and a cdylib:
[lib]
crate-type = ["rlib", "cdylib"]
One implementation, two delivery modes. The rlib keeps the existing
statically linked path working (the transactor's postgres/turso/s3
features become dependency toggles on these crates); the cdylib is the
loadable plugin. corium-store retains the traits, MemoryStore, FsStore,
encryption, the segment cache, and the key manifest — and loses all three
optional dependency sets.
Replace the feature-gated enums with a process-wide registry keyed by backend kind:
pub trait StorageBackend: Send + Sync {
fn kind(&self) -> &str; // "s3", "turso", "acme-gcs"
fn capabilities(&self) -> BackendCapabilities; // e.g. log placement
async fn open(&self, config: &StoreConfig) -> Result<Arc<dyn FullStore>, StoreError>;
async fn open_existing(&self, config: &StoreConfig) -> Result<Arc<dyn ReadStore>, StoreError>;
}
Compiled-in backends register at startup; loaded plugins register on load. Everything downstream then holds a trait object:
NodeStore becomes Arc<dyn FullStore> (BlobStore + RootStore); its
~40 lines of per-method match arms disappear.DiscoveredStore becomes a newtype over Arc<dyn ReadStore>; its five
hand-written delegating methods disappear.StoreSpec / DiscoveredStoreSpec become { kind: String, config: StoreConfig }.LogBackend::for_spec's hardcoded "these three backends get a root-backed
log" list becomes capabilities().log_placement, which the backend declares.
External backends get the right log behaviour without the transactor knowing
their names.compiled_storage_backends() becomes available_storage_backends(),
reading the registry.This step is independently valuable and carries no ABI risk. It can land before any dynamic loading exists.
corium-store-abi — the contract crateA small crate defining the boundary types, the ABI version, the entry-point symbol, and the error codes. Both hosts and third-party plugin authors depend on it; it is the only thing we promise stability on.
Entry point, resolved by symbol name:
#[unsafe(no_mangle)]
pub extern "C" fn corium_store_plugin_v1(out: *mut PluginDesc) -> i32;
PluginDesc reports the ABI version, the backend kinds served (a plugin may
serve several), capabilities, and the operation vtable.
async-ffiasync-ffi (0.5.1) provides FfiFuture<T>, a #[repr(C)] equivalent of
Box<dyn Future<Output = T> + Send>: any Send + 'static future converts with
.into_ffi(), and the waker vtable crosses the boundary. Plugin methods return
FfiFuture<RResult<...>> and the host .awaits them, so the async fn shape
of BlobStore/RootStore survives to every existing call site with no
completion-callback plumbing, no oneshot bookkeeping, and no leaked
user_data boxes.
Caveat: panics inside poll are caught and surfaced as FfiPoll::Panicked,
but a panic in drop glue or in a waker vtable function aborts the process. The
plugin guide must say so.
Each dynamic library gets its own copy of tokio's thread-locals. A plugin
calling Handle::current() does not see the host's runtime, because the two
tokio copies do not share TLS. tokio-postgres and the AWS SDK need their
reactor's timers and I/O driver, so the host cannot simply poll them.
The workable pattern: the plugin spawns work onto its own runtime and
returns an FfiFuture that only awaits a channel. Such a future needs no tokio
context to poll, so the host drives it normally.
Consequence for cancellation: dropping the FfiFuture drops the receiver, but
the spawned task keeps running unless the plugin wires an AbortHandle into
the future's drop path. Cancellation is available, not automatic — the ABI
should require it of conforming plugins and the testkit should check it.
The same duplication applies to any process-global state in shared
dependencies. Concretely: rustls's default CryptoProvider is global per
copy, so the PostgreSQL and S3 plugins each install their own at load.
abi_stable for the rest of the boundaryabi_stable (0.11.3) does not make Rust's ABI stable — it generates and
verifies the same repr(C) boundary we would hand-roll. Adopt it anyway, for
three things we would otherwise implement worse:
StableAbi embeds a structural type layout
and the loader diffs expected against actual, naming the field that
diverged. Hand-rolled, we get a version integer that is only as reliable as
our discipline about bumping it.Option for fields
past the last known one. This replaces reserving spare vtable slots and
hoping.RVec/RBox carry the originating library's
destructor in their own vtable, so the host can drop a buffer the plugin
allocated. This removes paired alloc/free — and with it a whole class of
use-after-free — from the design entirely.#[sabi_trait] additionally lets the list cursor stay a trait object rather
than three loose function pointers.
Costs, accepted: abi_stable becomes a version-pinned dependency that every
third-party plugin must match (at 0.x, the minor version is the compatibility
boundary), and it gives nothing for async, which is why async-ffi covers that
half. It is also effectively Rust-to-Rust: RVec's layout is not something a C
or Zig author would target. This is the decision to revisit if we ever want
non-Rust backend authors — that case wants a plain C ABI with a
cbindgen-generated header instead.
Expect one repo-specific friction: StableAbi is an unsafe trait, so its
derive emits unsafe impl, and forbid(unsafe_code) applies to macro-expanded
code. corium-store-abi will need to opt out of [workspace.lints] alongside
the loader.
BlobIdStream cannot cross the boundary, so the ABI
exposes list_open / list_next (batched) / list_close, and the host
adapts that into the existing stream type.tracing spans do not survive the
boundary, so the host installs a log-sink function pointer at open time and
the plugin emits structured events through it. Without this, plugin failures
are invisible in transactor logs.Configuration crosses as a JSON object (kind + config), which is
human-writable for the CLI (--store s3:{"bucket":...}), transportable on the
wire, and does not require third parties to depend on our protobuf schema.
Config carries credentials, so Debug must redact it exactly as
DiscoveredStoreSpec does today.
corium-store-plugin — the loaderA new host crate wrapping abi_stable's RootModule loading. Together with
corium-store-abi it opts out of [workspace.lints], following the precedent
already set by corium-wasm/Cargo.toml. These two crates are the only ones
that relax unsafe_code.
Behaviour:
abi_stable type layout before calling
anything; refuses mismatches with a message naming both versions and, from
the layout diff, the field that diverged.Library dropped while its allocations, threads, or
TLS destructors are live is the classic use-after-free in this pattern.
Loaded libraries leak deliberately, for the process lifetime.CORIUM_STORE_PLUGINS
(path-separator-delimited files or directories) → a platform plugin
directory. Never the current working directory.Security: loading a plugin is arbitrary code execution in the host process,
and the host hands it storage credentials. Plugin directories must be
trust-controlled like any other library path, and the transactor should require
an explicit opt-in flag rather than scanning by default. This belongs in
docs/operations.md.
pb.StorageConnection's oneof is closed. Add an open variant:
message PluginStorage {
string kind = 1; // "acme-gcs"
string config_json = 2; // backend-defined, may carry read-only credentials
}
Existing variants stay for compatibility; the five known backends keep their
typed messages and are translated into {kind, config} at the edge, so old and
new clients interoperate. A client that receives a kind it has no plugin for
returns the existing StorageConnectionError::Unsupported, now naming the
missing plugin and how to install it.
Python. corium becomes the only wheel containing the engine, and gains
the plugin loader. corium-turso and friends shrink to a single .so plus a
few lines of Python, advertised through an entry point:
[project.entry-points."corium.store_plugins"]
turso = "corium_turso:plugin_path"
At import, _api.py resolves the entry points and passes the paths to the
native module, which loads them. Two backends installed means two small
libraries, not two engines. The multi-module import fallback and the
"pick the module whose _storage_backends() claims this kind" dance in
_api.py (~50 lines) both disappear, as does the per-backend distribution
matrix in .github/workflows/python-wheels.yml.
Java. The same .so files ship in a per-platform jar, are extracted to a
cache directory, and are registered by path. No JNI-side backend variants.
Transactor / CLI. Unchanged by default: keeps statically linking whichever
backends its features enable, now via the split crates. --store-plugin <path> adds one at runtime.
Promote crates/corium-store/tests/store.rs into a published
corium-store-testkit crate so third-party backends can run the same suite —
this is the trait-level contract testing ADR-0007 already promised. Add
corium store verify <kind> <config> to run the kit against a live plugin, so
"is my backend correct?" is answerable without a Rust test harness.
EncryptedBlobStore wraps the plugin store, so
plugin code never sees plaintext or key material. Worth stating explicitly
in the plugin guide.RVec → host Vec). Segments are
megabyte-scale and every real backend is network- or disk-bound, so this is
noise. A borrowed-buffer fast path can be added later; abi_stable prefix
types make that a non-breaking addition.abi_stable version plugins were built against. Mitigations: the
layout check at load, and surfacing loaded plugins and their versions in
corium doctor / transactor startup logs.Each phase is shippable on its own.
Arc<dyn FullStore>; delete the #[cfg] fan-out
in backend.rs and discovery.rs. No ABI, no behaviour change. Unlocks
compile-time third-party backends immediately.corium-store-abi (abi_stable + async-ffi) +
corium-store-plugin; ship corium-store-turso as the first cdylib
(smallest driver, no cloud credentials in the test path); add
PluginStorage to the proto; add --store-plugin to the CLI. Prove the
plugin-owned-runtime pattern here — Turso is also the backend iOS statically
links, so it exercises both delivery paths from the start.corium-store-abi and corium-store-testkit,
write the third-party backend guide, add corium store verify, and record
an ADR superseding the relevant part of ADR-0007.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |