Skip to content

Replace SQLite/Postgres with a log-structured metadata store - #16

Merged
muralidhar-challa merged 39 commits into
mainfrom
feat/log-structured-metadata
Sep 3, 2026
Merged

Replace SQLite/Postgres with a log-structured metadata store#16
muralidhar-challa merged 39 commits into
mainfrom
feat/log-structured-metadata

Conversation

@muralidhar-challa

Copy link
Copy Markdown
Collaborator

Replaces the SQL backends with an append-only JSONL commit log in an
S3-compatible object store, materialised in memory at startup. There is no
database, no driver, no migrations and no local state, so uc-server needs no
volume and its replicas are disposable.

Design

Laid out like Delta's _delta_log: numbered JSONL commits with commitInfo
first, periodic checkpoints, and a _last_checkpoint pointer. Concurrency rests
on conditional writes (If-None-Match: *), which S3 gained in August 2024 and
MinIO supports.

That primitive is the reason this is possible now. UC is the commit coordinator
for its tenants' Delta tables, so before conditional PUT its own log would have
needed a coordinator — and the only candidate was UC, still booting.

Delta commits are partitioned one log per table, matching Delta's own layout.
commit_version is the object key, so UNIQUE(table_id, commit_version) stops
being a constraint to enforce and becomes the filename. It also keeps the only
unbounded-growth entity out of boot replay.

docs/log-structured-metadata.md has the full rationale, including why not Raft
and why not literal Delta format.

Also in this branch

  • Auth is verify-only. UC issues no tokens and holds no signing key: no key
    material, no /auth/tokens, no JWKS endpoint. --no-auth is folded into
    --oidc-issuer, so "auth enabled with no issuer" is unrepresentable.
  • Audit. Every commit records who made it, including deletes and grants,
    which have no *_by column to carry one.
  • Observability. Traces, logs and metrics over OTLP, inert unless
    OTEL_EXPORTER_OTLP_ENDPOINT is set. Graceful shutdown drains in-flight
    requests and flushes all three.
  • Migration. scripts/migrate_sqlite_to_log.py, with docs/migration.md.
  • Workspace lints at zero, unsafe_code = "forbid", UUIDv7 throughout.

Bugs found along the way

Several predate this work:

  • max_results=0 underflowed pagination — a panic in debug, and in release an
    empty page with no token, indistinguishable from "no more rows". Reachable
    from every list endpoint. The fragment was duplicated 18 times.
  • A user-supplied i64 model version was cast to i32 with as on the
    credential-vending path, so a request for version 4294967301 returned
    credentials for version 5.
  • remove_filtered_policy indexed vals[field_index + i] unguarded, in the
    authorization layer.
  • /jwks read a file only ever written on the key-generation path, so a server
    that loaded existing keys served 500s from it permanently.
  • Removing SQLite exposed that SELECT MAX(commit_version) over no rows decoded
    as 0, hiding a Delta version asymmetry: load and commit want different
    answers for an empty table, and both were getting 0.

Verification

Repo tests ran against both backends until SQL was removed — the same suite,
written for SQLite, passing unchanged on the log store is the evidence the port
preserved semantics.

End-to-end against MinIO RELEASE.2025-04-08: CRUD, restart-replay to the same
metastore id, and two independent server processes racing one catalog name
producing exactly one winner
— the claim the whole design rests on, previously
tested only against an in-process fake.

Profiled: reads p50 0.4 ms, writes p50 1.9 ms, cold start ~200 ms over 4,029
commits, and ~21 KiB resident per entity.

Note for deployment

That last number matters. The snapshot is resident, so memory scales with entity
count — roughly 229 MiB at 10,000 entities. Existing memory limits were sized for
SQLite keeping data on disk and will need raising.

The metadata root must be a bucket the credential-vending role cannot reach.
Vended credentials carry no session policy and are bucket-scoped, so a log placed
in the data bucket would be readable and rewritable by any client holding one.

Design doc plus a non-wired sketch of the object-store log, the commit loop,
and repos/catalog.rs ported as a worked example.

Nothing here is compiled into the build yet: store/ is not declared in lib.rs
and catalog_store.rs sits alongside the SQL catalog.rs rather than replacing
it, so the SQLite path is untouched.
Reworks the sketch into a compiling, tested module:

  - Commit files are JSONL (commitInfo first, one action per line) rather than
    a single JSON document, matching Delta's format and .json naming.
  - Checkpoints are deterministic JSONL state dumps; _last_checkpoint carries
    a line count so truncation is detectable.
  - Replay refuses gaps, holes, malformed lines and future formats rather than
    silently materialising partial state.
  - Adds Serialize/Deserialize to the 14 *Row models (prerequisite; additive,
    no behaviour change).

store/ is compiled but nothing calls it: repos/catalog.rs (SQL) is still the
live path and catalog_store.rs sits beside it for diffing.

22 tests pass, including the commit-race case where a losing writer must
re-evaluate its precondition rather than retry blind.
Both bodies of each repo now live under one module path: the logstore feature
picks *_store.rs over the SQL file via #[path], so callers keep saying
repos::catalog::create either way.

Adds Snapshot::scan_prefix for the parent-scoped 'WHERE parent_id = $1 AND
name > $2 ORDER BY name' pagination every child entity uses, and ports
repos/schema.rs -- which carries the repo layer's only JOIN (get_by_full_name),
now two index lookups.

Default (SQLite) and --features logstore both build; 22 tests pass.
One global log would serialise every write in an org through a single version
counter. Catalog CRUD does not care; every Delta commit does. And
uc_delta_commits is the only unbounded-growth entity, so leaving it in the main
log would make boot replay and every checkpoint scale with total commit history.

Delta commits now live at _uc_log/tables/{table_id}/NNN.json, one stream per
table, as Delta itself lays out _delta_log. The partition is safe because
UNIQUE(table_id, commit_version) lives entirely within one table's stream.

The split collapses the mechanism rather than complicating it: commit_version
IS the log version, so the constraint and the object key are the same thing.
repos::delta::insert is now a single conditional PUT -- no snapshot read, no
commit loop, no retry, no in-memory commit history.

Also fixes a collision the partitioning introduced: nested partition keys share
the _uc_log/ prefix and their final segment parses as a version, so
version_from_key had to start rejecting nested keys or a table's commits would
replay into the metastore snapshot as metadata.

29 tests pass; default SQLite build unaffected.
ObjectLog::list_after maps onto S3 ListObjectsV2, which caps at 1000 keys. A
backend returning a single page silently corrupted three paths: main-log replay
stopped early and served a stale metastore, latest_version returned below the
true head (making a Delta client commit at an existing version), and
list_for_table returned truncated history as if complete.

None of it errored, and the gap check could not see it: keys 1..N of a longer
log are contiguous, so tail truncation is indistinguishable from a shorter log.
A TruncatingLog paging at 10 reproduced all three -- replay reported version 10
of 25, latest_version 9 of 24.

Adds log::list_all_after, which pages to exhaustion and refuses a backend that
ignores start_after rather than spinning on it. All callers now go through it.

Also: latest_hint now updates with max rather than insert, so concurrent
callers finishing out of order cannot walk the hint backwards; and
DeltaLog::versions no longer relies on '-' sorting below '0' to make
starting_version 0 inclusive.

32 tests pass; default SQLite build unaffected.
Three natural keys were wrong, all silently:

  - User was keyed on email, but uc_users declares name UNIQUE and email is
    nullable with no constraint -- losing the real uniqueness check and
    dropping every null-email user out of the index.
  - Column had no key despite UNIQUE(table_id, ordinal_position).
  - Property had no key despite UNIQUE(entity_id, entity_type, property_key).

Adds pad_i64 so integer components order numerically, and tests pinning every
key to its declared constraint -- this class of error passes functional tests.

Also bounds ranged listings: DeltaLog::versions drained the whole partition
before filtering, so reading commits 5..10 of a 200-commit table paged all of
it. That is the hot Delta read path.

And corrects an overclaim: the NUL separator is unambiguous only while
non-final components are NUL-free. True today (user text is always last), but
a precondition on inputs rather than a property of the encoding, so the test
now states it exactly instead of asserting something false.

40 tests pass; default SQLite build unaffected.
Swaps all 114 Uuid::new_v4() call sites to Uuid::now_v7(), production and
tests alike. v7 is time-ordered, so ids sort by creation; the practical wins
here are readable checkpoint diffs (encode_checkpoint sorts by id, which under
v4 was arbitrary order) and creation-clustered per-table partition prefixes.

Deliberate tradeoff, recorded rather than inherited: v7 embeds a creation
timestamp, and UC returns these ids in API responses, so every id now discloses
when its row was created.

Checked before swapping that no site uses a UUID as an unguessable value --
v7 has 74 random bits to v4's 122. keys.rs uses rand::random for the key id,
not a UUID. jwt.rs uses one for the JWT jti, which is safe: it sits inside a
signed token, and the token already carries iat, so the timestamp leaks nothing
new.

No migration needed: existing v4 rows stay valid and parse identically.

Also fixes three things this surfaced:
  - uc-db's dev-dependency pinned uuid features = ["v4"], which would have
    broken the test build.
  - An indented block in delta_log.rs's module doc was compiled as a doctest;
    fenced as text. Pre-existing, invisible to --lib runs.
  - tests/test_repos.rs exercises the SQL path and imports nine unported
    modules, so it is gated off under the logstore feature.
Both ports tighten SQL behaviour rather than merely matching it:

  - metastore::get_or_init was a read-then-insert with no UNIQUE on
    uc_metastore, so two uc-servers starting together could both insert and
    neither would notice (get uses LIMIT 1). The check now runs inside the
    commit closure, so the loser adopts the winner's row.
  - property::replace was a DELETE plus N INSERTs whose own doc comment said it
    "must be called inside a transaction", leaving atomicity to each caller. As
    one commit it is atomic structurally.

Skips empty commits, which a test caught: get_or_init produces no actions once
the metastore exists and runs at every startup, so writing an empty commit
would burn a log version and an S3 object per boot, growing the log forever
without changing state.

Adds Snapshot::iter for lookups on non-UNIQUE columns and ids_under_prefix for
whole-group operations.

44 tests on logstore, 40 + 18 on SQLite. uc-auth (39) and uc-api (59) suites
verified green after the UUIDv7 swap.
Completes the repo layer: table, volume, function, model, user, credential,
external_location, staging. Every signature is unchanged, so uc-api still
compiles against either backend.

Behaviour differences, all deliberate and commented at the site:

  - function/model/user create mapped no unique violation, so a duplicate name
    was a 500. They now return ResourceAlreadyExists.
  - function::delete deleted parameters, then the function, then reported
    NotFound if the function was absent -- leaving the orphan-parameter
    deletion applied on the error path. As one commit, the error abandons the
    whole thing.
  - Lookups on non-UNIQUE columns (user email/external_id, staging_location,
    model version) sort by id before taking the first. SQLite's fetch_one
    picked arbitrarily among duplicates; get_by_location matters most, since
    the caller commits data against whichever row comes back.
  - user::find_or_create_by_external_id was a read-then-create over a column
    with no UNIQUE, so concurrent logins could both insert. The lookup now
    runs inside the commit closure.
  - external_location::find_by_path_prefix replaces ORDER BY LENGTH(url) DESC;
    ties are broken by id rather than arbitrarily.

Preserved deliberately: delete_model, delete_version and mark_committed do not
error on a missing row, matching their unchecked rows_affected.

44 tests on logstore, 40 + 18 on SQLite; both feature paths check clean.
tests/test_repos.rs was written against sqlite::memory: and is now backend-
agnostic: setup_pool builds either a SqlitePool or a Store over an in-memory
log, chosen by the `logstore` feature. Every one of its 18 tests passes
unmodified on both.

That is the evidence the port needs. Its whole claim is that repo semantics are
identical, so a suite written for the old backend passing untouched on the new
one is worth more than any test written to fit the new one.

Extracts store::memory::MemoryLog as a public type -- the reference
implementation of the ObjectLog contract (put_if_absent never overwrites,
list_after exclusive and ordered), usable from outside the crate and for a
single-process dev mode. store/tests.rs now shares it instead of keeping a
private copy.

All four combinations green: lib 40 (sqlite) / 44 (logstore), test_repos 18/18.
credentials, external_locations and models issued UPDATE/SELECT statements
directly against state.pool, bypassing the repo layer entirely -- so they would
have kept talking SQL after the store swap.

Adds the missing repo functions on BOTH backends (credential::update,
external_location::update, model::update_model / set_max_version /
list_versions / update_version / set_version_status) and points the handlers at
them. Behaviour preserved, including that a zero-row UPDATE reports success.

Direct sqlx in uc-api is now confined to delta_api/tables.rs (12) and lib.rs (2).
Answers a real gap. Commits are validated structurally -- JSON parse per line,
commitInfo required, format version, gapless versions, hole detection -- and
the object store covers transfer and at-rest integrity, so a per-commit
checksum would double the writes on the hot path to catch a case already
covered. Delta does not checksum commits either.

Checkpoints are different: size is only a line count, so a flipped byte inside
a line leaves it intact and a corrupted row that still parses as JSON gets
materialised as real state with nothing to notice.

_last_checkpoint now carries a content hash. FNV-1a, not a digest -- it catches
accidental corruption, which is the actual threat, and explicitly does not
detect tampering by someone who can write to the bucket. No new dependency on
the boot path.

The field is optional so pointers written by older builds still load rather
than being rejected as mismatches on upgrade. A failed check falls back to a
full log scan rather than erroring: the log can always rebuild the state, and
failing hard would turn a recoverable situation into an unstartable server.

Rides on encode_checkpoint's determinism, which was built for idempotent writes
-- two replicas checkpointing the same version hash identically.

47 tests on logstore, 40 + 18 on SQLite.
Twelve inline statements in delta_api/tables.rs plus a table rename become repo
functions implemented on both backends: property::set / delete_key,
table::patch / rename, delta::mark_backfilled.

With those gone db_err was dead, so uc-api no longer needs sqlx at all. Dropping
the dependency is the point: handlers now physically cannot reach past the repo
layer, so nothing can quietly reintroduce a raw statement that works on only one
backend.

delta::mark_backfilled needed a deliberate exception. It edits a commit object
that conditional PUT otherwise makes write-once, so DeltaLog::mutate_commit is
the sole overwrite path and is kept narrow: the OCC guarantee is about who first
claimed version N and is settled once the object exists, so editing the body
cannot create a second claimant; the function rejects any edit touching
table_id or commit_version, which are the object key; and with no
read-modify-write protection the only safe use is an idempotent monotonic
change where last-write-wins converges -- a latched flag, never a counter.

table::rename returns TableAlreadyExists where the bare SQL UPDATE tripped the
UNIQUE constraint into a 500, consistent with the other renames.

Green: uc-db 43+18 (sqlite) / 47+18 (logstore), uc-api 104, uc-auth 39.
The adapter spoke sqlx directly. Its surface reduces to five primitives --
load_all, insert, delete, delete_many, replace_all, clear -- now in
repos::casbin with a body per backend. uc-auth drops its sqlx dependency, and
its sqlite/postgres features forward to uc-db rather than to a driver.

CasbinRule carries no id on purpose: SQLite uses an INTEGER AUTOINCREMENT
surrogate and the log store a UUID, and neither means anything to casbin, whose
identity for a rule is the (ptype, v0..v5) tuple -- also the UNIQUE INDEX, and
so the natural key, which natural_key_for had been returning None for.

ORDER BY id survives only because ids are UUIDv7. The SQL orders by the
autoincrement surrogate, i.e. insertion order; Snapshot::iter_by_id reproduces
that by sorting on the UUID, which is creation-ordered under v7 and would be
arbitrary under v4.

save_policy gets strictly safer. The SQL wraps delete-then-insert in a
transaction with a comment about minimising the window where the table is empty
-- an authorizer reading mid-replace denies everything. As one commit that
window does not exist rather than merely being short.

Store is now a cheap Clone handle over an Arc'd interior, matching
SqlitePool's contract, because call sites clone the pool around.

The adapter's restart-survival tests run on both backends: 39 on each.
Totals green -- uc-db 61/65, uc-auth 39/39, uc-api 104.
Step 6. Store::get_or_create_object creates a singleton exactly once across
replicas -- GET, else generate and conditionally PUT, and on losing the race
re-read and adopt the winner's value. It refuses rather than falling back to
its own copy if the object vanishes in between, since proceeding with a key the
winner does not have is the exact failure it exists to prevent.

Key material cannot live in the log: it must be readable independently of
replay and every replica must agree on one value. So _uc_log/_keys.json is a
plain object, hex-encoded JSON so it is inspectable. Note this moves the secret
from a per-org PVC to the org's bucket prefix -- whatever holds that object is
as sensitive as the keypair.

Without the conditional create, two replicas booting together would each
generate a keypair and each persist it, and tokens signed by one would be
rejected by the other depending on which pod served the request, with nothing
visibly wrong at startup.

Also fixes a pre-existing bug this uncovered: /jwks read certs.json from the
config dir on every request, but that file was only ever written on the
key-generation path. A server that loaded existing keys, or whose file was
lost, served 500s from /jwks permanently. The document is now derived from the
keypair at startup and held in AppState.

Wiring main.rs onto the store loader lands in step 7, where the store is
actually constructed.

Green: uc-db 64/68, uc-auth 42/42, uc-api 104.
MemoryLog was the only implementation, so nothing could actually run against an
object store. S3Log is the real one, behind a `s3` feature so the store and its
tests still build without the AWS SDK.

Built on aws-sdk-s3 (1.144) rather than hand-rolled HTTP, so SigV4, retries and
endpoint resolution are not this module's problem. The part that matters is
that put_if_absent maps onto a conditional PutObject -- If-None-Match: * --
since the whole design rests on it.

Error mapping is where this could quietly go wrong, so it is explicit: 412
Precondition Failed and 409 Conflict both mean someone else holds the key and
become AlreadyExists; everything else stays an error. Collapsing them would
make a broken bucket look like a busy one, and the commit loop would spin
against it instead of surfacing the fault.

Key mapping is the other trap, and is tested: listed keys must be stripped back
to logical form, because leaving the org root on makes every commit look like a
nested path and version_from_key rejects those -- replay would find no commits
and report a healthy empty metastore.

list_after returns one page, as the trait permits; log::list_all_after drives
it to exhaustion.

53 tests on logstore+s3, 18 repo tests, SQLite path untouched.
--storage-root s3://bucket/prefix replaces --database-url under `logstore`:
no database, no migrations, no volume. Key material is loaded from the store
rather than the config dir, which means the store must be opened first -- the
reverse of the SQLite path, where the keys are files and come first.

Feature forwarding added through uc-api and uc-auth; SQLite-only helpers
(prepare_database_url, the shutdown S3 sync, mask_db_url, base64_encode) are
cfg'd out so both binaries build with zero warnings.

Verified end-to-end against MinIO RELEASE.2025-04-08, the build the cluster
uses. Everything before this ran against MemoryLog:

  - CRUD works; commits land as readable JSONL with commitInfo first
  - a duplicate catalog returns CATALOG_ALREADY_EXISTS, not a 500
  - a fresh process replays to version 5 and returns the same metastore id,
    so get_or_init adopts the existing row instead of making a second
  - two independent server processes racing one catalog name against a shared
    log produced exactly one winner: a 200, a 400, one commit. That is the
    claim the design rests on and it had only been tested against a fake.
  - _keys.json was written once and never regenerated across three processes

Two bugs the test suite could not have found:

  - startup failed outright writing token.txt. The config dir is not
    guaranteed to exist; on the SQLite path it was created as a side effect of
    persisting the keypair, and with keys in the object store nothing created
    it.
  - the startup banner logged the sqlite database_url on the log-store path,
    naming a database that is never opened.
Removes the object-store key path entirely -- the loader, the KEYS_KEY
constant, the opt-in flag, and the get_or_create_object primitive that existed
only to serve it.

The reason is specific. uc-credentials' vend calls assume_role() with no
.policy(), so a vended credential carries the role's full permissions, and that
role is bucket-scoped rather than prefix-scoped. A private key anywhere in that
bucket is readable by anything holding a vended credential -- every client that
can ask for table credentials -- and reading the signing key means forging
tokens for any principal.

Guarding it behind a flag, as the previous commit did, was the wrong shape: the
exposure is silent, so a deployment that reused the data bucket for
--storage-root would look entirely healthy.

Key material now comes from --key-file, the path a mounted Secret arrives at.
A file rather than an env var: env vars are inherited by child processes,
appear in crash dumps and process listings, and are easy to log by accident.

A missing key file is a startup error, never a cue to generate --
silently minting a keypair invalidates every issued token while looking like a
clean start. There is no config-dir fallback under logstore either, since
generating on a stateless replica mints a different keypair per pod.

--generate-key-file <path> is the only way to produce one: one-shot, writes and
exits, refuses to overwrite an existing file.

Verified against MinIO: startup refuses without --key-file and before opening
the store; with one, CRUD works and the bucket holds only commits -- no key
material at all.
Writes already converge without coordination: a stale replica cannot commit,
because its conditional PUT loses and it must replay and re-evaluate its
precondition first. Reads do not -- a replica serving only reads never learns
about another's commits and would be stale indefinitely, a gap the PVC did not
have because it allowed only one writer at all.

--refresh-interval-secs runs Store::catch_up on a timer, at one LIST per
interval, default off since a single replica does not need it. This bounds
staleness; it does not make reads linearizable, and a request can still land
between another replica's commit and the next refresh. Stated as such rather
than implied to be stronger.

Step 8 (rename AnyPool -> Store) is retired. It assumed the log store would
replace SQL outright, and it has not: both backends are supported and tested.
AnyPool is whichever handle the build selected, which is accurate; Store would
be a lie on the SQLite path, where it is a connection pool.
`rows.get(max_results as usize - 1)` at max_results = 0 panicked in debug and,
in release, wrapped to usize::MAX so `get` returned None -- an empty page with
no next token, indistinguishable to a client from "no more rows". Reachable
from every list endpoint: the clamp was `.unwrap_or(50).min(1000)`, with no
lower bound.

The fragment was duplicated eighteen times, once per list function per backend,
each carrying the same defect. Replaced with pagination::page, which guards a
non-positive limit and takes the token from the last row it actually returns
rather than by index arithmetic.

Writing a test for it surfaced a second, narrower overflow that I had
introduced in the store repos: `max_results as usize + 1` wraps for a negative
limit, since (-1i64) as usize is usize::MAX. The SQL path never hit it, binding
an i64 straight into LIMIT. pagination::over_fetch saturates instead.

At the API layer a non-positive max_results now means "unspecified" and falls
back to the default 50, rather than being passed through as a literal zero.

The second reported bug -- entity_type written as 'table' but matched as
"TABLE" -- is not a bug. permissions.rs matches securable_type, a separate
permissions-API parameter, and already normalises with to_uppercase(); it never
touches uc_properties.entity_type. Every property:: call uses lowercase
consistently.

uc-db 72 (sqlite) / 79 (logstore+s3), uc-auth 42/42, uc-api 104.
The read-freshness edit spliced out the sequencing list along with the section
it was replacing, so the doc had no status at all -- and I reported step 8 as
retired against a list that no longer existed.

Also corrects two places still describing key material as living at
_uc_log/_keys.json, which contradicts the decision to remove that path
entirely. Left as-is they would tell a future reader to reintroduce exactly the
exposure that was removed.

Records what is deliberately not done: log pruning, multi-replica in
production, and that logstore supersedes rather than combines with the SQL
backends.
The README described only the SQL backends, so a reader had no way to discover
the object-store one, its flags, or its constraints. Adds a storage-backend
table, a run example, the new CLI options, and the two-backend test command.

Corrects one line that this branch had already invalidated for the SQL builds
too: --config-dir was documented as holding "RSA keys, JWKS, token", but /jwks
is now derived from the keypair in memory and no longer reads certs.json.

Writing the flag list surfaced a misconfiguration trap: --storage-root was
accepted on the sqlite and postgres builds and silently ignored, so a
deployment could pass it and quietly run against a database instead. It is now
feature-gated, and those builds reject the argument outright.
put_if_absent maps onto PutObject's if_none_match, which older 1.x releases do
not expose, and the whole concurrency design rests on that one header. The
constraint was "1", and Cargo.lock is gitignored here, so a fresh clone resolved
whatever happened to be current with nothing recording the requirement.

Floored at 1.144 -- the version this was built and tested against. The true
minimum is lower, but has not been verified, and claiming an unverified floor
would be worse than a conservative one.
Answering "do we need sqlx?" for the logstore build: no. It was still compiled
in -- 185 of uc-db's 205 transitive dependencies -- purely because all 14 *Row
models derived FromRow unconditionally and lib.rs's error helpers convert
sqlx::Error. No SQL was ever executed there; the repo! macro already selects the
*_store.rs bodies.

sqlx is now an optional dependency behind a `sql` feature that both SQL backends
imply and `logstore` does not. FromRow moves to
#[cfg_attr(feature = "sql", derive(sqlx::FromRow))], and the error helpers into
a gated module.

uc-db --no-default-features --features logstore: 204 -> 84 transitive
dependencies, zero sqlx.

Building with --features logstore alone still works and still leaves the driver
linked, since default = ["sqlite"] stays on; the README now says so rather than
leaving it as a trap.

Green on both shapes: uc-db 72 (sqlite) / 79 (pure logstore+s3), uc-auth 42/42.
One backend, no feature flags. Deletes the 14 SQL repo bodies, both migration
sets, the sqlx type aliases and error helpers, --database-url and the
S3-backed-SQLite machinery around it, and the sqlite/postgres/sql/logstore
features across all four crates. The *_store.rs files lose the suffix that only
existed so they could sit beside the SQL ones.

sqlx is gone from the workspace dependency graph entirely.

Removing SQLite surfaced a real behaviour difference the dual-backend suite
could not, because the delta API tests only ever ran on SQLite:
`SELECT MAX(commit_version)` over no rows decoded as 0, so a table with no
commits reported version 0. The log store returns None honestly, which exposed
that the two call sites want different answers:

  - load reports the table's version, and must say 0 to match what create
    returns immediately above it — otherwise creating a table and loading it
    gives two different versions;
  - commit needs the *last committed* version, so an empty table must read as
    -1 for the next commit to land at 0. SQLite's 0 would have pushed the first
    commit to version 1.

Both call sites previously received 0 and the -1 branch was unreachable.

README rewritten for a single backend.

Green: uc-db 79, uc-auth 42, uc-api 104, uc-errors 18, uc-types 7,
uc-openapi 8, uc-credentials 18. All four crates build with zero warnings.
Dead code the SQL removal left behind:
  - --database-url was still accepted and silently ignored. It never had a cfg
    gate, so stripping the feature missed it -- the same trap --storage-root
    had on the SQL builds, in reverse. The binary now rejects it.
  - An orphaned doc comment about masking database-URL passwords had attached
    itself to fetch_oidc_jwks, documenting something that function does not do.
  - An empty "S3-backed SQLite support" section header.
  - uc-auth's test helper was still called in_memory_sqlite; it builds a
    MemoryLog. Renamed fresh_store.

Eleven unused dependencies, each confirmed by removing it and compiling:
uc-db (uc-types, uc-openapi, aws-config, thiserror), uc-auth (tracing,
thiserror), uc-api (tower-http, tracing, thiserror), uc-credentials (serde,
serde_json, tracing, thiserror), uc-types (uuid), uc-openapi (chrono),
uc-errors (thiserror, serde_json), uc-server (uc-errors, uc-openapi, serde,
rsa, rand, base64, hex -- the last four left over with base64_encode).
uc-db's `s3` feature also referenced aws-config, which it never used: the
caller passes a configured Client in.

Adds workspace lints, applied by every crate:
  - unsafe_code = "forbid". There is none, and forbid means a local #[allow]
    cannot reintroduce it.
  - unused_qualifications, unreachable_pub, clippy::all.

The set is deliberately one that passes clean: cast_possible_truncation /
cast_sign_loss (14 benign sites) and unwrap_used / expect_used / panic (~500
hits, nearly all in tests) are left off with the reasoning recorded, rather
than carried as standing warnings nobody reads.

clippy --all-targets: 0 warnings. cargo check --workspace --all-targets: 0.
Tests: uc-db 79, uc-auth 42, uc-api 104.
Enables cast_possible_truncation / cast_sign_loss / cast_possible_wrap and
unwrap_used / expect_used / panic / indexing_slicing. I had waved these off as
benign; one was not.

A real bug on the credential-vending path: temp_credentials cast a
user-supplied i64 model version to i32 with `as`, so a request for version
4294967301 silently returned credentials for version 5. Now a checked
conversion that rejects out-of-range input.

An unguarded panic in the authorization layer: db_adapter's
remove_filtered_policy indexed `vals[field_index + i]` with nothing
constraining field_index + len() to the six columns. Out of range now means
"no such field, does not match" -- so a rule is not deleted -- instead of a
panic.

The rest were guarded, but guarded at a distance:
  - authorizer's grant listing checked `p.len() >= 3` in one closure and
    indexed p[0..2] in another; now destructured so the check and the use are
    the same expression.
  - casbin::from_parts resized to six then indexed; now built from a padded
    iterator.
  - split3, delta_commits and OIDC discovery: `[]` on a length-checked Vec or
    an untrusted JSON body, replaced with slice patterns and `.get`.
  - Mutex::lock().unwrap() in MemoryLog and the credential cache: a poisoned
    lock meant one panicking thread made the component permanently unusable.
    Recovered with into_inner; neither map has an invariant a partial write
    could break.
  - Counts and ordinals cast usize -> i32: now saturating rather than wrapping.

One correction to an earlier claim: the credential-expiry cast was already
guarded by the branch above it and was not exploitable. Restructured so the
safety is local rather than three lines away, but it was not a live bug.

Test code keeps unwrap/expect/indexing behind a scoped allow with a note --
they are the idiom for asserting, and the enforcement target is production code.

clippy --all-targets: 0. clippy --lib --bins: 0.
Tests: uc-db 79, uc-auth 42, uc-api 104, uc-credentials 18, uc-errors 18.
Removes the signing half of authentication. UC is a resource server now: it
validates bearer tokens against the configured OIDC issuer's JWKS and has no
key material of its own.

Gone: KeyManager, JwtConfig, encode_token/decode_token, --key-file,
--generate-key-file, the token-exchange endpoint, /.well-known/jwks.json, the
dev admin token written to token.txt, and uc-auth's rsa/rand/hex dependencies.

--no-auth is folded into --oidc-issuer. Auth is enforced exactly when an issuer
is configured, so "auth enabled with no issuer" -- a server that accepts
nothing, since UC signs nothing -- is no longer representable rather than
being caught by a runtime check.

The evidence this is unused surface: nothing in the aispecs, aispec-ui or
manifests repos calls /auth/tokens or reads token.txt, and the operator already
passes --oidc-issuer pointing at the Kubernetes API server, so real callers
authenticate with projected ServiceAccount tokens. The keypair was signing
tokens no client asked for.

It also removes the problem introduced when key material moved off the PVC:
there is no key to put in a Secret, and no bucket-exposure question about where
a private key lives.

Tests asserting the removed endpoints now assert their absence, so a route
cannot quietly return and bring a signing key back with it.

clippy --all-targets: 0. uc-db 79, uc-auth 29, uc-api 103.
commitInfo.actor existed in the format and was hardcoded None. Populating it
closes the gap that matters for audit: created_by and updated_by are columns on
the entity, so they only survive while the entity does. Drop a table and all
that remains is {"remove": ...} with no actor -- "who dropped this table" was
unanswerable. Grants were worse: CasbinRule is ptype + v0..v5 with no *_by
column at all, so the single most security-relevant event in a catalog recorded
what changed and never who.

An actor on the commit covers every kind uniformly -- creates, updates,
removes, grants, property changes -- rather than the subset that happens to
have a column for it.

Actor carries both a stable id and the name as it was, because neither alone is
a sound audit record: an address is mutable, so a record keyed on it can
attribute an action to the wrong person after a rename; an id alone is
unreadable if the mapping is ever lost, and resolving it at read time would
show a two-year-old action under the person's *current* address. Git captures
author name and email at commit time for the same reason.

Carried as a task-local rather than an argument on ~40 repo functions and every
uc-api call site -- the same shape as a tracing span. A commit outside a request
records no actor, which is the honest answer for startup work no user performed.

Also drops two now-dead entries from AUTH_BYPASS_PATHS: the exchange and JWKS
routes they exempted no longer exist.

Tests: store-level capture, that a delete names the deleter rather than the
creator, that a grant names the granter, that no actor leaks between scopes,
that a rename does not re-attribute a past action, and end to end that a real
bearer token through the middleware lands on the commit.

uc-db 85, uc-auth 29, uc-api 104. clippy --all-targets: 0.
Nothing was instrumented. Adds OpenTelemetry traces exported over OTLP, and the
graceful shutdown they need to be reliable -- the two are coupled, since a
batch span processor drops whatever it holds if the process dies without a
flush.

Inert unless OTEL_EXPORTER_OTLP_ENDPOINT is set: no exporter, no batch
processor, no span layer, so a deployment that wants no telemetry pays nothing
rather than paying for failing exports. Configured through the standard OTEL_*
variables rather than bespoke flags.

Spans are placed where the latency actually is: http.request as the outermost
layer so store timings have a parent to attribute them to, store.commit
carrying uc.operation, the version that landed and how many attempts contention
cost, store.catch_up with the version range it replayed, and the individual
s3.* object operations. That distinguishes a slow request spent in the object
store from one spent retrying a lost conditional write.

Graceful shutdown closes a real gap independent of telemetry: axum::serve had no
shutdown future and nothing handled SIGTERM, so the process died on the default
disposition and severed in-flight requests. No metadata could be lost -- a
commit is durable before its handler returns -- but a client whose write had
already landed got a connection reset and, on retry, an ALREADY_EXISTS that
looks like a bug. On a rolling deploy that was a burst of client errors every
time.

Verified end to end against a real collector: 16 spans across 4 traces, store
and s3 spans sharing a trace id with their http.request parent, uc.* attributes
present; SIGTERM drained and the last write's spans arrived *after* the signal,
which is the flush working; and with no endpoint set the collector received
nothing.

uc-db 85, uc-api 104, clippy --all-targets: 0.
Traces alone were a third of it. Adds the other two signals.

Logs are exported over OTLP rather than left for the collector to scrape off
stdout, because the appender attaches the active trace and span ids. A log line
you cannot pivot to its trace is not much better than a log line on its own.
stdout keeps its human-readable output, which is what you read when the
collector itself is the problem.

That exposed a gap: there was no per-request logging at all. tower-http's
on_response now emits inside the http.request span, so the record carries the
trace id -- verified as a "finished processing request" log with status 200 and
a trace id shared with five spans.

It also needed a filter fix that would have silently broken it: the default
EnvFilter listed only uc_* targets, so the response event -- emitted under
tower_http -- was dropped. Request logging would have been configured, wired,
and invisible.

Metrics are deliberately only resident state: uc.store.entities by kind, and
uc.store.version. The whole catalog is in memory, so entity count is what
decides whether the process fits its limit, and that is state rather than an
event so spans cannot report it. Everything event-shaped -- request rate,
latency, commit contention -- is already on the spans, and the collector's
spanmetrics connector derives RED metrics from those; emitting both would be
double instrumentation that can disagree with itself.

The gauge callbacks use try_read and skip an interval under contention rather
than blocking a writer, since OTel observable callbacks are synchronous.

Verified against a collector with all three pipelines: gauges reported
Catalog=3, Schema=1, Metastore=1 and version=5 against a store holding exactly
that; shutdown flushes all three providers independently.

uc-db 85, uc-api 104, uc-auth 29. clippy --all-targets: 0.
Two comments described a particular deployment rather than the software: the
--enable-aws-credentials default was justified by "every deployment in this
project", and open_log_store's doc named `bucket.minio.svc`, an in-cluster
hostname from one environment. Both now say what is true generally -- S3-scheme
credentials are the common case, and path-style addressing is needed because
virtual-host style turns the bucket into a hostname prefix that most
self-hosted endpoints will not resolve.

MinIO stays named where it is a factual statement about what the code supports,
and AwsIamRole/role_arn stay because they are the upstream API's own types.

The status section had also gone stale in a way that mattered: it still
described key material landing in a Secret, both backends being supported
behind a feature flag, and a pending change in "a separate repo running in
production". None of those hold -- key material is gone entirely, SQL is gone
entirely, and referencing another repository's deployment does not belong here.
Records what a one-off migrator has to get right, before writing one.

The output is a single checkpoint plus its pointer rather than a replay of
every row as a commit: a checkpoint is the materialised state, and the store
prefers one at startup. Two objects instead of thousands.

Notes what no longer needs migrating -- the RSA keypair, since uc-server issues
no tokens and holds no signing key, so discarding it invalidates nothing.

The traps are the ones that fail quietly: a wrong size or checksum on
_last_checkpoint falls back to a full log scan, which here means an empty
catalog; ids must be preserved exactly or every grant and every client-held
table-uuid breaks later rather than at migration time; and delta commits belong
in per-table partitions, not the checkpoint, or every table silently loses its
commit history.
A throwaway script, not a maintained tool: stdlib only, read-only with respect
to the database, and writing to a local directory so the output can be
inspected before it is uploaded anywhere.

It emits one checkpoint plus its pointer rather than replaying every row as a
commit, because a checkpoint is the materialised state and the store prefers
one at startup. Two objects instead of thousands.

Why a migrator is needed at all, given how few objects an org has: managed
storage locations are {root}/schemas/{schema_id}/tables/{table_id}. The id is
the data location, so re-bootstrapping a catalog would mint new ids and orphan
every existing Delta file in place. Preserving ids is the entire job.

The parts that had to be got right, all of which fail quietly rather than
loudly: UUIDs are 16-byte BLOBs in SQLite and hyphenated strings in the log;
five INTEGER columns are bools in the Rust structs; casbin_rule has an INTEGER
surrogate and insertion order is significant, so v7 ids are minted in ascending
id order to preserve it; and delta commits go to per-table partitions where the
version is the object key, not into the checkpoint.

Verified end to end against MinIO: a fixture database built from the
pre-removal schema migrates, uc-server boots against the output and serves the
catalog, schema, table and volume over the API, the table id is byte-identical
to the one in SQLite, three delta commits land as partition objects, and a
write succeeds on top of the migrated state.
Committed for reuse, so the framing should match: the same migration runs once
per deployment, and reconstructing the details each time is where the mistakes
happen. Corrects docs/migration.md, which still described the tool as not yet
existing, and records what the end-to-end verification did and did not cover --
six entity kinds have no fixture rows, so their column mappings ride on the
generic path without being asserted.

@vishwateja-angirekula vishwateja-angirekula left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good work

CI's fmt job failed on this branch. main was clean, so all 161 hunks came from
here: I checked clippy after every change and never once ran cargo fmt.

Formatting only -- clippy stays at zero and the suites are unchanged at
uc-db 85, uc-auth 29, uc-api 104.
Snapshot stored every row as a Value: a map with a heap-allocated String per
field name, repeated per row, and every read paid a from_value to get a typed
struct back. Rows are now a Row enum, constructed on the way in and matched on
the way out. The wire format is unchanged -- commits and checkpoints still
carry `body` as arbitrary JSON.

Measured, identical load and method on release builds, 6,022 resident entities:

  Value   base 21M  peak 65M  settled 62M   7.0 KiB/entity
  typed   base 21M  peak 54M  settled 54M   5.6 KiB/entity

20%, not the 3-5x I estimated. A Rust enum is sized to its largest variant --
TableRow, at 17 fields -- so a two-field MetastoreRow occupies the same
footprint; the string contents are the same allocations either way; and
by_natural_key is untouched. Boxing the large variants would recover more if it
ever matters.

The type-safety results are worth more than the 20%:

  - The compiler rejected EntityKind::DeltaCommit and ::Dependency for having
    no model struct. Both were dead -- delta commits live in per-table
    partitions and are never resident, and uc_dependencies had no repository
    and nothing ever wrote it. Removed.
  - natural_key_for is a match on Row, so a renamed or missing field is a
    compile error. It previously read fields out of a Value by name and
    returned None on a miss, which would have silently dropped rows out of the
    index.
  - apply now returns Result and the three callers propagate it, so a row this
    build cannot read aborts replay instead of being skipped.
  - Repo reads are a clone rather than a JSON deserialisation.

Test fixtures had to become real rows, since a partial body is now rejected --
which is the behaviour, not an inconvenience. One test went away:
delta_commits_are_not_snapshot_entities asserted a property the type system now
enforces.

uc-db 84, uc-auth 29, uc-api 104. clippy 0, fmt clean.
`dependency` and `delta_commit` were removed from EntityKind once nothing
wrote to them, but the migrator still mapped uc_dependencies onto
`dependency`. On a database with rows there, it emitted upserts the server
cannot deserialise -- and the failure would land at replay on the new
deployment, after the old one had been torn down.

uc_dependencies now aborts the migration if it has rows, rather than
producing an unloadable checkpoint. uc_delta_commits keeps migrating: it
was never a snapshot entity, it becomes one object per per-table partition.

Verified against the live acme-inc database (1049 rows): 1046 entities
migrate, and all of them replay into the typed Row enum.
@muralidhar-challa
muralidhar-challa merged commit 9d27ff1 into main Sep 3, 2026
5 checks passed
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.

2 participants