Storage
How the storage service stores objects, where their bytes live, how the sweep reclaims them, and who can reach the object store.
Object storage is the storage service (services/storage) behind the gateway at /storage/v1, plus the worker's sweep and webhook delivery. The user guide is Storage; this page is how it works.
The pieces
| Piece | Role |
|---|---|
| Gateway | Checks the data key (or app-user token) and forwards /storage/v1/* with an identity token it mints for storage. Signed-URL downloads, one-time upload PUTs and public paths are routed with no credential. |
storage service | The public listener (CHELABASE_STORAGE_ADDR, behind the gateway) and the console listener (CHELABASE_STORAGE_INTERNAL_ADDR, for the orchestrator only, behind a bearer). Runs metadata statements in the branch's database and streams bytes to and from the object store. |
| Branch databases | The storage schema: buckets, objects, upload_tickets, upload_sessions, the policy helpers and the triggers that guard them. Installed and kept current by the worker's data API bootstrap. |
| Object store | One S3 bucket holding every database's blobs: SeaweedFS's chelabase-storage in the self-hosted install, or Amazon S3. |
| Orchestrator | Studio's storage routes (proxied to the console listener), webhook management, and the control-plane checks signed URLs need. |
| Worker | The sweep of unreferenced blobs, and webhook delivery from NATS. |
A request
An upload, for example:
- The gateway authenticates the key and forwards the request with its identity token: database, branch, role and claims.
- Storage finds the branch's compute through the orchestrator's internal listener (cached; NATS events for settings changes, branch deletes, resets and restores drop the cache at once) and checks the database's
storageschema is Chelabase's own, at a current version. Otherwise every route answers 503storage_unavailable, so astorageschema a user made is never served. - As the caller: in one transaction on a role session (
authenticator, then the key's role and claims), storage reads the bucket and checks the caller'sINSERTpolicy, before any byte is stored. - The body streams to the object store at the version's key, counted against the bucket's
file_size_limitas it arrives, with its type sniffed againstallowed_mime_types. Upload buffers are capped per process (CHELABASE_STORAGE_MAX_INFLIGHT_BYTES; 503busypast it), and an idle body fails afterCHELABASE_STORAGE_BODY_IDLE_SECS. - As storage: on its own login, storage writes an upload ticket vouching for the new version's bucket, name, size, etag, owner and role.
- As the caller again: the object row is inserted or upserted. Row-level security decides a second time, and the
protect_objectstrigger lets the row in only by consuming the matching ticket. - After the commit, storage publishes an object event to NATS for webhooks. A failed publish is logged and counted; it never fails the request.
A download reads the row as the caller (row-level security decides), then streams the version from the object store with the download headers. Storage's connections to a branch, both kinds, share the database's storage_max_connections budget (Studio's database settings) and the process-wide CHELABASE_STORAGE_MAX_CONNECTIONS. Every compute's TLS is verified against the platform CA.
Blob keys
{database_id}/{datoid}/{version_id} a version's bytes
_sweep/{database_id}/… the sweep's marks and the timelines it has seen
_sweep/purge/{database_id} a deleted database whose blobs are still to godatabase_id is the Chelabase database, shared by all of its branches; datoid is the Postgres database's OID on the branch; version_id is new for every upload and copy. A blob is never overwritten: an overwrite writes a new version and repoints the row, and a move changes only the row. That's what lets branches share blobs, signed URLs pin a version, and the sweep decide by reference alone.
Tickets and the owner
Only storage's login (chelabase_storage) can write storage.upload_tickets, and a ticket lives five minutes on one Neon timeline. Inserting an object row, or changing its bucket_id, version_id, size or etag, consumes a matching ticket, whatever the role: the database owner, a BYPASSRLS role or pg_write_all_data can't make a row name bytes storage didn't store for that request. A branch copies unconsumed tickets but never its parent's timeline, so they can't be spent there.
Write-once buckets are enforced in the same schema by triggers (guard_buckets, freeze_objects), so no role is exempt, storage's own included.
Signed URLs
A token is v1.{kid}.{payload}.{mac}: the payload is the claims (database, branch, Postgres database, purpose, bucket, name, version, the minting key, expiry), and the MAC is HMAC-SHA256 under a URL-signing key derived by the orchestrator from the platform secrets key. It's verified in constant time before anything in it is read.
Serving a signed URL also checks, each time, that the minting key is still live (cached up to 5 seconds; a key revocation, a member removal or a key rotation is broadcast over NATS so every replica drops it within a second) and that the signed version is still the row's current version on that branch. A one-time upload token is consumed in the control plane's metadata database, so it's spent once across every replica and restart. The public path reads buckets.public through storage's own login, never the caller's role.
The sweep
The worker sweeps each database every CHELABASE_STORAGE_SWEEP_SECS (6 hours), and after a branch delete, reset or restore. A pass:
- reads the object store's own clock, from a probe object, and checks it against every compute's clock;
- reads every live branch's references (every Postgres database,
storage.objectsandstorage.upload_tickets), backups included. A branch that isn't ready, has a reset or restore in flight, or can't be read stops the pass: it fails closed and deletes nothing; - lists the blobs, marks and open multipart uploads, then reads the branches again. A branch made, reset or restored meanwhile stops the pass;
- marks each blob nobody references that's older than the grace period, and deletes it only once its mark is older than the grace period plus the longest history a branch can be restored from.
The grace period (CHELABASE_STORAGE_SWEEP_GRACE_SECS) is at least 8 days: longer than the longest signed URL (7 days) plus a day, longer than Neon's default history, and far longer than any upload. Marks matter because a restore or a branch at a past point can bring back rows a pass never saw. The sweep records the timelines it has seen; a timeline it hasn't voids the marks made after its branch point (all of them for a restore, which detaches its timeline). So a database restored more often than the wait never reclaims anything, and that's logged.
A deleted database first writes its purge marker, then its delete removes every blob, upload and mark under its prefix; the periodic pass retries any purge still pending. CHELABASE_STORAGE_SWEEP_DRY_RUN=true counts what it would delete and deletes nothing.
The credential barrier
The object store holds every database's files, so as few things as possible can reach it:
- Two identities, two buckets. Neon's pageservers and safekeepers use identity
neon, which may use only bucketneon; storage and the worker use identitystorage, which may use onlychelabase-storage. There's no admin identity, and SeaweedFS's master, volume and filer ports listen on loopback only (just storage-iam-e2echecks it live). - Only storage and the worker hold the
storagekeys, as key files (re-read every five minutes, so rotating them needs no restart), never in environment values or command lines. - Computes never see an S3 credential, and the object store's endpoint is never exposed. There are no S3 presigned URLs: signed and public URLs are served by storage through the gateway.
- Storage's database login bypasses row-level security but has rights only on the
storageschema, runs only storage's fixed statements, and is locked down by the worker on every bootstrap: a compromise of the storage service can't read your other tables. - The console listener takes requests only with the orchestrator's bearer (
CHELABASE_STORAGE_INTERNAL_TOKEN), after the orchestrator has checked the member's access; owner mode needs Admin.
Webhooks
Storage publishes each committed change to the NATS JetStream stream CHELABASE_STORAGE_EVENTS (subject chelabase.storage.events.<database_id>), with each database capped at 100,000 pending events. The worker consumes it with durable per-endpoint state, signs each delivery with the endpoint's secret (sealed in the metadata database under its own purpose), and sends it through an outbound client that refuses non-public addresses at connect time. See Webhooks.
Tests
cargo nextest run -p chelabase-storage-serviceand-p chelabase-storage(with a conformance suite every backend passes);- the live tests in
services/worker/tests/storage_schema_live.rsandstorage_sweep_live.rs; just storage-e2e: the whole story through the gateway, from a policy to the sweep's purge;just storage-e2e --backend s3runs it again with storage and the worker switched to backends3on a second S3 endpoint, changing only their environment;just storage-iam-e2e: the bucket boundary between the two identities.
Realtime
The realtime service behind Studio's live rows and presence: how a socket is opened, how changes are found and filtered, its ports, settings, secrets and limits.
The schema merge engine
How a schema merge matches objects by OID, stores bases, applies the three-way rules, plans its phases and recovers from a crash.