Contract: storage semantics (normative)
Status: normative. Owner: P00 (frozen). Implementations: scion-store (MemMetaStore,
MemBlobStore, ObjectRepo; P03), scion-store-crdb and scion-store-s3 (P15). The shared
suites scion_store::conformance::{meta_store_suite, blob_store_suite} test this document.
SPEC references: 4.5, 5.1-5.4, 8.4, 8.5, 9.1, 13.2, 18.8; PLAN 2.3, 3.2, 6.8.
1. General rules
- One method = one transaction. Every
MetaStoremethod is exactly one serializable (CockroachDBSERIALIZABLE) transaction. It either commits all of its effects or none. The only exception is the informational read incas_ref'sLandedConflictpath (section 4.4), which reads an immutable row after the rollback. - Infrastructure failures surface as
ScionErrorUNAVAILABLE with subcodestore_unavailable(connection or backend down),txn_retry(CockroachDB40001retries exhausted, or a Mem forced failure) orinjected/injected_crash(fault decorators). A failed call has no effects, except for fail-after faults (meta.<method>.afterarmed withFaultAction::FailAfter), which apply the effect and then report UNAVAILABLE/injected ("committed, response lost"). - Idempotent inserts. Every insert of an identity-bearing row uses
ON CONFLICT DO NOTHING; an existing row is never modified by an insert, and the outcome isInsertOutcome::AlreadyExists. - Ordering. Methods returning lists return them in the order stated; "ascending" means bytewise ascending on the stated key (ids compare as their 34 raw bytes, paths bytewise).
- Repository id.
repo_id = i64::from_be_bytes(BLAKE3(repo_name)[0..8]) & i64::MAX. It is deterministic, so every server that loads the same configuration agrees without coordination.MetaStore::repo_id()returns it without IO. Every table is keyed byrepo_idfirst. - Times are
SystemTimesupplied by the caller (fromClock), stored asTIMESTAMPTZwith microsecond precision. Callers must not depend on sub-microsecond round trips. - Consistency.
Consistency::Strongreads are linearizable.Consistency::Stalereads may omit rows committed recently (CockroachDBAS OF SYSTEM TIME follower_read_timestamp(); Mem: a snapshotstale_lag_commitscommits old). Refs and the redaction epoch are always read strongly. Callers retry a stale miss strongly before reporting NOT_FOUND (SPEC 5.4).
2. Schema (CockroachDB DDL shape)
The authoritative DDL is scion_store_crdb::MIGRATIONS. Shape (all PRIMARY KEYs start with
repo_id):
| Table | Primary key | Columns |
|---|---|---|
repos |
repo_id |
name UNIQUE, created_at |
objects |
(repo_id, object_id) |
kind INT2, size, stored_size, inline_data BYTES NULL, state STRING ('live'/'quarantined'/'purged'), created_at |
object_uploads |
(repo_id, object_id, principal) |
at |
object_paths |
(repo_id, object_id, path) |
first_changeset, at |
changesets |
(repo_id, changeset_id) |
encoded, generation, skip BYTES[], change_id, created_by, created_at |
changeset_children |
(repo_id, parent_id, child_id) |
|
refs |
(repo_id, name) |
target, version, updated_at |
ref_log |
(repo_id, name, version) |
old_target NULL, new_target NULL, principal, at, landed BYTES (canonical list of (change_id 16, source 34, result 34)) |
landed |
(repo_id, ref, change_id) |
version, source_changeset, result_changeset |
derived |
(repo_id, type, type_version, key) |
value NULL, storage_key NULL, source_object NULL, at |
derive_leases |
(repo_id, type, type_version, key) |
holder, expires_at |
redactions |
(repo_id, object_id) |
state ('quarantined'/'purged'), reason, actor, created_at, purge_after, epoch |
counters |
(repo_id, name) |
value (redaction_epoch) |
audit_log (PLAN 6.8) |
(repo_id, seq) |
at, actor, action, target, reason, detail |
derived.source_object is an implementation column that does not participate in identity
(allowed by SPEC 5.2); it keys content-bearing records by their source object (SPEC 9.1).
Secondary indexes:
- Changeset id prefix: served by a range scan on the
changesetsprimary key: the 64-hex prefixpmaps to the byte range[01 03 || lo(p), 01 03 || hi(p))wherelopadspwith0andhiis the next prefix (an odd final nibble is handled by the padding). No extra index is needed. derived_by_source ON derived (repo_id, source_object) WHERE source_object IS NOT NULL.redactions_by_epoch ON redactions (repo_id, epoch).redactions_by_purge_after ON redactions (repo_id, purge_after) WHERE state = 'quarantined'.
migrate() is idempotent (CREATE ... IF NOT EXISTS). connect() ensures the repos row with
INSERT ... ON CONFLICT DO NOTHING.
3. CockroachDB transaction discipline
- Each method runs
BEGIN ISOLATION LEVEL SERIALIZABLE; ...; COMMITon a pooled connection (deadpool-postgres). SQLSTATE40001(and40003ambiguous-result on read-only methods) is retried client-side with full-jitter backoff, at most 10 attempts; exhaustion is UNAVAILABLE/txn_retry. - Connection and pool errors are UNAVAILABLE/store_unavailable.
Consistency::Stalereads run as a single statement withAS OF SYSTEM TIME follower_read_timestamp()outside an explicit transaction.- Unique violations other than those mapped below (
23505onlanded) are bugs and surface as UNAVAILABLE/store_unavailable with an ERROR log event.
4. Method contracts
For each method: reads, writes, conflict behaviour, outcome, SQL shape, Mem notes.
4.1 Objects
ping() - SELECT 1. Mem: succeeds unless a forced failure applies.
get_objects(ids, c) - Reads objects rows for ids; returns one entry per input id in
input order (None when absent; duplicates allowed). SQL: SELECT ... FROM objects WHERE repo_id = $1 AND object_id = ANY($2), reordered client-side.
insert_object(row) - INSERT INTO objects ... ON CONFLICT (repo_id, object_id) DO NOTHING. Returns Inserted or AlreadyExists. An existing row is never changed (in particular
a quarantined or purged row stays so; ObjectRepo rejects such ids before calling).
record_upload(id, principal, at) - INSERT INTO object_uploads ... ON CONFLICT DO NOTHING (the first at is kept).
has_upload(id, principal) - Point read on object_uploads.
object_paths(id, limit) - Rows of THIS id only: SELECT ... WHERE repo_id = $1 AND object_id = $2 ORDER BY path LIMIT $3.
4.2 Changesets
get_changesets(ids, c) - As get_objects, over changesets.
changesets_by_prefix(hex_prefix, limit) - hex_prefix is 0-64 lowercase hex digits of the
digest (callers validate the 8-digit minimum). Returns changeset ids whose digest hex starts with
it, ascending, at most limit. SQL: primary-key range scan (section 2).
children(parent) - SELECT child_id FROM changeset_children WHERE parent_id = $2 ORDER BY child_id.
has_root_changeset() - True iff any changeset has no parents, i.e. generation = 1.
SQL: SELECT EXISTS (SELECT 1 FROM changesets WHERE repo_id = $1 AND generation = 1).
commit_changeset(bundle) - SPEC 5.3 step 2, one transaction:
INSERT INTO changesets ... ON CONFLICT DO NOTHING- its outcome is the method's outcome.INSERT INTO changeset_children (parent, child)for every parent,ON CONFLICT DO NOTHING.INSERT INTO object_pathsfor every row,ON CONFLICT DO NOTHING.INSERT INTO derivedfor every eager row,ON CONFLICT DO NOTHING.
Each insert is independent, so re-committing a bundle whose changeset row exists still fills in
any missing child/path/derived rows (crash recovery) and returns AlreadyExists. The caller
guarantees that every referenced object already exists (SPEC 4.5 invariant 2).
4.3 Refs
get_ref(name), list_refs() - Strong reads (list_refs ascending by name).
cas_ref(cas) - SPEC 5.3 step 3, 8.4, 8.5. One transaction:
SELECT target, version FROM refs WHERE repo_id = $1 AND name = $2 FOR UPDATE.- Check
cas.expect:Absentrequires no row;Version(v)requires a row with versionv. On failure: returnVersionMismatch { current }with no writes. - Compute the new version:
Version(v)->v + 1.Absent->1 + max(ref_log.version for this name)(1for a name that never existed). Re-creating a deleted ref therefore continues its numbering, keeping<ref>@<version>unambiguous and theref_logkey unique.
- Apply:
Absent+Some(t)inserts the ref;Version(v)+Some(t)updatesSET target = t, version = v + 1, updated_at = at WHERE version = v;Version(v)+Nonedeletes the ref.Absent+Noneis INVALID_ARGUMENT. - Insert the
ref_logrow(name, new_version, old_target, new_target, principal, at, landed). - Insert one
landedrow percas.landedentry withversion = new_version. A unique violation (an existing(ref, change_id)) rolls back the WHOLE transaction (nothing from steps 4-7 persists) and the method returnsLandedConflict { existing }, whereexistingis the conflicting row read strongly after the rollback (landed rows are immutable). - Insert
cas.auditintoaudit_logwhen present. - Return
Committed(Some(new_row)), orCommitted(None)after a delete.
Mem: the version check, the landed uniqueness check and all writes happen under the single mutex; a landed conflict is detected before any write.
ref_log(name, from_version, limit) - Entries with version >= from_version, ascending,
at most limit.
get_landed(name, change_id) - Point read on landed.
4.4 Derived data and leases
get_derived(k, c) - Point read on derived.
put_derived(rows) - INSERT ... ON CONFLICT DO NOTHING for every row (lazy write-back is
idempotent and deterministic, SPEC 4.5 invariant 3).
delete_derived_by_source(types, source) - DELETE FROM derived WHERE repo_id = $1 AND source_object = $2 AND type = ANY($3); returns the number of rows deleted (SPEC 13.5).
try_lease(k, holder, ttl, now) - Takes or renews the lease when no lease row exists, the
existing one has expires_at <= now, or it is held by holder; then
expires_at = now + ttl and the result is true. Otherwise false, no writes. Leases only
deduplicate work; correctness never depends on them (SPEC 9.1). Decorators count lease_calls
and emit a scion::lock span.
release_lease(k, holder) - DELETE ... WHERE holder = $holder; releasing a lease not held
is a no-op.
4.5 Redaction (SPEC 13.2, 18.8)
redact(t) - One transaction:
- Every id in
t.idsmust have anobjectsrow, else NOT_FOUND and no effects. (Kind checks, Blob/Meta only, are the Redactor's job.) epoch = counters.redaction_epoch + 1(row created at 0 when absent), written back.- For each id: when its
redactionsrow is absent, insert(Quarantined, reason, actor, created_at = at, purge_after, epoch); when present and Quarantined, refreshreason,actor,purge_afterandepoch; when Purged, leave it unchanged. - Set
objects.state = 'quarantined'for every id not already purged. - Append one
audit_logrow per id (action = "redact",target= id text, reason, actor). - Return
epoch.
unredact(ids, audit) - Every id must have a Quarantined redactions row, else
INVALID_ARGUMENT/not_quarantined and no effects (PLAN 6.7: unredact after purge is
not_quarantined). Then: epoch + 1; delete the redactions rows; objects.state = 'live';
append audit; return the new epoch.
mark_purged(ids, audit) - For every id: redactions.state = 'purged',
objects.state = 'purged', objects.inline_data = NULL. The epoch is unchanged (the object
stays redacted). Append audit.
redaction_epoch() - Strong read of counters.redaction_epoch (0 when absent).
list_redactions(since_epoch) - In one snapshot: rows with epoch > since_epoch ordered by
(epoch, object_id), plus the current epoch. Consumer rule (daemons, P16): every epoch
increment is either a redact (whose rows carry that epoch) or an unredact (whose rows were
deleted). When current_epoch exceeds the largest epoch among the returned rows (or rows are
missing for some epochs), the consumer re-lists from since_epoch = 0 and REPLACES its set, so
unredactions propagate. since_epoch = 0 always returns the complete current set.
due_purges(now, limit) - Quarantined ids with purge_after <= now, ascending by
(purge_after, object_id), at most limit.
4.6 Audit (PLAN 6.8)
append_audit(row) - Insert with a new monotonically increasing seq.
audit_log(limit) - The most recent limit rows, returned oldest first (newest last).
5. In-memory implementation notes (MemMetaStore, MemBlobStore)
- All state lives in
impersistent maps behind ONEparking_lot::Mutex; each trait method takes the lock once, so the store is strictly serializable and every method is atomic. - After each committing method the new state root is pushed onto a ring of the last
K = max(stale_lag_commits, 1)snapshots.Consistency::Stalereads the snapshotstale_lag_commitscommits old (or the latest when the lag is 0);advance_stale_view()makes stale reads see the latest state;set_stale_lagchanges the lag. fail_txn_every = Some(n): every n-th method call (counting every async method, reads included) fails UNAVAILABLE/txn_retry before taking any effect.MemBlobStore: aBTreeMap<String, Bytes>;corrupt(key)flips one bit of the stored value;set_down(true)makes every operation fail UNAVAILABLE/store_unavailable.getof a missing key isOk(None);deleteandcopyof a missing key are idempotent (copyof a missing source is NOT_FOUND).
6. BlobStore contract
put overwrites; get of a missing key is Ok(None); copy(from, to) copies bytes (NOT_FOUND
when from is missing); delete is idempotent; exists is a point check; list(prefix)
returns keys ascending. Object storage must provide strong read-after-write consistency
(SPEC 3.5). S3 keys are prefixed with S3Config.key_prefix.
7. ObjectRepo: key layout and write ordering
Key layout (SPEC 5.1): <prefix>/r/<repo_id>/<letter>/<hex[0:2]>/<hex> for live bytes and
<prefix>/q/<repo_id>/<letter>/<hex[0:2]>/<hex> for quarantined bytes. <repo_id> is decimal,
<hex> the 64 lowercase hex digits of the digest, <letter> the kind letter (b t c m), and
for derived kinds x (kind 0x10) or x<2-hex kind> (other derived kinds).
Where bytes live:
- Blobs always go to object storage as a zstd frame (level
zstd_level) of the raw content. - Trees, Metas and derived objects whose canonical encoding is at most
inline_threshold_bytesare stored inline (objects.inline_data, uncompressed canonical bytes); larger ones as zstd frames in object storage. - Changesets are stored only in
changesets.encoded(written bycommit_changeset);put_encoded(Kind::Changeset, ..)is INVALID_ARGUMENT/invalid_kind.getof a changeset id reads thechangesetsrow. stored_sizeis the frame length (object storage) or the inline length.
Write ordering (SPEC 5.3 step 1):
- Validate: size against
max_blob_bytes(TOO_LARGEmax_blob_bytes), decoded form of known kinds (Tree, Meta, HistoryNode), digest (put_verified: INTEGRITY on mismatch). - Re-introduction blocking: when an
objectsrow exists with state quarantined or purged, fail REDACTED. - Write the bytes (object storage
put), unless inline. Checkpointupload.after_bytes. insert_objectwithstate = live(ON CONFLICT DO NOTHING). Checkpointupload.after_row.
A live row therefore always implies durable bytes. Bytes without a row are harmless orphans.
Reads: stat reads stale first and retries a miss strongly (SPEC 5.4). get /
get_frame: missing row -> NOT_FOUND; state quarantined or purged -> REDACTED; otherwise load the
inline data or the live key, decompress, and verify BLAKE3 against the id. On mismatch: record an
integrity failure in Counters, emit an ERROR event event="integrity_alert" with the object id,
and return INTEGRITY (PLAN 6.10). Every successful read records an object read by kind and raw
bytes (inline trees count as trees_read). Decoded trees, metas and changesets are cached in
bounded LRUs (immutable data), so they keep serving while the metadata store is down.
Quarantine moves (SPEC 13.2): quarantine_bytes copies live -> quarantine (checkpoint
quarantine.after_copy), then deletes the live key; both steps are idempotent, so a crash
between them is completed by re-running. Inline objects have no bytes to move (their inline data
is never served and is cleared by mark_purged). restore_bytes copies quarantine -> live, then
deletes the quarantine key. purge_bytes deletes both keys.
8. fsck (SPEC 4.5 oracle)
fsck walks every changeset row and every ref and reports Violations: missing root tree,
meta, parents or delta blobs (MissingObject), stored bytes not hashing to the id
(DigestMismatch, when verify_bytes), non-canonical encodings (BadEncoding),
delta != FileDiff(T1, root_tree) (DeltaMismatch), missing required eager rows
(MissingEager), wrong generation or skip pointers (BadGraph), and refs whose target is
missing (RefTargetMissing). Quarantined or purged bytes are not violations.