ADR 0004: Row ids are UUID v7 stored as BLOBs, and their type says where they came from
Status
Accepted.
Context
Row ids started as UUID v4 strings. Two costs followed:
- A random id is written into an index at a random leaf on every insert. SQLite feels that mildly, and PostgreSQL would pay a page split and a full-page WAL write per row. That backend is issue #4.
- The paged listings break ties on a whole-second
created_atwithORDER BY created_at, id. With v4 ids, rows created in the same second come back in a different random order for each pair.
Converting ids from text to BLOBs added a third problem. SQLite never compares a
bound String equal to a BLOB, so an internal lookup that kept a &str
parameter would match nothing, silently, for ever. The case that exposed this
was a job-retention test. It inserted a job with a hand-written id and asserted
it was gone afterwards. Had Job::find_by_id taken a &str and parsed it
internally, that assertion would have passed whether or not the sweep had run.
Decision
-
Every id is minted in one place (
mint()incrates/store/src/id.rs), as a UUID version 7. The leading 48-bit millisecond timestamp makes ids created close together share a prefix, and makes them sort by creation. -
Ids are stored as the 16 bytes themselves.
sqlx’suuidfeature encodes aUuidas a SQLite BLOB, and maps the same type to PostgreSQL’s nativeuuid. -
Existing rows keep their v4 ids. An id is a foreign key, a
kidis a credential a client stored, and an order id is part of a URL a client polls. A table can therefore hold both versions, and only the v7 ids sort by creation. -
The parameter type records provenance. In
crates/store/src/:- an id typed
&strmay be junk from outside the process, so it is parsed and an unparseable value answers “absent”; - an id typed
Uuidwas read out of a row.
There is no third case, and no parallel
_uuidvariant of any function. - an id typed
-
parse()is deliberately narrower thanUuid::try_parse. It accepts only the 36-character hyphenated form thatUuid::to_string()produces, so an id written in another spelling answers “not found” rather than resolving. -
Columns that look like ids but do not point at a row stay text. The list, and how to query a BLOB id by hand, are in Database Schema.
Consequences
- An internal lookup handed a stale string fixture is a compile error rather than a silently empty result.
- Only a few functions keep
&str, where the value genuinely arrives from outside the process (the request path and job payloads). They are listed incrates/store/src/id.rs. - The conversion migration (
20260827120000_uuid_ids_as_blobs.sql) is the worked example of the DROP-cascade hazard in ADR 0003. - Some fresh values are deliberately not row ids and do not go through
mint(): thex-request-idfallback, the job lease owner and a notification’sdelivery_id.
Enforced by
- The type system, for
&strversusUuid. every_id_column_is_declared_a_blobandthe_blob_migration_preserves_every_row(crates/store/src/db.rs).- The unit tests of
parseincrates/store/src/id.rs.