Architecture¶
Arca implements the S3 API from the HTTP layer down to the storage layer in Rust, without wrapping an existing S3 library. This page describes how the code is organised today, how a request travels through it, how data and metadata are stored, and the design decisions (with their rationale) that contributors must preserve.
It is a reference for contributors and operators. Operating procedures live in the guides, which this page links to rather than repeats.
System Overview¶
Arca ships as one server binary (arca) plus a separate web console (console/, a static web application that talks to the Admin API like any other client). A node serves the S3 API, the Admin API (/admin/*) and, when clustered, the inter-node API (/cluster/v1/*) on a single port.
Object bytes live as UUID-named blob files on the local filesystem, each with a JSON sidecar (.meta). Metadata (buckets, objects, versions, identities, configuration, journals) lives in a metadata backend: embedded SQLite (default) or an external PostgreSQL server.
graph LR
C["S3 clients<br/>aws-cli · boto3 · mc · rclone"] -->|"S3 API"| A["Arca node"]
CON["Web console"] -->|"Admin API /admin/*"| A
A --> DB[("Metadata backend<br/>SQLite or PostgreSQL")]
A --> FS["Filesystem<br/>UUID blobs + .meta sidecars"]
A <-->|"/cluster/v1/* (HA only)"| P["Peer nodes"]
Crate Structure¶
The server is a five-crate Cargo workspace:
| Crate | Role | Depends on |
|---|---|---|
| arca-core | Domain types (ObjectRecord, BlobId, ...), every storage trait, policy evaluation, S3 XML types, cluster wire types and pure merge logic, error codes. No I/O. |
(none) |
| arca-auth | AWS Signature V4: request and presigned-URL verification, presigned-URL generation, outbound signing. No I/O. | (no workspace crate) |
| arca-proto | S3 and Admin HTTP adapter: Axum router, handlers, middleware, XML error responses, AppState. Handlers call the storage traits directly. |
arca-core, arca-auth |
| arca-storage | Storage implementations: SQLite and PostgreSQL metadata stores, filesystem blob store, encryption/compression/SSE-C decorators, metadata cache, backend migration copier. | arca-core |
| arca-server | The binary: configuration, CLI, store composition and wiring, background workers, cluster decorators and membership, TLS, notification connectors, outbound replication, offline tools (recover, fsck, gc, migrations). |
all four |
graph TD
SERVER["arca-server"]
PROTO["arca-proto"]
STORAGE["arca-storage"]
AUTH["arca-auth"]
CORE["arca-core"]
SERVER --> PROTO
SERVER --> STORAGE
SERVER --> AUTH
SERVER --> CORE
PROTO --> AUTH
PROTO --> CORE
STORAGE --> CORE
arca-auth is a leaf: it shares no types with the rest of the workspace. arca-proto never depends on arca-storage; where a handler needs a capability that only a concrete store has (SSE-C keys, raw blob access for the cluster endpoints), arca-core defines a trait for it (SsecBlobOps, RawBlobOps) and arca-server injects the implementation into AppState.
There is no separate use-case layer: S3 and Admin handlers in arca-proto/src/handlers/ orchestrate the work themselves, calling state.metadata, state.blob (or state.blob_for_write(bucket)) and the other stores held by AppState.
Request Flow¶
Middleware order¶
The order below is the one built by build_router (arca-proto/src/router.rs) and async_main (arca-server/src/main.rs), outermost first:
flowchart TD
REQ(["HTTP request"]) --> L1["CloseOnUnreadBodyLayer<br/>Connection: close if the body was left unread"]
L1 --> L2["NormalizeLayer<br/>save OriginalUri, virtual-host rewrite<br/>(only when server.domain is set), strip trailing slash"]
L2 --> L3["Request ID"] --> L4["Validate<br/>header count, null bytes, metadata size"]
L4 --> L5["Per-IP rate limit"] --> L6["Audit"] --> L7["Trace"] --> L8["CORS"]
L8 --> R{"Route"}
R -->|"/admin/*"| ADM["Admin auth (JSON errors)<br/>health and metrics are public"]
R -->|"/cluster/v1/*"| CL["Cluster auth<br/>(v1/health is public)"]
R -->|"S3 routes"| S1["Maintenance drain"]
S1 --> S2["SigV4 auth + authorization"] --> S3["Per-credential rate limit"]
S3 --> H["S3 handler"]
- Outside the router.
NormalizeLayerandCloseOnUnreadBodyLayerwrap theRouteras plain Tower layers, because middleware attached with Axum'sRouter::layerruns only after a route has been matched.NormalizeLayermust rewrite the path before routing (clients such asmcsend/bucket/, and a virtual-hosted-style request carries the bucket in theHostheader); auth then verifies the signature against the savedOriginalUri.CloseOnUnreadBodyLayeris outermost so it also sees responses produced by middleware (an auth reject answers before any handler runs); on HTTP/1.x it addsConnection: closewhenever the request body was not read to the end, so unread bytes cannot desync the next request on a keep-alive connection. - Common layers. Request ID, validation, per-IP rate limiting, audit, tracing and CORS apply to every route. Rate limiters and the audit writer are no-ops when disabled in configuration; audit entries are handed to a background writer over a bounded channel.
- S3 layers. The maintenance drain answers
503to every S3 request while a maintenance-mode job runs on the node (the Admin API stays live). SigV4 auth resolves the identity and its effective policies, then the per-credential rate limiter runs. - Virtual-host rewrite. With
[server] domainset,NormalizeLayerturnsHost: {bucket}.{domain}plus the path/keyinto the path-style/{bucket}/key(the bucket root/becomes/{bucket}) before routing, taking the host from theHostheader or, on HTTP/2, the URI authority. The rewrite comes before the trailing-slash rule because once the bucket is in the host,/dir/is a key (a directory marker), not a bucket path. It cannot be aRouter::layermiddleware: that runs after routing, when the path parameters are already bound to the unrewritten path. The rules themselves are inarca-proto/src/middleware/virtual_host.rs.bin/test virtual-hostruns the end-to-end tests against a server started with a domain.
S3 query-parameter routing¶
S3 overloads the same method and path with query parameters and headers. Axum routes on method and path only (/, /{bucket}, /{bucket}/{*key}), so each handler dispatches internally. The main cases:
| Route | Without query parameters | With query parameters / headers |
|---|---|---|
GET /{bucket} |
ListObjects (V1) | ?list-type=2 ListObjectsV2, ?versions ListObjectVersions, ?uploads ListMultipartUploads, ?location, ?versioning, ?encryption, ?tagging, ?lifecycle, ?object-lock, ?notification, ?replication, ?compression |
HEAD /{bucket} |
HeadBucket | |
POST /{bucket} |
(501) | ?delete DeleteObjects |
PUT /{bucket}/{*key} |
PutObject; CopyObject when x-amz-copy-source is present |
?partNumber=&uploadId= UploadPart (UploadPartCopy with x-amz-copy-source), ?tagging, ?retention, ?legal-hold |
POST /{bucket}/{*key} |
?uploads CreateMultipartUpload, ?uploadId= CompleteMultipartUpload |
|
DELETE /{bucket}/{*key} |
DeleteObject | ?uploadId= AbortMultipartUpload, ?tagging |
mc sends ?uploads= and ?delete= (with =); the dispatchers accept both forms. The full operation list is in the S3 API reference.
Storage Architecture¶
Storage traits¶
All storage is reached through async traits defined in arca-core/src/store/:
| Trait | Responsibility |
|---|---|
MetadataStore |
Buckets, bucket config, objects and versions (including conditional writes), multipart uploads and parts, tags, object lock, re-encryption updates, cluster apply/manifest/tombstone operations |
BlobStore |
Streamed blob put/get/delete, sidecar writes, multipart concat |
SsecBlobOps |
SSE-C blob put/get with a per-request customer key |
RawBlobOps |
Verbatim on-disk blob and sidecar access (cluster transfer, blob GC) |
CredentialStore, UserStore, TeamStore, GrantStore |
Identities and RBAC grants |
ServerConfigStore |
Instance settings managed from the console |
AuditStore, MetricsStore |
Audit log and metrics history |
NotificationStore, NotificationConnector |
Notification event log; delivery connectors (registered in a ConnectorRegistry) |
ReplicationStore |
Outbound replication journal |
PresignedUrlStore |
Tracking of generated presigned URLs |
MaintenanceStore |
Maintenance jobs and their logs |
ControlTombstoneStore, ControlSnapshotStore |
Cluster control-plane deletions and snapshot reconcile |
SqliteStore and PgStore each implement every store trait except the blob and connector ones; open_stores in main.rs turns the chosen backend into a set of trait objects.
Implementations and decorators¶
| Type | Crate | Kind |
|---|---|---|
SqliteStore |
arca-storage | Metadata backend (embedded) |
PgStore |
arca-storage | Metadata backend (PostgreSQL) |
CachingMetadataStore |
arca-storage | MetadataStore decorator: in-memory LRU + TTL cache of bucket lookups and latest-object lookups, invalidated on writes |
CachingIdentityStore |
arca-storage (identity_cache.rs) |
Decorator of the credential, user, team and grant stores (and the control snapshot and tombstones): caches the lookups every authenticated request makes; any write through it empties the whole cache, and entries expire after 5 seconds, which bounds the delay of a change written by another process |
FsBlobStore |
arca-storage | BlobStore and RawBlobOps on the local filesystem |
EncryptingBlobStore |
arca-storage | BlobStore decorator: SSE-S3 / SSE-KMS (AES-256-GCM, see Encryption) |
CompressingBlobStore |
arca-storage | BlobStore decorator: per-bucket transparent compression (see Compression) |
SsecBlobStore |
arca-storage | SsecBlobOps over FsBlobStore |
ClusterMetadataStore |
arca-server (cluster/cluster_meta.rs) |
MetadataStore decorator: replicates object rows and bucket-level control ops to peers |
ClusterBlobStore, ClusterSsecBlobStore |
arca-server (cluster/cluster_blob.rs) |
Blob decorators: replicate blobs to peers, read-repair missing blobs |
ClusterCredentialStore, ClusterUserStore, ClusterGrantStore, ClusterTeamStore, ClusterServerConfigStore |
arca-server (cluster/cluster_control.rs) |
Identity and settings decorators: replicate control-plane mutations |
Composition as wired¶
async_main in arca-server/src/main.rs builds the following stacks (outer to inner; bracketed layers are present only when the condition holds):
AppState field |
Stack |
|---|---|
metadata |
[ClusterMetadataStore, cluster] → [CachingMetadataStore, [server.cache] enabled, default on] → SqliteStore or PgStore |
blob |
[ClusterBlobStore, cluster] → CompressingBlobStore → [EncryptingBlobStore, master key configured] → FsBlobStore |
plain_blob (only when a master key is configured) |
[ClusterBlobStore, cluster] → CompressingBlobStore → FsBlobStore |
ssec_blob |
[ClusterSsecBlobStore, cluster] → SsecBlobStore → FsBlobStore |
cluster_raw_blob (cluster only) |
FsBlobStore as RawBlobOps |
credentials, users, grants, teams |
[cluster decorator, cluster] → CachingIdentityStore (one shared instance) → backend store |
server_config |
[cluster decorator, cluster] → backend store |
| audit, metrics, notification, replication, presigned-URL, maintenance stores | backend store, never cluster-wrapped |
Rules that follow from this wiring:
- Compression sits above encryption: plaintext is compressed, then the compressed bytes are encrypted.
- Write routing:
AppState::blob_for_write(bucket)returnsblobwhen encryption is on globally or for that bucket (abucket_configlookup cached for 30 seconds), otherwiseplain_blob. Reads always go throughblob; the encryption layer reads the sidecar to tell encrypted blobs from plain ones, so mixed content coexists. - Cluster decorators sit on top, above cache, compression and encryption, so they ship already-encoded bytes and canonical rows verbatim. The receive endpoints and anti-entropy apply through the inner (pre-decorator) stores, so a replicated write is never fanned out again. The identity cache sits below them, so those applies invalidate it too.
- A read parses the sidecar once. Each blob layer's
getreads the.metasidecar and hands it down throughBlobStore::get_known(the cluster decorator hands down the one its read-repair probe read), instead of every layer, and every inner range read of a compressed payload, reading it again.
Metadata backends¶
SQLite (arca-storage/src/sqlite/, via tokio-rusqlite): one write connection serialises every mutation, and a pool of read-only connections (PRAGMA query_only) serves SELECTs round-robin. Every connection runs in WAL mode, which lets readers proceed during a write, and keeps up to 256 prepared statements (prepare_cached; SQL that varies per call, such as a page size, is a bound parameter so one statement serves every call). The request-path writes (object writes and deletes, multipart, object tags, replication journal entries, notification events) go through a group committer (sqlite/group_commit.rs): the writes that queue up while a commit is in flight run in the next transaction, each under its own savepoint, and every caller is answered only after that transaction's COMMIT, so concurrent writes share one WAL fsync. A group is whatever is already queued, at most 64 writes, with no waiting for it to fill; a write that fails (a refused precondition, a constraint) rolls back its savepoint alone, and when the group itself cannot commit, every write of it that had succeeded gets the error, since nothing of it is durable. The writes of a group run in submission order, each seeing the ones before it, exactly as separate transactions on the one writer would, so the conditional-write CAS and the cluster seq order are unchanged. The other writes (control plane, maintenance, cluster apply, bulk copies) call the writer directly, one transaction each. DeleteObjects applies its whole batch in one grouped write, a nested savepoint per key (delete_objects_if). The writer opens its transactions as IMMEDIATE: most of them read before they write, and a DEFERRED one would ask for the WAL write lock as a read transaction, where SQLite skips busy_timeout and fails at once if a reader holds that lock for an instant (readers take it briefly when they catch the wal-index header mid-update). The database is {data_dir}/arca.db.
PostgreSQL (arca-storage/src/pg/, via sqlx): an async connection pool, with the server's default READ COMMITTED isolation (Arca does not raise it). Enabled with metadata_backend = "postgres" and a [storage.postgres] section. Object writes are serialised per key on a single node and per node in a cluster (the write gate, see Conditional Writes).
Both backends keep a version-tracked _migrations table and apply pending migrations at startup. The two migration sequences are numbered independently (sqlite/migrations.rs, pg/migrations/*.sql) but converge on the same logical schema. Switching backend in place is arca migrate-db (see Configuration migration).
Database tables¶
The same tables exist in both backends:
| Table | Content | In a cluster |
|---|---|---|
buckets |
Bucket name, creation time | Replicated |
bucket_config |
Per-bucket settings as key/value (versioning, encryption, compression, lifecycle, object lock, policy, notifications, replication, ...) | Replicated |
bucket_tags |
Bucket tags | Replicated |
objects |
One row per object version: blob id, size, ETag, content type, metadata JSON, encryption, lock state, checksum, replication status, version_id / is_latest / is_delete_marker, cluster fields (seq, is_tombstone, lock_updated_at, content_updated_at) |
Replicated |
object_seq |
Single-row node-local counter that stamps objects.seq (PostgreSQL: cluster mode only; a single node uses the object_seq_local sequence) |
Node-local |
object_tags |
Object tags per version | Replicated best-effort (TD-033) |
multipart_uploads, parts |
In-progress multipart uploads and their parts | Replicated |
users, credentials, teams, team_members, grants, user_grants, team_grants |
Identities and RBAC (see Access Control) | Replicated |
server_config |
Console-managed instance settings, plus this node's node_id |
Replicated, except node-local keys such as node_id |
control_tombstones |
Deleted control-plane entities, for cluster convergence | Shipped in control snapshots |
audit_log |
Audit entries | Node-local |
metrics_snapshot |
Metrics history | Node-local |
notification_events |
Notification event log and delivery status | Node-local |
replication_journal |
Outbound replication work queue | Node-local |
presigned_urls |
Generated presigned URLs | Node-local |
maintenance_jobs, maintenance_job_logs |
Maintenance jobs and logs | Node-local |
A partial unique index on objects(bucket, key) WHERE is_latest guarantees at most one current version per key. In a versioned bucket every write adds a row and demotes the previous latest; a delete adds a delete-marker row. In an unversioned bucket a write replaces the key's row.
Filesystem layout¶
{data_dir}/
├── arca.db # SQLite only
└── blobs/
└── 55/0e/ # prefix directories (blob_prefix_depth, default 2)
├── 550e8400-...-446655440000 # blob file
├── 550e8400-...-446655440000.meta # JSON sidecar
└── 550e8400-...-446655440000.tmp # only while a write is in progress
FsBlobStore::blob_path builds the path from the BlobId alone: the UUID's hex digits (hyphens removed) give 1 to 4 two-character prefix directories ([storage] blob_prefix_depth, clamped to 1..4), then the UUID itself is the file name. Object keys never reach the filesystem, so a key such as ../../etc/passwd cannot cause path traversal. Blob ids are minted with BlobId::new() (UUID v4); the cluster blob endpoints (/cluster/v1/blob/{blob_id}) reject any path parameter that is not a UUID before it reaches the blob layer. Blob ids carried inside replicated records and sidecars from (authenticated) peers are not yet validated (TD-041).
Sidecar .meta format¶
Each blob has a sidecar holding SidecarMeta (arca-core/src/store/blob.rs) as JSON:
{
"bucket": "my-bucket",
"key": "photos/2024/vacation.jpg",
"size": 1234567,
"etag": "d41d8cd98f00b204e9800998ecf8427e",
"content_type": "image/jpeg",
"last_modified": "2026-02-27T14:30:00+00:00",
"metadata": {"x-amz-meta-author": "pietro"}
}
| Field | Meaning |
|---|---|
bucket, key |
Owning object. Part blobs use the key {key}#{upload_id}#{part_number} |
size, etag |
Plaintext size and ETag |
content_type, last_modified, metadata |
Object attributes (metadata holds x-amz-meta-* and system headers) |
encryption (optional) |
BlobEncryptionInfo: algorithm, wrapped DEK, nonces, key id |
compression (optional) |
BlobCompressionInfo: algorithm, chunk size, original and compressed size. When the blob is also encrypted, compressed_size is the length of the bytes the encryption layer encrypted (the compressed frame), while size stays the object's size |
version_id (optional) |
Version id (currently never set: sidecars are written before the commit assigns the version, TD-032) |
composite (optional) |
List of CompositePart for a composite blob (see below) |
Sidecars serve two purposes: runtime reads (the encryption, compression and composite layers read them to decide how to decode a blob) and offline rebuilds (arca recover). The sidecar format is documented for operators in Disaster Recovery.
PutObject write path¶
sequenceDiagram
participant H as put_object handler
participant B as Blob stack
participant F as FsBlobStore
participant M as Metadata stack
H->>H: early checks (bucket, early precondition reject, size, tags)
H->>B: put_with_hints(blob_id, body stream)
B->>F: put (after compression / encryption, if any)
F->>F: bounded channel to blocking worker: MD5 + write {id}.tmp, rename to {id}
H->>H: verify body digests (mismatch: delete the new blob, 400)
H->>B: write_sidecar (cluster: blob + sidecar fan out here)
H->>M: put_object_tagged_if(record, precondition, tags)
M-->>H: (old record, version id) or 412 / 404
H->>B: refused: delete the new blob; accepted: delete the old blob in background
- The request body becomes a
ByteStream(handlers/body.rs); AWS chunked encoding (x-amz-content-sha256: STREAMING-*) is decoded on the fly, its trailing headers are collected,max_body_sizeis enforced while streaming, and the digests the request declares are computed (see Request body integrity). FsBlobStore::putruns a producer/consumer pipeline: the async task forwards chunks through a bounded channel (capacity 4) to aspawn_blockingworker that MD5-hashes and writes each chunk. The worker writes{id}.tmp, opened withcreate_new(O_CREAT | O_EXCL),fsyncs it when the stream ends, and the file is then renamed to its final name and the directoryfsynced. The ETag is the hex MD5 of the plaintext: the encryption and compression layers report the plaintext digest.- The handler verifies the body digests against the stored blob (deleting it on a mismatch), writes the sidecar, then commits the metadata row through
put_object_tagged_if, which evaluates the precondition, assigns the version id and stores thex-amz-taggingtags of that version in one transaction (see Conditional writes). CopyObject resolves the destination's tags (the request's underREPLACE, the source version's underCOPY) before it writes, and CompleteMultipartUpload passes the tags given at CreateMultipartUpload, so no version is ever visible without its tags. MetadataStore::put_objectandput_object_ifreturn(Option<ObjectRecord>, Option<String>): the record that was overwritten (so the caller can delete its now-orphaned blob, done in a background task) and the version id assigned to the new row.
GetObject streams the blob back through the same stack (ReaderStream over the file, 64 KiB reads; an encrypted blob is read one whole on-disk chunk at a time and each chunk is decrypted in place); range requests seek and limit the read.
Write order and crash consistency¶
The order is always blob file → sidecar → metadata row. If the process dies in between:
| Crash after | State left behind | Detected / handled by |
|---|---|---|
| part of the blob | {id}.tmp |
arca fsck (stale temp files); reclaimed by blob GC and arca fsck --repair |
| blob | blob without sidecar or row | arca fsck (orphaned blob); reclaimed by blob GC and arca fsck --repair |
| sidecar | blob and sidecar, no row | arca fsck; arca recover can rebuild the row from the sidecar |
| row | consistent |
A write that fails, or whose request is dropped when the client disconnects, removes its own temp file: a guard owns the temp path from its creation until the rename that publishes it (TempFileGuard in fs/blob.rs), so only a process crash leaves one behind.
Durability: a write is acknowledged only after its data is on stable storage. The blob temp file is fsynced before it is renamed to its final name (newly created shard directories are fsynced too). Sidecars are written atomically (temp file in the same directory, fsync, rename, directory fsync), so a sidecar is either the old one or the complete new one, never partial. The blob rename itself is not followed by a directory fsync: a blob is durable once its sidecar has been written. Blob and sidecar live in the same directory, so the sidecar's directory fsync persists both entries, and every writer (PutObject, CopyObject, UploadPart, UploadPartCopy, CompleteMultipartUpload, cluster receive and repair, re-encryption) writes the sidecar before committing the metadata row, failing the request and discarding the fresh blob when the sidecar write fails. That makes 3 fsyncs per small PUT: blob data, sidecar data, directory. The same applies to the replicated raw blob writes and the concat fallback; the offline in-place re-encryption keeps a directory fsync on its own rename. SQLite commits with synchronous=FULL (an fsync of the WAL per transaction), so the metadata row is durable too; PostgreSQL follows its own synchronous_commit. All of this is the [storage] fsync = true default; false skips every fsync and uses SQLite NORMAL, keeping the temp + rename protocol, so writes stay atomic but are no longer durable (live server only: the offline tools are always durable). The cost depends on the disk's synchronous-write latency: on 1 KiB PUTs at concurrency 16, -37% on local NVMe and -78% on gp3 EBS, almost nothing on large objects (see Configuration). On slow disks the single SQLite writer would cap the commit rate at one per fsync; the group committer shares that fsync among the writes of concurrent requests. The audit writer commits in batches (1024 entries or 1 s, at once on drain) so that audit logging does not add an fsync per request.
arca fsck compares the database with the files on disk (missing blobs, orphaned blobs and sidecars, sidecar/row mismatches, temp files, unfinished multipart uploads, and MD5 checksums with --verify-checksums), using the same referenced set as blob GC (blob_gc::referenced_blob_ids), so it understands composites and in-progress parts. arca fsck --repair fixes what it can, re-verifying each item before its fix (see the CLI reference).
Known gaps, tracked in Technical Debt:
- A failed metadata write keeps its blob (TD-057). When the row's commit fails with a database error, the handler answers 500 and keeps the blob and sidecar it wrote, as after a crash before the row (table above): the error does not prove that nothing was committed (a cluster quorum shortfall comes after the local commit, an error at COMMIT leaves the outcome unknown). Only a refused precondition deletes the fresh blob.
recoveris lossy (TD-014, TD-032). It drops multipart (composite) objects, re-creates parts as bogus objects, keeps the oldest version of each key, restores only buckets, objects and credentials, and always writes a SQLite database. Treat it as a last resort; see Disaster Recovery.
Multipart uploads and composite blobs¶
- UploadPart stores each part as an ordinary UUID blob through the same write stack as PutObject, with a sidecar whose key is
{key}#{upload_id}#{part_number}, and a row inparts. Re-uploading a part number deletes the previous part blob. (UploadPartCopy writes a part sidecar only when the part is encrypted.) - CompleteMultipartUpload validates the part list (ETags, order, 5 MiB minimum for all but the last part), then calls
BlobStore::concat:FsBlobStore::concat: when every part has a sidecar and is plain (no encryption, no compression), it returns a composite result without copying any byte. Otherwise it copies the parts into a new blob.EncryptingBlobStore::concat: when every part is SSE-S3 encrypted and uncompressed, it returns a composite that keeps each part's own DEK and nonce. Otherwise it decrypts and re-encrypts into a new blob.
- A composite blob has no file of its own: its sidecar lists the part blobs (
composite), and reads stream the overlapping byte range of each part in order, opening one part at a time (composite::concat_lazilyin arca-storage) so a read holds one file descriptor whatever the part count; the first part is opened before the response starts, so a missing part is still an error response. The decrypt-and-re-encrypt fallback ofEncryptingBlobStore::concatreads its parts the same way. The part blobs stay on disk for as long as the composite exists;FsBlobStore::deleteon a composite cascades into its parts, whiledelete_assembledremoves only the assembled blob's own file and sidecar (used when a conditional Complete is refused, so the client can retry with the same parts). For non-composite results the part blobs are deleted after the commit. - The composite ETag is computed by the handler as
hex(MD5(binary_MD5(part1) || binary_MD5(part2) || ...))-{count}: the 16-byte binary digests are concatenated, not their hex strings. - The Complete commit is
MetadataStore::complete_multipart_object_tagged: the object row (same CAS and versioning rules asput_object_if), its tags and the consumption of the upload and its parts are ONE transaction that fails withNoSuchUpload, writing nothing, when the upload is gone. An abort (client, lifecycle orarca fsck --repair) racing a Complete therefore cannot delete the parts of an object that was just committed, and a Complete that loses discards only its assembled blob (delete_assembled). PostgreSQL locks the upload row (FOR UPDATE) first, anddelete_multipart_uploaddeletes the upload row before its parts. In a cluster only the local commit is atomic; an abort on another node at the same moment is settled by anti-entropy. - SSE-C is not supported for multipart uploads (TD-010). Re-encryption maintenance jobs skip composite objects (TD-018).
Conditional Writes¶
If-Match / If-None-Match on PutObject and CompleteMultipartUpload, and the conditional headers of DeleteObject and DeleteObjects, are compare-and-swap operations on the object's current state.
- The authoritative check is inside the metadata transaction.
put_object_if,delete_object_ifanddelete_object_version_if(arca-core/src/store/metadata.rs) take aWritePrecondition/DeletePreconditionand evaluate it, with the sameevaluatefunction, inside the transaction that installs the new version, delete marker or tombstone. On a mismatch nothing is written and the store returnsPreconditionFailed(orNoSuchKeyforIf-Matchon a missing object). The unconditionalput_object/delete_object/delete_object_versionare default methods that pass an empty precondition. - Handler checks are only an optimisation. Handlers may reject a stale request before reading its body; that early check cannot decide a race, the commit-time check does.
- SQLite is atomic by construction: every write runs on the single writer connection, so no other writer can commit between the check and the write (
sqlite/metadata.rs). - PostgreSQL runs at READ COMMITTED, where a plain SELECT never blocks. Each objects-writing transaction therefore takes a lock first, the write gate (
pg/write_gate.rs), and only then runs the CAS read. Once the gate is granted, every earlier writer of the key has committed, so the read sees current state. A CAS read placed before the gate would read a stale snapshot and let two conflicting writers both succeed (bin/test postgresruns the conditional-write suite against it). The gate depends on the mode:- Single node: a transaction-level advisory lock on a hash of
(bucket, key)(pg_advisory_xact_lock, FNV-1a 64), so writes to different keys run in parallel and only the writers of one key wait for each other. A DeleteObjects batch takes the locks of all its keys first, in ascending order, so overlapping batches cannot deadlock; it then applies the whole batch in one transaction, one savepoint per entry. A hash collision only adds a wait. Decision H13. - Cluster: the
object_seqcounter row (UPDATE ... RETURNING), which serialises every object write of the node until commit, because theseqcursor must be commit-ordered (next section).
- Single node: a transaction-level advisory lock on a hash of
- A losing writer has already uploaded its whole body. The blob is written before the commit, so on refusal the handler deletes it and answers
412(Arca never returns AWS's409 ConditionalRequestConflict). - In a cluster the CAS is exact per node only (TD-025). The serving node commits locally and then fans out; two conditional writes for the same key on two different nodes can both succeed and are then resolved by last-writer-wins. See High Availability for the operational mitigation.
Authentication and Request Integrity¶
- SigV4 verification lives in
arca-auth(verify_request,verify_presigned_request), shared by three middlewares: S3 (middleware/auth.rs, S3 XML errors), Admin (middleware/admin_auth.rs, JSON errors) and inter-node (middleware/cluster_auth.rs). The computed and provided signatures are compared in constant time (subtle::ConstantTimeEq). - Anti-replay: header-signed requests are accepted only when
x-amz-dateis within 15 minutes of the server clock (REPLAY_WINDOW_SECS); presigned URLs carry their own expiry (at most 7 days). - Authorization: after authentication the S3 middleware loads the user's effective grants and evaluates them (deny overrides allow); root bypasses policy evaluation. See Access Control.
- HTTP/2: browsers send
:authorityinstead ofHost; the S3 and Admin auth middlewares synthesisehostfrom the URI authority when it is missing.
Request body integrity¶
Signatures cover the method, URI, query and signed headers; the body is checked separately, against whatever digest the request declares. PutObject and UploadPart (handlers/integrity.rs, body stream from body::verified_body_stream) verify, as AWS does:
| Declared by | Mismatch | Malformed |
|---|---|---|
Content-MD5 |
400 BadDigest |
400 InvalidDigest |
x-amz-checksum-{crc32,crc32c,crc64nvme,sha1,sha256} header, or the aws-chunked trailer named by x-amz-trailer |
400 BadDigest ("The CRC32 you specified did not match the calculated checksum.") |
400 InvalidRequest (also for two checksums at once, or a declared trailer that never arrives) |
hex x-amz-content-sha256 |
400 XAmzContentSHA256Mismatch |
not verified: UNSIGNED-PAYLOAD and the STREAMING-* keywords carry no digest |
- Before the body: the declarations are parsed first, so a malformed one is refused without reading or storing anything.
- While the body streams: only the digests the request asked for are computed (CRCs with the
crccrate, SHA1/SHA256 with RustCrypto), on the decoded plaintext. The MD5 is not recomputed: every blob stack (plain, compressed, SSE-S3/KMS, SSE-C) already returns the plaintext MD5 as the ETag. - After the body, before the commit: the handler compares the digests once the blob store has written the blob, and on a mismatch deletes that blob before any sidecar or metadata row references it. The verified checksum is what is recorded on the object (or the part) and returned with
x-amz-checksum-mode: ENABLED. - aws-chunked trailers: the decoder reads the trailing headers after the last chunk (
STREAMING-UNSIGNED-PAYLOAD-TRAILER, which boto3 uses over HTTPS, and the signed-TRAILERvariant). -
aws-chunked signatures: a signed body (
STREAMING-AWS4-HMAC-SHA256-PAYLOAD, with or without-TRAILER, what MinIO clients send over plain HTTP) is authenticated as a chain. The request signature seeds the signature of the first chunk, each chunk signs its data and the signature before it, and a trailer signs its headers and the last chunk signature. The decoder verifies each link as the body streams (arca_auth::ChunkVerifier); a chunk or trailer that does not verify, a chunk with no signature, and a body that ends before its signed final chunk all fail with403 SignatureDoesNotMatch, before anything is committed. Presigned URLs carry no seed signature, so a signed-chunk body is refused there.STREAMING-UNSIGNED-PAYLOAD-TRAILERhas no chunk signatures to check. -
Multipart checksums: an upload created with a checksum algorithm gives its object a checksum at Complete time, built from the part checksums with no object byte read (
handlers/multipart_checksum.rs). COMPOSITE is the algorithm over the concatenated raw part checksums with-Nappended; FULL_OBJECT is the CRC of the whole object, rebuilt from the part CRCs and sizes with a CRC combination (zlib'scrc32_combinegeneralised to CRC32, CRC32C and CRC64NVME, O(log size) per part, checked against the Ceph s3-tests vectors). A part of such an upload is checksummed by the server when the client declares no checksum.plan_completevalidates the request (object size, checksum type, whole-object header, part checksums in the XML) before any blob is assembled. - Part layout: every object completed from parts keeps its layout (
ObjectRecord.parts: number, size and checksum of each part) in the nullable JSON columnobjects.parts(a multipart object can have 10,000 parts). The layout travels with the object row, so replication, anti-entropy andmigrate-dbcarry it. It is read on demand (MetadataStore::get_object_parts, used by?partNumberandObjectParts): the queries that serve GET, HEAD, listings and writes do not select the column, so a request costs the same whatever the number of parts. Only the queries that read a row to write it back or hand it to a peer (apply_remote_object, the replication manifest) select it, otherwise the layout would be lost.
Clustering¶
This section covers the architecture only; High Availability is the operator guide and HA Design Decisions records the individual decisions. A cluster is fully replicated (every node holds every object), symmetric (byte-identical configuration on every node, each node self-assigns and persists its node_id in server_config) and has no leader for the data path.
Decorators over the storage traits¶
Clustering adds no code to the S3 handlers. When [cluster] enabled = true, main.rs wraps the stores with the cluster decorators listed above:
ClusterBlobStorewrites locally, and onwrite_sidecar(when bytes and sidecar are both on disk) ships both to peers. Onget, a blob missing locally is fetched from a live peer (read-repair).deleteis local only; peers reclaim orphaned blobs with their own GC.ClusterMetadataStorecommits locally, then sends the row exactly as stored (including the version id the origin minted) to every peer. Inquorummode an admission gate refuses writes when too few nodes are live, and the write is acknowledged only when the local copy plus peer ACKs (row applied and blob present) reach the write quorum, otherwise503(the local copy is not rolled back). Inavailablemode fan-out is best-effort. Bucket-level operations (create/delete,bucket_config, tags) and in-progress multipart state are sent as control operations to/cluster/v1/op.- The identity and settings decorators replicate credential, user, team, grant and server-config mutations, which is what lets a client authenticate against any node.
Dedicated inter-node transport¶
Replication uses the internal /cluster/v1/* endpoints (arca-proto/src/handlers/cluster.rs, client in arca-server/src/cluster/client.rs), never the public S3 API. A replica must keep the original blob_id and version_id identities and store the bytes verbatim, still encrypted and compressed, which an S3 PUT cannot express. Requests are SigV4-signed with the shared [cluster] secret, carry the sender's node id for loop prevention, and can additionally use mutual TLS ([cluster.tls]). Peer liveness is an authenticated challenge-response ping: a peer that cannot prove it holds the secret is never eligible for fan-out or quorum. JSON endpoints cap bodies at 2 MiB; blob bodies stream.
Change tracking: the seq cursor¶
Every write to objects on a node, including rows applied from a peer, stamps a fresh value of a node-local, commit-ordered counter into objects.seq. A peer pulls POST /cluster/v1/manifest with "everything after seq N" and advances its per-peer high-water mark (in memory; it restarts from zero after a restart, which costs one idempotent full pass).
- SQLite: the
object_seqcounter is incremented on the single writer connection, inside the row's transaction. - PostgreSQL: in cluster mode the
object_seqcounter row is updated withUPDATE ... RETURNINGinside the row's transaction. Its row lock is held until commit, so seq order equals commit order. A PostgreSQL SEQUENCE is not transactional: a row holding seq N could commit after seq N+1, and a peer that had already advanced past N would never receive it. A single node has no peer to feed, so it takesseqfrom such a sequence,object_seq_local, unique and monotonic but not commit-ordered, and avoids serialising every write on the counter (H13). When the store opens, both generators are raised to the highestseqin use (the counter, the sequence, every row; never lowered), so a switch between modes, bymigrate-topologyor by editing the config, or amigrate-dbcopy never issues aseqthat is already stored. - Why not
last_modified: a wall-clock cursor breaks under clock skew between nodes and under several writes sharing a timestamp, and either case makes the cursor skip rows. A counter written in commit order cannot skip.
Deletes and conflict resolution¶
- Tombstones only in cluster mode.
SqliteStore/PgStore::set_cluster_modeis turned on when clustering is enabled; a hard delete then marks the rowis_tombstone(blob cleared,last_modifiedset to the delete time, freshseq) instead of removing it. A tombstone is an ordinary row in the manifest, so last-writer-wins converges deletions with no special case and anti-entropy cannot resurrect a deleted object. Single-node deployments delete rows outright. Tombstones older than the configured grace are purged, unless a known peer has been unseen for longer than the grace. - Replica conflict rule (
ObjectRecord::resolve_replicated,arca-core/src/types.rs), for an incoming row against the local row of the same version:- A strictly newer
last_modifiedreplaces the whole row; a strictly older one loses. - Equal
last_modifiedwith a different ETag is a concurrent overwrite: the row with the greaterblob_idwins as a whole. - Equal
last_modifiedand equal ETag is the same content lineage, and two in-place registers merge independently, each adopting the incoming value only if strictly newer: the lock register (retention mode, retain-until date, legal hold) ordered bylock_updated_at, and the content register (blob_id, encryption algorithm and key id, changed by re-encryption) ordered bycontent_updated_at. Merging them separately keeps a re-encryption from reverting a newer legal hold and vice versa. - If the result is identical to the local row, nothing is rewritten (no new
seq), so caught-up nodes do not redeliver to each other forever.
- A strictly newer
Anti-entropy and the control plane¶
The anti-entropy worker (cluster/anti_entropy.rs) runs on every node:
- Objects: pulls each live peer's manifest incrementally and applies rows through the inner store (no re-fan-out).
- Control plane: pulls each peer's full
ControlSnapshot(credentials, users, teams, grants, memberships and attachments, buckets, bucket config and tags, server config, in-progress multipart uploads and parts, control tombstones) and merges it per row by last-writer-wins (plan_control_merge), with tombstones for deletions. Real-time changes also go over/cluster/v1/op; the snapshot is the convergence mechanism. A full snapshot is used instead of an operation log because the control plane is tiny, an op-log grows without bound, and an op-log cannot bootstrap a node that has been away past its GC horizon. - Blobs: repairs blobs missing for local rows (budgeted per tick) and garbage-collects orphan blob files, composite-aware and grace-protected.
- Not reconciled: object tags are replicated only by best-effort fan-out (TD-033).
Node-local tables (audit_log, metrics_snapshot, notification_events, replication_journal, presigned_urls, maintenance_jobs, maintenance_job_logs, object_seq) are never replicated; the console reads another node's audit log, metrics history, notification events and replication journal through a signed admin proxy (/cluster/v1/admin/*).
Discovery¶
Peers are found by mDNS (_arca._tcp.local), a static seed list or a DNS name resolving to all nodes (cluster/membership.rs), and verified by the authenticated ping. Discovery only supplies addresses. A gossip protocol was not adopted: at the small cluster sizes Arca targets it adds complexity without benefit.
Background Workers¶
Spawned by async_main (arca-server/src/worker.rs, maintenance.rs, replicator/, cluster/):
| Worker | Purpose | Guide |
|---|---|---|
| Audit writer | Batched inserts of audit entries (1024 entries or 1 s, at once on drain) | Monitoring |
| Metrics | Periodic metrics snapshots | Monitoring |
| Retention | Purges old audit, metrics, notification and replication-journal entries and expired presigned-URL records | Configuration |
| Lifecycle | Applies bucket lifecycle rules | S3 API |
| Notification | Delivers S3 events through the connector registry | Connectors |
| Replication | Drains the outbound replication journal to S3 destinations | Replication |
| Maintenance | Runs maintenance jobs (encrypt, decrypt, migrate-db) |
Maintenance |
| Blob GC | Single node, opt-in: reclaims orphan blob files | Storage Reclamation |
| Membership, anti-entropy | Cluster only | High Availability |
Outbound replication to external S3 endpoints is a separate feature from clustering: it uses the public S3 API of the destination and its own journal.
Key Design Decisions¶
Configuration migration without data migration¶
Any configuration change must be possible in place, on the existing data directory, without standing up a new instance and copying data over (as MinIO requires for some topology changes). The current tools:
arca migrate-db --to sqlite|postgrescopies all metadata to the other backend; blob files are not touched.arca migrate-topology --to-cluster | --to-singlemoves between standalone and HA: the cluster is fully replicated, so no data is redistributed.arca encrypt-existing/decrypt-existingandcompress-existing/decompress-existingrewrite blobs in place.- Re-encryption and
migrate-db(PostgreSQL to SQLite only, TD-019) also run online as maintenance jobs.
For new features this means: storage formats (sidecars, blob layout, schema) must be evolvable, every option that affects data layout needs a migration path, and users must never have to re-upload objects because of an infrastructure change.
No s3s crate¶
The S3 protocol adapter is built on Axum instead of the s3s crate, to avoid a pre-1.0 dependency at the core and to keep precise control over error formats, header handling and query-parameter routing.
Streaming-first I/O¶
No path buffers a whole object in memory: PutObject and UploadPart stream to disk while hashing, GetObject streams from the file, CopyObject streams source to destination, and the cluster transport streams blob bodies. CPU-heavy work on the write path (MD5, AEAD encryption) runs on blocking threads so the async runtime keeps pulling network bytes; decryption on GET runs inline on the runtime, one 64 KiB chunk per poll.
Copy is a real data copy¶
CopyObject (and UploadPartCopy) reads the source and writes a new blob with a new BlobId; there is no reference counting between objects. Deleting either object never affects the other. The only blobs shared across records are composite parts, and those belong to exactly one composite.
Opaque continuation tokens¶
ListObjectsV2 continuation tokens are opaque to clients. Today the token is the base64 encoding of the last key returned; clients must not depend on that.
Admin API on the same port, console as a separate application¶
The Admin API lives under /admin/* on the S3 port, authenticated with SigV4 and answering JSON. The console is a separate application, so the server binary stays small, the two can be deployed and released independently, and the storage engine does not serve a web UI. See Admin API and Web Console.
CLI¶
The arca binary exposes these subcommands (clap): serve, credential, user, recover, fsck, gc, tls, encryption, compress-existing, decompress-existing, encrypt-existing, decrypt-existing, migrate-db, migrate-topology, cluster. Options and examples are in the CLI Reference.
Technology Choices¶
Exact versions are pinned in Cargo.toml and the Dockerfiles.
| Component | Choice | Rationale |
|---|---|---|
| Language | Rust | Memory safety, predictable performance |
| HTTP | Axum + Tower; one hyper-util accept loop for both the HTTP and the HTTPS listener (serve.rs) |
Tokio-native, composable middleware |
| TLS | rustls (ring provider) + tokio-rustls, certificates hot-reloaded on SIGHUP |
Pure-Rust TLS with the ring crypto backend |
| XML | quick-xml + serde | Fast, serde integration |
| Metadata | SQLite (tokio-rusqlite) or PostgreSQL (sqlx) | Embedded zero-config default, external database when needed |
| Metadata cache | moka | Bounded LRU with TTL |
| Auth | Own SigV4 implementation (hmac, sha2, subtle) | Testable against AWS test vectors |
| Encryption | AES-256-GCM via ring, 64 KiB chunks, per-object DEK wrapped by the master key |
Streaming, envelope encryption |
| Cluster discovery | mdns-sd, static list or DNS | Zero-config on a LAN, explicit elsewhere |
| CLI | clap (derive) | Type-safe argument parsing |
| Logging | tracing + tracing-subscriber | Structured logs, JSON output, runtime-reloadable level |
Known Limitations¶
Open workarounds and bugs, each with an ID referenced in the code, are listed in Technical Debt; planned work is in the Roadmap. The items most relevant to this page are TD-014 and TD-032 (recover), TD-025 (cluster CAS), TD-033 (object tags in a cluster), TD-034 (no body integrity check), and TD-037 (virtual-hosted-style addressing).