Storage¶
Layout¶
This is the default layout. A repository can instead keep its metadata in PostgreSQL and its objects in an
S3-compatible bucket; config.json then carries a storage section and .ag/ holds only the configuration and
the keys. See PostgreSQL and S3 and ADR-0004.
.ag/
├── config.json {"format":1,"namespace":…,"default_stream":…[,"storage":…]} — written last by `init`
├── meta.db SQLite (WAL); -wal and -shm files exist while open
├── store/
│ ├── objects/ab/cdef… immutable objects, 0444, fan-out by the first byte of the ID
│ ├── tmp/ write staging (empty when idle)
│ └── gc.lock coordination between writers and garbage collection
└── keys/ private keys, 0600 (override the location with $AG_KEY_DIR)
Object store¶
ObjectStore is a trait; shipped backends are the filesystem store, an S3-compatible store (below) and an
in-memory store (tests). The rules in this section hold for every backend unless stated otherwise.
- Write: temp file →
fsync→chmod 0444→ atomicrename→fsyncof the directory. A reader never sees a partial object. - Read: the envelope is parsed and the content ID recomputed; a mismatch is
Corrupt, never data. - Idempotent and self-healing: putting an object that exists and verifies refreshes its age and returns;
putting one whose stored copy is damaged replaces it, so re-publishing repairs corruption
(
fs_detects_corruption_and_put_heals_it). - Size limit per blob (default 256 MiB) enforced on write.
- Durability contract: each backend states what an acknowledged write survives
(
ObjectStore::durability). The filesystem store survives process crash, kernel panic and power loss if the disk honoursfsync; it does not survive loss of the disk, host or site — a successful write is not evidence of replication. Put.ag/storeon replicated storage or takeag backups if you need more. See Durability & backup. - GC coordination: every
putholds a shared lock ongc.lock;delete_if_older_thantakes it exclusively, re-checks the object's age and unlinks. A writer that re-puts an old object just before GC acts therefore always wins, closing the "collected what a writer is about to reference" race.
S3-compatible store¶
S3ObjectStore (crate feature s3, on by default) speaks the S3 REST API with a hand-written AWS Signature V4
over a blocking HTTP client; there is no SDK and no async runtime on this path. Keys are
<prefix>/objects/<2 hex>/<62 hex>, the same fan-out and envelope as the filesystem store.
- Write: a conditional create (
PUTwithIf-None-Match: *). If the object exists (HTTP 412) it is read back and verified; a healthy one has its age refreshed by a server-sideCopyObjectonto itself withREPLACEmetadata (soLastModifiedmoves and a collector keeps it); a damaged one is overwritten, which repairs the store. A service that ignores the conditional header rewrites identical bytes, which is equivalent. One request per object (no multipart), so the largest object is the repository's blob limit. - Read: the envelope is parsed and the content ID recomputed on every
get, exactly as for files. - Errors: connection errors, timeouts and 408/409/429/5xx are retried with backoff; anything else that is not a success is an error. A 404 means "absent" only where absence is a valid answer, so a permission problem is never mistaken for a missing object.
- Durability contract (
ObjectStore::durability, names3-compatible): an acknowledged write survives a crash or loss of this host, and loss of a disk or node if the service replicates or erasure-codes (Amazon S3 and multi-node MinIO do). It does not survive loss of the storage of a single-node service, loss of a site the bucket is not replicated across, or deletion by anyone with write access (use bucket versioning). A 2xx is not evidence of replication — the same honesty as the filesystem store. - GC coordination —
ObjectLock. S3 gives no atomicity across requests, so writers and the collector share a lock:put(and the age refresh) hold it shared;delete_if_older_thanholds it exclusive across its whole check-then-delete. Implementations:InProcessLock(one process),FileLock(processes on one host, for SQLite metadata) andPostgresAdvisoryLock(session-level advisory locks on a dedicated connection; a crashed process releases it automatically).ObjectStore::block_gctakes the shared side for backups. The race and its fix are reproduced ins3_gc_cannot_delete_an_object_a_writer_just_reput. - Object ages are the server's
LastModified. GC grace periods are hours, so clock differences do not matter in practice.
Metadata store¶
Two backends behind one API: SQLite (the default) with journal_mode=WAL, synchronous=FULL,
busy_timeout=30s, trusted_schema=OFF, and PostgreSQL (Meta::open_postgres, one schema per repository).
All SQL lives in crates/ag-storage/src/meta/ and is exposed as methods on a transaction handle (Tx),
composed by the engine inside Meta::write (commit on Ok, roll back on Err). Every query is written once, in the
subset of SQL both engines share, on top of a thin Conn/Param/Row layer (meta/db.rs); ?N placeholders
become $N for PostgreSQL, and PostgreSQL text columns use the "C" collation and integers are BIGINT so
ordering and arithmetic match SQLite's.
Transaction semantics per backend¶
The engine's correctness argument is "one writer at a time, reading a state nobody can change until it commits" (the stream CAS and the policy evaluation happen in one such transaction). Both backends provide it:
| SQLite | PostgreSQL | |
|---|---|---|
Meta::write |
BEGIN IMMEDIATE: the database write lock, across threads and processes |
READ COMMITTED, first statement pg_advisory_xact_lock(key(schema)): writers of one repository are serialized, writers of different repositories are not |
Meta::read |
deferred transaction: a snapshot | REPEATABLE READ READ ONLY: a snapshot, never blocked |
| waiting for the write lock | busy_timeout 30 s |
lock_timeout 30 s |
| wait expires | MetaError::Retriable |
MetaError::Retriable |
| connection lost | n/a (in-process) | MetaError::ConnectionLost; during_commit: true means the outcome is unknown |
| crashed writer | the OS releases the file lock | the server ends the session and releases the lock |
| schema version | PRAGMA user_version (and the meta table) |
the meta table, key schema |
| migrations | migrations/sqlite/NNNN_*.sql in a BEGIN IMMEDIATE |
migrations/postgres/NNNN_*.sql in a transaction holding an advisory lock |
Why READ COMMITTED and not a stronger level for writers: a snapshot is taken at the first statement, which is
the lock request itself — before the lock is granted — so a stronger level would read the state as it was
before the previous writer committed. Under READ COMMITTED each statement after the lock sees the latest
commit. The stream CAS is additionally a conditional UPDATE … WHERE version = ?, so it holds even for a
writer that did not take the lock.
PostgreSQL aborts a transaction after any failed statement, SQLite does not; a write whose closure ignored such
an error is rolled back rather than committed half done (a_swallowed_statement_error_does_not_commit_a_half_transaction).
The tests that pin this down: writers_of_one_repository_are_serialized_and_see_each_others_commits,
many_connections_racing_the_stream_cas_never_lose_an_update, readers_are_never_blocked_by_a_writer_and_see_a_snapshot,
a_connection_killed_mid_transaction_is_an_error_and_nothing_is_applied,
waiting_too_long_for_the_write_lock_is_a_retriable_error, opening_twice_at_once_migrates_exactly_once. The
metadata, engine, policy, lifecycle and agent suites run unchanged on PostgreSQL.
| Table | Contents | Authority |
|---|---|---|
changesets, changeset_parents, changeset_paths |
index of published changesets: generation, first-parent depth, parents, touched paths | rebuildable from objects (ag reindex) |
streams, reflog |
stream heads, versions; append-only head history | source of truth |
intents, intent_objects |
durable operations, idempotency keys, staged objects | source of truth |
outbox |
transactional events (changeset.published, stream.updated) |
source of truth for the one in-process consumer, tree derivation (at-least-once); stream.updated is a recorded notification that nothing consumes and the next pass marks dispatched. The external event feed is the audit log, not the outbox |
audit |
hash-chained audit log | source of truth, tamper-evident |
policies |
append-only policy versions | source of truth |
proposals, reviews, attestations, pins, workspaces, queue |
collaboration state | source of truth |
agents, agent_runs, tasks, task_deps, leases, counters, provenance |
agent coordination; counters provides fencing tokens |
source of truth |
trees, changeset_trees |
derived tree manifests | disposable cache |
jobs |
the asynchronous job queue (schema version 2): leased claims, retry with backoff, dedupe keys | source of truth |
There are deliberately no foreign keys, which lets garbage collection remove rows in any order. ag verify checks
the references that matter for safety: every object's hash, each changeset against its index row and its parents,
stream heads (published, and their cached tree against history), reflog tails, and that every pin, open-proposal
head and workspace head/base names a changeset the object store can produce (the roots garbage collection starts
from). It does not check rows that merely mention a proposal or an operation (reviews, queue entries, intents) or
cached trees other than the stream heads' (those are verified when read, or rebuilt by ag reindex).
Derived data¶
Trees are derived (tree(c) = apply(tree(first_parent(c)), ops(c))) and cached sparsely:
- keyframes: the tree of every changeset whose first-parent depth is a multiple of 32
(
KEYFRAME_INTERVAL), written from the outbox by a pass that runs inline right after each publication and integration commits (best effort: a failed event stays pending and is retried by the next pass orag reindex;Repository::derive_statusreports the pending count and the last failure); - live heads: the tree of each stream head, open proposal head and workspace head, written eagerly;
- everything else is rebuilt on demand by replaying fewer than 32 operation lists from the nearest cached
ancestor (
resolve_tree).
A cached manifest is verified on read (decode + recompute the tree ID); a bad cache entry is treated as a
miss. ag reindex drops and rebuilds all derived data, which doubles as a proof that it is disposable
(derived_trees_are_disposable_and_reindex_rebuilds_them,
derived_tree_cache_is_sparse_but_every_tree_is_still_readable).
Scaling notes (indicative)¶
Measured on a small shared VM with a synthetic repository of 3 000 files and 1 500 commits
(release build): import from Git 13 s; creating a workspace (checking out 3 000 files) 0.3 s; snapshot 0.2 s; propose 0.5 s;
integrate 0.8 s; ag verify (hash 9 000 objects, recompute a head tree through 1 500 commits) 0.4 s;
metadata DB 4 MB. These are not SLOs and they are single-process numbers on SQLite and local files (no network, no PostgreSQL or S3
latency, see ADR-0006); load numbers for the server are not published. They show where the design stands.
Known limits:
- trees are flat manifests (O(files) per derived tree and per merge); hierarchical Merkle directories are on the roadmap;
- blobs are read fully into memory (bounded by the blob limit), and snapshots hash every file of the working directory;
- writers of one repository are serialized, on every backend: throughput per repository is bounded by the round trips of a short transaction. SQLite is a local file (one host); PostgreSQL and S3 lift that limit for the number of processes and hosts, not for concurrent writers of the same repository.