Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

.nornir/object-store.md

skade pluggable object store — skade::ObjectStore

skade::ObjectStore is the backend-agnostic bytes-IO abstraction for the Iceberg data/metadata files a Warehouse reads and writes. It decouples skade from any single S3 client so a consumer (e.g. an isolated Njord mail-server) can run skade → Iceberg on a shared MinIO with the backend of its choice. skade/src/object_store.rs, wired in skade/src/warehouse.rs.

Why it exists

Before this, the published skade crate hardwired LocalFsStorageFactory in Warehouse::open — local disk only. The real S3 work (the s3 feature, the configurable MinIO endpoint) lived in the unpublished bench/ crate (bench/src/s3_storage.rs, backed by our own aegir client). A direct consumer of the crate had no way to point skade at an object store, and no way to pick which S3 client to use.

The Njord mail-server is isolated and already links aws-sdk-s3; it must write to a shared MinIO. Pulling a second S3 client (rust-s3) into that build is wasteful, and rust-s3 doesn't even compile in every workspace (see below). So skade needs a trait the consumer can satisfy with the SDK it already has.

The trait (skade/src/object_store.rs)

#[async_trait]
pub trait ObjectStore: Debug + Send + Sync {
    async fn put(&self, key: &str, bytes: Bytes) -> Result<()>;
    async fn get(&self, key: &str) -> Result<Bytes>;
    async fn list(&self, prefix: &str) -> Result<Vec<String>>;
    async fn delete(&self, key: &str) -> Result<()>;
    async fn exists(&self, key: &str) -> Result<bool>;

    // Provided (overridable) — S3 backends do a ranged GET / HEAD.
    async fn get_range(&self, key: &str, range: Range<u64>) -> Result<Bytes>;
    async fn size(&self, key: &str) -> Result<Option<u64>>;
}

Keys are opaque, slash-separated path-like strings. Result is skade::Result (one error type over the stack).

Backends (feature-gated)

backend feature crate what
MemoryStore (always) — in-process map; tests / ephemeral
LocalFsStore rustfs std only embedded zero-external-dep local filesystem
RustS3Store s3 rust-s3 0.35 MinIO/S3 via configurable endpoint (default S3)
AwsS3Store aws-s3 aws-sdk-s3 1.x MinIO/S3 reusing the consumer's own SDK

default = [] — no S3 client is pulled unless asked for, and Warehouse::open is byte-for-byte unchanged (still LocalFsStorageFactory). Existing users and the existing local-path path are untouched.

Note on "rustfs"

The rustfs crate on crates.io (0.0.x) is a full distributed object-storage server, not an embeddable client — useless as a library dep. What "local object store" actually means for a consumer is a persistent store with no S3 server and no extra crates, so the rustfs feature ships LocalFsStore: keys → files under a root dir, std-only.

Note on rust-s3 / maybe-async

rust-s3 uses maybe-async. In a workspace that also links gix (which flips maybe-async to is_sync globally) rust-s3 will not compile — this is exactly why the bench crate used our own aegir client instead. The published skade crate has its own detached workspace, so the s3 feature builds cleanly for direct consumers. A consumer who does hit the gix conflict (or who already links aws-sdk-s3, like Njord) should use the aws-s3 backend.

How the warehouse uses it (the iceberg bridge)

iceberg-rust drives all blob IO through its own Storage / StorageFactory extension points (FileIOBuilder::new(factory)). skade bridges the trait into that machinery rather than re-plumbing the writer/reader:

ObjectStore (trait)
   └─ ObjectStoreBridge   : impl iceberg::io::Storage      (read/write/list/delete → trait)
        ▲
ObjectStoreFactory        : impl iceberg::io::StorageFactory (build() → bridge)
        │ given to
RedbCatalogBuilder::with_storage_factory(...)  →  FileIO  →  every data/metadata blob

The ObjectStore bridge: one trait, fronted into iceberg's Storage / FileIO, sends every data + metadata blob to a feature-gated backend while the redb catalog stays local.

Two constructors (both additive — open is unchanged):

// 1. From a live store (in-process; e.g. Njord's own aws-sdk-s3 client wrapped).
Warehouse::open_with_store(db_path, "s3://bucket/warehouse", Arc::new(store)).await?;

// 2. From a serializable config (settings/env-driven; backend built lazily).
Warehouse::open_with_object_store_config(
    db_path, "s3://bucket/warehouse",
    ObjectStoreConfig::AwsS3(S3Config { endpoint: "http://minio:9000".into(), .. }),
).await?;

The redb catalog file stays on local disk at db_path (it is a single-file ACID mmap, not an object); only the Iceberg data + metadata blobs go through the ObjectStore. The warehouse_uri is the location iceberg stamps into table metadata and hands back to the store on every IO.

typetag / serialization caveat (honest)

iceberg's StorageFactory and Storage are #[typetag::serde] — a built storage must survive a config round-trip. A live SDK client is not serializable, so ObjectStoreFactory serializes as a small ObjectStoreConfig enum and rebuilds the backend in build(), exactly as iceberg's own S3 FileIO does. Consequence: open_with_store (live store) is for the in-process flow; if iceberg ever serializes the factory and rebuilds it, the live store is dropped and a MemoryStore is reconstructed. For a durable, restart-safe S3 warehouse, prefer open_with_object_store_config (config-built, fully reconstructible).

Pointing at MinIO / rustfs / aws

use skade::{Warehouse, ObjectStoreConfig, S3Config};

let cfg = S3Config {
    endpoint: "http://minio:9000".into(),  // empty → real AWS
    region: "us-east-1".into(),
    bucket: "njord-mail".into(),
    access_key_id: "minio".into(),
    secret_access_key: "minio123".into(),
    path_style: true,                       // required for MinIO
};

// rust-s3 backend (feature "s3")
let wh = Warehouse::open_with_object_store_config(
    "/var/lib/njord/catalog.redb", "s3://njord-mail/warehouse",
    ObjectStoreConfig::RustS3(cfg.clone())).await?;

// aws-sdk-s3 backend (feature "aws-s3") — Njord reuses its own SDK
let wh = Warehouse::open_with_object_store_config(
    "/var/lib/njord/catalog.redb", "s3://njord-mail/warehouse",
    ObjectStoreConfig::AwsS3(cfg)).await?;

// embedded local store (feature "rustfs")
let wh = Warehouse::open_with_object_store_config(
    "/var/lib/njord/catalog.redb", "file:///var/lib/njord/store",
    ObjectStoreConfig::LocalFs { root: "/var/lib/njord/store".into() }).await?;

Flow note — Njord mail-server on shared MinIO

Njord (isolated)                    shared MinIO
  ├─ aws-sdk-s3 (already linked) ───────┐
  ├─ skade [features = ["aws-s3"]]      │
  │    Warehouse::open_with_object_store_config(
  │        catalog.redb (local disk),   │   redb stays local
  │        "s3://njord-mail/warehouse", │
  │        AwsS3(S3Config{ endpoint: minio })) 
  │            └─ AwsS3Store ──put/get/list──▶ s3://njord-mail/warehouse/...
  └─ table.append(batch) → parquet + metadata land in MinIO via the ONE SDK

One S3 SDK in the build, the catalog ACID on local disk, the lake on shared MinIO. That is the unblock.

Tests (inject-assert) — skade/tests/object_store.rs

No live MinIO required (CI-safe):

cargo test --test object_store                  # MemoryStore + warehouse-over-memory
cargo test --features rustfs --test object_store # + LocalFsStore + config round-trip

The live-MinIO S3 backends (s3, aws-s3) are build-verified in CI; their wire behavior is exercised against a real MinIO out of band (they share the same ObjectStoreBridge + trait contract the memory/local tests pin).