You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Status (2026-08-09): superseded. Maintainer review chose a different direction — a native single-stage Dockerfile workflow orchestrated by aenv, streaming context into the build VM via envd with no host-side persistence. The shared durable context store, the coordinator, and stock E2B SDK COPY compatibility are out of scope; per-step caching and multi-stage support are separate follow-ups. Focused design: #147. The original proposal below is kept for reference.
TL;DR: Adopt the reviewer's per-step cache, while retaining a slim shared context-staging path required by stock-SDK upload timing and cross-node routing; validate it through Phase 0/0.5 before enabling per-step reuse.
Prompted by review feedback on [1/4] feat(snapshot): template build-context archive store (posixfs + oss) #71 proposing (a) uploading build context directly into the build sandbox via envd, and (b) deduplicating builds by snapshotting after each COPY/RUN step so each step yields a cacheable, reusable OverlayBD layer, found via a tree of cache keys with prefix matching.
Affected component
Snapshot / template builder (cross-cutting: API server / proxy, gateway / scheduler in later phases).
RFC intent and possible closure
The #71 review proposes an execution model — cache every mutating build step as an OverlayBD layer and resume the longest completed prefix — that this RFC recommends adopting, subject to the evidence and constraints gathered here. One stock-SDK constraint remains to be placed: context uploads complete before the build-start request, and with AgentENV's current independent request routing those bytes need a staging home until the consuming step runs. The RFC therefore proposes retaining a smaller context store as transport and a present oracle — not as the build cache — and asks for decisions on:
the per-step cache model (key structure, context bindings, publication semantics) in §Proposed approach;
the metadata coordinator for multi-node OSS deployments (§Metadata coordination) — required from Phase 0, because upload bindings and build-state transitions need linearizable writes that the OSS backend cannot express today;
the attempt state machine, promotion outbox, and the reconciler actors that make crashes converge (§Failure contract, §Cache publication);
the form of context staging (shared store vs the alternatives in §Alternatives considered);
the phasing (including a final-key-only Phase 0.5) and default-off gating of the cache;
Useful closure outcomes: agreement on the target model with the stack revised to match; agreement on a different staging design from §Alternatives; or a recorded decision that execution caching is deferred and the stack lands as a compatibility-only milestone.
Problem statement
The stock E2B SDK's Template.build() has one hard correctness requirement AgentENV does not meet, and one capability the protocol anticipates that AgentENV lacks:
Context transport (correctness). The SDK resolves every COPY/ADD through GET /templates/{templateID}/files/{hash} and PUTs a tar archive to the returned URL with a bare HTTP client, finishing all uploads before it sends the build-start request (Template.build). Without a place to accept those uploads and make the bytes available to the build node, COPY builds cannot work at all.
Execution reuse (performance and cache semantics). Today's template builder re-executes every step of every build. The E2B protocol exposes per-step cache controls — TemplateStep.force, TemplateBuildStartV2.force, NodeDetail.cachedBuilds exist in AgentENV's src/api/openapi.yml — which AgentENV parses and ignores; implementing a step cache gives those fields their intended function (the schema exposes cache control, though it does not by itself mandate any particular cache granularity).
The open PR stack (#71–#74) implements transport via a repository-backed archive store, host-side COPY planning, and envd-streamed execution — but no execution caching. The #71 review asks whether the durable store is necessary and proposes per-step layer caching as the deduplication mechanism. This RFC answers with a combined design and a survey of how the same problem is solved elsewhere.
Use case
Stock E2B SDK (JS/Python) template builds with COPY/ADD against single-node POSIX and multi-node OSS deployments.
Iterative template development: repeated builds where most steps and most context bytes are unchanged; the user expects unchanged prefixes to be skipped and unchanged context not to be re-uploaded. Scope note: rebuilding an existing alias in place is unsupported (parked with [Parked] feat(snapshot): E2B alias rebuild semantics on template publish #70), so "iterative" here means repeated builds under new template names or IDs; the cache makes those cheap.
Cluster operation: the upload-link request, the upload PUT, and the build start are routed independently by the gateway today (services/gateway/internal/schedule_hint.go recognizes only /sandboxes* routes), so any node may serve any of them.
Current behavior and workarounds
Template builds run all steps in one sandbox and capture one snapshot at the end (src/template/runner.rs); there is no step caching, so every rebuild pays the full step cost.
[1/4] feat(snapshot): template build-context archive store (posixfs + oss) #71–[4/4] feat(template): execute COPY/ADD steps and document E2B builds #74 add the context store, upload API, COPY planning, and COPY execution. Pre-merge gaps found while preparing this RFC (fixes are folded into §Proposed approach): the OSS store's import is an exists check followed by an unconditional PUT, not an atomic first-write publication (src/snapshot/repository/backends/oss/build_files.rs); the OSS try_start_build transition is an unguarded read-modify-write, and the OSS client has no conditional-write primitive at all (src/snapshot/repository/backends/oss/{repository,client}.rs — an in-repo comment notes the S3-compatible path supports neither If-None-Match nor x-oss-forbid-overwrite); the gateway does not classify a bare upload PUT as streaming, so it inherits the short request deadline (services/gateway/internal/server.go, isStreamingRequest); and gateway error logs serialize the full upstream URL including the bearer query token.
No workaround exists for execution reuse; force/cachedBuilds are dead schema fields.
Desired behavior
Given an unchanged step prefix (same base, same steps, same bound context generations, same machine/runtime fingerprint), when a template build runs, then the prefix is not re-executed: the build resumes from the deepest cached checkpoint and executes only the suffix — and produces the same result a cold build would (cold-miss/resume equivalence).
Given a context archive bound to a build, then that exact archive generation remains available to the build until it reaches a terminal state — regardless of GC, later uploads, or forceUpload by other builds; repeated link requests never silently rebind an existing binding to a newer generation.
Given force on a step or build, when the build runs, then that step and everything after it re-execute, and refreshed results replace the stale cache entries without leaving mixed-generation chains observable.
Given a node crash in any build state — including mid-promotion — then the build converges to a visible terminal state via lease expiry and reconcilers; no fenced-out worker can commit canonical state, and terminal states are monotonic (once Ready, no later write demotes it).
Cached artifacts are bounded: retention and safe deletion exist in every phase where the cache can be enabled.
These are scoped guarantees, not absolutes: re-upload happens when retention expired before any binding or scope differs; re-execution happens on force, fingerprint change, or a lost cross-node publication race.
SDK constraints and current deployment assumptions
Observed behaviors of the stock SDKs (pinned: SDK @ cab27aa, infra @ b8bba83):
Call sequence.POST /v3/templates (register) → per-COPY hash computation → GET /templates/{templateID}/files/{hash} per COPY instruction → PUT <url> for each missing archive → only after all uploads complete, POST /v2/templates/{templateID}/builds/{buildID} → status polling. The JS SDK parallelizes link requests and uploads; the Python SDK is sequential in both its sync and async clients; all finish uploads before build start (index.ts, template_sync/main.py, template_async/main.py).
The hash is per-COPY-step, client-computed, and semantic. A sha256 digest seeded with a constructed COPY <src> <dest> string, then each file's context-relative path, mode, size, contents, and symlink targets; uid/gid/mtime deliberately excluded; files sorted for determinism (utils.ts calculateFilesHash). It is a digest of the step's semantic inputs, not of the uploaded tar bytes — the instruction string is not present in the tar, so the server cannot recompute or verify this hash from the uploaded archive.
present: true normally skips the upload, but a per-instruction forceUpload both re-uploads despite presentand marks that COPY step force — and it does so using the URL from the same link response, without a second GET (upload loop, COPY construction). Every link response must therefore carry a usable upload URL, present or not.
At upload-link time the server has no recipe and no step arguments — only (templateID, hash) plus the request's auth context. No step-prefix computation is possible when answering present.
Deployment assumptions (properties of AgentENV today, not SDK law — changing them is part of the design space in §Alternatives considered):
(D1) The gateway routes the register, upload-link, upload, and build-start requests independently (round_robin/random, no template pinning), so today the shared snapshot repository is the only thing making them coherent. AgentENV registration mints a fresh snapshot ID per build request (v3_templates_post → SnapshotId::generate()), so nothing about the protocol pins consecutive requests to one node.
(D2) Context must remain available until the consuming step executes (or the build reaches a terminal state). Durable object storage is one way to provide that guarantee; leases over node-local staging are another.
Prior art
E2B upstream: a context store plus a per-step layer cache, joined by filesHash
E2B's build-cache bucket contains two kinds of objects (paths.go; the path helpers take a generic cacheScope — the public API passes the team ID, with a server-side fallback to the template ID when unset):
{scope}/files/{sha256}.tar # build-context archives, addressed by the SDK hash
{scope}/index/{layerHash} # tiny JSON: cache key -> build (snapshot) ID
The upload-link handler stats the blob and mints a presigned URL; present = blob exists (template_layer_files_upload.go, upload_layer_files_template.go; 30-minute URL TTL). Self-hosted deployments can emulate the presigned URL with an HMAC-signed upload endpoint over filesystem storage; any-node coherence then requires that storage to be shared (a shared mount or object store) rather than a single node's disk.
The builder is a phase engine running Firecracker VMs. Each step's cache key chains through its parent: H(parentHash ‖ stepType ‖ join(args) ‖ filesHash) (phases/steps/hash.go) — the "tree of cache keys with prefix matching" from the [1/4] feat(snapshot): template build-context archive store (posixfs + oss) #71 review. All phase hashes are computable statically from the recipe; the engine computes and probes them phase by phase in forward order, continuing to probe after a miss (phase.go); a hit skips that phase's Build().
A cached layer is a full pausable VM snapshot (memfile + rootfs, stored as diffs against parents) under a fresh build ID. The context tar is downloaded from the store only on a cache miss, by whatever node runs the build, streamed into the guest via envd and extracted in-guest (copy.go). The upload-link request and build-start request each independently pick a build node; nothing reconciles them — coherent only because both talk to the same shared store.
Correctness details adopted below: the hash→buildID index entry is written only after every snapshot artifact has finished uploading (layer_executor.go); force is sticky (builder.go); two global invalidation levers exist (a hashing version folded into the base hash; a minimum cached-format version at lookup — cache.go).
Gaps not to copy: uploads are never verified against the claimed hash (containment is scoping only), and there is no durable-object GC for context tars, index entries, or intermediate layer snapshots (node-local caches do have TTL cleanup).
BuildKit: transfer and caching are orthogonal, joined by a content digest
BuildKit (8f96667) has no durable context-archive store; it persists the client's context as a live snapshot keyed by a heuristic sharedKey, updated by an incremental stat-based diff that only transfers changed file bodies (source/local/source.go, fsutil receive.go @ 6d9dc2e). The local-context vertex is deliberately uncacheable across sessions (ops/source.go); all COPY cache reuse comes from a server-side content checksum attached as an alternative cache key on the consuming step (edge.go, contenthash).
Relevant lessons: the E2B protocol shape (client hash + whole-archive upload) trades away incremental transfer and gains a one-round-trip skip plus node independence; the cost — one changed byte re-uploads the whole archive — must be accepted explicitly, and in a VM builder the per-step capture cost (full-VM memory) may dominate overall build time regardless of transfer efficiency, so benchmarks decide. In BuildKit's remote-cache export, min mode exports the layers of the resulting image while max additionally exports intermediate-stage layers (cache mode docs); on OverlayBD the writable upper is the per-step rootfs diff, so exporting intermediate rootfs layers carries little marginal data cost (sealing is O(mapping count), not free — §Proposed approach). BuildKit's GC tiers by reproducibility — cheap-to-reproduce classes get short-TTL small-budget tiers (gcpolicy.go) — and identical in-flight work is deduplicated per vertex.
Industry survey (trade-off examples, not verdicts)
Modal (a close peer: bespoke image format, own builder; sandboxes are secure containers by default, with VM sandboxes as an opt-in beta): a content-addressed file store (per-file sha256, batched existence negotiation, presigned blob upload) plus per-layer get-or-create where each layer names its parent image ID (modal-client @ bef1b70, Images guide: breaking one layer "causes cascading rebuilds for all subsequent layers"). Modal distinguishes force_build (rebuild and overwrite cache) from ignore_cache (rebuild without publishing).
Daytona: context tars content-addressed in S3 with skip-if-exists; the platform cache key is a single hash of dockerfile + sorted(contextHashes) for the whole build; intermediate BuildKit state is runner-local, while completed snapshots propagate across runners (v0.190.0, snapshot manager). A coherent store-only + whole-build-cache design; reuse granularity is the whole build.
Depot / Fly.io: Depot attaches ephemeral builder instances to a persistent per-project/per-architecture cache volume (Ceph-backed) and routes a project's builds to a shared builder to maximize hits (Depot architecture). Fly.io's earlier shared remote builders drew user reports of large context re-transfer in specific setups (flyctl#591 for --local-only, community reports of large COPY . . contexts); Fly now defaults to Depot-managed builders (announcement). These illustrate the operational coupling that builder-affinity approaches take on; they are not evidence that such approaches fail.
kaniko (daemonless per-step caching; pinned @ 236ba56): rolling composite key seeded by the base-image digest, extended per instruction with content hashes of referenced files; the cache index is an OCI registry, shared across machines by construction; strict stop-after-first-miss (composite_cache.go, caching docs).
OverlayBD upstream (pinned @ 4f5c597): BuildKit ≥ 0.22 with the overlaybd snapshotter builds native OverlayBD images; in the native writable-layer mode the snapshotter's Commit() seals the live writable layer into an immutable lower per committed filesystem snapshot (QUICKSTART, buildkit docs/overlaybd.md). The conversion path keeps a dedup database keyed by OCI chain_id — a cumulative parent-chain identity used for conversion dedup (overlaybd_builder.go). This validates per-step filesystem-diff sealing on this storage format; AgentENV's repeated full-VM checkpoint loop (Phase 1) goes beyond what upstream exercises and is validated by this RFC's own benchmarks and tests.
Proposed approach
Execution model
Adopt the #71 review's model. A template build becomes:
Freeze context bindings (below), then compute the full cache-key chain for the recipe — statically, before any sandbox exists.
Look up the final-result entry for final_key first; on a miss, probe resumable checkpoint keys from the last mutating step toward the base until the deepest valid entry is found (validation includes lineage, below); after choosing it, execute the full suffix without further cache probes. (This is a design choice for VM builders — resuming is the expensive operation, so we pick the resume point once; E2B's engine instead probes forward per phase and can skip later phases after a miss.)
Boot/resume from the chosen checkpoint — whose stored CommandContext restores the logical build state — verify it against the recipe-derived expected context, replay any metadata-only steps in the suffix, execute the mutating steps, checkpointing after each.
Promote the final checkpoint to the user-visible template snapshot via the promotion protocol below; intermediate checkpoints are hidden cache records.
This Phase-1 design needs no new artifact format (entries are committed snapshots; layers are ordinary managed layers), but it does need new repository metadata (context bindings, index records, attempt records, a hidden record purpose) and a metadata coordinator on multi-node OSS — it is not "zero new primitives."
Metadata coordination and fencing (required decision)
The design needs linearizable metadata transitions: context-generation publication, upload intents, build bindings and freeze, index create-if-absent and fenced replace, attempt acquisition/renewal, lease grants, GC barriers. On POSIX these map to link/rename/O_EXCL. On the OSS backend no such primitive exists today: the client is unconditional GET/PUT/DELETE (src/snapshot/repository/backends/oss/client.rs), and an in-repo comment records that the Alibaba-OSS S3-compatible path supports neither If-None-Match nor x-oss-forbid-overwrite (src/snapshot/repository/backends/oss/repository.rs). Coordinator options:
(a) Native conditional writes where the object store supports them (AWS S3 added If-None-Match conditional puts in August 2024 and ETag-based If-Match conditional writes in November 2024; support varies across S3-compatibles and is absent on Alibaba OSS's S3 path). Capability-detected; simplest where available.
(c) Restrict multi-node OSS operation of the upload/build surface to a single designated writer until (a)/(b) lands.
Recommendation: (b) for multi-node OSS, (a) opportunistically, (c) as the fallback posture. The coordinator is a Phase-0 requirement for multi-node OSS — generation publication, bindings, Waiting expiry, and atomic quota admission need it even with the execution cache disabled.
Coordinator durability contract. The scheduler's existing Redis usage is droppable routing state; this is not. Bindings, intents, attempts, promotion intents, paused-sandbox roots, and GC leases are correctness-grade: the coordinator must run with AOF/replication and no eviction, or the deployment must be able to rebuild coordinator state from durable repository records. After a coordinator loss, GC remains closed until a recovery barrier completes (state rebuilt or verified); "coordinator reachable again" alone does not reopen GC. This contract is part of the coordinator capability definition.
Fencing across systems. A coordinator check followed by an unconditional worker write to OSS or an OCI registry is not fencing: the worker can lose its lease between check and write. The rules:
Coordinator transitions CAS on their transition-specific precondition (state, epoch, lease — see the state machine in §Failure contract) in one operation; a reconciler closes a fence atomically by transitioning the attempt out of its current state, after which no same-epoch CAS against the old state can succeed.
Workers write shared storage only at immutable, uniquely-identified paths (fresh candidate snapshot IDs, intent-qualified promotion staging); such writes are harmless if the writer was fenced out, because nothing references them until a coordinator-CAS'd pointer selects them.
Canonical mutable or user-visible writes — the target snapshot record, alias binding, registry tags, Ready visibility — are never performed as unconditional worker writes; they are steps of the promotion outbox (§Cache publication), each gated by an intent-state CAS, executable by any actor but only in intent order. (Today's OSS publish writes artifacts, then alias, then the committed record unconditionally — src/snapshot/repository/backends/oss/{layout,repository}.rs — which is exactly the pattern the outbox replaces for promotion.)
Cache identity and the trust boundary
filesHash is a client-computed semantic digest the server cannot verify (constraint 2), and current API auth accepts any non-empty credential with no identity (src/api/impls/auth.rs, in-code TODO). Cache identity therefore cannot rest on the client hash alone.
Context bindings (generational, with upload intents). The store keeps:
Every GET files/{hash} returns a usable upload URL backed by a created-or-reused UploadIntent with a reserved generation — including when present: true, because the stock SDK's forceUpload PUTs to the URL from that same response without a second GET (constraint 4). Binding is create-if-absent: if the build already has a binding for this hash it is renewed, never rebound; if a published generation exists and no binding does, present: true and the current generation is bound; if none exists, present: false. Intents that are never used simply expire.
A PUT never writes the reserved generation's object key directly. The body streams to a unique temporary object; the server computes blob_digest and publishes the bytes as an immutable content-addressed blob. Intent completion and binding replacement are then one atomic coordinator transition, mutually exclusive with build-start freeze:
Only after this CAS does the server return 2xx. Replays of the same grant token: same digest against an already-Completed intent succeeds only if the build's current (or frozen) binding still is that generation — otherwise it returns a post-freeze conflict; a different digest conflicts outright. There is no window in which a completed intent can rebind a build that has since frozen, and concurrent replays can never overwrite each other's bytes.
forceUpload therefore works with no special casing: the re-upload completes the reserved intent, producing a new generation bound to this build (and the SDK independently marks the step force); other builds' bindings keep their pinned generations.
Build-start is one coordinator transition: freeze bindings + record request digest + acquire attempt atomically; the key chain is computed from the frozen (filesHash, blob_digest) pairs, and every recipe-referenced hash must have a frozen binding. After freeze, all PUTs and rebinds for the build are rejected. Registered-but-never-started builds carry a Waiting deadline; expiry releases bindings and intents.
GC never deletes a generation with live bindings. A malformed or digest-mismatched object is treated as absent/repairable, never as permanently present. (blob_digest detects corruption and pins identity; it cannot prove semantic correspondence between tar and filesHash — containment for a poisoned upload is scoping plus the fact that the poisoned generation only feeds cache keys derived from it.)
Scoping. Archives, bindings, index records, and cache entries are namespaced by tenant scope, not template ID — registration mints a fresh snapshot ID per build (v3_templates_post), so per-template scoping would destroy exactly the cross-build reuse this design exists for. Until authenticated tenancy lands, deployments must explicitly set single_trust_domain = true to enable the upload/cache surface — an operator acknowledgment, not a silent default; the scope key is threaded through from day one so real tenancy is a configuration change, not a redesign.
Key structure. Keys are computed over a versioned, length-delimited canonical encoding (no ambiguous string concatenation). Step classification: RUN, COPY/ADD, and WORKDIR (it runs mkdir -p in the guest) are mutating; ENV, USER, EXPOSED_PORT, VOLUME, LABEL are context-only (USER subject to the execution-identity fix below). Two key kinds exist:
logical_step_key # one per recipe step, chained through every step
resumable_checkpoint_key = logical_step_key of a mutating step # has a CheckpointEntry
base_key = H(v ‖ fingerprint ‖ base_identity)
step_key = H(v ‖ parent_logical_key ‖ step_kind ‖ canonical(args) ‖ filesHash? ‖ blob_digest?)
final_key = H(v ‖ last_logical_key ‖ canonical(finalization inputs)) # has a FinalResultEntry
fingerprint is derived from the canonical effective launch configuration (not a hand-maintained field list); it must cover at least: resolved base image manifest/layer identity, architecture, disk size, vCPU/memory geometry, virtualization mode, CPU config/template, Firecracker/kernel/envd/tools-image versions, snapshot & cache format versions, and a builder-semantics version. canonical(finalization inputs) covers the normalized/defaulted ready command, the startup command and whether it was inherited or explicit, the effective final CommandContext, ensure_default_user behavior, and a finalization-semantics version. v is the global hashing-version lever; a minimum cached-format version is enforced at lookup (both per E2B).
Execution-identity exclusion (USER). Today USER only updates the host-side CommandContext (src/template/step_executor.rs), and RUN passes envd only env vars and cwd — not the user — so a cold build executes every RUN as envd's boot-time default user. But resuming from a checkpoint re-initializes envd with the snapshot's stored default_user (src/sandbox/firecracker/sandbox.rs, envd init), so the same suffix would execute under a different user than a cold build — a cold-miss/resume divergence. Phase 1 therefore excludes recipes containing USER from the execution cache entirely — they neither read nor write cache entries (they build exactly as today). The proper fix — exec/process requests carrying the effective user explicitly, which also makes cold builds honor USER per Docker semantics — is follow-up work; when it lands, the envd/runtime version enters the fingerprint so old checkpoints with the divergent semantics can never be resumed under the new ones. A cold-miss vs resume equivalence test for the cached step vocabulary is mandatory regardless.
Custom extension. The start-fresh hook may inject instance-specific boot args while cache hits resume via start-resume — two semantically different paths, and today's hook API has no way to declare cache equivalence. When [custom_extension].url is configured, the cache is disabled. A real opt-in would need a defined contract (contract version, cache salt, canonical effective params, and explicit authorization of skipped hooks) — future work, out of scope here.
force semantics.force (build-level, per-step, or induced by forceUpload) bypasses cache reads for the forced step and, stickily, everything downstream. Refreshing shared entries is done safely via lineage (below): forced results replace pointers with a generation-CAS fenced through the attempt state machine; displaced targets become delayed-GC orphans.
CheckpointEntry.parent names the nearest resumable ancestor (skipping context-only steps), or a typed root edge{base_key, base_identity} for the first mutating step (a fresh image base has no parent snapshot). Metadata-only steps contribute to the key chain but never resolve to snapshots — their effects live in the next checkpoint's stored post-step CommandContext, and on resume the builder replays any metadata-only suffix steps deterministically.
Lineage validation: a chain of pointers is only usable if each record's parent reference resolves consistently (parent snapshot_id matches the parent key's current entry, or the root edge matches the build's base identity). This applies to FinalResultEntry too — a final hit validates the full lineage back to the root, so a forced replace of any ancestor also invalidates stale final pointers. Descendants of a replaced ancestor fail lineage and are treated as misses (and queued for repair) instead of silently gluing mixed-generation chains together. build_base_checkpoint_id is optional on final entries: Phase 0.5 takes no pre-finalization capture, so its promoted templates lack the field and derived builds from them fall back to cache-disabled semantics.
Records are written only after the checkpoint's publication fully completes; writes are create-if-absent, and invalid or lineage-broken records are replaceable via compare-delete / replace-if-observed-invalid (coordinator-mediated), so a crashed publisher cannot squat a key forever.
Every lookup validates: record exists, purpose/kind matches, schema/fingerprint versions compatible, target record and artifacts present, lineage intact. Authoritative invalidity (missing target, broken lineage, version mismatch) is a miss plus a repair task; transient unavailability (network or service errors while validating) merely bypasses the cache for this build — it neither queues repair nor mutates any pointer. Neither case surfaces an error to the build.
Cache entry publication and promotion
Cache checkpoints are committed snapshots with a BuildCache record purpose and cache-schema version. Hidden means enforced, not cosmetic: every public resolve/get/status/delete/alias/launch path rejects purpose = BuildCache records (today GET/status/delete and sandbox launch accept any snapshot UUID — src/api/impls/template.rs, src/api/impls/sandbox.rs); only internal cache APIs may read them. To keep rollback safe, the cache catalog lives in a separate repository namespace that older binaries do not scan; alternatively a downgrade requires disabling admission, draining leases, purging cache/P2P state, and completing GC first.
Two publication paths, both required:
publish_cache_candidate: always stores layers as managed/object-storage layers and never publishes source-registry OCI tags or takes publication ownership — regardless of snapshot_image_storage = source_registry. Candidate metadata retains per-disk origin repositories from the original image resolution: managed materialization rewrites layer URLs to AgentENV storage, and the current exporter derives the target repository from image.json's repoBlobUrl (src/snapshot/repository/backends/common/acr/source_image.rs) — without recorded provenance, promotion could no longer publish to the source registry.
promote_committed_snapshot: produces the final user snapshot under the target build's snapshot ID, driven by a PromotionIntent that works as a transactional outbox, with per-disk plans (rootfs and each attached drive may publish to different repositories/tags):
Entering Publishing (writing the intent) is the irreversible commit decision: from that point the intent converges forward to Ready — lease expiry hands it to the promotion reconciler (the named recovery actor for node death mid-promotion), it never triggers a Failed-plus-cleanup path, so no cleanup can race a late writer. Failed is declared only for authoritative, permanent errors, and its cleanup follows the ownership rules below. Two write classes are distinguished:
Repository-side results (target record, alias, Ready) are never unconditional external writes: the record and alias content are staged at intent-qualified immutable paths, and canonical visibility is a coordinator pointer flip — public reads resolve the target/alias through the coordinator-committed pointer, so a fenced-out worker's stale staging is unreachable by construction.
Registry tags are inherently external mutable writes; they are made safe by determinism, not CAS: registry_committed is reached only when every disk plan's tag push has completed, each push is idempotent-by-content (the plan carries exact config/manifest bytes with the per-disk ownership_nonce embedded in the OCI config), and duplicate executors of the same intent write identical bytes.
Registry adoption and ownership. The current publisher requires the tag to be absent before pushing (ensure_manifest_absent, src/snapshot/repository/backends/common/acr/publisher.rs), so a crash between registry PUT and record write would make a naive retry fail forever. With the per-disk plans, a reconciler retry adopts an existing manifest only when its digest matches that disk plan — proving it is this promotion's own remnant, not another writer's tag — and a crash after publishing only some drives' tags resumes with the remaining plans. Any cleanup deletes a tag only after re-reading that it still points at the intent's digest; when ownership cannot be proven, the artifact is left as an orphan rather than deleted.
Each mutating checkpoint stores its exact post-step CommandContext — CommittedSnapshot already carries context, and the resume path already initializes envd from it (src/sandbox/firecracker/config.rs); the builder replays the recipe's metadata effects only to verify the stored context, never as the source of truth.
fromTemplate builds (Phase-1 requirement, not an option). The current runner captures the user snapshot after startup/finalization (src/template/runner.rs), so building fromTemplate on the final snapshot would resume a finalized VM and re-run startup on top of it. User templates therefore record build_base_checkpoint_id — the checkpoint taken after ensure_default_user and before startup/finalization — in snapshot metadata (a GC root, preserved across rollback). The final candidate, the singleflight shared result, and the PromotionIntent all carry and pin this ID, so every waiter's promotion writes it into its own user snapshot. Derived builds extend the build-base checkpoint (startup runs exactly once, in the finalization phase of whichever build publishes); sandboxes keep launching from the final snapshot. Templates predating the field fall back to today's semantics with the cache disabled for that build.
Build identity (MMDS). Every build VM currently receives its sandbox/snapshot ID via MMDS (src/sandbox/firecracker/mmds.rs). The contract:
The host-side SandboxId remains unique per VM; only the MMDS payload presented to cache-eligible build VMs uses a stable, cache-neutral identity — from Phase 0.5 onward (any build whose result may be promoted under a different identity).
At user launch, live MMDS serves the target sandbox/template identity.
MMDS values persisted to disk or memory by build steps remain neutral in the cached state; promotion cannot and does not rewrite them. Templates whose steps persist build identity must opt out of caching entirely (deployment-level cache disable, or the template is documented as unsupported for cached builds) — note that force does not help here: forced builds still run in neutral-MMDS mode and refresh the shared cache with neutral values.
Publication, concurrency, and races
Concurrent identical builds are not assumed to produce identical bytes: RUN may depend on time/network/randomness, and independently sealed OverlayBD uppers embed distinct UUIDs, so layer digests normally differ even for equivalent work. Therefore:
A builder always publishes its checkpoint as a complete candidate snapshot first, then attempts the index write (create-if-absent; fenced replace only under force). Exactly one candidate wins per key; losers are complete-but-unreferenced snapshots handled as delayed-GC orphans. (Managed-layer digest dedup still collapses genuinely identical layers to zero bytes.)
Phase-1 singleflight is whole-suffix, per node, keyed by (scope, final_key) — scoped so distinct tenants never merge flights. The leader executes the remaining suffix and publishes the neutral cache candidate (with its build_base_checkpoint_id); each waiter then performs its own promotion under its own build ID and attempt epoch from that shared candidate. A leader's promotion failure (alias, registry, target publication) affects only the leader. Two builds never share a live mutable VM — only completed immutable checkpoints. Per-step rendezvous is deferred. Forced builds bypass flights entirely.
Cross-node duplicate execution is tolerated (bounded waste, converges via create-if-absent); cross-node in-flight merging is explicitly out of scope.
Failure contract
Build execution today is a detached in-process task holding all of its state in memory (src/api/impls/template.rs), and the OSS Waiting → Building transition is an unguarded read-modify-write — node death leaves Building forever. This RFC requires:
A durable attempt record per build with an explicit state machine and transition-specific fences:
The owner renews the lease while Active. The attempt reconciler transitions expired-lease Active attempts to the existing Error status with reason NodeLost (no new schema status) and releases bindings/quota; expired-lease Publishing attempts are instead handed to the promotion reconciler, which drives the intent forward to Ready (§Cache publication) — Failed there is reserved for authoritative permanent errors, never for lease expiry. Terminal states are monotonic — Ready can never be overwritten by a late error; a closed attempt rejects all subsequent same-epoch CAS attempts by construction.
The minimal attempt lifecycle ships in Phase 0: Waiting → Active(lease) → Ready | Error, with renewal, the expiry reconciler, and terminal release of bindings, intents, and quota — because Phase 0 already introduces freeze+acquire and durable bindings that would otherwise pin forever when a node dies. Phase 0.5 adds Publishing and the promotion outbox; Phase 1 adds checkpoint progress tracking.
Phase-1 failure policy: fail visibly, do not requeue. A user retry is a new build that reuses all committed checkpoints, which is where the cache already does the heavy lifting. Durable automatic requeue would additionally require persisting the full canonical BuildJobSpec and is deferred — listed as an open question.
Final promotion is idempotent by (build_id, attempt_epoch) through PromotionIntent; the promotion crash windows (registry tag written but record missing; candidate promoted but status not yet Ready) converge via the reconciler's adopt-by-digest-and-nonce retry, and tests must cover both.
Lifecycle and bounded storage
Per-step caching multiplies snapshot records and managed layers by roughly the step count, and managed-layers/ is today an append-only CAS that record deletion never touches. Bounded storage is therefore a requirement of every phase in which the cache can be enabled: the cache ships default-off, and enabling it requires retention to be implemented.
Roots and leases must be cluster-visible and object-exact:
Layers are read lazily for a sandbox's whole life, so starting/running/paused sandboxes hold distributed exact-layer leases for the layers their stacks reference, released on delete or lifecycle transition — with acquire-before-release ordering on Running↔Paused transitions (the new state's roots are registered before the old state's are dropped). A node that cannot renew its leases must self-stop its VMs within the lease safety margin — otherwise a partitioned node could keep reading layers whose leases have lapsed and been swept.
Paused sandboxes are currently persisted in node-local RocksDB (src/orchestrator/persistence/file_backed.rs), invisible to another node's GC. A coordinator-backed paused-root record is written before a pause is visible as successful.
Deletion uses a mandatory per-object barrier, not best-effort revalidation: Live → Deleting → external DELETE → Deleted, held in the coordinator. Deleting blocks new leases and new references; a publisher that finds an object in Deleting waits for Deleted and re-uploads rather than adopting the doomed object.
P2P release is durable unpublish work with retry (P2pTransport::unpublish on the owning node) — ForgetP2pArtifact alone only drops the scheduler hint.
GC fails closed when the coordinator is unavailable, and stays closed after coordinator recovery until the durability-contract recovery barrier completes (§Metadata coordination).
Sweep order:
Retire the index pointer (expired, displaced, or lineage-broken).
Tombstone the cache record — retaining its manifest so layer membership stays enumerable.
Wait out exact-object reader leases (leases pin snapshot_id/blob_digest, never mutable keys).
Mark reachable objects from all roots: current index targets, live attempt/candidate/promotion-intent records, user-visible snapshots (including build_base_checkpoint_id references), coordinator-registered paused/running sandbox roots (including attached-drive and memory layers), and fixed artifacts.
Transition sweep targets through the Deleting barrier; delete; enqueue durable P2P unpublish.
Delete the tombstoned record last.
Source-registry publications never enter cache lifecycle by construction (publish_cache_candidate cannot create them; promotion creates them under the user snapshot's identity and ownership).
Depth budget and preflight. OverlayBD stacks have a hard 255-layer ceiling that binds each stack independently — rootfs, memory, and every writable attached drive (each Phase-1 checkpoint appends a memory layer too); captures whose staged runtime suffix exceeds 32 layers additionally trigger a merge-rewrite of that staged suffix (DEFAULT_MAX_OVERLAYBD_SNAPSHOT_LAYERS — already-published checkpoint layers are not rewritten). The per-build checkpoint budget is computed dynamically:
budget = min(rootfs remaining, memory remaining,
min over writable attached drives of remaining)
- reserved captures (pre-finalization checkpoint + final capture)
- safety margin
Preflight: before build start, the builder verifies the base stack can accommodate at least the reserved captures; if not, it compacts/rebases first or rejects with an actionable error — "degrade to non-checkpointing" only works when the mandatory captures still fit. Past the budget, later steps execute without checkpointing and the build still completes. (The memory term is deliberately conservative: successive captures of one live execution are typically siblings against the inherited memory base rather than an ever-growing live stack; the budget can be relaxed with benchmark evidence without affecting correctness.)
Alternatives considered
The design space decomposes into independent dimensions — where uploaded bytes land (sandbox / node disk / gateway / shared store), how long they must survive (until consumption vs durable), placement coupling (pinned build node vs any node), and failure policy (retry, re-upload, fail). Points in that space we evaluated:
Direct upload into the build sandbox. Under the current stock-SDK request sequence, the final build VM cannot be the upload sink: at upload-link time neither the recipe nor the base is known (constraint 5), so there is nothing to boot yet. Uploading "into the sandbox" therefore decomposes into alternative 2 (pin a node early, stage bytes there). Note [4/4] feat(template): execute COPY/ADD steps and document E2B builds #74's execution path already streams the archive into the sandbox via envd — staging is the hop between the SDK's upload and that last mile, not a competing transport.
Registration-time build→node binding + node-local CAS/spool. Entirely viable without pre-creating a VM: bind the build to a node at POST /v3/templates, route upload-links/PUTs to that node (new gateway capability), stage in a node-local content-addressed spool. Trade-offs vs a shared store: adds scheduler/gateway coupling and exposes staged bytes to single-node loss before consumption; present: true across builds only holds for builds bound to the same node, unless the spool replicates — a node-local lossy CAS answers strictly fewer present queries than a shared durable CAS. On single-node POSIX deployments this is effectively the proposed design (the repository is node-local there). A reasonable future direction once placement pinning exists for other reasons.
P2P context CAS. Stage archives node-locally, advertise via the existing P2P artifact index, fetch on demand at build time. To back present: true it needs synchronous replication or a durable fallback — the current P2P index is best-effort and can only serve as a hint. Compatible with adopting later behind the store's staging role.
Gateway involvement — two distinct designs: a gateway spool (gateway persists uploads; makes the stateless gateway stateful — rejected for now) vs a stateless tee (gateway streams through to a chosen node and/or durable fallback; keeps the gateway stateless but adds a routing dependency — plausible as an optimization of 2/3).
Always present: false + execution caching only. Protocol-compliant and simplest; every build re-uploads all context (the SDK tolerates it; forceUpload semantics unaffected). Loses upload dedup entirely — expected to be costly for large contexts; benchmark required. Kept as a fallback posture if the store is descoped.
A1: context store without execution caching (the open stack as implemented, after Phase-0 hardening). Wire-compatible and correctness-complete — with no cache, every step executes, force is trivially honored, cachedBuilds may legitimately be empty. It fails this RFC's execution-reuse goal, not E2B compatibility; it is the natural first milestone on the way to the full design.
A1 + whole-build cache key (Daytona's shape; Phase 0.5 below). One key over (recipe, frozen bindings, fingerprint) short-circuiting identical rebuilds. Not subsumed by per-step caching in operational terms: it avoids per-step O(RAM) captures, per-step hidden-record proliferation (one hidden final candidate per build remains), and depth pressure entirely, and may remain the better mode for repetitive CI rebuilds, high-memory builds, or depth-capped builds. It also exercises the same key/index/promotion/GC machinery, which is why it is proposed as its own phase rather than an alternative.
Proposed composition: per-step layer caching (execution reuse) + the slimmed shared store (transport, present oracle, miss-time materialization). The narrow claim: under the current arbitrary request routing (D1), with cross-build/cross-node present: true semantics, and without SDK modifications, shared staging is the minimal increment. If placement pinning lands later, alternatives 2–4 can take over the staging role; placement, lease, and upload-routing interfaces would change, while the execution-cache key/checkpoint model carries over unchanged.
Compatibility and operational impact
API/config changes:
No SDK-visible endpoint changes (endpoints are dictated by the E2B protocol). [template_build] gains: cache toggles (default-off), single_trust_domain acknowledgment gate, retention budgets/TTLs, depth margins, coordinator configuration; grant TTL/limits carry over from [2/4] feat(api): E2B build-context upload endpoints and configuration #72 with single-use claims removed.
force and cachedBuilds become functional rather than ignored (later phases for the latter). Error gains a NodeLost reason (no new status value).
Public template/sandbox APIs reject purpose = BuildCache snapshots on every direct-ID path.
Snapshot or storage format changes:
Additive: BuildCache records in a separate cache-catalog namespace (invisible to older binaries); cache-schema version; build_base_checkpoint_id on user snapshots; context generation/binding/intent records; index/attempt/lease/promotion-intent records; server-computed blob_digest. Committed snapshot manifests and layer formats are unchanged in Phases 0/0.5/1; Phase 2's ColdBootRootfs entries would introduce a new tagged artifact kind (see rollout — not resolved by this RFC).
New host/runtime requirements:
Single-node and POSIX deployments: none. Multi-node OSS deployments require a metadata coordinator from Phase 0 (Redis with AOF/replication/no-eviction — or rebuildable state — or a conditional-write-capable object store) — upload bindings and build-state transitions need it even with the execution cache disabled. TLS on public_base_url is required for the upload surface.
Upgrade and rollback considerations:
"Cache disabled" means no-cache execution — Phase 0 still changes operational behavior (bindings, Waiting deadlines, quotas, TLS requirement, coordinator on multi-node OSS); it does not change build results. Hashing-version and minimum-format-version levers allow global or format-scoped invalidation without migration. A full cache purge is safe only after: draining exact-object reader leases, draining or failing active attempts, promotion intents, and publication writers, completing queued P2P unpublish work, and resolving build_base_checkpoint_id roots — checkpoints referenced by user snapshots are user-snapshot roots, not cache-owned, so the purge either preserves them or clears the field (dropping those templates to legacy fromTemplate semantics). The separate cache namespace keeps downgraded binaries from ever observing cache records as templates.
Security posture:
Upload/cache surface requires single_trust_domain = true until authenticated tenant identity exists; scope key threaded now. Grants bind (scope, buildID, hash, reserved generation, method, exact size ceiling, expiry); quota admission is atomic and happens before the body is read; grant replay is idempotent by digest and cannot mint generations. Bearer query tokens are redacted from error logs, access logs, traces, metrics, and forwarded-URI headers.
Phase 0.5 — final-key-only cache (experimental, default-off). One whole-build key over (recipe, frozen bindings, fingerprint) with a FinalResultEntry; hit ⇒ promotion from the cached final candidate via the PromotionIntent outbox and promotion reconciler; cache-neutral MMDS from this phase on. Exercises keys, index records, validation, promotion, retention, and the coordinator end-to-end with no per-step machinery and minimal storage growth.
Phase 1 — per-step cache (experimental, default-off). Recipes containing USER are excluded from the cache (§Cache identity; the explicit effective-user exec API is follow-up work). Then: key chain + fingerprint; logical vs resumable keys with typed root edges; runner refactored from run-all-steps-then-capture-once (src/template/runner.rs) into a per-step checkpoint loop over the existing live pause→capture→in-place-resume primitive (SandboxBackend::snapshot()); post-step CommandContext stored per checkpoint (replay for verification and metadata-only suffixes); build_base_checkpoint_id (post-ensure_default_user, pre-startup) threaded through candidate, singleflight result, and promotion; candidate publication via publish_cache_candidate + lineage-validated index; (scope, final_key) singleflight with independent waiter promotion; checkpoint progress on the attempt record; mark-and-sweep retention with exact-object leases, Deleting barriers, coordinator-backed sandbox roots, and fail-closed behavior; dynamic depth budget with preflight. Memory cost is accepted and measured: with default mincore selection each checkpoint copies O(resident RAM); with opt-in dirty-page tracking (00ba6cb) the Firecracker query is logically non-clearing (/vm/dirty-memory-ranges), so successive checkpoints re-copy pages dirtied since VM start/resume — complete images, cumulative cost. Benchmarks on both paths gate the default.
Phase 2 — cold-boot cache entries (follow-up design; not yet proven safe, not resolved by this RFC). A distinct ColdBootRootfs cache-entry kind (new tagged artifact kind; publisher/resolver branches). Known preconditions already identified: in-guest syncfs/fsfreeze before sealing is a prerequisite even for the pause-based variant — a paused VM's page cache is not flushed into the OverlayBD upper, so a completed RUN's file contents can exist only in memory, and background processes/tmpfs/mounts/IPC state vanish on cold boot. Cold-boot entries would be restricted to steps declared filesystem-only/hermetic; arbitrary RUN steps stay on Phase-1 entries; writable attached drives unsupported initially. This phase proceeds only with its own design note and safety evidence.
Phase 3 — cache-aware scheduling. A node-local residency catalog (distinct from the shared index — cachedBuilds must reflect node-local materialization/page-cache warmth to be useful), heartbeat population of NodeDetail.cachedBuilds, and a template-build schedule hint.
Acceptance criteria
Moved to the first comment below (GitHub issue size limit), together with the full commit-pinned reference list.
Custom-extension cache opt-in (requires a contract API that does not exist yet).
SDK modifications of any kind.
Open questions
Tenancy timing. Is the single_trust_domain = true gate acceptable until authenticated identity lands, or should authenticated tenant ownership be a Phase-0 prerequisite?
Coordinator choice (§Metadata coordination): Redis (with the stated durability contract) vs native conditional writes vs single-writer restriction — which do maintainers prefer as the supported multi-node OSS posture?
Staging home. Shared repository store now (proposed), or invest directly in placement pinning + node-local/P2P staging (alternatives 2–3)?
Requeue. Is Error(NodeLost) + user retry acceptable long-term, or is durable requeue (with a persisted BuildJobSpec) wanted — and in which phase?
Cache gating. What benchmark thresholds flip any cache default from off to on?
Depth margins and step-cap numbers — settle with benchmarks (including whether the conservative memory-stack term can be relaxed).
Staleness of non-hermetic RUN. Rely purely on force (proposed, Docker/E2B semantics) or add opt-in per-template cache TTL?
Contribution
I can implement it.
Pre-submission checklist
I searched existing issues and discussions and did not find a duplicate.
I described a concrete problem and use case, not only a proposed technology.
I understand that acceptance of the problem does not imply approval of a particular implementation.
Full commit-pinned reference list is in the first comment below.
TL;DR: Adopt the reviewer's per-step cache, while retaining a slim shared context-staging path required by stock-SDK upload timing and cross-node routing; validate it through Phase 0/0.5 before enabling per-step reuse.
Affected component
Snapshot / template builder (cross-cutting: API server / proxy, gateway / scheduler in later phases).
RFC intent and possible closure
The #71 review proposes an execution model — cache every mutating build step as an OverlayBD layer and resume the longest completed prefix — that this RFC recommends adopting, subject to the evidence and constraints gathered here. One stock-SDK constraint remains to be placed: context uploads complete before the build-start request, and with AgentENV's current independent request routing those bytes need a staging home until the consuming step runs. The RFC therefore proposes retaining a smaller context store as transport and a
presentoracle — not as the build cache — and asks for decisions on:Useful closure outcomes: agreement on the target model with the stack revised to match; agreement on a different staging design from §Alternatives; or a recorded decision that execution caching is deferred and the stack lands as a compatibility-only milestone.
Problem statement
The stock E2B SDK's
Template.build()has one hard correctness requirement AgentENV does not meet, and one capability the protocol anticipates that AgentENV lacks:COPY/ADDthroughGET /templates/{templateID}/files/{hash}andPUTs a tar archive to the returned URL with a bare HTTP client, finishing all uploads before it sends the build-start request (Template.build). Without a place to accept those uploads and make the bytes available to the build node,COPYbuilds cannot work at all.TemplateStep.force,TemplateBuildStartV2.force,NodeDetail.cachedBuildsexist in AgentENV'ssrc/api/openapi.yml— which AgentENV parses and ignores; implementing a step cache gives those fields their intended function (the schema exposes cache control, though it does not by itself mandate any particular cache granularity).The open PR stack (#71–#74) implements transport via a repository-backed archive store, host-side COPY planning, and envd-streamed execution — but no execution caching. The #71 review asks whether the durable store is necessary and proposes per-step layer caching as the deduplication mechanism. This RFC answers with a combined design and a survey of how the same problem is solved elsewhere.
Use case
COPY/ADDagainst single-node POSIX and multi-node OSS deployments.PUT, and the build start are routed independently by the gateway today (services/gateway/internal/schedule_hint.gorecognizes only/sandboxes*routes), so any node may serve any of them.Current behavior and workarounds
src/template/runner.rs); there is no step caching, so every rebuild pays the full step cost.importis anexistscheck followed by an unconditionalPUT, not an atomic first-write publication (src/snapshot/repository/backends/oss/build_files.rs); the OSStry_start_buildtransition is an unguarded read-modify-write, and the OSS client has no conditional-write primitive at all (src/snapshot/repository/backends/oss/{repository,client}.rs— an in-repo comment notes the S3-compatible path supports neitherIf-None-Matchnorx-oss-forbid-overwrite); the gateway does not classify a bare uploadPUTas streaming, so it inherits the short request deadline (services/gateway/internal/server.go,isStreamingRequest); and gateway error logs serialize the full upstream URL including the bearer query token.force/cachedBuildsare dead schema fields.Desired behavior
forceUploadby other builds; repeated link requests never silently rebind an existing binding to a newer generation.forceon a step or build, when the build runs, then that step and everything after it re-execute, and refreshed results replace the stale cache entries without leaving mixed-generation chains observable.Ready, no later write demotes it).These are scoped guarantees, not absolutes: re-upload happens when retention expired before any binding or scope differs; re-execution happens on
force, fingerprint change, or a lost cross-node publication race.SDK constraints and current deployment assumptions
Observed behaviors of the stock SDKs (pinned: SDK @
cab27aa, infra @b8bba83):POST /v3/templates(register) → per-COPY hash computation →GET /templates/{templateID}/files/{hash}per COPY instruction →PUT <url>for each missing archive → only after all uploads complete,POST /v2/templates/{templateID}/builds/{buildID}→ status polling. The JS SDK parallelizes link requests and uploads; the Python SDK is sequential in both its sync and async clients; all finish uploads before build start (index.ts, template_sync/main.py, template_async/main.py).COPY <src> <dest>string, then each file's context-relative path, mode, size, contents, and symlink targets; uid/gid/mtime deliberately excluded; files sorted for determinism (utils.tscalculateFilesHash). It is a digest of the step's semantic inputs, not of the uploaded tar bytes — the instruction string is not present in the tar, so the server cannot recompute or verify this hash from the uploaded archive.PUTis a bare HTTP client: no auth headers; the URL is the credential; exactContent-Lengthrequired (S3 presigned PUT rejects chunked encoding; see the in-code comment in buildApi.ts referencing [JS SDK] uploadFile uses chunked transfer encoding, causing 501 NotImplemented on S3 presigned PUT URLs e2b-dev/E2B#1243). The default client upload timeout is 1 hour (configurable).present: truenormally skips the upload, but a per-instructionforceUploadboth re-uploads despitepresentand marks that COPY stepforce— and it does so using the URL from the same link response, without a secondGET(upload loop, COPY construction). Every link response must therefore carry a usable upload URL,presentor not.(templateID, hash)plus the request's auth context. No step-prefix computation is possible when answeringpresent.Deployment assumptions (properties of AgentENV today, not SDK law — changing them is part of the design space in §Alternatives considered):
round_robin/random, no template pinning), so today the shared snapshot repository is the only thing making them coherent. AgentENV registration mints a fresh snapshot ID per build request (v3_templates_post→SnapshotId::generate()), so nothing about the protocol pins consecutive requests to one node.Prior art
E2B upstream: a context store plus a per-step layer cache, joined by
filesHashE2B's build-cache bucket contains two kinds of objects (paths.go; the path helpers take a generic
cacheScope— the public API passes the team ID, with a server-side fallback to the template ID when unset):present= blob exists (template_layer_files_upload.go, upload_layer_files_template.go; 30-minute URL TTL). Self-hosted deployments can emulate the presigned URL with an HMAC-signed upload endpoint over filesystem storage; any-node coherence then requires that storage to be shared (a shared mount or object store) rather than a single node's disk.H(parentHash ‖ stepType ‖ join(args) ‖ filesHash)(phases/steps/hash.go) — the "tree of cache keys with prefix matching" from the [1/4] feat(snapshot): template build-context archive store (posixfs + oss) #71 review. All phase hashes are computable statically from the recipe; the engine computes and probes them phase by phase in forward order, continuing to probe after a miss (phase.go); a hit skips that phase'sBuild().forceis sticky (builder.go); two global invalidation levers exist (a hashing version folded into the base hash; a minimum cached-format version at lookup — cache.go).BuildKit: transfer and caching are orthogonal, joined by a content digest
BuildKit (
8f96667) has no durable context-archive store; it persists the client's context as a live snapshot keyed by a heuristicsharedKey, updated by an incremental stat-based diff that only transfers changed file bodies (source/local/source.go, fsutil receive.go @6d9dc2e). The local-context vertex is deliberately uncacheable across sessions (ops/source.go); all COPY cache reuse comes from a server-side content checksum attached as an alternative cache key on the consuming step (edge.go, contenthash).Relevant lessons: the E2B protocol shape (client hash + whole-archive upload) trades away incremental transfer and gains a one-round-trip skip plus node independence; the cost — one changed byte re-uploads the whole archive — must be accepted explicitly, and in a VM builder the per-step capture cost (full-VM memory) may dominate overall build time regardless of transfer efficiency, so benchmarks decide. In BuildKit's remote-cache export,
minmode exports the layers of the resulting image whilemaxadditionally exports intermediate-stage layers (cache mode docs); on OverlayBD the writable upper is the per-step rootfs diff, so exporting intermediate rootfs layers carries little marginal data cost (sealing is O(mapping count), not free — §Proposed approach). BuildKit's GC tiers by reproducibility — cheap-to-reproduce classes get short-TTL small-budget tiers (gcpolicy.go) — and identical in-flight work is deduplicated per vertex.Industry survey (trade-off examples, not verdicts)
bef1b70, Images guide: breaking one layer "causes cascading rebuilds for all subsequent layers"). Modal distinguishesforce_build(rebuild and overwrite cache) fromignore_cache(rebuild without publishing).dockerfile + sorted(contextHashes)for the whole build; intermediate BuildKit state is runner-local, while completed snapshots propagate across runners (v0.190.0, snapshot manager). A coherent store-only + whole-build-cache design; reuse granularity is the whole build.--local-only, community reports of largeCOPY . .contexts); Fly now defaults to Depot-managed builders (announcement). These illustrate the operational coupling that builder-affinity approaches take on; they are not evidence that such approaches fail.236ba56): rolling composite key seeded by the base-image digest, extended per instruction with content hashes of referenced files; the cache index is an OCI registry, shared across machines by construction; strict stop-after-first-miss (composite_cache.go, caching docs).4f5c597): BuildKit ≥ 0.22 with the overlaybd snapshotter builds native OverlayBD images; in the native writable-layer mode the snapshotter'sCommit()seals the live writable layer into an immutable lower per committed filesystem snapshot (QUICKSTART, buildkit docs/overlaybd.md). The conversion path keeps a dedup database keyed by OCI chain_id — a cumulative parent-chain identity used for conversion dedup (overlaybd_builder.go). This validates per-step filesystem-diff sealing on this storage format; AgentENV's repeated full-VM checkpoint loop (Phase 1) goes beyond what upstream exercises and is validated by this RFC's own benchmarks and tests.Proposed approach
Execution model
Adopt the #71 review's model. A template build becomes:
final_keyfirst; on a miss, probe resumable checkpoint keys from the last mutating step toward the base until the deepest valid entry is found (validation includes lineage, below); after choosing it, execute the full suffix without further cache probes. (This is a design choice for VM builders — resuming is the expensive operation, so we pick the resume point once; E2B's engine instead probes forward per phase and can skip later phases after a miss.)CommandContextrestores the logical build state — verify it against the recipe-derived expected context, replay any metadata-only steps in the suffix, execute the mutating steps, checkpointing after each.This Phase-1 design needs no new artifact format (entries are committed snapshots; layers are ordinary managed layers), but it does need new repository metadata (context bindings, index records, attempt records, a hidden record purpose) and a metadata coordinator on multi-node OSS — it is not "zero new primitives."
Metadata coordination and fencing (required decision)
The design needs linearizable metadata transitions: context-generation publication, upload intents, build bindings and freeze, index create-if-absent and fenced replace, attempt acquisition/renewal, lease grants, GC barriers. On POSIX these map to
link/rename/O_EXCL. On the OSS backend no such primitive exists today: the client is unconditional GET/PUT/DELETE (src/snapshot/repository/backends/oss/client.rs), and an in-repo comment records that the Alibaba-OSS S3-compatible path supports neitherIf-None-Matchnorx-oss-forbid-overwrite(src/snapshot/repository/backends/oss/repository.rs). Coordinator options:If-None-Matchconditional puts in August 2024 and ETag-basedIf-Matchconditional writes in November 2024; support varies across S3-compatibles and is absent on Alibaba OSS's S3 path). Capability-detected; simplest where available.scheduler.redis_addralready backs scheduler binding HA).Recommendation: (b) for multi-node OSS, (a) opportunistically, (c) as the fallback posture. The coordinator is a Phase-0 requirement for multi-node OSS — generation publication, bindings,
Waitingexpiry, and atomic quota admission need it even with the execution cache disabled.Coordinator durability contract. The scheduler's existing Redis usage is droppable routing state; this is not. Bindings, intents, attempts, promotion intents, paused-sandbox roots, and GC leases are correctness-grade: the coordinator must run with AOF/replication and no eviction, or the deployment must be able to rebuild coordinator state from durable repository records. After a coordinator loss, GC remains closed until a recovery barrier completes (state rebuilt or verified); "coordinator reachable again" alone does not reopen GC. This contract is part of the coordinator capability definition.
Fencing across systems. A coordinator check followed by an unconditional worker write to OSS or an OCI registry is not fencing: the worker can lose its lease between check and write. The rules:
Readyvisibility — are never performed as unconditional worker writes; they are steps of the promotion outbox (§Cache publication), each gated by an intent-state CAS, executable by any actor but only in intent order. (Today's OSS publish writes artifacts, then alias, then the committed record unconditionally —src/snapshot/repository/backends/oss/{layout,repository}.rs— which is exactly the pattern the outbox replaces for promotion.)Cache identity and the trust boundary
filesHashis a client-computed semantic digest the server cannot verify (constraint 2), and current API auth accepts any non-empty credential with no identity (src/api/impls/auth.rs, in-code TODO). Cache identity therefore cannot rest on the client hash alone.Context bindings (generational, with upload intents). The store keeps:
Every
GET files/{hash}returns a usable upload URL backed by a created-or-reusedUploadIntentwith a reserved generation — including whenpresent: true, because the stock SDK'sforceUploadPUTs to the URL from that same response without a secondGET(constraint 4). Binding is create-if-absent: if the build already has a binding for this hash it is renewed, never rebound; if a published generation exists and no binding does,present: trueand the current generation is bound; if none exists,present: false. Intents that are never used simply expire.A
PUTnever writes the reserved generation's object key directly. The body streams to a unique temporary object; the server computesblob_digestand publishes the bytes as an immutable content-addressed blob. Intent completion and binding replacement are then one atomic coordinator transition, mutually exclusive with build-start freeze:Only after this CAS does the server return 2xx. Replays of the same grant token: same digest against an already-
Completedintent succeeds only if the build's current (or frozen) binding still is that generation — otherwise it returns a post-freeze conflict; a different digest conflicts outright. There is no window in which a completed intent can rebind a build that has since frozen, and concurrent replays can never overwrite each other's bytes.forceUploadtherefore works with no special casing: the re-upload completes the reserved intent, producing a new generation bound to this build (and the SDK independently marks the stepforce); other builds' bindings keep their pinned generations.Build-start is one coordinator transition:
freeze bindings + record request digest + acquire attemptatomically; the key chain is computed from the frozen(filesHash, blob_digest)pairs, and every recipe-referenced hash must have a frozen binding. After freeze, allPUTs and rebinds for the build are rejected. Registered-but-never-started builds carry aWaitingdeadline; expiry releases bindings and intents.GC never deletes a generation with live bindings. A malformed or digest-mismatched object is treated as absent/repairable, never as permanently
present. (blob_digestdetects corruption and pins identity; it cannot prove semantic correspondence between tar andfilesHash— containment for a poisoned upload is scoping plus the fact that the poisoned generation only feeds cache keys derived from it.)Scoping. Archives, bindings, index records, and cache entries are namespaced by tenant scope, not template ID — registration mints a fresh snapshot ID per build (
v3_templates_post), so per-template scoping would destroy exactly the cross-build reuse this design exists for. Until authenticated tenancy lands, deployments must explicitly setsingle_trust_domain = trueto enable the upload/cache surface — an operator acknowledgment, not a silent default; the scope key is threaded through from day one so real tenancy is a configuration change, not a redesign.Key structure. Keys are computed over a versioned, length-delimited canonical encoding (no ambiguous string concatenation). Step classification:
RUN,COPY/ADD, andWORKDIR(it runsmkdir -pin the guest) are mutating;ENV,USER,EXPOSED_PORT,VOLUME,LABELare context-only (USERsubject to the execution-identity fix below). Two key kinds exist:fingerprintis derived from the canonical effective launch configuration (not a hand-maintained field list); it must cover at least: resolved base image manifest/layer identity, architecture, disk size, vCPU/memory geometry, virtualization mode, CPU config/template, Firecracker/kernel/envd/tools-image versions, snapshot & cache format versions, and a builder-semantics version.canonical(finalization inputs)covers the normalized/defaulted ready command, the startup command and whether it was inherited or explicit, the effective finalCommandContext,ensure_default_userbehavior, and a finalization-semantics version.vis the global hashing-version lever; a minimum cached-format version is enforced at lookup (both per E2B).Execution-identity exclusion (
USER). TodayUSERonly updates the host-sideCommandContext(src/template/step_executor.rs), andRUNpasses envd only env vars and cwd — not the user — so a cold build executes everyRUNas envd's boot-time default user. But resuming from a checkpoint re-initializes envd with the snapshot's storeddefault_user(src/sandbox/firecracker/sandbox.rs, envdinit), so the same suffix would execute under a different user than a cold build — a cold-miss/resume divergence. Phase 1 therefore excludes recipes containingUSERfrom the execution cache entirely — they neither read nor write cache entries (they build exactly as today). The proper fix — exec/process requests carrying the effective user explicitly, which also makes cold builds honorUSERper Docker semantics — is follow-up work; when it lands, the envd/runtime version enters the fingerprint so old checkpoints with the divergent semantics can never be resumed under the new ones. A cold-miss vs resume equivalence test for the cached step vocabulary is mandatory regardless.Custom extension. The
start-freshhook may inject instance-specific boot args while cache hits resume viastart-resume— two semantically different paths, and today's hook API has no way to declare cache equivalence. When[custom_extension].urlis configured, the cache is disabled. A real opt-in would need a defined contract (contract version, cache salt, canonical effective params, and explicit authorization of skipped hooks) — future work, out of scope here.forcesemantics.force(build-level, per-step, or induced byforceUpload) bypasses cache reads for the forced step and, stickily, everything downstream. Refreshing shared entries is done safely via lineage (below): forced results replace pointers with a generation-CAS fenced through the attempt state machine; displaced targets become delayed-GC orphans.Index records, lineage, and validation
Two index record kinds exist:
CheckpointEntry.parentnames the nearest resumable ancestor (skipping context-only steps), or a typed root edge{base_key, base_identity}for the first mutating step (a fresh image base has no parent snapshot). Metadata-only steps contribute to the key chain but never resolve to snapshots — their effects live in the next checkpoint's stored post-stepCommandContext, and on resume the builder replays any metadata-only suffix steps deterministically.snapshot_idmatches the parent key's current entry, or the root edge matches the build's base identity). This applies toFinalResultEntrytoo — a final hit validates the full lineage back to the root, so a forced replace of any ancestor also invalidates stale final pointers. Descendants of a replaced ancestor fail lineage and are treated as misses (and queued for repair) instead of silently gluing mixed-generation chains together.build_base_checkpoint_idis optional on final entries: Phase 0.5 takes no pre-finalization capture, so its promoted templates lack the field and derived builds from them fall back to cache-disabled semantics.Cache entry publication and promotion
Cache checkpoints are committed snapshots with a
BuildCacherecord purpose and cache-schema version. Hidden means enforced, not cosmetic: every public resolve/get/status/delete/alias/launch path rejectspurpose = BuildCacherecords (todayGET/status/delete and sandbox launch accept any snapshot UUID —src/api/impls/template.rs,src/api/impls/sandbox.rs); only internal cache APIs may read them. To keep rollback safe, the cache catalog lives in a separate repository namespace that older binaries do not scan; alternatively a downgrade requires disabling admission, draining leases, purging cache/P2P state, and completing GC first.Two publication paths, both required:
publish_cache_candidate: always stores layers as managed/object-storage layers and never publishes source-registry OCI tags or takes publication ownership — regardless ofsnapshot_image_storage = source_registry. Candidate metadata retains per-disk origin repositories from the original image resolution: managed materialization rewrites layer URLs to AgentENV storage, and the current exporter derives the target repository fromimage.json'srepoBlobUrl(src/snapshot/repository/backends/common/acr/source_image.rs) — without recorded provenance, promotion could no longer publish to the source registry.promote_committed_snapshot: produces the final user snapshot under the target build's snapshot ID, driven by aPromotionIntentthat works as a transactional outbox, with per-disk plans (rootfs and each attached drive may publish to different repositories/tags):Entering
Publishing(writing the intent) is the irreversible commit decision: from that point the intent converges forward toReady— lease expiry hands it to the promotion reconciler (the named recovery actor for node death mid-promotion), it never triggers aFailed-plus-cleanup path, so no cleanup can race a late writer.Failedis declared only for authoritative, permanent errors, and its cleanup follows the ownership rules below. Two write classes are distinguished:Ready) are never unconditional external writes: the record and alias content are staged at intent-qualified immutable paths, and canonical visibility is a coordinator pointer flip — public reads resolve the target/alias through the coordinator-committed pointer, so a fenced-out worker's stale staging is unreachable by construction.registry_committedis reached only when every disk plan's tag push has completed, each push is idempotent-by-content (the plan carries exact config/manifest bytes with the per-diskownership_nonceembedded in the OCI config), and duplicate executors of the same intent write identical bytes.Registry adoption and ownership. The current publisher requires the tag to be absent before pushing (
ensure_manifest_absent,src/snapshot/repository/backends/common/acr/publisher.rs), so a crash between registry PUT and record write would make a naive retry fail forever. With the per-disk plans, a reconciler retry adopts an existing manifest only when its digest matches that disk plan — proving it is this promotion's own remnant, not another writer's tag — and a crash after publishing only some drives' tags resumes with the remaining plans. Any cleanup deletes a tag only after re-reading that it still points at the intent's digest; when ownership cannot be proven, the artifact is left as an orphan rather than deleted.Each mutating checkpoint stores its exact post-step
CommandContext—CommittedSnapshotalready carriescontext, and the resume path already initializes envd from it (src/sandbox/firecracker/config.rs); the builder replays the recipe's metadata effects only to verify the stored context, never as the source of truth.fromTemplatebuilds (Phase-1 requirement, not an option). The current runner captures the user snapshot after startup/finalization (src/template/runner.rs), so buildingfromTemplateon the final snapshot would resume a finalized VM and re-run startup on top of it. User templates therefore recordbuild_base_checkpoint_id— the checkpoint taken afterensure_default_userand before startup/finalization — in snapshot metadata (a GC root, preserved across rollback). The final candidate, the singleflight shared result, and thePromotionIntentall carry and pin this ID, so every waiter's promotion writes it into its own user snapshot. Derived builds extend the build-base checkpoint (startup runs exactly once, in the finalization phase of whichever build publishes); sandboxes keep launching from the final snapshot. Templates predating the field fall back to today's semantics with the cache disabled for that build.Build identity (MMDS). Every build VM currently receives its sandbox/snapshot ID via MMDS (
src/sandbox/firecracker/mmds.rs). The contract:SandboxIdremains unique per VM; only the MMDS payload presented to cache-eligible build VMs uses a stable, cache-neutral identity — from Phase 0.5 onward (any build whose result may be promoted under a different identity).forcedoes not help here: forced builds still run in neutral-MMDS mode and refresh the shared cache with neutral values.Publication, concurrency, and races
Concurrent identical builds are not assumed to produce identical bytes:
RUNmay depend on time/network/randomness, and independently sealed OverlayBD uppers embed distinct UUIDs, so layer digests normally differ even for equivalent work. Therefore:force). Exactly one candidate wins per key; losers are complete-but-unreferenced snapshots handled as delayed-GC orphans. (Managed-layer digest dedup still collapses genuinely identical layers to zero bytes.)(scope, final_key)— scoped so distinct tenants never merge flights. The leader executes the remaining suffix and publishes the neutral cache candidate (with itsbuild_base_checkpoint_id); each waiter then performs its own promotion under its own build ID and attempt epoch from that shared candidate. A leader's promotion failure (alias, registry, target publication) affects only the leader. Two builds never share a live mutable VM — only completed immutable checkpoints. Per-step rendezvous is deferred. Forced builds bypass flights entirely.Failure contract
Build execution today is a detached in-process task holding all of its state in memory (
src/api/impls/template.rs), and the OSSWaiting → Buildingtransition is an unguarded read-modify-write — node death leavesBuildingforever. This RFC requires:A durable attempt record per build with an explicit state machine and transition-specific fences:
The owner renews the lease while
Active. The attempt reconciler transitions expired-leaseActiveattempts to the existingErrorstatus with reasonNodeLost(no new schema status) and releases bindings/quota; expired-leasePublishingattempts are instead handed to the promotion reconciler, which drives the intent forward toReady(§Cache publication) —Failedthere is reserved for authoritative permanent errors, never for lease expiry. Terminal states are monotonic —Readycan never be overwritten by a late error; a closed attempt rejects all subsequent same-epoch CAS attempts by construction.The minimal attempt lifecycle ships in Phase 0:
Waiting → Active(lease) → Ready | Error, with renewal, the expiry reconciler, and terminal release of bindings, intents, and quota — because Phase 0 already introduces freeze+acquire and durable bindings that would otherwise pin forever when a node dies. Phase 0.5 addsPublishingand the promotion outbox; Phase 1 adds checkpoint progress tracking.Phase-1 failure policy: fail visibly, do not requeue. A user retry is a new build that reuses all committed checkpoints, which is where the cache already does the heavy lifting. Durable automatic requeue would additionally require persisting the full canonical
BuildJobSpecand is deferred — listed as an open question.Final promotion is idempotent by
(build_id, attempt_epoch)throughPromotionIntent; the promotion crash windows (registry tag written but record missing; candidate promoted but status not yetReady) converge via the reconciler's adopt-by-digest-and-nonce retry, and tests must cover both.Lifecycle and bounded storage
Per-step caching multiplies snapshot records and managed layers by roughly the step count, and
managed-layers/is today an append-only CAS that record deletion never touches. Bounded storage is therefore a requirement of every phase in which the cache can be enabled: the cache ships default-off, and enabling it requires retention to be implemented.Roots and leases must be cluster-visible and object-exact:
src/orchestrator/persistence/file_backed.rs), invisible to another node's GC. A coordinator-backed paused-root record is written before a pause is visible as successful.Live → Deleting → external DELETE → Deleted, held in the coordinator.Deletingblocks new leases and new references; a publisher that finds an object inDeletingwaits forDeletedand re-uploads rather than adopting the doomed object.P2pTransport::unpublishon the owning node) —ForgetP2pArtifactalone only drops the scheduler hint.Sweep order:
snapshot_id/blob_digest, never mutable keys).build_base_checkpoint_idreferences), coordinator-registered paused/running sandbox roots (including attached-drive and memory layers), and fixed artifacts.Deletingbarrier; delete; enqueue durable P2P unpublish.Source-registry publications never enter cache lifecycle by construction (
publish_cache_candidatecannot create them; promotion creates them under the user snapshot's identity and ownership).Depth budget and preflight. OverlayBD stacks have a hard 255-layer ceiling that binds each stack independently — rootfs, memory, and every writable attached drive (each Phase-1 checkpoint appends a memory layer too); captures whose staged runtime suffix exceeds 32 layers additionally trigger a merge-rewrite of that staged suffix (
DEFAULT_MAX_OVERLAYBD_SNAPSHOT_LAYERS— already-published checkpoint layers are not rewritten). The per-build checkpoint budget is computed dynamically:Preflight: before build start, the builder verifies the base stack can accommodate at least the reserved captures; if not, it compacts/rebases first or rejects with an actionable error — "degrade to non-checkpointing" only works when the mandatory captures still fit. Past the budget, later steps execute without checkpointing and the build still completes. (The memory term is deliberately conservative: successive captures of one live execution are typically siblings against the inherited memory base rather than an ever-growing live stack; the budget can be relaxed with benchmark evidence without affecting correctness.)
Alternatives considered
The design space decomposes into independent dimensions — where uploaded bytes land (sandbox / node disk / gateway / shared store), how long they must survive (until consumption vs durable), placement coupling (pinned build node vs any node), and failure policy (retry, re-upload, fail). Points in that space we evaluated:
POST /v3/templates, route upload-links/PUTs to that node (new gateway capability), stage in a node-local content-addressed spool. Trade-offs vs a shared store: adds scheduler/gateway coupling and exposes staged bytes to single-node loss before consumption;present: trueacross builds only holds for builds bound to the same node, unless the spool replicates — a node-local lossy CAS answers strictly fewerpresentqueries than a shared durable CAS. On single-node POSIX deployments this is effectively the proposed design (the repository is node-local there). A reasonable future direction once placement pinning exists for other reasons.present: trueit needs synchronous replication or a durable fallback — the current P2P index is best-effort and can only serve as a hint. Compatible with adopting later behind the store's staging role.present: false+ execution caching only. Protocol-compliant and simplest; every build re-uploads all context (the SDK tolerates it;forceUploadsemantics unaffected). Loses upload dedup entirely — expected to be costly for large contexts; benchmark required. Kept as a fallback posture if the store is descoped.forceis trivially honored,cachedBuildsmay legitimately be empty. It fails this RFC's execution-reuse goal, not E2B compatibility; it is the natural first milestone on the way to the full design.(recipe, frozen bindings, fingerprint)short-circuiting identical rebuilds. Not subsumed by per-step caching in operational terms: it avoids per-step O(RAM) captures, per-step hidden-record proliferation (one hidden final candidate per build remains), and depth pressure entirely, and may remain the better mode for repetitive CI rebuilds, high-memory builds, or depth-capped builds. It also exercises the same key/index/promotion/GC machinery, which is why it is proposed as its own phase rather than an alternative.Proposed composition: per-step layer caching (execution reuse) + the slimmed shared store (transport,
presentoracle, miss-time materialization). The narrow claim: under the current arbitrary request routing (D1), with cross-build/cross-nodepresent: truesemantics, and without SDK modifications, shared staging is the minimal increment. If placement pinning lands later, alternatives 2–4 can take over the staging role; placement, lease, and upload-routing interfaces would change, while the execution-cache key/checkpoint model carries over unchanged.Compatibility and operational impact
API/config changes:
[template_build]gains: cache toggles (default-off),single_trust_domainacknowledgment gate, retention budgets/TTLs, depth margins, coordinator configuration; grant TTL/limits carry over from [2/4] feat(api): E2B build-context upload endpoints and configuration #72 with single-use claims removed.forceandcachedBuildsbecome functional rather than ignored (later phases for the latter).Errorgains aNodeLostreason (no new status value).purpose = BuildCachesnapshots on every direct-ID path.Snapshot or storage format changes:
BuildCacherecords in a separate cache-catalog namespace (invisible to older binaries); cache-schema version;build_base_checkpoint_idon user snapshots; context generation/binding/intent records; index/attempt/lease/promotion-intent records; server-computedblob_digest. Committed snapshot manifests and layer formats are unchanged in Phases 0/0.5/1; Phase 2'sColdBootRootfsentries would introduce a new tagged artifact kind (see rollout — not resolved by this RFC).New host/runtime requirements:
public_base_urlis required for the upload surface.Upgrade and rollback considerations:
Waitingdeadlines, quotas, TLS requirement, coordinator on multi-node OSS); it does not change build results. Hashing-version and minimum-format-version levers allow global or format-scoped invalidation without migration. A full cache purge is safe only after: draining exact-object reader leases, draining or failing active attempts, promotion intents, and publication writers, completing queued P2P unpublish work, and resolvingbuild_base_checkpoint_idroots — checkpoints referenced by user snapshots are user-snapshot roots, not cache-owned, so the purge either preserves them or clears the field (dropping those templates to legacyfromTemplatesemantics). The separate cache namespace keeps downgraded binaries from ever observing cache records as templates.Security posture:
single_trust_domain = trueuntil authenticated tenant identity exists; scope key threaded now. Grants bind(scope, buildID, hash, reserved generation, method, exact size ceiling, expiry); quota admission is atomic and happens before the body is read; grant replay is idempotent by digest and cannot mint generations. Bearer query tokens are redacted from error logs, access logs, traces, metrics, and forwarded-URI headers.Rollout plan
present:trueand on upload 2xx, TTL grants (single-use claims dropped), tenant-scope threading +single_trust_domaingate, archive retention subordinate to bindings,Waitingdeadlines, atomic freeze+acquire build-start, minimal attempt lifecycle (Waiting → Active(lease) → Ready | Error) with renewal, theError(NodeLost)expiry reconciler, and terminal release of bindings/intents/quota, coordinator for multi-node OSS, gateway timeout/redaction fixes. [3/4] feat(template): host-side COPY plan (archive rewrite to guest paths) #73 (copy_plan.rs, zero store deps) and [4/4] feat(template): execute COPY/ADD steps and document E2B builds #74 (envd streaming + in-guest extraction) are unchanged — they are required under every alternative above.(recipe, frozen bindings, fingerprint)with aFinalResultEntry; hit ⇒ promotion from the cached final candidate via thePromotionIntentoutbox and promotion reconciler; cache-neutral MMDS from this phase on. Exercises keys, index records, validation, promotion, retention, and the coordinator end-to-end with no per-step machinery and minimal storage growth.USERare excluded from the cache (§Cache identity; the explicit effective-user exec API is follow-up work). Then: key chain + fingerprint; logical vs resumable keys with typed root edges; runner refactored from run-all-steps-then-capture-once (src/template/runner.rs) into a per-step checkpoint loop over the existing live pause→capture→in-place-resume primitive (SandboxBackend::snapshot()); post-stepCommandContextstored per checkpoint (replay for verification and metadata-only suffixes);build_base_checkpoint_id(post-ensure_default_user, pre-startup) threaded through candidate, singleflight result, and promotion; candidate publication viapublish_cache_candidate+ lineage-validated index;(scope, final_key)singleflight with independent waiter promotion; checkpoint progress on the attempt record; mark-and-sweep retention with exact-object leases,Deletingbarriers, coordinator-backed sandbox roots, and fail-closed behavior; dynamic depth budget with preflight. Memory cost is accepted and measured: with default mincore selection each checkpoint copies O(resident RAM); with opt-in dirty-page tracking (00ba6cb) the Firecracker query is logically non-clearing (/vm/dirty-memory-ranges), so successive checkpoints re-copy pages dirtied since VM start/resume — complete images, cumulative cost. Benchmarks on both paths gate the default.ColdBootRootfscache-entry kind (new tagged artifact kind; publisher/resolver branches). Known preconditions already identified: in-guestsyncfs/fsfreezebefore sealing is a prerequisite even for the pause-based variant — a paused VM's page cache is not flushed into the OverlayBD upper, so a completedRUN's file contents can exist only in memory, and background processes/tmpfs/mounts/IPC state vanish on cold boot. Cold-boot entries would be restricted to steps declared filesystem-only/hermetic; arbitraryRUNsteps stay on Phase-1 entries; writable attached drives unsupported initially. This phase proceeds only with its own design note and safety evidence.cachedBuildsmust reflect node-local materialization/page-cache warmth to be useful), heartbeat population ofNodeDetail.cachedBuilds, and a template-build schedule hint.Acceptance criteria
Moved to the first comment below (GitHub issue size limit), together with the full commit-pinned reference list.
Non-goals
Open questions
single_trust_domain = truegate acceptable until authenticated identity lands, or should authenticated tenant ownership be a Phase-0 prerequisite?Error(NodeLost)+ user retry acceptable long-term, or is durable requeue (with a persistedBuildJobSpec) wanted — and in which phase?RUN. Rely purely onforce(proposed, Docker/E2B semantics) or add opt-in per-template cache TTL?Contribution
I can implement it.
Pre-submission checklist
Full commit-pinned reference list is in the first comment below.