Storage decisions: recommendations and alternatives

Status: Proposal, 2026-09-10. Not built. Numbers come from the S3 storage experiment (2,328 execution roots, 12 upload comparisons) and the query proof of concept (15 million synthetic calls, 13 queries). Anything without a measurement is marked as a starting value.

This page records the storage choices that are still open and a recommendation for each. The hosted architecture fixes the shape: the publisher writes the profiler's files to the customer's S3 bucket, S3 notifies a queue, a projector builds ClickHouse facts and Lance datasets from the objects. This page fixes the details: bucket layout, keys, object formats, limits, derived data, retention, access and queue settings. The team records each choice in the decision record at the end.

Terms

TermMeaning
ScopeOne tenant, project and environment. Every key starts with it.
StreamThe files one process writes. Named by the process's euid, a random 32-hex ID.
SegmentOne immutable file in a stream. Meta segments hold execution start and end records. Data segments hold call records.
SequenceThe position of a segment in its stream, starting at 1 with no gaps.
ValueThe recorded arguments, output or error of one call, stored as one file.
CIDContent ID. The SHA-256 of a value's bytes. Two identical values have one CID.
Function tableA file that lists a program's function names, source paths and spans. Also identified by a CID.
Large valueA value of 32 KiB or more. Stored as its own object.
Value packOne object that holds all small values from one flush.
FlushOne publisher cycle: collect closed files, upload them.
PrefixThe leading part of an S3 key, used like a directory. S3 policies, notifications and lifecycle rules can target a prefix.
Evidence, derived, temporaryThe three prefixes in the bucket. Evidence is what the publisher writes. Derived is what the projector builds from it. Temporary is background query results.

Background

What the profiler writes

The profiler writes under .baml/profiles-v1/ in the project. The publisher ships these files without changing them.

FilePathContent
Meta segmentstreams/<euid>/meta/<seq>.bamlmetaStreamStarted (sequence 1 only), EngineStarted, RootStarted, RootEnded
Data segmentstreams/<euid>/data/<seq>.bamldataOne group per execution: call records, timing aggregates, value references by CID
Valuecas/sha256/<2 hex>/<64 hex>.bamlvalueOne value body, codec 1
Function tablesame path, codec 2Function names, paths and spans

Facts the design depends on:

Sizes from the experiment

Two test corpora of 1,024 executions each, 25 segment files each:

CorpusDistinct inputsValue and table filesTotal filesBytes as filesBytes as one compressed object
Moderate reuse641932182,963,606485,453
Near-unique1,0243,0733,0983,459,8661,894,409

More distinct values means more files. Uploading each file as its own object made 14.2 times more objects for 1.17 times more bytes. Compression used zlib level 3 in the experiment; production uses zstd, so sizes need one re-measurement.

What the experiment measured

Each row is the time to upload the whole corpus and read every object back with verification, from one runner with eight concurrent requests. "As files" uploaded each profiler file as its own object. "Bundled" packed the whole corpus into one object. These are single runs, not p95 values, and not an ingestion rate.

WorkloadExecutionsObjects as filesObjects bundledFiles, msBundled, ms
Small baseline2423134087
Moderate reuse1,02421812,178142
Near-unique values1,0243,098128,115279
Large-value tail25630813,100440

A rebuild check uploaded one 485,453-byte bundle with 1,024 executions and built Lance data from it twice. Both builds produced 41,439 facts and 192 values with identical digests. Fault tests showed that a valid S3 checksum does not prove a valid file: validation must read the bytes, so it runs in the projector.

Expected object rate

The target is 50,000 executions per second across all clients. With 1,000 clients at 50 executions per second each and a 5 second flush, each client writes about one meta segment, a few data segments and one value pack per flush. That is 600 to 1,500 objects per second. S3 accepts at least 3,500 writes per second per prefix. This is arithmetic, not a measurement.

Decision 1: bucket and keys

Recommendation. One bucket per customer deployment, created by Studio's deployment tooling. Three top-level prefixes. Keys mirror the profiler's local paths.

text
evidence/v1/<tenant>/<project>/<environment>/
  streams/<euid>/meta/<seq>.bamlmeta.zst        notifies the queue
  streams/<euid>/data/<seq>.bamldata.zst        read by key
  streams/<euid>/values/<flush_ulid>.bamlpack.zst   notifies the queue
  cas/values/<2 hex>/<cid>.bamlvalue.zst        large values
  cas/tables/<2 hex>/<cid>.bamltable.zst        function tables
  quarantine/<original key>.quarantine          written by the projector
derived/v1/<scope>/<dataset>/                   Lance datasets
temporary/v1/<scope>/jobs/<job_id>/result.ndjson   background query results

<seq> is 20 digits so keys sort numerically. <cid> is the 64-hex digest. <flush_ulid> is a time-sortable ID the publisher assigns to each flush.

Rules:

Alternatives:

OptionTrade-off
Prefixes inside a bucket the customer already ownsLess provisioning. The bucket's existing lifecycle rules, replication and notification configuration must be audited, because S3 allows one notification rule per prefix and suffix pair.
One bucket shared by many tenants in a hosted cellFewer buckets. Every request must enforce scope; a prefix is not an authorization check.

Decision 2: objects

Recommendation. Each profiler file becomes one object, except small values, which travel together in a value pack.

FileBecomesWhy
Meta segmentOne objectThe projector reads it on notification.
Data segmentOne objectThe projector reads it by key when an execution ends.
Value of 32 KiB or moreOne object, keyed by CIDStored once per scope. The gateway checks whether the key exists before the publisher uploads it.
Value under 32 KiBAn entry in the flush's value packAvoids one object per small value: the near-unique corpus had 3,073 of them.
Function tableOne object, keyed by CIDShared by every process that runs the same build.
flowchart LR
  M["Meta segment"] -->|notifies| Q["Queue"]
  P["Value pack<br/>small values of one flush"] -->|notifies| Q
  D["Data segment"] -->|read by key| PR["Projector"]
  L["Large value<br/>keyed by CID"] -->|read by key| PR
  T["Function table<br/>keyed by CID"] -->|read by key| PR
  Q --> PR
  PR --> CH["ClickHouse<br/>public facts"]
  PR --> LA["Lance<br/>private columns and values_v1"]

Every object is one zstd frame of the original file. Three checksums apply and are not interchangeable: S3 verifies the SHA-256 of the compressed object on upload, the file's own trailing SHA-256 covers the original bytes, and the CID identifies a value's body.

The 32 KiB threshold is a configuration value the gateway hands out. Below it, a duplicate costs a few hundred bytes in a pack. Above it, deduplication and one-object reads are worth one gateway check per value.

Value pack layout, before compression:

text
BAMLPCK1        8-byte magic
u16 version     1
u32 manifest_len
manifest        canonical JSON: {"schema":"baml-pack/1","flush_id":"<ulid>","process_euid":"<hex>",
                "entries":[{"cid":"bamlv_1_<64 hex>","codec":1,"offset":0,"length":812,"size":812}]}
entries         the value files, unchanged, one after another

Sorted keys, no whitespace. offset and length locate an entry in the entries region. The projector ingests a pack whole, so entries are not compressed individually and carry no extra digest: each entry already has its trailing checksum and its CID. A pack holds at most 8 MiB of entries or 100,000 entries. A flush with more starts another pack.

Alternatives:

OptionTrade-off
One archive per flush that contains every file it referencesNo ordering between objects. Each archive repeats the values it references, needs a new file format and reader, and duplicates bytes: modeled at 12.87 GiB against 0.13 GiB for a 32-second, 50,000-execution-per-second run. Retired.
Every value as its own objectBest deduplication. One request per value: 28,115 ms against 279 ms for the near-unique corpus.
Per-entry zstd frames inside the packOne value readable by byte range. Slightly larger, and a second compression rule. Not needed because the projector reads packs whole.

Decision 3: flush interval and limits

Recommendation. Starting values, all served by GET /v1/publisher/config so they change without a runtime release.

LimitValueReason
Flush interval5 seconds; 1 second on LambdaFreshness against object count. A Lambda extension has about 2 seconds to flush on shutdown.
Pack size8 MiB of entries or 100,000 entriesBounds projector memory per pack.
Objects per authorize call100Bounds gateway checks per call. A larger flush makes several calls.
Object size256 MiB compressedOnly an oversize data segment reaches this. The projector is sized for it. Single PUT, no multipart.
Large-value threshold32 KiBSee decision 2.

The experiment modeled flush timers of 250 ms and 1 s. At 250 ms, 1,000 idle clients produced 50,000 objects in 32 seconds. The 5 second value is a starting point.

Alternatives:

OptionTrade-off
1 second everywhereFresher data. Up to five times more objects from lightly loaded clients.
30 secondsFewer objects. Up to 30 seconds of data lost on a hard kill.

Decision 4: derived data in Lance

The projector builds Lance datasets under derived/v1/. They hold the private columns and are rebuilt from the evidence prefix if lost.

Recommendation.

DatasetContentKeyed by
calls_v1, threads_v1, and the other catalog relationsOne dataset per relation per scope, private columns onlyExecution and call IDs
values_v1Value bodies for small values; the CAS key for large valuesCID

The projector ingests every value pack into values_v1, so a value stored in several packs collapses to one row. A call row stores the CIDs of its values. Reading a value is one lookup in values_v1, then one GET by key if the row holds only a CAS key. If a value is missing, the query reports it as unavailable. The coordinator never scans the bucket.

Indexes and maintenance, starting values with no measurement behind them:

Alternatives:

OptionTrade-off
One dataset per relation per function, as in the proof of conceptPrunes by function without an index. Many small datasets and versions to bind per query.
Value body copied into every call rowOne read per value. Duplicate bytes per occurrence.
Prune versions immediatelySmallest derived prefix. A running query loses its snapshot.

Decision 5: query results

Recommendation. Interactive queries page with a keyset cursor. Background jobs write a result file.

Interactive: default page 100 rows, maximum 1,000. The cursor is opaque. It carries the snapshot, the Lance version and the last row's sort key, so a page is stable while new data lands. A cursor whose Lance version has been pruned returns a typed error. The OpenAPI gains the cursor field after this pick.

Background: the coordinator writes temporary/v1/<scope>/jobs/<job_id>/result.ndjson, rows first and the queryOutcome record last. The client downloads it through a short-lived presigned GET from the coordinator. A lifecycle rule deletes it after 7 days. Result cap 1 GiB; a job over the cap fails with a typed error. This is the only use of the temporary prefix. Lance does not use it.

Alternatives:

OptionTrade-off
Offset pagingSimpler. Rows shift as data lands, and deep pages rescan.
Stream background results without a fileNo temporary prefix. A dropped connection loses the job; the proof-of-concept anti-join ran 9.7 seconds cold.
Delete the result on first downloadSmaller footprint. A failed download loses the result.

Decision 6: retention and erasure

Recommendation.

Alternatives:

OptionTrade-off
Rewrite segments without the erased executionKeeps the other executions. New objects, new facts, new Lance rows, and evidence is no longer immutable.
One stream per executionExact erasure. Many more small streams and objects.

Decision 7: access control and encryption

Recommendation. Server-side encryption with S3-managed keys by default. Customer-managed KMS keys as an option for customers who need to revoke access; the gateway then names the key in the presigned URL and the projector and coordinator roles get decrypt permission.

Each service has its own IAM role with the smallest grant its step needs. Boundary services have no access to the bucket.

RoleAllowedDenied
PublisherPUT through a presigned URL. No AWS credentials.Everything else
GatewayPresign PUT under the scope. Read object metadata under the scope, for the per-object check.Delete, policy changes
ProjectorRead evidence/v1/. Write derived/v1/. Write quarantine markers.Delete or overwrite evidence
CoordinatorRead evidence/v1/ and derived/v1/. Read and write temporary/v1/.Write evidence or derived
Index, compaction and sweep workerRead and write derived/v1/. List stream prefixes under evidence/v1/, so the sweep can find streams that stopped.Read evidence bodies, write or delete evidence
Erasure toolList versions and delete within an approved scope.Runs as a separate identity

Bucket policy: TLS only, no PutObject under evidence/v1/ without If-None-Match, public access blocked, ACLs disabled, and no runtime role may change versioning, lifecycle, encryption or policy. The experiment ran with one role and did not test role isolation.

Alternatives:

OptionTrade-off
Customer-managed KMS keys for every deploymentKey revocation everywhere. Key policy work in every deployment, and a lost key makes evidence unreadable.
Encryption inside the publisherThe storage provider never sees plaintext. Every read by key and every rebuild needs the key.

Decision 8: queue and notifications

Recommendation. S3 sends an object-created event to one SQS Standard queue for each new meta segment and each new value pack. Two notification rules on the queue: prefix evidence/v1/ with suffix .bamlmeta.zst, and the same prefix with suffix .bamlpack.zst. Data segments, CAS objects and quarantine markers do not notify. The projector reads them by key.

Starting values: visibility timeout 5 minutes, maximum receives 8, dead-letter retention 14 days, batch size 1. A message that fails returns to the queue after the visibility timeout. After 8 receives it moves to the dead-letter queue for an operator.

The sweep, which closes executions whose stream stopped without RootEnded, runs on a schedule from the index and compaction worker, not from the queue. Its interval and silence limit are open items on the system contract.

Alternatives:

OptionTrade-off
Batch size 10Fewer invocations. One failure redelivers the whole batch.
Visibility timeout 15 minutesRoom for a slow object. Slower redelivery after a crash.
FIFO queue per streamOrdered delivery. Not needed: upload order already puts data segments before meta segments, and projection is duplicate-safe.
One rule for every objectSimpler configuration. Every data segment and CAS object raises a message the projector only drops.

Decision record

DecisionRecommendationPick
1. Bucket and keysDedicated bucket, three prefixes, keys mirror the local stream layoutpending
2. ObjectsOne object per file; small values in a pack; 32 KiB threshold; one zstd frame per objectpending
3. Flush and limits5 s flush, 8 MiB packs, 100 objects per call, 256 MiB object cappending
4. LanceOne dataset per relation per scope plus values_v1 by CID; 24 hour version windowpending
5. Query resultsKeyset cursor, page 100 to 1,000; job results in the temporary prefix, 7 day expirypending
6. Retention and erasureAge-based retention; erasure by stream; shared values by reference countpending
7. Access and encryptionSSE-S3 default, KMS optional; one role per servicepending
8. QueueSQS Standard, two suffix rules, 5 min visibility, 8 receives, dead-letter queuepending

Record a pick by replacing pending with the option chosen and, if it differs from the recommendation, the reason.

Retired questions

The earlier version of this page asked about acceptance manifests, HEAD-based acceptance, orphan fencing, receipt objects, multipart uploads and self-contained bundles. The hosted architecture closed them: there is no per-upload record in Boundary, the projector validates bytes, every object is a single PUT, and the upload unit is the profiler's own file. See deferred decisions.

Sources

SourceUsed for
s3-experiment/storage-bench/REPORT.mdUpload timings, object counts, sizes, flush model
s3-experiment/storage-bench/integration/README.mdRebuild check and fault tests
s3-experiment/storage-bench/profiler/inspection-report.mdCorpus composition, reader fault cases
query-experiment/report.mdLance, Foyer and DataFusion roles; query latencies
bex_prof_store crate at BAML commit 048bf98aSegment layout, sequences, CIDs, value limits
Hosted architecture, system contractTopology, flows, endpoints, publisher limits