From 5a81d22cd63b11246d54f0bf8c816618d0b345f0 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 7 Sep 2026 10:56:24 +0800 Subject: [PATCH 1/6] docs: propose self-hosted remote cache RFC Co-authored-by: GPT-6 Codex --- docs/rfcs/0001-remote-cache.md | 427 +++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 docs/rfcs/0001-remote-cache.md diff --git a/docs/rfcs/0001-remote-cache.md b/docs/rfcs/0001-remote-cache.md new file mode 100644 index 000000000..ec3894312 --- /dev/null +++ b/docs/rfcs/0001-remote-cache.md @@ -0,0 +1,427 @@ +# RFC: Self-hosted remote cache for `vp run` + +Status: Proposed. This document specifies the first version; the APIs and configuration below do not exist yet. + +Date: 2026-09-07. Repository baseline: `9a1d32cf`. + +## 1. Motivation + +Developers and CI should reuse successful task results without copying a whole local cache directory between machines. Users must be able to deploy the service into their own Cloudflare account, retain control of its data and credentials, and operate it without a Vite+ hosted account or license service. + +The current [docs deployment action](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/.github/actions/deploy-docs/action.yml) restores and saves `docs/node_modules/.vite/task-cache` through GitHub Actions Cache. Its keys include OS, architecture, ref, and commit. Restore prefixes select the latest cache for the ref, then fall back to `main`; task fingerprints determine whether its contents can actually be reused. This is a useful CI bootstrap, but developers cannot use it as a native shared task cache, and each save transfers a directory snapshot. + +This RFC proposes an optional second cache tier: check local results, check remote results, then execute. A successful execution remains available locally even if the remote service fails. Remote storage holds individual execution results, including their observed inputs, output files, and terminal output. + +## 2. Decisions and scope + +| Area | Version 1 decision | +| ----------- | -------------------------------------------------------------------------------------------------------- | +| Hosting | Open-source TypeScript Worker, private R2 Standard bucket, D1 database, and a Cron Trigger | +| Ownership | One deployment per organization or team; explicit project and trust namespaces within it | +| Client | Native Rust integration in the execution engine; provider-independent versioned HTTPS protocol | +| Discovery | Bounded candidate lookup followed by local validation of explicit and inferred inputs | +| Identity | SHA-256, canonical portable metadata, and an explicit platform/toolchain compatibility identity | +| Publication | Immutable results; upload all bytes before an atomic D1 transition makes a result visible | +| Trust | Scoped bearer tokens and mandatory publisher signatures; CI and developer write permissions are separate | +| Transfer | Streaming, retryable chunks of at most 64 MiB; at most 1 GiB compressed per result | +| Retention | Fixed 30-day result lifetime, bounded discovery, quotas, and incremental cleanup | +| Failure | Remote failures become misses or skipped uploads, with bounded waits and diagnostics | + +Version 1 includes local-to-CI, CI-to-local, and CI-to-CI reuse on compatible machines, on macOS, Linux, and Windows. A fresh checkout with an empty local cache must be able to hit an automatically inferred remote result. No Git commit, branch, absolute checkout root, or local database identity is required for a hit. + +Cross-OS and cross-architecture reuse are outside version 1. For example, a macOS developer can share with macOS CI, or use a Linux development container to share with matching Linux CI. A later portable-task mode needs a separate correctness contract. Also excluded are remote execution, a hosted SaaS, anonymous/public caches, OIDC federation, a web dashboard, cross-project content deduplication, and direct compatibility with Nx or Turborepo clients. + +## 3. Current implementation and implications + +The following are source identifiers, not proposed public API names: + +| Evidence at the baseline | Consequence for this design | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`ExecutionCache` and `CacheEntryKey`](../../crates/vt/src/session/cache/mod.rs) store SQLite rows keyed by spawn fingerprint and resolved input/output configuration. Input contents are in the value. | The existing key is a discovery key, not a complete immutable result key. Simply exposing `GET`/`PUT` for that key would overwrite results from other revisions. | +| [`ExecutionCache::try_hit`](../../crates/vt/src/session/cache/mod.rs) compares explicit glob inputs and then validates the post-run fingerprint. | A remote hit must do both checks on the receiving machine. A server lookup alone cannot establish a hit. | +| [`PostRunFingerprint`](../../crates/vt/src/session/execute/fingerprint.rs) records file contents, missing paths, directories and their optional entry lists, tracked environment variables, and bulk environment queries. | A remote manifest must preserve these observations, including negative dependencies and environment match sets. A plain output archive is insufficient. | +| [`ExecutionCacheKey` and `SpawnFingerprint`](../../crates/vt_plan/src/cache_metadata.rs) distinguish task identity from execution configuration. Outside-workspace programs are identified by name. | Keep execution-based sharing between equivalent tasks, but add toolchain identity before sharing between machines. | +| [`TrackedPathAccesses::from_raw`](../../crates/vt/src/session/execute/tracked_accesses.rs) drops outside-workspace and `.git` accesses. | Existing inference is not a hermetic build guarantee. External dependencies need a declared environment contract. | +| [`update_cache`](../../crates/vt/src/session/execute/cache_update.rs) rejects failures, cancellation, incomplete tracking, inferred read/write overlap, and tool-requested cache disabling. | Remote publication must inherit every eligibility check. It must not export arbitrary successful process output. | +| [`archive`](../../crates/vt/src/session/cache/archive.rs) writes regular files as `tar.zst`; [`replay_cache_hit`](../../crates/vt/src/session/execute/mod.rs) replays terminal output before extraction. | Remote import needs a bounded, validated staging path and must complete restoration before reporting a hit or replaying output. | +| [`cache_schema_dir_name`](../../crates/vt/src/session/cache/mod.rs) selects local schema `v18`; file hashes use `xxHash3_64`, while [`EnvValueHash`](../../crates/vt_plan/src/envs.rs) uses SHA-256. | Do not publish SQLite files, Rust memory layouts, `wincode` values, or existing non-cryptographic input hashes as the wire format. | + +The first implementation must extract a common validation layer with a versioned digest representation. Preserve local mismatch explanations and existing task scheduling. The additional SHA-256 work is required for remote-capable executions; local-only execution can retain its fast hash path. + +## 4. Lessons from Nx and Turborepo + +Nx's [cache client](https://github.com/nrwl/nx/blob/fc41a1b479677fdb4a5166c85053c152ea3abb6a/packages/nx/src/tasks-runner/cache.ts) checks local storage, retrieves a remote result on a miss, and imports that result into the local cache. Its [self-hosted protocol](https://nx.dev/docs/kb/self-hosted-caching) defines authenticated archive upload/download at `/v1/cache/{hash}`, including `403` for forbidden access and `409` for attempted overwrite. Adopt local promotion and immutable publication. Nx's [deprecation guidance](https://nx.dev/docs/reference/deprecated/self-hosted-cache-packages) also identifies poisoning risks in shared bucket caches. Immutability prevents replacement, but cannot prevent an untrusted writer from publishing a bad result first. + +Turborepo publishes an [HTTP API specification](https://github.com/vercel/turborepo/blob/f6d5f18dfdcea4070ae8dc07300e5a735d529eb7/apps/docs/lib/remote-cache-openapi.json) with artifact existence, download, upload, query, and event operations. Its [HTTP cache implementation](https://github.com/vercel/turborepo/blob/f6d5f18dfdcea4070ae8dc07300e5a735d529eb7/crates/turborepo-cache/src/http.rs) integrates archive transfer and signature verification. Its [remote caching guide](https://turborepo.dev/docs/core-concepts/remote-caching) describes optional HMAC-SHA256 artifact signatures and treating invalid signatures as misses. Adopt an open protocol and verification before use. Use asymmetric publisher signatures here so readers need no signing secret. + +Neither protocol directly solves this engine's post-execution dependency discovery. Our separate candidate index is intentional. An adapter for another client would need its own namespace and artifact semantics; sharing a hash spelling does not make results interchangeable. + +## 5. User configuration and trust setup + +Add a workspace-root `run.remoteCache` setting in `vite.config.*`. Package configurations cannot replace the endpoint or credential source. Proposed configuration: + +```ts +export default { + run: { + remoteCache: { + url: 'https://cache.example.com', + project: 'docs', + namespace: 'trusted', + epoch: '1', + mode: 'read', + environment: 'node-toolchain-2026-09', + trustedKeys: { + 'ci-2026-09': '', + }, + }, + tasks: { + 'build:site': { + command: 'vitepress build', + env: ['DOCS_SITE_ORIGIN'], + output: ['.vitepress/dist/**'], + }, + 'private-report': { + command: 'node private-report.mjs', + remoteCache: false, + }, + }, + }, +}; +``` + +`remoteCache` is absent by default. A configured service defaults to `read`. Modes are `off`, `read`, `write`, and `read-write`; they control only the remote tier. A task-level `remoteCache: false` disables both remote directions for that task but permits local caching. Existing task `cache: false`, `--no-cache`, and tool-requested cache disabling take precedence over all remote settings. + +Credentials come only from the host environment or a user-owned credential file outside the checkout. `VP_REMOTE_CACHE_TOKEN` supplies the scoped bearer token. Writers additionally provide `VP_REMOTE_CACHE_SIGNING_KEY` and `VP_REMOTE_CACHE_SIGNING_KEY_ID`. The private key is base64-encoded PKCS#8 Ed25519; trusted public keys are base64-encoded raw 32-byte keys. No token or private key is accepted in checked-in configuration or a command-line flag. Tokens contain a public lookup ID and a cryptographically random 256-bit secret; store only the secret's SHA-256 digest and compare it in constant time. Give tokens explicit expiry dates and support overlapping old/new credentials during rotation. + +Support `VP_REMOTE_CACHE_URL`, `VP_REMOTE_CACHE_PROJECT`, `VP_REMOTE_CACHE_NAMESPACE`, `VP_REMOTE_CACHE_EPOCH`, `VP_REMOTE_CACHE_MODE`, `VP_REMOTE_CACHE_ENVIRONMENT`, and `VP_REMOTE_CACHE_TRUSTED_KEYS` as explicit host overrides. `TRUSTED_KEYS` is a JSON key-ID-to-public-key map that replaces the configured map. Host values override workspace values. `--no-remote-cache` overrides both and leaves local caching enabled. Missing credentials or required trust settings disable the remote tier with one diagnostic. Invalid configuration is reported before task execution. No automatic fallback to another service or namespace is allowed. + +Remove all `VP_REMOTE_CACHE_*` variables from child environments, environment fingerprints, runner-aware environment APIs, serialized plans, and debug output, even when a task requests `env: ["*"]`. The client must not expose these control credentials to tools through normal environment propagation. This does not isolate credentials from malicious code running under the same OS account; CI secret placement must respect the job's trust boundary. + +`environment` is an operator-maintained identifier for external build dependencies, such as an image digest or a pinned development-toolchain revision. It is required in version 1. Matching labels assert equivalent external dependencies; they do not measure them. Documentation must explain when to change this value and when to disable remote caching. + +### Recommended permissions + +| Principal | Namespace | Permission and signing keys | +| --------------------------------------------------- | --------------- | ---------------------------------------------------------------- | +| Protected CI jobs | `trusted` | Read/write token and a CI publisher private key | +| Developer reading CI results | `trusted` | Read-only token and trusted public keys | +| Developer publishing shared local results | `team` | Individual read/write token and individual publisher private key | +| CI job that intentionally accepts developer results | `team` | Read-only token and an explicit approved publisher key set | +| Untrusted fork pull request | None by default | No tokens or signing keys | + +This supports both directions of reuse without silently making release builds trust every laptop. Each invocation selects one namespace. Automatic fallback from `trusted` to `team` is forbidden. A team that wants full bidirectional sharing can explicitly use `team` in both developer and CI environments. + +For jobs that run code from a pull request, endpoint, namespace, epoch, mode, and trusted keys must come from protected workflow settings if credentials are supplied. Do not give shared write credentials to arbitrary PR code, including via `pull_request_target`. A read token also grants access to potentially private artifacts and logs. A cache signature proves publisher identity and byte integrity, not that the publisher ran trustworthy code. + +## 6. Cache identity and discovery + +### Canonical encoding + +Define `vp-cache-v1` metadata independently of local storage. Use UTF-8 JSON with RFC 8785 JSON Canonicalization Scheme (JCS). Reject duplicate object keys, unsupported fields for the selected format, invalid UTF-8, and numbers outside the schema's safe integer range. Encode digests as lowercase hexadecimal, bytes as base64, and paths as workspace-relative strings with `/` separators. Normalize neither path case nor Unicode. Sort semantic sets before encoding; preserve command argument and terminal event order. + +Use SHA-256 over file bytes and canonical observations. Hash each domain as the canonical array `[domain, value]` to avoid ambiguous concatenation. An empty value, an absent environment variable, and a missing file have different tagged encodings. Normative schemas and Rust/TypeScript golden vectors must ship before protocol implementation is considered complete. The [JCS specification](https://www.rfc-editor.org/rfc/rfc8785) defines the byte-level canonicalization. + +### Two keys + +```text +compatibility = { + fingerprint_format, artifact_format, engine_cache_abi, + os, arch, platform_abi, filesystem_semantics, + runtime_identity, executable_identity, lockfile_digests, environment +} + +lookup_key = SHA256(JCS(["vp-cache-lookup-v1", { + compatibility, spawn_fingerprint, input_config, output_config +}])) + +result_key = SHA256(JCS(["vp-cache-result-v1", { + lookup_key, explicit_inputs, inferred_inputs, + tracked_envs, tracked_env_queries +}])) +``` + +Every storage/API identity also includes `(project, namespace, epoch)`. These values are bound by the signature. Branch and commit may appear in optional diagnostics, but never determine a hit. `ExecutionCacheKey` remains a local diagnostic association; equivalent execution configurations can still share results. + +`lookup_key` is available before a task runs. `result_key` identifies a particular observed input state. Output bytes, execution duration, and terminal output are excluded from `result_key`; two executions with the same inputs compete for one immutable result. + +Compatibility includes the engine cache ABI, initially tied to the exact released engine version and build features. It also includes OS/architecture, Linux libc family and version where applicable, OS release, and filesystem case-sensitivity behavior. Managed Node.js uses its full version and module ABI. Hash the resolved executable's bytes; if it is a script or shim, include its interpreter identity. Include the workspace package-manager identity and lockfile paths/content digests. Resolve these values once per invocation where possible, and invalidate memoized file digests on change. Do not invoke arbitrary project commands merely to discover a version. + +These conservative dimensions can cause misses between machines that would have produced identical output. That is preferable to reusing a native binary or tool result under an incompatible runtime. Unknown required compatibility information disables remote reuse for that execution. Child tools and external libraries that the engine cannot identify automatically are part of the declared `environment` contract. + +Keep raw command arguments and tracked environment values semantically unchanged when hashing; hash environment values rather than serializing their plaintext. Do not replace arbitrary occurrences of an absolute path inside strings. Such values can cause a miss across checkout roots. Tools that embed absolute roots in output must be configured for relocatable output or excluded from remote caching. Logs can contain the original checkout path; do not rewrite them. + +### Candidate lookup and validation + +1. Validate a compatible local entry first. On a local hit, make no remote request and do not backfill old entries into remote storage. +2. Request candidates for `lookup_key`. D1 returns only committed, unexpired entries from active publishers in the authorized scope, newest first with `result_key` as a stable tie-breaker. Return eight references per page, with an opaque cursor bound to the scope, lookup key, and first-page publication watermark. +3. Fetch candidate manifests, verify their digests and trusted publisher signatures, and require their scope, formats, and lookup key to match locally computed values. Never let a manifest replace local command/configuration data. +4. Re-enumerate explicit globs using the current resolved configuration; compare the full path set and SHA-256 file digests. Validate every inferred observation: file content, absence, directory existence, and a sorted entry-name/type set when enumeration was observed. Validate input symlink chains as specified in section 7. Re-evaluate tracked environment queries against the same planning context used by runner-aware APIs. Compare missing values and complete query match sets. +5. Recompute `result_key` from the current observations. A candidate is eligible only if every observation matches and the result key matches. Then fetch and restore its artifact as described below. +6. Stop after 32 candidates, 16 MiB of manifest metadata, or the lookup deadline, whichever comes first. No valid candidate means execute locally. A bounded search may miss an older valid result; it cannot turn a mismatch into a hit. + +The service keeps all unexpired results subject to project quotas; the 32-candidate limit bounds each lookup rather than overwriting older entries. The client can retain authenticated manifest metadata to avoid repeated downloads, but must revalidate inputs and current local trust policy before reuse. There is no local-history requirement for cold lookup. + +For example, two branches run the same `build` command. Both have the same lookup key, but changed source bytes produce different result keys. Both results can coexist. A fresh checkout evaluates each candidate against its own source tree. Creating a previously missing import or adding a directory entry invalidates the corresponding candidate. + +Input inference retains its existing limits: ignored inputs, untracked environment values, time, network responses, and unobserved external files can affect output. Remote caching does not make non-deterministic tasks safe. Preserve complete raw tracking long enough to flag outside-workspace writes and unsupported path representations; these make a result ineligible for remote publication. External reads need the documented environment contract. Compute explicit input SHA-256 values before execution, then create inferred observations after the existing tracking/overlap checks. Recheck observed inputs before publication and immediately before restoration; a detected change cancels reuse/publication. This detects ordinary concurrent edits, not arbitrary mutation-and-reversion races. Do not claim filesystem snapshot isolation. + +## 7. Wire artifact and safe restoration + +Each result consists of one signed manifest and one canonical `tar.zst` stream, split into numbered transport chunks. The archive contains regular output files under `outputs/` and one `stdio.json` with ordered stdout/stderr byte events. No-output tasks still have a valid archive for terminal output. Duration is metadata; the only publishable exit status is success. + +The signed manifest body contains: + +| Field group | Contents | +| ----------- | ------------------------------------------------------------------------------------------------------------------------- | +| Identity | Protocol/format versions, scope, lookup key, result key, compatibility digest, publisher key ID | +| Inputs | Explicit input map, inferred tagged observations, tracked environment hashes and queries | +| Artifact | Compressed SHA-256 and size; ordered chunk sizes and SHA-256 digests; expanded size | +| Outputs | Exact workspace-relative file inventory, content SHA-256, byte size, executable bit; terminal event byte count and digest | +| Result | Success status and elapsed milliseconds | + +The envelope contains `body` and a base64 Ed25519 `signature` over `JCS(["vp-cache-manifest-v1", body])`. The manifest digest is SHA-256 of the canonical envelope. Public keys are pinned by the consumer's configuration, not accepted from a download. The Worker verifies the publisher key is active and permitted for the token's scope; consumers verify independently. Cloudflare supports Ed25519 through its [Web Crypto API](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/). + +Archive creation sorts paths and removes host-specific owner/group names, timestamps, and unnecessary metadata. Preserve file bytes and the executable bit; never preserve ownership, ACLs, setuid/setgid bits, or arbitrary extended attributes. Version 1 rejects symlink/hardlink outputs, devices, FIFOs, sparse files, and other special entries for remote publication. Workspace input symlinks must resolve within the workspace, and the remote fingerprint includes every link in the resolution chain, its relative target, and the final resolved observation; loops or outside-workspace targets disable remote reuse. Validate ancestor links as well as the final path. This is stricter than the current local archive path. + +Client and server enforce these protocol maxima; deployments may lower them and advertise effective limits: + +| Limit | Maximum | +| ------------------------------------------ | ---------------------------------------------- | +| Canonical manifest | 4 MiB | +| Chunk | 64 MiB; all but the final chunk have this size | +| Compressed archive | 1 GiB, at most 16 chunks | +| Expanded archive including terminal output | 8 GiB | +| Output files | 100,000 | +| Terminal output bytes | 16 MiB | +| Metadata nesting | 32 levels | + +An oversized result remains local and produces a remote-skip diagnostic. Do not silently truncate logs or omit output files to fit limits. Compress and hash to a temporary disk file, then stream its chunks with bounded memory. The Worker never decompresses an archive or buffers artifact chunks. Multiple chunk requests avoid the account-plan [request size limit of 100 MB on Free/Pro](https://developers.cloudflare.com/workers/platform/limits/) and the runtime's 128 MB per-isolate memory limit. + +The receiving client must complete the following before a cache hit is observable: + +1. Download into an invocation-owned staging directory, verify each chunk digest/size and the complete compressed digest, then decompress with explicit byte, file-count, path-length, nesting, and disk-space bounds. A manifest's stated expanded size is not a substitute for counting actual bytes. +2. Validate every archive entry against the signed output inventory. Reject duplicates, undeclared entries, missing entries, absolute paths, `..`, NUL, drive/UNC paths, Windows alternate data streams and reserved names, and case aliases on a case-insensitive destination. Reject `.git` and the runner's own cache/staging directories. Interpret `/` consistently on all platforms; never treat a backslash as a permitted escape route. +3. Require all output paths to satisfy the current explicit output rules or the signed auto-output inventory under the matching configuration. Verify each extracted file's digest and executable bit. Auto-output inventories are publisher-authorized filesystem writes, which is another reason writers must be trusted. +4. Restore regular files with workspace-root-anchored operations that reject symlink/reparse-point ancestors. Use temporary files on the destination filesystem and atomic replacement per file. Journal replacements and retain backups until the whole restore succeeds. If restore fails, roll back before running the task; if rollback cannot complete, fail with a local filesystem error. Never execute a task over a partly restored workspace and call that a remote miss. +5. Commit the local cache record and artifact reference, then replay terminal output and report a remote hit. Recover or clean incomplete restore journals before later invocations use affected paths. Concurrent invocations must coordinate overlapping restore paths; the existing scheduler still orders task dependencies. + +This gives atomic publication of local cache metadata and recoverable multi-file restoration, not a filesystem-wide atomic transaction. No staging file is promoted to the workspace before all downloaded bytes and paths are verified. Existing files outside the output inventory are left in place, matching current restoration behavior; tasks that require deletion side effects are not eligible for remote caching in version 1. + +## 8. Cloudflare service architecture + +```mermaid +flowchart LR + Dev[Developer: vp run] --> LocalDev[Local cache] + CI[CI: vp run] --> LocalCI[Local cache] + Dev -->|HTTPS and scoped token| API[Cloudflare Worker] + CI -->|HTTPS and scoped token| API + API -->|authorization and result index| DB[(D1)] + API -->|stream manifests and chunks| Objects[(Private R2 bucket)] + Cron[Cron Trigger] -->|incremental cleanup| API +``` + +The Worker performs authentication, bounded schema checks, publisher-signature validation, indexed lookup, upload coordination, and streaming. R2 holds artifact chunks and manifests through a binding. Disable public bucket access and do not expose S3 credentials, public object URLs, or presigned URLs to clients. + +D1 holds compact metadata; large manifests remain in R2. This avoids depending on D1's [2 MB row limit](https://developers.cloudflare.com/d1/platform/limits/). Use prepared statements, indexes, conditional writes, and transactional `batch()` calls. [D1 batches roll back if a statement fails](https://developers.cloudflare.com/d1/worker-api/d1-database/); a conditional update affecting zero rows is not an exception, so dependent changes must be guarded in SQL as well. Do not perform a read in JavaScript and assume a later write is still exclusive. + +Keep authorization, reservation, commit, and deletion on the D1 primary. Version 1 also reads candidates from the primary, with read replication disabled. If replication is introduced later, [Sessions and bookmarks](https://developers.cloudflare.com/d1/best-practices/read-replication/) must preserve read-after-publish behavior, and replica lag must not delay credential revocation. + +### Logical data model + +| Table | Key data and indexes | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `namespaces` | `(project, namespace, epoch)`; active flag, retention, byte/entry quotas, charged bytes/entries | +| `tokens` | Public token ID, SHA-256 of a random secret, principal ID, allowed scope/modes, expiry, revocation; lookup by token ID | +| `publisher_keys` | Scope, key ID, Ed25519 public key, allowed principal IDs, active flag | +| `entries` | Unique `(scope, lookup_key, result_key)`; upload ID/generation, owner principal, immutable manifest digest and total size, state, lease deadline, publish/expiry timestamps | +| `chunks` | Unique `(upload_id, ordinal)`; expected size/digest and upload receipt | + +Add indexes on `(scope, lookup_key, state, published_at DESC, result_key)` and `(state, lease_deadline)` / `(state, expires_at)` for discovery and cleanup. A monotonically increasing publication sequence supplies the page watermark. Never store source file contents or plaintext tracked environment values in D1. Use an unpredictable upload ID; R2 keys are server-derived: + +```text +v1/////manifest.json +v1/////chunks/0000 +``` + +No cross-entry or cross-project blob sharing occurs in version 1. Deleting one generation cannot break another result, and no reference-counted garbage collector is needed. + +R2 provides [strong consistency for writes and deletes](https://developers.cloudflare.com/r2/reference/consistency/). Its [Worker API](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/) supports conditional writes and a supplied SHA-256 checksum. Use create-only object writes and checksum verification for manifests/chunks. A successful D1 commit is the visibility boundary across the two stores; there is no distributed D1/R2 transaction. + +## 9. HTTP protocol and publication state machine + +All cache endpoints start with `/v1/projects/{project}/namespaces/{namespace}/epochs/{epoch}` (abbreviated `S` below). Project, namespace, epoch, and publisher key IDs match `[a-z0-9][a-z0-9_-]{0,63}`; keys are 64 lowercase hex characters. Upload IDs are server-generated random 128-bit values encoded as 32 lowercase hex characters. Reject non-canonical or multiply encoded path segments. Authorize the complete scope before looking up entries or accessing R2. A token never gains permissions from caller-supplied project or namespace headers. Read-only principals can use discovery/download endpoints; write-only principals can use capabilities and their own upload lifecycle, without general artifact access. + +All responses use `Cache-Control: private, no-store`. Require verified HTTPS in production, with no redirects. Local development permits HTTP only on explicit loopback URLs. The server returns paths relative to its own origin; the client rejects arbitrary artifact URLs. Do not forward credentials across hosts or disable certificate validation. No CORS access is enabled by default. + +| Request | Behavior | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET /healthz` | Public liveness only; no configuration or dependency details | +| `GET S/capabilities` | Authenticated formats, effective size limits, and retention | +| `GET S/lookups/{lookup_key}?cursor=...` | `200` with candidate references, publisher key IDs, manifest digests, result keys, and next cursor; an empty list is a miss | +| `GET S/results/{lookup_key}/{result_key}/manifest` | `200` canonical signed envelope for a ready result; otherwise `404` | +| `GET S/results/{lookup_key}/{result_key}/chunks/{ordinal}` | Stream one immutable chunk of a ready result; otherwise `404` | +| `POST S/uploads` | Body is the canonical signed manifest, with `Idempotency-Key`; reserve the immutable result and quota, then persist the manifest; `201` with upload ID, deadline, and relative chunk paths | +| `PUT S/uploads/{upload_id}/chunks/{ordinal}` | Require `Content-Length`; verify expected digest and length while writing the stream; `204` on verified success or an identical retry | +| `POST S/uploads/{upload_id}/commit` | Confirm all bytes and permissions, then publish; `201` for the winning commit, `200` for its identical retry | +| `DELETE S/uploads/{upload_id}` | Owner can abandon a pending upload; idempotent `204`; cannot delete a published result | + +Each chunk response includes `Content-Length` and its SHA-256 in `VP-Cache-SHA256`. The signed manifest remains the client's authority for integrity; an R2 ETag is not a content checksum contract. JSON errors have `{ "code": "...", "requestId": "..." }`, without arbitrary reflected request text. `401` means missing/expired/revoked credentials; `403` means insufficient scope or publisher permission. `400` covers malformed data, `413` size limits, `422` checksum/signature/schema failure, `429` quota/rate limits, and `503` transient storage failure. `409` distinguishes `already_exists`, `upload_in_progress`, `manifest_conflict`, and `lease_expired`. + +### Publication + +```text +absent -> uploading -> ready -> deleting -> absent + \------------> deleting -> absent +``` + +1. Authenticate and validate the signed manifest, scope, bounds, and result key derived from its input observations. The service treats the lookup key as opaque; it cannot validate the publisher's actual command or source tree. In one D1 transaction, reserve the full manifest-plus-archive byte count and entry slot, insert an `uploading` row, and create its expected chunk rows. Quota checks and counter updates must be enforced in the same transaction, using SQL constraints/triggers or guarded statements. Return no usable upload location until reservation succeeds. +2. Persist the canonical manifest under the reserved upload ID. Each chunk PUT must match its reserved size/digest and owner, and the upload must still be active. Stream directly to an R2 create-only write with the expected SHA-256. After success, conditionally record its receipt in D1. A lost response or failed receipt write is recovered by checking the existing object's checksum and size on retry; never overwrite different bytes. +3. At commit, verify the stored manifest and all expected chunk receipts and R2 objects. In a single D1 transaction, require the token, publisher key, and namespace to still be active, compare the upload ID/state/deadline, and transition to `ready`, setting publication sequence/time and `expires_at`. Authorization predicates must be part of the guarded transition, so concurrent revocation cannot be bypassed by an earlier JavaScript check. Return success only after that transaction commits. Retention starts at publication; there is no background publication through `waitUntil()`. +4. Readers select only `ready` entries and check expiry/authorization again for manifest and chunk reads. If an object is missing despite a ready row, treat it as a miss, record an integrity error, and schedule that entry for cleanup. Do not replay logs or partial files. + +Idempotency keys are random 128-bit values, bound to principal, scope, and manifest digest for the upload lifetime. Persist this binding in `entries` with a unique `(principal, scope, idempotency_key)` constraint. Repeating the same reservation returns its upload ID and missing chunks; if the manifest write was interrupted, the same request must finish that write first. Reusing the key with different bytes returns `409`. Another writer for an `uploading` result receives `409 upload_in_progress` and need not wait. After an abandoned generation is reclaimed, a new reservation always gets a new upload ID. + +A published result cannot be replaced. A new reservation for it receives `409 already_exists` with the winning manifest digest. The client treats this as upload deduplication, not a failed task. Compare output-file inventory/content digests if both manifests are available: different output files under the same result key produce a nondeterminism diagnostic. Duration or log timing differences alone are not proof of incorrect output. An identical commit retry is successful only for the original upload ID. + +An upload lease lasts 15 minutes and is not extended in version 1. Server-side body transfer deadlines are two minutes per chunk. On a late write after lease loss, reject the receipt and remove that generation's object where possible; it can never become visible. Cleanup may safely retry removal because upload IDs are never reused. + +## 10. Failure handling and local integration + +Remote lookup begins only after task dependencies finish and the existing local lookup misses. Avoid network I/O while holding a SQLite lock or an output-restoration lock. Configure the session once and share an HTTP connection pool, per-invocation manifest/digest memoization, and a bounded transfer scheduler across tasks. + +Default budgets are 2 seconds to connect, 5 seconds for discovery including manifest validation, 2 minutes per artifact request, and 5 minutes for a complete artifact transfer. The receiving client rechecks inputs after download before restoration. Permit at most four transfers and two local compression/decompression jobs per invocation. End-to-end deadlines include queueing and retries. Use at most two jittered retries for connection failures, `408`, `429`, and `5xx`, respecting `Retry-After` within the deadline. Do not retry invalid credentials or invalid artifacts. After three transport/service failures, stop new remote work for the invocation; a new invocation can try again. + +| Condition | Client behavior | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Missing result, no matching candidate, or bounded search exhausted | Execute normally | +| Timeout, unavailable service, or exhausted quota | Execute locally or keep the local result; summarize once | +| `401` / `403` | Disable the affected remote direction for this invocation and report setup information without secrets | +| Invalid signature, digest, manifest, or archive | Discard and quarantine the entry ID for the invocation; report rejected remote data; execute only if the workspace is untouched or fully rolled back | +| Failed/cancelled/non-cacheable task | Never upload | +| Upload conflict | Keep local result; use the immutable-winner rules above | +| Local extraction, disk, or rollback failure | Surface a local error when safe execution cannot continue | +| Unsupported protocol/format | Disable remote use and state the compatibility reason; local execution continues | + +After an eligible execution, save the local result first and enqueue its immutable export with owned file handles or a pinned spool copy. Do not let a subsequent local cache update delete an archive that is still being uploaded. Drain uploads before normal process exit, for at most 30 seconds after the last task finishes; then cancel remaining uploads and report skipped publication. Cancellation aborts remote work promptly. The task's success never depends on an upload finishing. + +The new local schema must distinguish locally executed results from remote imports and persist compatibility, strong input digests, signed manifest, artifact digest, and source scope. When remote mode is enabled, local hits must match the current compatibility and consumer trust policy. Remote imports must still have an accepted publisher key and epoch; changing either invalidates those imports. Locally executed records belong to the current user's local trust boundary and are never automatically signed and republished on a hit. + +Legacy entries do not contain the required strong fingerprints or provenance. Do not derive a new remote result by rehashing the current tree beside an old archive. Keep local-only behavior available; the first remote-enabled run of such a task must execute again to create a verified export. Bump the local schema when implementing this, independently of the remote wire version. + +Extend cache events with `local_hit`, `remote_hit`, `remote_miss`, `remote_rejected`, and `remote_upload_skipped`, preserving existing miss explanations. Report aggregate transferred bytes and remote wait time, plus individual reasons in debug output. `vp cache clean` continues to affect only local storage. A future explicit admin operation may purge a remote namespace; a normal client token cannot do so. + +## 11. Retention, quotas, and operations + +Each namespace has an operator-defined storage-byte quota, entry quota, request-rate limit, and maximum active uploads per principal. Pending and ready entries both consume reserved bytes. All upload/retry paths enforce the same limits. Use primary-backed counters for exact reservations; an edge-local rate limiter can be an additional load-shedding measure, not a global quota guarantee. Request and CPU charges can still occur for rejected traffic, so these are storage/admission controls, not a hard Cloudflare bill cap. + +Results expire 30 days after publication. Reads and duplicate uploads do not extend the lifetime. Expired rows stop being discoverable immediately. Hourly Cron work processes bounded batches and persists a cursor so a backlog does not require one long invocation: + +1. Atomically claim expired ready entries or abandoned uploads as `deleting`. No commit can win after this transition. +2. Delete only the claimed upload generation's manifest/chunks. Allow a five-minute grace after an upload lease expires to cover in-flight requests, then repeat cleanup of deleting generations. Treat missing objects as successful deletion. +3. Remove chunk rows and release quota only after cleanup succeeds. Retain a short-lived tombstone for late writes and retry cleanup when a receipt arrives for an abandoned upload. A failed delete keeps the row and quota charged until retried. + +Configure an R2 lifecycle backstop longer than the maximum published retention plus upload lifetime and grace: 32 days from object creation for the default policy. Do not refresh object ages by overwriting them. [R2 lifecycle deletion is asynchronous](https://developers.cloudflare.com/r2/buckets/object-lifecycles/), so D1 expiry governs visibility. The backstop removes objects left by crashes, lost metadata, or exceptionally late writes. If retention changes, the deployment tooling must update and validate this relationship. Lifecycle alone must not delete still-live artifacts. + +Log request ID, principal ID, operation, status, bytes, duration, and error class. Do not log bearer tokens, private keys, raw request bodies, environment names/values, archive contents, or full source paths by default. Track hit/miss/rejection rates, candidate count, transferred bytes, upload conflicts, active reservations, D1 latency/errors, R2 errors, and cleanup backlog. Alerts should cover sustained authorization failures, signature rejection, storage thresholds, and cleanup lag. Worker Logs and Cloudflare's D1/R2 metrics are sufficient initially; product telemetry must not require a central collection service. + +Artifacts and logs may contain source maps, generated source, secrets printed by tasks, and private filenames. They are private project data. Hashing environment values is not encryption and can expose low-entropy values to guessing. Operators choose who can read data and where Cloudflare stores it; signing does not hide it. The public documentation must include task exclusions and credential rotation procedures. + +Token revocation takes effect on the next API request through primary D1 authorization, including chunk requests and commits. Key revocation blocks new publication and server reads from that publisher. For a compromised publisher, rotate the epoch and distribute updated client trust settings, then clean local imports. Server-side revocation cannot erase already downloaded bytes or revoke an offline client's local trust configuration. + +Cache artifacts need no backup for correctness; losing them causes recomputation. Back up operator policy separately, and document D1 migration/restore procedures. After a partial D1/R2 restore, reconcile references or start a fresh epoch; do not assume D1 recovery also restores deleted R2 bytes. Upgrade schemas additively before upgrading the Worker, retain previous protocol support during rollout, and roll back Worker code only while the database remains compatible. + +## 12. Self-deployment and GitHub Actions migration + +Deliver a `packages/remote-cache` template in this repository, with TypeScript sources, pinned dependencies, a lockfile, `wrangler.jsonc`, D1 migrations, OpenAPI/JSON schemas, and an operator guide. The deployment must require only the user's Cloudflare account and local Node.js tooling. No external SaaS is part of the request path. + +The template's setup flow must: + +1. Create a private R2 Standard bucket and D1 database, then bind them as `ARTIFACTS` and `INDEX`. Give each environment separate resources. +2. Apply D1 migrations, install the lifecycle backstop and Cron Trigger, and configure retention/quotas. Pin a tested Worker compatibility date and enforce streaming/CPU bounds. +3. Bootstrap project/namespace/epoch, token hashes, and publisher public keys through an operator-only CLI using Cloudflare credentials. Generate individual random tokens and signing keys locally; never send publisher private keys to the Worker. No public administration endpoint is required. +4. Deploy to `workers.dev` or an optional custom domain with HTTPS, keep R2 public access disabled, and verify that unauthenticated reads/writes fail. +5. Print non-secret client configuration and a credential-storage guide. Run a small write/read/revoke/delete smoke test with temporary scoped credentials and isolated test data. + +Publish exact tested commands with the implementation. The setup tooling should be idempotent, name every resource it creates, and support upgrades plus explicit teardown. Removing the Worker alone must not be described as removing stored data or ending storage charges. Include a local Miniflare workflow and a Cloudflare deployment smoke test; local emulation alone does not prove production consistency or limits. + +Workers Paid is the production baseline because signature/schema processing and cleanup need measured CPU headroom. A Free-plan demonstration may be documented only after the same bounds and load tests pass there. Self-hosted means the software is open and user-operated, not that infrastructure use is unconditionally free. + +For the docs action, keep dependency installation and package-manager caching. Add remote credentials only to the `vp run build` step. A protected CI step would supply these proposed values: + +```yaml +- run: vp run build + working-directory: docs + env: + DOCS_SITE_ORIGIN: ${{ inputs.site-origin }} + VP_REMOTE_CACHE_URL: ${{ vars.VP_REMOTE_CACHE_URL }} + VP_REMOTE_CACHE_PROJECT: docs + VP_REMOTE_CACHE_NAMESPACE: trusted + VP_REMOTE_CACHE_EPOCH: '1' + VP_REMOTE_CACHE_MODE: read-write + VP_REMOTE_CACHE_ENVIRONMENT: ${{ vars.VP_REMOTE_CACHE_ENVIRONMENT }} + VP_REMOTE_CACHE_TRUSTED_KEYS: ${{ vars.VP_REMOTE_CACHE_TRUSTED_KEYS }} + VP_REMOTE_CACHE_TOKEN: ${{ secrets.VP_REMOTE_CACHE_CI_TOKEN }} + VP_REMOTE_CACHE_SIGNING_KEY_ID: ci-2026-09 + VP_REMOTE_CACHE_SIGNING_KEY: ${{ secrets.VP_REMOTE_CACHE_CI_SIGNING_KEY }} +``` + +This excerpt belongs only in a job authorized to receive the writer credentials. Other jobs use a separate read-only setup or no remote credentials. The existing [docs task configuration](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/docs/vite.config.ts) fingerprints `DOCS_SITE_ORIGIN`; preserve this distinction so production and preview outputs cannot be confused. + +During a canary, the existing task-directory restore/save can remain, but it must stay within the same CI trust boundary and cannot be treated as signed provenance. Once remote-enabled clients and a fresh-run population are verified, remove the task-cache key computation and task-directory restore/save steps. Keep the dependency cache managed by `setup-vp`. To roll back, set remote mode to `off`; builds continue with local caching and can retain/reintroduce the old task-directory optimization. + +## 13. Cost and capacity model + +Use R2 Standard because cache reads are frequent and results expire relatively quickly. As checked on 2026-09-07, [R2 Standard pricing](https://developers.cloudflare.com/r2/pricing/) lists $0.015/GB-month, $4.50/million Class A operations, $0.36/million Class B operations, and no Internet egress charge. [Workers Standard pricing](https://developers.cloudflare.com/workers/platform/pricing/) includes a $5 monthly subscription, 10 million requests, and 30 million CPU milliseconds, then $0.30/million requests and $0.02/million CPU milliseconds. [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) depends on rows read/written and stored bytes, with included monthly allowances on Paid. Recheck these rates before publishing the deployment guide. + +Count one reservation, one commit, and `C` upload requests for a result with `C` chunks. A successful cold remote hit uses one or more discovery requests, `M` manifest reads, and `C` chunk reads. R2 also receives checksum/receipt recovery checks, commit-time HEADs, cleanup lists/deletes, and manifest writes. D1 index maintenance and quota operations add rows written. A task cache hit is therefore not one billable HTTP operation. + +For an illustrative small team, assume 100 GB-month retained in R2, 100,000 monthly publications, 1 million remote hits, one chunk/result, and one candidate/hit. That is approximately 3.3 million client requests before misses, retries, capabilities, and cleanup. Assuming 5 ms Worker CPU/request, the core workload uses about 16.5 million CPU ms and fits the cited Workers allowances. The 200,000 object PUTs and roughly 2.2 million GET/HEAD operations also fit R2's monthly allowances of 1 million Class A and 10 million Class B operations. With an otherwise unused account allowance, R2 storage costs `(100 - 10) * $0.015 = $1.35`; adding Workers gives approximately $6.35/month before tax, assuming D1 stays within its included allowances. Budget separately for misses, extra candidates, retries, cleanup, and other account usage; apply billable-unit rounding when allowances are exceeded. This is a workload estimate, not a benchmark or price guarantee. + +D1 is a metadata service, not an unlimited global coordinator. Its [documented per-database limits](https://developers.cloudflare.com/d1/platform/limits/) include 10 GB on Paid and serialized query execution. Use indexed, bounded queries and measure p95 latency and queueing. When a deployment approaches its metadata/throughput limit, partition projects into separate deployments/databases. Do not add a global shared D1 database as an implicit SaaS scale-out strategy. + +## 14. Alternatives and tradeoffs + +| Alternative | Reason not selected for version 1 | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Keep GitHub Actions Cache as the remote backend | Remains a CI directory-snapshot workflow and does not give developers a native deployable task-cache service | +| Expose the local SQLite database/archive directory in R2 | Couples machines to mutable local state, weak input digests, and local schema/layout; cannot safely merge concurrent results | +| Workers plus R2 only | Viable for an already-complete immutable key, but bounded inferred-input discovery, quota reservations, revocation, and upload cleanup need coordinated metadata; rebuilding them with object lists and CAS adds complexity | +| Workers KV as the authoritative index or token store | Its eventual consistency is unsuitable for prompt revocation and publication coordination; see [KV consistency](https://developers.cloudflare.com/kv/concepts/how-kv-works/) | +| Durable Objects for each lookup key | Can coordinate publication, but project-wide authorization, quota accounting, discovery, and cleanup still need a design across objects; D1 SQL suits the first deployment scale | +| Direct presigned R2/S3 access | Adds credential/signing and upload-finalization complexity; Worker-proxied chunks give the same authorization and bounds on each request | +| One unbounded HTTP archive | Exceeds account upload limits for large artifacts; fixed chunks keep memory and retry work bounded | +| Require explicit input lists or hash the whole checkout | Removes default inferred-input reuse or adds unrelated invalidations; neither is required by the existing cache model | +| Global content-addressed blob store | Adds reference tracking and deletion races for limited initial benefit; per-result chunks can be reclaimed independently | +| Optional symmetric signatures | Simpler key setup, but readers must share a signing secret; asymmetric signatures separate reader and publisher capabilities | + +## 15. Implementation sequence and acceptance criteria + +This RFC does not implement or deploy the service. Approval starts the following work; each stage has a concrete release gate. + +1. **Portable identity and validation.** Add canonical schemas, SHA-256 observations, compatibility identity, and an export/import representation in the engine. Cover the existing fingerprint variants exhaustively, including environment-query match sets. Keep local-only behavior and cache miss explanations. Publish golden vectors shared by Rust and TypeScript; schema changes require explicit format/version decisions. +2. **Untrusted artifact handling.** Implement bounded canonical archive creation, signature verification, staged extraction, guarded replacement, journal recovery, and local provenance. Test hostile/truncated archives, digest failures, symlink/reparse escapes, case collisions, executable bits, permissions/disk failures, rollback, and cancellation. Fuzz manifest parsing and extraction boundaries before enabling remote downloads. +3. **Cloudflare reference service.** Implement the versioned API, D1 migrations, R2 chunk transfer, permissions, immutable commit, quotas, and GC. Ship a machine-readable OpenAPI contract and conformance suite. Verify duplicate writers, identical retries, different-body retries, failed uploads/receipts/commits, expired leases, late writes, missing objects, revoked principals/keys, concurrent quota reservations, and cleanup/commit races against a real isolated Cloudflare deployment. +4. **Native client and configuration.** Integrate after local misses and after successful local updates, add transfer deadlines/concurrency, secret filtering, provenance policy, upload draining, and diagnostics. Cover task disables, CLI precedence, environment overrides, credential absence, `401`/`403`/`429`/`5xx`, service outage, cancellation, and normal process exit while uploads remain pending. +5. **Deployment and canary.** Ship the template and operator guide, exercise clean-account setup/upgrade/rollback/teardown, and migrate the docs action in a separate change. Measure latency, transfer sizes, CPU, D1 rows, storage, and recomputation avoided with actual docs builds before declaring the default limits suitable. + +The end-to-end suite must prove the user outcome, not just successful API round trips: + +- Machine A executes and publishes; machine B, with a different absolute checkout root and empty local cache, restores identical output bytes and terminal events without executing. Repeat in both developer-to-CI and CI-to-developer directions using the configured trust namespace. +- Repeat compatible-machine tests on Windows, macOS, and Linux. Do not skip a platform. Any essential-capability exception is limited to musl, with its unavailable requirement documented. +- Changing explicit content, adding/removing a glob match, changing an inferred input, creating a missing path, changing a directory listing, changing tracked env/query membership, changing command/config, or changing toolchain/platform/environment must prevent stale reuse. Reverting source inputs should recover an older candidate within the discovery window. +- Exercise a real artifact larger than 100 MB to prove chunked transfer works through the configured Cloudflare endpoint. Enforce compressed/expanded/log/file limits and verify that rejected artifacts leave the workspace intact. +- A reader token cannot upload, another project cannot discover/read artifacts, an untrusted publisher cannot populate the trusted namespace, a revoked publisher cannot commit, and no remote credentials reach child processes or logs through wildcard env requests. +- Network loss before and during commit never exposes a partial result. Concurrent writers expose one immutable winner. GC cannot delete a newly published generation, and failures do not release quota before cleanup. +- With the remote service unavailable, an unchanged task uses a valid local result, and a local miss executes successfully within the configured network budget. Turning remote caching off restores local-only behavior. +- The docs canary retains `DOCS_SITE_ORIGIN` separation, removes only the task-directory cache steps after proving cold remote hits, and demonstrates a rollback with remote mode disabled. + +Performance targets for the canary are p95 metadata lookup below 500 ms from representative developer/CI locations and less than 5% added task time when a result is ineligible for remote use. These are targets to measure, not asserted Cloudflare guarantees. Investigate misses caused by compatibility partitioning, candidate caps, or transfer deadlines before widening any correctness boundary. + +## 16. Review questions + +The proposal makes implementable defaults, but these product tradeoffs deserve explicit RFC review: + +- Is mandatory publisher signing with individual keys an acceptable setup cost for the first release? The proposed default is yes, with key generation and configuration handled by the setup CLI. +- Is same-platform reuse sufficient for the first release? The proposed default is yes; cross-platform reuse requires an explicit portable-task contract and separate fixtures. +- Should developer-to-release-CI sharing be enabled by default? The proposed default is no; teams can opt into the shared `team` namespace and its publisher policy. +- Do measured docs and representative monorepo workloads fit the proposed discovery, metadata, archive, and timeout limits? Keep the bounds in version 1, and adjust defaults from canary evidence without weakening validation. From a53929a30c309603cd9c1f1cd3dcdbee8ce1a613 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 7 Sep 2026 11:32:36 +0800 Subject: [PATCH 2/6] docs: quantify remote cache capacity and artifact sizes Co-authored-by: GPT-6 Codex --- docs/rfcs/0001-remote-cache.md | 250 +++++++++--- docs/rfcs/remote-cache-size-study/README.md | 79 ++++ docs/rfcs/remote-cache-size-study/measure.py | 175 +++++++++ .../rfcs/remote-cache-size-study/results.json | 355 ++++++++++++++++++ .../rfcs/remote-cache-size-study/sources.json | 71 ++++ 5 files changed, 877 insertions(+), 53 deletions(-) create mode 100644 docs/rfcs/remote-cache-size-study/README.md create mode 100644 docs/rfcs/remote-cache-size-study/measure.py create mode 100644 docs/rfcs/remote-cache-size-study/results.json create mode 100644 docs/rfcs/remote-cache-size-study/sources.json diff --git a/docs/rfcs/0001-remote-cache.md b/docs/rfcs/0001-remote-cache.md index ec3894312..c3929fd8a 100644 --- a/docs/rfcs/0001-remote-cache.md +++ b/docs/rfcs/0001-remote-cache.md @@ -24,7 +24,7 @@ This RFC proposes an optional second cache tier: check local results, check remo | Publication | Immutable results; upload all bytes before an atomic D1 transition makes a result visible | | Trust | Scoped bearer tokens and mandatory publisher signatures; CI and developer write permissions are separate | | Transfer | Streaming, retryable chunks of at most 64 MiB; at most 1 GiB compressed per result | -| Retention | Fixed 30-day result lifetime, bounded discovery, quotas, and incremental cleanup | +| Retention | Seven-day retention by default; optional 30-day history, storage budgets, and incremental cleanup | | Failure | Remote failures become misses or skipped uploads, with bounded waits and diagnostics | Version 1 includes local-to-CI, CI-to-local, and CI-to-CI reuse on compatible machines, on macOS, Linux, and Windows. A fresh checkout with an empty local cache must be able to hit an automatically inferred remote result. No Git commit, branch, absolute checkout root, or local database identity is required for a hit. @@ -154,7 +154,7 @@ Keep raw command arguments and tracked environment values semantically unchanged 1. Validate a compatible local entry first. On a local hit, make no remote request and do not backfill old entries into remote storage. 2. Request candidates for `lookup_key`. D1 returns only committed, unexpired entries from active publishers in the authorized scope, newest first with `result_key` as a stable tie-breaker. Return eight references per page, with an opaque cursor bound to the scope, lookup key, and first-page publication watermark. -3. Fetch candidate manifests, verify their digests and trusted publisher signatures, and require their scope, formats, and lookup key to match locally computed values. Never let a manifest replace local command/configuration data. +3. Verify the small signed descriptors returned with candidates, then fetch their manifests and verify the signed manifest digests. Require scope, formats, and lookup key to match locally computed values. Never let a manifest replace local command/configuration data. 4. Re-enumerate explicit globs using the current resolved configuration; compare the full path set and SHA-256 file digests. Validate every inferred observation: file content, absence, directory existence, and a sorted entry-name/type set when enumeration was observed. Validate input symlink chains as specified in section 7. Re-evaluate tracked environment queries against the same planning context used by runner-aware APIs. Compare missing values and complete query match sets. 5. Recompute `result_key` from the current observations. A candidate is eligible only if every observation matches and the result key matches. Then fetch and restore its artifact as described below. 6. Stop after 32 candidates, 16 MiB of manifest metadata, or the lookup deadline, whichever comes first. No valid candidate means execute locally. A bounded search may miss an older valid result; it cannot turn a mismatch into a hit. @@ -167,19 +167,22 @@ Input inference retains its existing limits: ignored inputs, untracked environme ## 7. Wire artifact and safe restoration -Each result consists of one signed manifest and one canonical `tar.zst` stream, split into numbered transport chunks. The archive contains regular output files under `outputs/` and one `stdio.json` with ordered stdout/stderr byte events. No-output tasks still have a valid archive for terminal output. Duration is metadata; the only publishable exit status is success. +Each result consists of a small signed descriptor, a validation manifest, and one canonical `tar.zst` stream split into numbered transport chunks. The archive contains regular output files under `outputs/` and one `stdio.json` with ordered stdout/stderr byte events. No-output tasks still have a valid archive for terminal output. Duration is metadata; the only publishable exit status is success. -The signed manifest body contains: +The descriptor is bounded to 8 KiB so server-side authentication and signature verification do not scale with the number of inferred inputs. It contains: -| Field group | Contents | -| ----------- | ------------------------------------------------------------------------------------------------------------------------- | -| Identity | Protocol/format versions, scope, lookup key, result key, compatibility digest, publisher key ID | -| Inputs | Explicit input map, inferred tagged observations, tracked environment hashes and queries | -| Artifact | Compressed SHA-256 and size; ordered chunk sizes and SHA-256 digests; expanded size | -| Outputs | Exact workspace-relative file inventory, content SHA-256, byte size, executable bit; terminal event byte count and digest | -| Result | Success status and elapsed milliseconds | +| Field group | Contents | +| ----------- | ----------------------------------------------------------------------------------------------- | +| Identity | Protocol/format versions, scope, lookup key, result key, compatibility digest, publisher key ID | +| Manifest | SHA-256 and byte size of the canonical validation manifest | +| Artifact | Compressed SHA-256 and size; ordered chunk sizes and SHA-256 digests; expanded size | +| Bounds | Output-file count and terminal-output byte count | -The envelope contains `body` and a base64 Ed25519 `signature` over `JCS(["vp-cache-manifest-v1", body])`. The manifest digest is SHA-256 of the canonical envelope. Public keys are pinned by the consumer's configuration, not accepted from a download. The Worker verifies the publisher key is active and permitted for the token's scope; consumers verify independently. Cloudflare supports Ed25519 through its [Web Crypto API](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/). +The descriptor envelope contains `body` and a base64 Ed25519 `signature` over `JCS(["vp-cache-descriptor-v1", body])`. Its digest is SHA-256 of the canonical envelope. D1 stores this small envelope and returns it inline with candidate references, so discovery does not add a separate descriptor download. Public keys are pinned by the consumer's configuration, not accepted from a download. The Worker verifies that the publisher key is active and permitted for the token's scope; consumers verify independently. Cloudflare supports Ed25519 through its [Web Crypto API](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/). + +The validation manifest contains the matching identity, full explicit/inferred input observations, tracked environment hashes and queries, exact output-file inventory with SHA-256/size/executable bits, terminal-output digest, success status, and elapsed milliseconds. The client verifies its signed digest before parsing, then checks that all duplicated identity and bound fields match the descriptor. Changing any manifest byte invalidates the descriptor's signature binding. The full input and result-key checks remain on the consuming client. + +The Worker treats the manifest as opaque bytes and uses R2's checksum verification on upload. It never parses or canonicalizes a multi-megabyte manifest or decompresses an artifact. This preserves the 4 MiB manifest limit for Free users while moving expensive validation to the clients, which already need to perform it. All hosting profiles use the same protocol and mandatory signatures; Free support does not reduce validation or task input coverage. Archive creation sorts paths and removes host-specific owner/group names, timestamps, and unnecessary metadata. Preserve file bytes and the executable bit; never preserve ownership, ACLs, setuid/setgid bits, or arbitrary extended attributes. Version 1 rejects symlink/hardlink outputs, devices, FIFOs, sparse files, and other special entries for remote publication. Workspace input symlinks must resolve within the workspace, and the remote fingerprint includes every link in the resolution chain, its relative target, and the final resolved observation; loops or outside-workspace targets disable remote reuse. Validate ancestor links as well as the final path. This is stricter than the current local archive path. @@ -187,7 +190,8 @@ Client and server enforce these protocol maxima; deployments may lower them and | Limit | Maximum | | ------------------------------------------ | ---------------------------------------------- | -| Canonical manifest | 4 MiB | +| Signed descriptor | 8 KiB | +| Canonical validation manifest | 4 MiB | | Chunk | 64 MiB; all but the final chunk have this size | | Compressed archive | 1 GiB, at most 16 chunks | | Expanded archive including terminal output | 8 GiB | @@ -220,21 +224,22 @@ flowchart LR Cron[Cron Trigger] -->|incremental cleanup| API ``` -The Worker performs authentication, bounded schema checks, publisher-signature validation, indexed lookup, upload coordination, and streaming. R2 holds artifact chunks and manifests through a binding. Disable public bucket access and do not expose S3 credentials, public object URLs, or presigned URLs to clients. +The Worker performs authentication, bounded descriptor-schema checks, publisher-signature validation, indexed lookup, upload coordination, and streaming. R2 holds artifact chunks and manifests through a binding. Disable public bucket access and do not expose S3 credentials, public object URLs, or presigned URLs to clients. -D1 holds compact metadata; large manifests remain in R2. This avoids depending on D1's [2 MB row limit](https://developers.cloudflare.com/d1/platform/limits/). Use prepared statements, indexes, conditional writes, and transactional `batch()` calls. [D1 batches roll back if a statement fails](https://developers.cloudflare.com/d1/worker-api/d1-database/); a conditional update affecting zero rows is not an exception, so dependent changes must be guarded in SQL as well. Do not perform a read in JavaScript and assume a later write is still exclusive. +D1 holds compact metadata and signed descriptors; large manifests remain in R2. This avoids depending on D1's [2 MB row limit](https://developers.cloudflare.com/d1/platform/limits/). Use prepared statements, indexes, conditional writes, and transactional `batch()` calls. [D1 batches roll back if a statement fails](https://developers.cloudflare.com/d1/worker-api/d1-database/); a conditional update affecting zero rows is not an exception, so dependent changes must be guarded in SQL as well. Do not perform a read in JavaScript and assume a later write is still exclusive. Keep authorization, reservation, commit, and deletion on the D1 primary. Version 1 also reads candidates from the primary, with read replication disabled. If replication is introduced later, [Sessions and bookmarks](https://developers.cloudflare.com/d1/best-practices/read-replication/) must preserve read-after-publish behavior, and replica lag must not delay credential revocation. ### Logical data model -| Table | Key data and indexes | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `namespaces` | `(project, namespace, epoch)`; active flag, retention, byte/entry quotas, charged bytes/entries | -| `tokens` | Public token ID, SHA-256 of a random secret, principal ID, allowed scope/modes, expiry, revocation; lookup by token ID | -| `publisher_keys` | Scope, key ID, Ed25519 public key, allowed principal IDs, active flag | -| `entries` | Unique `(scope, lookup_key, result_key)`; upload ID/generation, owner principal, immutable manifest digest and total size, state, lease deadline, publish/expiry timestamps | -| `chunks` | Unique `(upload_id, ordinal)`; expected size/digest and upload receipt | +| Table | Key data and indexes | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `deployment_budget` | Singleton byte/entry limits and charged totals shared by every namespace and epoch | +| `namespaces` | `(project, namespace, epoch)`; active flag, retention, byte/entry quotas, charged bytes/entries | +| `tokens` | Public token ID, SHA-256 of a random secret, principal ID, allowed scope/modes, expiry, revocation; lookup by token ID | +| `publisher_keys` | Scope, key ID, Ed25519 public key, allowed principal IDs, active flag | +| `entries` | Unique `(scope, lookup_key, result_key)`; upload ID/generation, owner principal, immutable signed descriptor and its digest, manifest receipt, total size, state, lease deadline, publish/expiry timestamps | +| `chunks` | Unique `(upload_id, ordinal)`; expected size/digest and upload receipt | Add indexes on `(scope, lookup_key, state, published_at DESC, result_key)` and `(state, lease_deadline)` / `(state, expires_at)` for discovery and cleanup. A monotonically increasing publication sequence supplies the page watermark. Never store source file contents or plaintext tracked environment values in D1. Use an unpredictable upload ID; R2 keys are server-derived: @@ -253,19 +258,20 @@ All cache endpoints start with `/v1/projects/{project}/namespaces/{namespace}/ep All responses use `Cache-Control: private, no-store`. Require verified HTTPS in production, with no redirects. Local development permits HTTP only on explicit loopback URLs. The server returns paths relative to its own origin; the client rejects arbitrary artifact URLs. Do not forward credentials across hosts or disable certificate validation. No CORS access is enabled by default. -| Request | Behavior | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET /healthz` | Public liveness only; no configuration or dependency details | -| `GET S/capabilities` | Authenticated formats, effective size limits, and retention | -| `GET S/lookups/{lookup_key}?cursor=...` | `200` with candidate references, publisher key IDs, manifest digests, result keys, and next cursor; an empty list is a miss | -| `GET S/results/{lookup_key}/{result_key}/manifest` | `200` canonical signed envelope for a ready result; otherwise `404` | -| `GET S/results/{lookup_key}/{result_key}/chunks/{ordinal}` | Stream one immutable chunk of a ready result; otherwise `404` | -| `POST S/uploads` | Body is the canonical signed manifest, with `Idempotency-Key`; reserve the immutable result and quota, then persist the manifest; `201` with upload ID, deadline, and relative chunk paths | -| `PUT S/uploads/{upload_id}/chunks/{ordinal}` | Require `Content-Length`; verify expected digest and length while writing the stream; `204` on verified success or an identical retry | -| `POST S/uploads/{upload_id}/commit` | Confirm all bytes and permissions, then publish; `201` for the winning commit, `200` for its identical retry | -| `DELETE S/uploads/{upload_id}` | Owner can abandon a pending upload; idempotent `204`; cannot delete a published result | - -Each chunk response includes `Content-Length` and its SHA-256 in `VP-Cache-SHA256`. The signed manifest remains the client's authority for integrity; an R2 ETag is not a content checksum contract. JSON errors have `{ "code": "...", "requestId": "..." }`, without arbitrary reflected request text. `401` means missing/expired/revoked credentials; `403` means insufficient scope or publisher permission. `400` covers malformed data, `413` size limits, `422` checksum/signature/schema failure, `429` quota/rate limits, and `503` transient storage failure. `409` distinguishes `already_exists`, `upload_in_progress`, `manifest_conflict`, and `lease_expired`. +| Request | Behavior | +| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /healthz` | Public liveness only; no configuration or dependency details | +| `GET S/capabilities` | Authenticated formats, effective size limits, and retention | +| `GET S/lookups/{lookup_key}?cursor=...` | `200` with candidate references, signed descriptors, result keys, and next cursor; an empty list is a miss | +| `GET S/results/{lookup_key}/{result_key}/manifest` | `200` streamed validation manifest for a ready result; otherwise `404` | +| `GET S/results/{lookup_key}/{result_key}/chunks/{ordinal}` | Stream one immutable chunk of a ready result; otherwise `404` | +| `POST S/uploads` | Body is the canonical signed descriptor, with `Idempotency-Key`; reserve the result and quota; `201` with upload ID, deadline, and relative manifest/chunk paths | +| `PUT S/uploads/{upload_id}/manifest` | Require `Content-Length`; stream and verify the reserved manifest digest/size; `204` on success or an identical retry | +| `PUT S/uploads/{upload_id}/chunks/{ordinal}` | Require `Content-Length`; verify expected digest and length while writing the stream; `204` on verified success or an identical retry | +| `POST S/uploads/{upload_id}/commit` | Confirm all bytes and permissions, then publish; `201` for the winning commit, `200` for its identical retry | +| `DELETE S/uploads/{upload_id}` | Owner can abandon a pending upload; idempotent `204`; cannot delete a published result | + +Each chunk response includes `Content-Length` and its SHA-256 in `VP-Cache-SHA256`. The signed descriptor and its manifest digest remain the client's authority for integrity; an R2 ETag is not a content checksum contract. JSON errors have `{ "code": "...", "requestId": "..." }`, without arbitrary reflected request text. `401` means missing/expired/revoked credentials; `403` means insufficient scope or publisher permission. `400` covers malformed data, `413` size limits, `422` checksum/signature/schema failure, `429` quota/rate limits, and `503` transient storage failure. `409` distinguishes `already_exists`, `upload_in_progress`, `descriptor_conflict`, and `lease_expired`. ### Publication @@ -274,14 +280,14 @@ absent -> uploading -> ready -> deleting -> absent \------------> deleting -> absent ``` -1. Authenticate and validate the signed manifest, scope, bounds, and result key derived from its input observations. The service treats the lookup key as opaque; it cannot validate the publisher's actual command or source tree. In one D1 transaction, reserve the full manifest-plus-archive byte count and entry slot, insert an `uploading` row, and create its expected chunk rows. Quota checks and counter updates must be enforced in the same transaction, using SQL constraints/triggers or guarded statements. Return no usable upload location until reservation succeeds. -2. Persist the canonical manifest under the reserved upload ID. Each chunk PUT must match its reserved size/digest and owner, and the upload must still be active. Stream directly to an R2 create-only write with the expected SHA-256. After success, conditionally record its receipt in D1. A lost response or failed receipt write is recovered by checking the existing object's checksum and size on retry; never overwrite different bytes. -3. At commit, verify the stored manifest and all expected chunk receipts and R2 objects. In a single D1 transaction, require the token, publisher key, and namespace to still be active, compare the upload ID/state/deadline, and transition to `ready`, setting publication sequence/time and `expires_at`. Authorization predicates must be part of the guarded transition, so concurrent revocation cannot be bypassed by an earlier JavaScript check. Return success only after that transaction commits. Retention starts at publication; there is no background publication through `waitUntil()`. +1. Authenticate and validate the small signed descriptor, scope, digest syntax, and size/chunk bounds. The service treats lookup and result keys as opaque; it cannot validate the publisher's actual command or source tree. In one D1 transaction, reserve the full manifest-plus-archive byte count and entry slot, insert an `uploading` row, and create its expected chunk rows. Quota checks and counter updates must be enforced in the same transaction, using SQL constraints/triggers or guarded statements. Return no usable upload location until reservation succeeds. +2. Upload the canonical validation manifest and chunks under the reserved upload ID. Each PUT must match its reserved size/digest and owner, and the upload must still be active. Stream directly to an R2 create-only write with the expected SHA-256. After success, conditionally record its receipt in D1. A lost response or failed receipt write is recovered by checking the existing object's checksum and size on retry; never overwrite different bytes. +3. At commit, verify the manifest receipt and all expected chunk receipts against R2 HEAD checks for size/checksum. Do not read and parse the manifest. Bound concurrent HEAD calls and keep the entire request within the Free profile's query/subrequest budget. In a single D1 transaction, require the token, publisher key, and namespace to still be active, compare the upload ID/state/deadline, and transition to `ready`, setting publication sequence/time and `expires_at`. Authorization predicates must be part of the guarded transition, so concurrent revocation cannot be bypassed by an earlier JavaScript check. Return success only after that transaction commits. Retention starts at publication; there is no background publication through `waitUntil()`. 4. Readers select only `ready` entries and check expiry/authorization again for manifest and chunk reads. If an object is missing despite a ready row, treat it as a miss, record an integrity error, and schedule that entry for cleanup. Do not replay logs or partial files. -Idempotency keys are random 128-bit values, bound to principal, scope, and manifest digest for the upload lifetime. Persist this binding in `entries` with a unique `(principal, scope, idempotency_key)` constraint. Repeating the same reservation returns its upload ID and missing chunks; if the manifest write was interrupted, the same request must finish that write first. Reusing the key with different bytes returns `409`. Another writer for an `uploading` result receives `409 upload_in_progress` and need not wait. After an abandoned generation is reclaimed, a new reservation always gets a new upload ID. +Idempotency keys are random 128-bit values, bound to principal, scope, and descriptor digest for the upload lifetime. Persist this binding in `entries` with a unique `(principal, scope, idempotency_key)` constraint. Repeating the same reservation returns its upload ID, manifest receipt status, and missing chunks; interrupted manifest uploads resume through the idempotent manifest PUT. Reusing the key with different bytes returns `409`. Another writer for an `uploading` result receives `409 upload_in_progress` and need not wait. After an abandoned generation is reclaimed, a new reservation always gets a new upload ID. -A published result cannot be replaced. A new reservation for it receives `409 already_exists` with the winning manifest digest. The client treats this as upload deduplication, not a failed task. Compare output-file inventory/content digests if both manifests are available: different output files under the same result key produce a nondeterminism diagnostic. Duration or log timing differences alone are not proof of incorrect output. An identical commit retry is successful only for the original upload ID. +A published result cannot be replaced. A new reservation for it receives `409 already_exists` with the winning descriptor digest. The client treats this as upload deduplication, not a failed task. Compare output-file inventory/content digests if both manifests are available: different output files under the same result key produce a nondeterminism diagnostic. Duration or log timing differences alone are not proof of incorrect output. An identical commit retry is successful only for the original upload ID. An upload lease lasts 15 minutes and is not extended in version 1. Server-side body transfer deadlines are two minutes per chunk. On a late write after lease loss, reject the receipt and remove that generation's object where possible; it can never become visible. Cleanup may safely retry removal because upload IDs are never reused. @@ -304,7 +310,7 @@ Default budgets are 2 seconds to connect, 5 seconds for discovery including mani After an eligible execution, save the local result first and enqueue its immutable export with owned file handles or a pinned spool copy. Do not let a subsequent local cache update delete an archive that is still being uploaded. Drain uploads before normal process exit, for at most 30 seconds after the last task finishes; then cancel remaining uploads and report skipped publication. Cancellation aborts remote work promptly. The task's success never depends on an upload finishing. -The new local schema must distinguish locally executed results from remote imports and persist compatibility, strong input digests, signed manifest, artifact digest, and source scope. When remote mode is enabled, local hits must match the current compatibility and consumer trust policy. Remote imports must still have an accepted publisher key and epoch; changing either invalidates those imports. Locally executed records belong to the current user's local trust boundary and are never automatically signed and republished on a hit. +The new local schema must distinguish locally executed results from remote imports and persist compatibility, strong input digests, signed descriptor and manifest, artifact digest, and source scope. When remote mode is enabled, local hits must match the current compatibility and consumer trust policy. Remote imports must still have an accepted publisher key and epoch; changing either invalidates those imports. Locally executed records belong to the current user's local trust boundary and are never automatically signed and republished on a hit. Legacy entries do not contain the required strong fingerprints or provenance. Do not derive a new remote result by rehashing the current tree beside an old archive. Keep local-only behavior available; the first remote-enabled run of such a task must execute again to create a verified export. Bump the local schema when implementing this, independently of the remote wire version. @@ -312,17 +318,17 @@ Extend cache events with `local_hit`, `remote_hit`, `remote_miss`, `remote_rejec ## 11. Retention, quotas, and operations -Each namespace has an operator-defined storage-byte quota, entry quota, request-rate limit, and maximum active uploads per principal. Pending and ready entries both consume reserved bytes. All upload/retry paths enforce the same limits. Use primary-backed counters for exact reservations; an edge-local rate limiter can be an additional load-shedding measure, not a global quota guarantee. Request and CPU charges can still occur for rejected traffic, so these are storage/admission controls, not a hard Cloudflare bill cap. +Each namespace has an operator-defined storage-byte quota, entry quota, request-rate limit, and maximum active uploads per principal. Add a deployment-wide byte/entry budget so separate projects and epochs cannot each consume the entire free allowance. Reservations must satisfy both budgets atomically. Pending and ready entries both consume reserved bytes. All upload/retry paths enforce the same limits. Use primary-backed counters for exact reservations; an edge-local rate limiter can be an additional load-shedding measure, not a global quota guarantee. Request and CPU charges can still occur for rejected traffic, so these are storage/admission controls, not a hard Cloudflare bill cap. -Results expire 30 days after publication. Reads and duplicate uploads do not extend the lifetime. Expired rows stop being discoverable immediately. Hourly Cron work processes bounded batches and persists a cursor so a backlog does not require one long invocation: +Results expire seven days after publication in the default `free` hosting profile. Operators can select 30-day history or another explicit retention in an explicit custom hosting profile; retention is an operational choice, not a protocol or billing-plan difference. A custom profile can still use Workers Free. Reads and duplicate uploads do not extend the lifetime. Expired rows stop being discoverable immediately. A Cron Trigger runs every five minutes and persists a cursor. Each Free invocation processes at most 16 entries in one bounded batch; Paid can process 256. Group known object keys into bounded R2 delete calls instead of listing the bucket or looping until empty: 1. Atomically claim expired ready entries or abandoned uploads as `deleting`. No commit can win after this transition. 2. Delete only the claimed upload generation's manifest/chunks. Allow a five-minute grace after an upload lease expires to cover in-flight requests, then repeat cleanup of deleting generations. Treat missing objects as successful deletion. 3. Remove chunk rows and release quota only after cleanup succeeds. Retain a short-lived tombstone for late writes and retry cleanup when a receipt arrives for an abandoned upload. A failed delete keeps the row and quota charged until retried. -Configure an R2 lifecycle backstop longer than the maximum published retention plus upload lifetime and grace: 32 days from object creation for the default policy. Do not refresh object ages by overwriting them. [R2 lifecycle deletion is asynchronous](https://developers.cloudflare.com/r2/buckets/object-lifecycles/), so D1 expiry governs visibility. The backstop removes objects left by crashes, lost metadata, or exceptionally late writes. If retention changes, the deployment tooling must update and validate this relationship. Lifecycle alone must not delete still-live artifacts. +Configure an R2 lifecycle backstop longer than the maximum published retention plus upload lifetime and grace: nine days from object creation for seven-day retention, or 32 days for 30-day retention. Use separate prefixes or the longest applicable lifetime if policies share a bucket. Do not refresh object ages by overwriting them. [R2 lifecycle deletion is asynchronous](https://developers.cloudflare.com/r2/buckets/object-lifecycles/), so D1 expiry governs visibility. The backstop removes objects left by crashes, lost metadata, or exceptionally late writes. If retention changes, the deployment tooling must update and validate this relationship. Lifecycle alone must not delete still-live artifacts. -Log request ID, principal ID, operation, status, bytes, duration, and error class. Do not log bearer tokens, private keys, raw request bodies, environment names/values, archive contents, or full source paths by default. Track hit/miss/rejection rates, candidate count, transferred bytes, upload conflicts, active reservations, D1 latency/errors, R2 errors, and cleanup backlog. Alerts should cover sustained authorization failures, signature rejection, storage thresholds, and cleanup lag. Worker Logs and Cloudflare's D1/R2 metrics are sufficient initially; product telemetry must not require a central collection service. +Log request ID, principal ID, operation, status, bytes, duration, and error class. Do not log bearer tokens, private keys, raw request bodies, environment names/values, archive contents, or full source paths by default. Track hit/miss/rejection rates, candidate count, transferred bytes, upload conflicts, active reservations, D1 latency/errors, R2 errors, and cleanup backlog. Alerts should cover sustained authorization failures, signature rejection, storage thresholds, and cleanup lag. Worker Logs and Cloudflare's D1/R2 metrics are sufficient initially; product telemetry must not require a central collection service. Sample successful request logs at 1% by default and bound error logging; no paid analytics or Logpush service is required. Read hits must not write last-access timestamps, token-usage rows, or analytics rows to D1. Exact byte/entry accounting happens at reservation and deletion; approximate edge rate limits and provider usage metrics cover request traffic without a database write on every read. Artifacts and logs may contain source maps, generated source, secrets printed by tasks, and private filenames. They are private project data. Hashing environment values is not encryption and can expose low-entropy values to guessing. Operators choose who can read data and where Cloudflare stores it; signing does not hide it. The public documentation must include task exclusions and credential rotation procedures. @@ -344,7 +350,7 @@ The template's setup flow must: Publish exact tested commands with the implementation. The setup tooling should be idempotent, name every resource it creates, and support upgrades plus explicit teardown. Removing the Worker alone must not be described as removing stored data or ending storage charges. Include a local Miniflare workflow and a Cloudflare deployment smoke test; local emulation alone does not prove production consistency or limits. -Workers Paid is the production baseline because signature/schema processing and cleanup need measured CPU headroom. A Free-plan demonstration may be documented only after the same bounds and load tests pass there. Self-hosted means the software is open and user-operated, not that infrastructure use is unconditionally free. +Workers Free is the default production target for individuals and small teams. Setup selects the `free` hosting profile: seven-day retention, an 8 GB deployment-wide R2 byte budget including pending/deleting objects, and a 20,000-entry budget. Warn at 400 MB of actual D1 storage and at 80% of daily/monthly operation allowances. The `paid` profile changes operational budgets only after the operator selects them; subscribing to Workers Paid does not silently raise storage limits or retention. Section 13 quantifies when staying free is practical and when an upgrade helps. Real Free-plan CPU, cleanup, and usage measurements are a release gate, rather than a reason to require Paid in advance. For the docs action, keep dependency installation and package-manager caching. Add remote credentials only to the `vp run build` step. A protected CI step would supply these proposed values: @@ -369,15 +375,153 @@ This excerpt belongs only in a job authorized to receive the writer credentials. During a canary, the existing task-directory restore/save can remain, but it must stay within the same CI trust boundary and cannot be treated as signed provenance. Once remote-enabled clients and a fresh-run population are verified, remove the task-cache key computation and task-directory restore/save steps. Keep the dependency cache managed by `setup-vp`. To roll back, set remote mode to `off`; builds continue with local caching and can retain/reintroduce the old task-directory optimization. -## 13. Cost and capacity model +## 13. Free and Paid capacity comparison + +The default should stay within free allowances for representative individual and small-team workloads. This section separates provider limits from workload estimates. All prices are USD before tax, checked on 2026-09-07; all allowances assume no other applications consume them. Use a 30-day month and decimal MB/GB for the estimates. Team size alone is not a capacity measure: unique result size, publication frequency, remote hit rate, and retention matter more. + +### Provider allowances and limits + +A Free website/account plan, Workers Free/Paid, and R2 usage billing are separate choices. This service needs no Pro/Business website plan and can use `workers.dev`. [R2 must be enabled as a subscription](https://developers.cloudflare.com/r2/get-started/), even when usage is covered by its free allowance. R2 overages can produce charges while Workers remains Free; upgrading Workers does not increase the R2 free allowance. The setup guide must explain this distinction using Cloudflare's [billing model](https://developers.cloudflare.com/billing/understand/how-billing-works/). + +| Workers resource | Free | Paid Standard | +| ---------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Subscription | $0 | $5/month minimum | +| Dynamic requests | 100,000/day; daily hard limit | 10 million/month included; then $0.30/million; no equivalent daily quota | +| HTTP CPU | 10 ms per invocation | 30 million CPU ms/month included; then $0.02/million CPU ms; 30 s per-invocation default, configurable to 5 min | +| Cron CPU at this design's five-minute interval | 10 ms per invocation | 30 s per invocation | +| Memory | 128 MB per isolate | 128 MB per isolate | + +Sources: [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) and [runtime limits](https://developers.cloudflare.com/workers/platform/limits/). Network/storage wait time does not consume Worker CPU. A monthly average below 3 million requests does not prove Free eligibility: one busy day can exceed the daily limit. The 10 ms CPU limit applies to each request and each Cron invocation, not an average across them. + +| D1 resource | Free | Paid Standard | +| ----------------------------- | ------------------------------------- | ---------------------------------------------------------- | +| Rows read | 5 million/day | 25 billion/month included; then $0.001/million | +| Rows written | 100,000/day | 50 million/month included; then $1/million | +| Stored data | 5 GB/account; **500 MB per database** | 5 GB included; then $0.75/GB-month; **10 GB per database** | +| Queries per Worker invocation | 50 | 1,000 | + +Sources: [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) and [D1 limits](https://developers.cloudflare.com/d1/platform/limits/). Count index maintenance and eventual deletion as writes, not just inserted result rows. Free daily exhaustion prevents further database work until reset; it does not automatically convert the service to Paid. Multiple deployments in one account share the daily allowance. + +| R2 Standard resource | Included with either Workers plan | Beyond the included allowance | +| -------------------------------------- | --------------------------------- | ----------------------------- | +| Storage | 10 GB-month/month | $0.015/GB-month | +| Class A operations, including PUT/LIST | 1 million/month | $4.50/million | +| Class B operations, including GET/HEAD | 10 million/month | $0.36/million | +| Internet egress; object DELETE | No charge | No charge | + +Source: [R2 pricing](https://developers.cloudflare.com/r2/pricing/). Included usage is account-wide, not per bucket. R2 storage billing uses the monthly average of daily peak usage; include pending uploads, expired objects awaiting deletion, and abandoned objects. R2 rounds billable storage/operation units up, so a small operation overage can cost a whole additional million-operation unit. The reference service uses Standard storage; Infrequent Access does not have the same free tier. + +### Model: translate task activity into billable work + +Let `L` be daily remote lookups **after local misses**, `H` remote hits, `U` new successful publications, `M` average manifests downloaded per lookup, `C` chunks per artifact, `S` mean stored MB per newly published result, and `R` retention days. For `S`, count the compressed `tar.zst` bytes (outputs and terminal events) plus the validation manifest's bytes in R2. Account for the signed descriptor in D1. Local hits make no service request. Retried or conflicting publications still consume operations, even though they do not add retained results. + +A publication takes one descriptor reservation, one manifest PUT, `C` chunk PUTs, and one commit. A hit takes discovery, its candidate manifest downloads, and `C` chunk GETs. Unmatched candidates also cause manifest reads. Use these planning equations: + +```text +Worker invocations/day = ceil(1.10 * (L * (1 + M) + H * C + U * (C + 3))) + 300 +R2 Class A/month = ceil(30 * 1.10 * U * (C + 1)) +R2 Class B/month = ceil(30 * 1.10 * (M * L + C * H + (C + 1) * U)) +Retained R2 GB = U * S * R / 1000 +``` + +The 10% reserve covers ordinary retries, capabilities, conflicts, and cleanup overhead; 300 extra daily invocations conservatively cover the 288 Cron runs plus routine management. Normal GC deletes known object keys without an R2 LIST per result. R2 HEAD checks at commit are included. Reserve more for outages, deep candidate searches, or many small invocations; these formulas are not an upper bound against arbitrary traffic. + +D1 costs depend on the actual schema and query plans. Until the implementation is measured, budget **64 rows read per lookup plus 32 per publication**, and **40 rows written over the complete life of a one-chunk result**, including indexes, receipts, quota counters, publication, GC, and tombstones. Reserve another 10% plus 5,000 daily reads/1,000 daily writes for maintenance. These are engineering budgets, not Cloudflare per-request rates: + +```text +D1 reads/day = ceil(1.10 * (64 * L + 32 * U)) + 5000 +D1 writes/day = ceil(1.10 * 40 * U) + 1000 +D1 storage MB = retained_results * 4096 / 1000000 # provisional one-chunk mean +``` + +The 4 KiB metadata estimate includes an ordinary small descriptor and indexes; an 8 KiB maximum descriptor or a 16-chunk result costs more. Measure `rows_read`, `rows_written`, and actual database bytes, including migrations and cleanup, before claiming these budgets. Additional chunks add receipt/index work; the one-chunk D1 equation must not be applied unchanged to large artifacts. + +### Result-size assumption and measurement + +Use **5 MB for a small-output scenario**, not a measured average or artifact-size limit. Measurements of four established products' published Vite frontend outputs on 2026-09-07 give the following comparison. The [artifact study](remote-cache-size-study/README.md) includes pinned sources, exact byte counts, and a reproduction script. + +| Product and sampled release | Output files MB | `tar.zst` MB | `tar.zst` MB without source maps | +| ------------------------------- | --------------: | -----------: | -------------------------------: | +| Directus `@directus/app@17.1.1` | 20.81 | 6.97 | 6.97 | +| Docmost `v0.95.0` | 14.83 | 4.77 | 4.77 | +| Hoppscotch `2026.8.0` | 127.53 | 32.18 | 12.18 | +| n8n `n8n-editor-ui@2.16.2` | 162.76 | 34.71 | 13.15 | + +These measurements isolate frontend output files from official npm releases or Docker build COPY layers and recompress them with zstd level 3. They exclude backend code, dependencies, terminal events, and the proposed validation manifest. We did not rebuild the projects or measure their private cloud deployments. The map-free column is a sensitivity calculation; the cache must preserve source maps when the task requires them. + +Three of these four releases exceed 5 MB after compression. Add **50 MB as a complete-result planning case for mature SaaS frontends**, with room above the sampled frontend archives, and 100 MB as a stress case. Neither value is a measured population average or a guaranteed upper bound. At 200 new results/day and seven-day retention, a 50 MB mean needs 70 GB, so the earlier 7 GB estimate applies only to the 5 MB scenario. The Free-capacity estimates remain conditional on workload and measured service CPU. + +Measure one cached task execution at a time. A monorepo run can publish several task results. A GitHub Actions archive of the whole local cache can contain many tasks and historical versions, so its size cannot substitute for `S`. Tasks with only terminal output, library builds, and application or documentation builds need separate samples; file inventories and compressibility differ. + +Before using these scenarios to support a claim about average users: + +1. Sample small repositories and monorepos across normal developer and CI changes. Include the docs canary, builds with source maps and static assets, and tasks with no output files. Record task type, compatibility identity, and observation period. +2. Measure the proposed remote archive and manifest bytes for each new stored result. Existing local output archives provide preliminary output-size data; add the proposed terminal-event encoding and validation manifest before estimating remote storage. Count separate stored copies across namespaces or compatibility identities, while excluding read hits and retries that create no new copy. +3. Compute `S = total newly stored bytes / new stored results / 1,000,000`. Report the mean, median, p95, maximum, and sample counts by task type and repository. Weight the storage mean by new publications; an average of repository averages can hide frequently rebuilt large tasks. +4. Record daily new bytes and peak retained bytes over at least two retention windows. Include pending uploads and cleanup lag. Report the fraction of sampled deployments that fit the default budget and the sample's limits before extrapolating to most users. + +The release-artifact sample supplies size evidence for four frontend builds. It does not measure publication rates, hit rates, or the mix of tasks across users. At 200 new results/day and seven-day retention, the 1, 5, 20, 50, and 100 MB sensitivity cases require 1.4, 7, 28, 70, and 140 GB respectively, before pending uploads and cleanup lag. + +### Illustrative workloads + +Assume 80% of remote lookups hit (`H = 0.8 * L`), every remaining lookup publishes one new result (`U = 0.2 * L`), one manifest is read per lookup even on misses (`M = 1`), and every artifact fits one chunk (`C = 1`). These are illustrative user profiles, not measured usage statistics. Averaging 5 ms Worker CPU per modeled invocation is a **Paid-cost assumption**; Free eligibility still requires every operation class to stay within its 10 ms limit. + +| Workload | Remote lookups/day | New results/day | Mean result | Retention | Retained R2 | Worker invocations/day | D1 writes/day | +| -------------------------------- | -----------------: | --------------: | ----------: | --------: | ----------: | ---------------------: | ------------: | +| Individual | 100 | 20 | 1 MB | 7 days | 0.14 GB | 696 | 1,880 | +| Small-team scenario | 500 | 100 | 5 MB | 7 days | 3.5 GB | 2,280 | 5,400 | +| Active small-team scenario | 1,000 | 200 | 5 MB | 7 days | 7 GB | 4,260 | 9,800 | +| Same active team, longer history | 1,000 | 200 | 5 MB | 30 days | 30 GB | 4,260 | 9,800 | +| Same active team, larger outputs | 1,000 | 200 | 20 MB | 7 days | 28 GB | 4,260 | 9,800 | +| Mature SaaS frontend scenario | 1,000 | 200 | 50 MB | 7 days | 70 GB | 4,260 | 9,800 | +| Growing team | 20,000 | 4,000 | 5 MB | 7 days | 140 GB | 79,500 | 177,000 | +| High volume | 100,000 | 20,000 | 5 MB | 7 days | 700 GB | 396,300 | 881,000 | + +| Workload | Workers Free plus R2 | Workers Paid plus R2 | Recommended choice | +| -------------------- | ------------------------------------------ | -------------------- | ------------------------------------------------------------------------- | +| Individual | $0 | About $5/month | Default Free profile | +| Small team | $0 | About $5/month | Default Free profile | +| Active small team | $0 | About $5/month | Default Free profile; monitor storage | +| Longer history | About $0.30/month in R2 | About $5.30/month | Keep Workers Free; explicitly allow more R2 storage if 30 days are useful | +| Larger outputs | About $0.27/month in R2 | About $5.27/month | Keep Workers Free with a larger paid storage budget, or shorten retention | +| Mature SaaS frontend | About $0.90/month in R2 | About $5.90/month | Keep Workers Free if CPU fits; shorten retention or allow more R2 storage | +| Growing team | Cannot sustain the modeled D1 daily writes | About $6.95/month | Paid compute/D1 plus an explicit storage budget | +| High volume | Exceeds Free Workers/D1 limits | About $21.01/month | Paid, with load testing and larger operational budgets | + +The $0 rows fit the default 8 GB object budget and provider operation allowances, subject to measured CPU and cleanup behavior. The other rows assume the operator raises application budgets; the default profile would stop accepting more data. D1 read estimates range from 12,744/day for the individual to 82,440/day for the active small team, well below 5 million. The active team retains about 5.7 MB of modeled D1 data, below the 500 MB database limit. Its monthly R2 workload is 13,200 Class A and 72,600 Class B operations. This is why storage is the first expected limit for these profiles. + +For the high-volume Paid row, 11.889 million monthly Worker invocations at 5 ms use 59.445 million CPU ms. Worker subscription plus overages is about $6.16. R2 storage is `(700 - 10) * $0.015 = $10.35`; 1.32 million Class A operations cost $4.50 after the free allowance and rounding; 7.26 million Class B operations remain included. Modeled D1 usage is 232.47 million reads, 26.43 million writes, and roughly 573 MB stored, within Paid's included allowances. Total: about $21.01. Other account use, tax, logs beyond included allowances, and higher CPU/retry rates can change the bill. + +### How much fits, and what should trigger payment? + +With the same 80% hit rate and seven-day retention, the 8 GB application budget supports: + +| Mean stored result | New results/day within 8 GB | Remote lookups/day at 80% hits | New results/day if keeping 30 days | +| ------------------ | --------------------------: | -----------------------------: | ---------------------------------: | +| 1 MB | 1,142 | 5,710 | 266 | +| 5 MB | 228 | 1,140 | 53 | +| 20 MB | 57 | 285 | 13 | +| 50 MB | 22 | 110 | 5 | +| 100 MB | 11 | 55 | 2 | + +These are steady-state storage ceilings rounded down, not recommended operating targets. Retention lag and upload reservations also consume the budget. The 100 MB case concerns storage only; its archive can require multiple chunks, so use the general operation equations with the actual `C`. With 5 MB results, changing remote hit rate from 80% to 50% reduces the supported lookups from 1,140 to 456/day; raising it to 95% increases them to 4,560/day. A large number of reads can be cheap when few new artifacts are stored. Large output sets or low reuse can fill a free cache even for one developer. + +Ignoring storage, the common-mix model reaches Free's daily Workers quota at about 25,176 lookups, D1's read quota at about 64,501, and D1's write quota at about 11,250. Use approximately 8,977 lookups/day as the 80%-of-write-allowance planning threshold, not the hard ceiling; the 20,000-entry budget and storage budget can bind earlier. Actual CPU or burst load may bind earlier. For tiny artifacts, D1 writes can therefore become the first limit; a paid upgrade is not determined by user count alone. + +At the assumed 5 ms average, Paid's included 30 million CPU ms cover about 6 million invocations/month, equivalent to roughly 50,000 daily lookups in this model. R2 storage for that workload is additional; $5 is the compute subscription, not an all-inclusive 350 GB cache plan. Beyond included usage, charges grow with operations and retained bytes. Paid has no single maximum number of builds: per-request limits, D1 database size, query latency, and concurrency still apply. D1 executes queries serially per database; test peaks and partition projects into separate databases/deployments when needed, rather than inferring throughput from a monthly allowance. -Use R2 Standard because cache reads are frequent and results expire relatively quickly. As checked on 2026-09-07, [R2 Standard pricing](https://developers.cloudflare.com/r2/pricing/) lists $0.015/GB-month, $4.50/million Class A operations, $0.36/million Class B operations, and no Internet egress charge. [Workers Standard pricing](https://developers.cloudflare.com/workers/platform/pricing/) includes a $5 monthly subscription, 10 million requests, and 30 million CPU milliseconds, then $0.30/million requests and $0.02/million CPU milliseconds. [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) depends on rows read/written and stored bytes, with included monthly allowances on Paid. Recheck these rates before publishing the deployment guide. +### Decisions to keep normal use free -Count one reservation, one commit, and `C` upload requests for a result with `C` chunks. A successful cold remote hit uses one or more discovery requests, `M` manifest reads, and `C` chunk reads. R2 also receives checksum/receipt recovery checks, commit-time HEADs, cleanup lists/deletes, and manifest writes. D1 index maintenance and quota operations add rows written. A task cache hit is therefore not one billable HTTP operation. +1. Default to Workers Free, seven-day retention, 8 GB of reserved object bytes, and 20,000 retained/pending entries across the deployment. Storage is Standard; custom domains, Queues, Durable Objects, and paid analytics are not prerequisites. A full cache rejects new reservations and preserves existing results until expiry; it never drops verification or silently buys capacity. +2. Parse and verify only the 8 KiB signed descriptor in the Worker. Stream the full manifest and artifact to R2 with checksum verification. Keep cryptographic file hashing, input validation, archive work, and decompression on the client. Do not solve Free CPU limits by disabling signatures or excluding inferred inputs. +3. Avoid D1 writes on read hits. Use indexed candidate queries, page/transfer bounds, local promotion, and per-invocation memoization. Charge storage reservations exactly; track request usage through provider metrics rather than a global SQL update per GET. +4. Keep Free cleanup to one batch of at most 16 entries every five minutes: at most 4,608 reclaimed entries/day before retries. Bound D1 queries and R2 calls per invocation, coalesce deletes, and retain the cursor on CPU/error interruption. Pause new publications when cleanup falls behind. Paid's 256-entry batches provide more cleanup headroom without changing cache semantics. +5. Alert at 80% of service allowances and expose actual storage, publication count, average result size, retention, and quota skips through the operator CLI. Include all projects, environments, abandoned uploads, and other Cloudflare applications in the account budget. R2's free allowance is not a hard spending cap; delayed usage metrics, exceptional orphaned bytes, and abusive traffic prevent a guarantee of a zero bill. +6. Offer the cheapest relevant next step: shorten retention or exclude low-value outputs first; permit a small R2 storage charge if storage alone is limiting; select Workers Paid when CPU, daily requests, D1 writes, or the database limit requires it. Never recommend the $5 subscription solely because stored artifacts exceed 10 GB. -For an illustrative small team, assume 100 GB-month retained in R2, 100,000 monthly publications, 1 million remote hits, one chunk/result, and one candidate/hit. That is approximately 3.3 million client requests before misses, retries, capabilities, and cleanup. Assuming 5 ms Worker CPU/request, the core workload uses about 16.5 million CPU ms and fits the cited Workers allowances. The 200,000 object PUTs and roughly 2.2 million GET/HEAD operations also fit R2's monthly allowances of 1 million Class A and 10 million Class B operations. With an otherwise unused account allowance, R2 storage costs `(100 - 10) * $0.015 = $1.35`; adding Workers gives approximately $6.35/month before tax, assuming D1 stays within its included allowances. Budget separately for misses, extra candidates, retries, cleanup, and other account usage; apply billable-unit rounding when allowances are exceeded. This is a workload estimate, not a benchmark or price guarantee. +Free support is a release requirement. Measure the individual and both small-team profiles on a real Workers Free deployment, with p99 CPU below 8 ms and no CPU-limit errors for descriptor verification, maximum-size streamed manifest/chunks, candidate pages, and GC. Verify quota behavior and recovery across daily resets, and ensure GC sustains the tested publication rate. If a path exceeds budget, optimize or split that path before claiming Free compatibility. A low average CPU value alone is insufficient. -D1 is a metadata service, not an unlimited global coordinator. Its [documented per-database limits](https://developers.cloudflare.com/d1/platform/limits/) include 10 GB on Paid and serialized query execution. Use indexed, bounded queries and measure p95 latency and queueing. When a deployment approaches its metadata/throughput limit, partition projects into separate deployments/databases. Do not add a global shared D1 database as an implicit SaaS scale-out strategy. +Record artifacts from representative small repositories and monorepos, their actual average/p95 sizes, manifest sizes, publication rates, hit rates, and SQL counters during the canary. The RFC supports a plausible zero-cost target for individuals and small teams; claiming that **most average users** fit it requires those workload measurements. Publish the measured coverage and the formulas so users can estimate their own costs rather than relying on a universal free-user count. ## 14. Alternatives and tradeoffs @@ -402,7 +546,7 @@ This RFC does not implement or deploy the service. Approval starts the following 2. **Untrusted artifact handling.** Implement bounded canonical archive creation, signature verification, staged extraction, guarded replacement, journal recovery, and local provenance. Test hostile/truncated archives, digest failures, symlink/reparse escapes, case collisions, executable bits, permissions/disk failures, rollback, and cancellation. Fuzz manifest parsing and extraction boundaries before enabling remote downloads. 3. **Cloudflare reference service.** Implement the versioned API, D1 migrations, R2 chunk transfer, permissions, immutable commit, quotas, and GC. Ship a machine-readable OpenAPI contract and conformance suite. Verify duplicate writers, identical retries, different-body retries, failed uploads/receipts/commits, expired leases, late writes, missing objects, revoked principals/keys, concurrent quota reservations, and cleanup/commit races against a real isolated Cloudflare deployment. 4. **Native client and configuration.** Integrate after local misses and after successful local updates, add transfer deadlines/concurrency, secret filtering, provenance policy, upload draining, and diagnostics. Cover task disables, CLI precedence, environment overrides, credential absence, `401`/`403`/`429`/`5xx`, service outage, cancellation, and normal process exit while uploads remain pending. -5. **Deployment and canary.** Ship the template and operator guide, exercise clean-account setup/upgrade/rollback/teardown, and migrate the docs action in a separate change. Measure latency, transfer sizes, CPU, D1 rows, storage, and recomputation avoided with actual docs builds before declaring the default limits suitable. +5. **Deployment and canary.** Ship the template and operator guide, exercise clean-account Free setup and explicit Paid upgrade/rollback/teardown, and migrate the docs action in a separate change. Verify section 13's Free CPU and usage budgets, cleanup throughput, and representative workload coverage. Measure latency, transfer sizes, CPU, D1 rows, storage, and recomputation avoided with actual docs builds before declaring the default limits suitable. The end-to-end suite must prove the user outcome, not just successful API round trips: @@ -424,4 +568,4 @@ The proposal makes implementable defaults, but these product tradeoffs deserve e - Is mandatory publisher signing with individual keys an acceptable setup cost for the first release? The proposed default is yes, with key generation and configuration handled by the setup CLI. - Is same-platform reuse sufficient for the first release? The proposed default is yes; cross-platform reuse requires an explicit portable-task contract and separate fixtures. - Should developer-to-release-CI sharing be enabled by default? The proposed default is no; teams can opt into the shared `team` namespace and its publisher policy. -- Do measured docs and representative monorepo workloads fit the proposed discovery, metadata, archive, and timeout limits? Keep the bounds in version 1, and adjust defaults from canary evidence without weakening validation. +- Do measured docs and representative monorepo workloads fit the default Free profile and the proposed discovery, metadata, archive, and timeout limits? Keep the bounds in version 1, and adjust defaults from canary evidence without weakening validation. Workers Paid is an explicit scale/capacity option, not a deployment prerequisite. diff --git a/docs/rfcs/remote-cache-size-study/README.md b/docs/rfcs/remote-cache-size-study/README.md new file mode 100644 index 000000000..64a7f1f24 --- /dev/null +++ b/docs/rfcs/remote-cache-size-study/README.md @@ -0,0 +1,79 @@ +# Vite SaaS frontend artifact measurements + +Measured on 2026-09-07 for [the remote-cache RFC](../0001-remote-cache.md#13-free-and-paid-capacity-comparison). + +The four sampled frontend outputs compress to **4.77–34.71 MB** with zstd level 3. A 5 MB result is a useful small-output case, but three of these four releases exceed it. Use a 50 MB planning case for mature SaaS frontends alongside the smaller scenarios. This sample does not establish the average cache size across users or tasks. + +## Projects and scope + +We selected established products with public source and downloadable release artifacts. Each repository had more than 20,000 GitHub stars when checked; this selects recognizable projects, not a random sample of Vite users. The sample includes commercial products with different source licenses. + +| Project | Product | GitHub stars at observation | Measured release | Published frontend output | +| ------------------------------------------------------ | ---------------------------- | --------------------------: | -------------------------------------- | -------------------------------------------------- | +| [Directus](https://github.com/directus/directus) | Data platform / headless CMS | 37,786 | `@directus/app@17.1.1`, from `v12.3.1` | npm package `dist/` | +| [Docmost](https://github.com/docmost/docmost) | Collaborative wiki | 21,602 | `v0.95.0` | Docker build's `/app/apps/client/dist/` COPY layer | +| [Hoppscotch](https://github.com/hoppscotch/hoppscotch) | API development platform | 80,228 | `2026.8.0` | Docker build's `/site/selfhost-web/` COPY layer | +| [n8n](https://github.com/n8n-io/n8n) | Workflow automation | 203,576 | `n8n-editor-ui@2.16.2` | npm package `dist/` | + +These are measurements of **published build products**. We did not run local builds or measure the vendors' private cloud deployments. Docker samples use `linux/amd64` manifests and the layer that copies the frontend from the build stage, before runtime dependency installation or startup transformations. We exclude the Docker base image, server code, dependencies, and unrelated npm package files. + +The pinned source confirms Vite usage: + +| Project | Build evidence | Output/configuration evidence | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Directus | [`build: vite build`](https://github.com/directus/directus/blob/973be10df8b0305569dc0dc53e187c648133c8d6/app/package.json) | [Vite configuration](https://github.com/directus/directus/blob/973be10df8b0305569dc0dc53e187c648133c8d6/app/vite.config.js) | +| Docmost | [`build: tsc && vite build`](https://github.com/docmost/docmost/blob/4132dd597c956a27423607d008708c0e214690da/apps/client/package.json) | [Vite configuration](https://github.com/docmost/docmost/blob/4132dd597c956a27423607d008708c0e214690da/apps/client/vite.config.ts), [Dockerfile COPY](https://github.com/docmost/docmost/blob/4132dd597c956a27423607d008708c0e214690da/Dockerfile) | +| Hoppscotch | [`generate` calls `build`, which invokes Vite](https://github.com/hoppscotch/hoppscotch/blob/ac145e7f758151b41fd46d3e5f513886ce9068ba/packages/hoppscotch-selfhost-web/package.json) | [Vite configuration](https://github.com/hoppscotch/hoppscotch/blob/ac145e7f758151b41fd46d3e5f513886ce9068ba/packages/hoppscotch-selfhost-web/vite.config.ts), [production Dockerfile](https://github.com/hoppscotch/hoppscotch/blob/ac145e7f758151b41fd46d3e5f513886ce9068ba/prod.Dockerfile) | +| n8n | [`build` invokes `vite build`](https://github.com/n8n-io/n8n/blob/9bdde69954a4d7d1569d37b6fd3a3f55f55b297a/packages/frontend/editor-ui/package.json) | [Vite configuration](https://github.com/n8n-io/n8n/blob/9bdde69954a4d7d1569d37b6fd3a3f55f55b297a/packages/frontend/editor-ui/vite.config.mts) | + +[sources.json](sources.json) records artifact URLs, SHA-256 digests, source commits, npm provenance URLs, and Docker manifest/layer digests. We checked npm downloads against their registry SHA-512 integrity values and Docker layers against their SHA-256 digests. We obtained npm source commits from the registry's provenance records; the Docker source references identify the corresponding release tags. We did not verify attestation signatures or independently rebuild the releases. + +## Measured sizes + +All sizes use decimal MB (`1 MB = 1,000,000 bytes`). “Output” sums regular-file bytes, including static assets and source maps present in the selected directory. “Archive” measures a single sorted GNU tar stream compressed with `zstd -3 -T1`, using zstd `1.5.7`. We normalize tar paths and metadata; archive sizes approximate the proposed cache format rather than reproducing an implemented remote-cache archive byte for byte. + +| Project | Files | Output MB | Archive MB | Source-map MB before compression | Archive MB with `.map` files omitted | +| ---------- | ----: | --------: | ---------: | -------------------------------: | -----------------------------------: | +| Directus | 401 | 20.81 | **6.97** | 0 | 6.97 | +| Docmost | 230 | 14.83 | **4.77** | 0 | 4.77 | +| Hoppscotch | 471 | 127.53 | **32.18** | 89.82 | 12.18 | +| n8n | 1,477 | 162.76 | **34.71** | 118.66 | 13.15 | + +Source maps comprise about 70% of Hoppscotch's uncompressed output and 73% of n8n's. Their configurations enable source maps for these builds; n8n also enables its legacy-browser plugin for releases. Hoppscotch includes a TypeScript worker, while n8n includes worker and WebAssembly assets. A page's initial JavaScript download omits much of this build output and cannot substitute for the cache size. + +The last column is a controlled sensitivity calculation on the same files, not another build. Removing maps reduces the compressed archives to 12.18 MB and 13.15 MB respectively, still above 5 MB. A cache must preserve the outputs required by its task; these measurements do not justify silently dropping source maps. For Directus and Docmost, the published frontend directories contain no `.map` files. + +These archives contain frontend output files only. The proposed cache also stores terminal events and an inferred-input validation manifest. A real cached task can produce additional files outside `dist/`; release packaging can omit such files. Measure those bytes during implementation before assigning a complete per-result size. Backend and shared-package builds are separate task results unless the operator caches them as one task. + +## Effect on the free storage budget + +Using the RFC's 8 GB application budget and seven-day retention, the measured output archives alone give these steady-state ceilings: + +| Project-sized result | New results/day within 8 GB | Storage at 200 new results/day, seven days | +| -------------------- | --------------------------: | -----------------------------------------: | +| Directus | 164 | 9.75 GB | +| Docmost | 239 | 6.68 GB | +| Hoppscotch | 35 | 45.06 GB | +| n8n | 32 | 48.59 GB | + +Calculate the ceiling as `floor(8,000,000,000 / (archive_bytes * 7))`. These are artifact-only upper bounds; manifests, logs, pending uploads, and delayed deletion lower them. A new cache key stores another complete archive in the proposed version 1 service, even when many assets match the previous build. Read hits do not create another copy. Count separate namespaces and compatibility identities where the service stores separate results. + +For a **50 MB complete-result planning case**, the same budget supports at most **22 new results/day** with seven-day retention, before operational headroom. At 200 new results/day, storage reaches 70 GB. With other usage assumptions unchanged, raising the storage budget would cost about **$0.90/month in R2 storage** beyond the 10 GB included allowance; Workers could remain Free. This uses the RFC's steady-state 30-day billing model and [R2 Standard pricing](https://developers.cloudflare.com/r2/pricing/), checked on the measurement date. The default profile would reject additional publications instead of raising its budget. + +Keep 5 MB for a small-output scenario, add 50 MB for mature frontend builds, and retain a 100 MB stress case. These values are planning inputs, not estimates of population averages. A few full-frontend publications per day can fit the free budget; hundreds of publications per day need smaller results, shorter retention, or additional storage. The number of developers does not determine which case applies. + +This sample covers one release per product and favors mature applications. It does not measure a publication-weighted average, daily change rate, cache hit rate, or the fraction of ordinary users that stay free. The RFC's canary still needs those measurements across task types and successive changes. + +## Reproduce + +Run from the repository root with Python 3 and the `zstd` CLI installed: + +```sh +python3 docs/rfcs/remote-cache-size-study/measure.py \ + --cache-dir /tmp/vite-cache-size-study \ + --output /tmp/vite-cache-size-study-results.json +``` + +The script downloads about 84 MB of pinned npm archives and Docker layers, checks SHA-256 digests, and decompresses them into temporary tar files for reading. It does not install packages, execute project code, or start containers. It writes compressed comparison archives in the cache directory. Allow about 600 MB of disk space. Registry availability and anonymous Docker pull limits can affect reruns. + +[results.json](results.json) contains exact byte counts, compressed archive digests, file-type totals, and the five largest files for each sample. Use those counts for calculations; the tables round values for readability. diff --git a/docs/rfcs/remote-cache-size-study/measure.py b/docs/rfcs/remote-cache-size-study/measure.py new file mode 100644 index 000000000..795e7da43 --- /dev/null +++ b/docs/rfcs/remote-cache-size-study/measure.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Measure pinned, published Vite outputs without installing or running projects. + +Requires Python 3 and the zstd CLI. Usage: + python3 measure.py --cache-dir /tmp/vite-cache-size-study --output results.json +""" + +import argparse +import collections +import gzip +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import tarfile +import urllib.parse +import urllib.request + + +def read_url(url, headers=None): + return urllib.request.urlopen( + urllib.request.Request( + url, headers={"User-Agent": "vite-task-cache-size-study", **(headers or {})} + ), + timeout=60, + ) + + +def sha256(path): + digest = hashlib.sha256() + with path.open("rb") as source: + for block in iter(lambda: source.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def download(sample, cache): + target = cache / (sample["id"] + ".tgz") + if not target.exists() or sha256(target) != sample["sha256"]: + headers = {} + if sample.get("docker_repository"): + query = urllib.parse.urlencode( + { + "service": "registry.docker.io", + "scope": "repository:" + sample["docker_repository"] + ":pull", + } + ) + with read_url("https://auth.docker.io/token?" + query) as response: + token = json.load(response)["token"] + headers["Authorization"] = "Bearer " + token + partial = target.with_suffix(".partial") + with read_url(sample["url"], headers) as response, partial.open("wb") as output: + shutil.copyfileobj(response, output) + if sha256(partial) != sample["sha256"]: + raise ValueError("Source digest mismatch: " + sample["id"]) + partial.replace(target) + return target + + +def compress(source, members, destination): + # Match the engine's ordinary zstd level (3). Normalize GNU tar headers, + # sort paths, and include regular files only. No filesystem extraction. + with destination.open("wb") as output: + process = subprocess.Popen( + ["zstd", "-3", "-T1", "-q", "-c"], stdin=subprocess.PIPE, stdout=output + ) + try: + with tarfile.open(fileobj=process.stdin, mode="w|", format=tarfile.GNU_FORMAT) as archive: + for name, member in members: + header = tarfile.TarInfo("outputs/" + name) + header.size = member.size + header.mode = 0o644 + with source.extractfile(member) as data: + archive.addfile(header, data) + finally: + process.stdin.close() + status = process.wait() + if status: + raise RuntimeError("zstd failed: " + str(status)) + return {"bytes": destination.stat().st_size, "sha256": sha256(destination)} + + +def measure(sample, cache): + packed = download(sample, cache) + unpacked = cache / (sample["id"] + ".tar") + # Decompress once so sorted reads do not repeatedly rewind a gzip stream. + with gzip.open(packed, "rb") as source, unpacked.open("wb") as output: + shutil.copyfileobj(source, output) + + with tarfile.open(unpacked) as source: + prefix = sample["prefix"] + members = [] + for member in source: + if not member.name.startswith(prefix): + continue + name = member.name[len(prefix):] + if member.isdir(): + continue + if not member.isfile() or ".." in PurePosixPath(name).parts or name.startswith("/"): + raise ValueError("Unsupported output member: " + member.name) + members.append((name, member)) + members.sort(key=lambda item: item[0]) + if not members or len({name for name, _ in members}) != len(members): + raise ValueError("Empty or duplicate output paths: " + sample["id"]) + + by_extension = collections.defaultdict(lambda: {"files": 0, "bytes": 0}) + for name, member in members: + group = by_extension[PurePosixPath(name).suffix or "(none)"] + group["files"] += 1 + group["bytes"] += member.size + without_maps = [(name, member) for name, member in members if not name.endswith(".map")] + complete = compress(source, members, cache / (sample["id"] + ".tar.zst")) + no_maps = ( + compress(source, without_maps, cache / (sample["id"] + "-no-maps.tar.zst")) + if len(without_maps) != len(members) + else complete.copy() + ) + return { + "id": sample["id"], + "source_sha256": sample["sha256"], + "source_download_bytes": packed.stat().st_size, + "prefix": prefix, + "files": len(members), + "output_bytes": sum(member.size for _, member in members), + "tar_zstd": complete, + "without_maps": { + "files": len(without_maps), + "output_bytes": sum(member.size for _, member in without_maps), + "tar_zstd": no_maps, + }, + "by_extension": dict(sorted(by_extension.items())), + "largest_files": [ + {"path": name, "bytes": member.size} + for name, member in sorted(members, key=lambda item: item[1].size, reverse=True)[:5] + ], + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cache-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + samples = json.loads(Path(__file__).with_name("sources.json").read_text()) + args.cache_dir.mkdir(parents=True, exist_ok=True) + results = [] + for sample in samples["samples"]: + result = measure(sample, args.cache_dir) + results.append(result) + print( + sample["id"], + "files=" + str(result["files"]), + "output_bytes=" + str(result["output_bytes"]), + "zstd_bytes=" + str(result["tar_zstd"]["bytes"]), + "without_maps_zstd_bytes=" + str(result["without_maps"]["tar_zstd"]["bytes"]), + flush=True, + ) + version = subprocess.check_output(["zstd", "--version"], text=True) + args.output.write_text( + json.dumps( + { + "measurement_date": samples["measurement_date"], + "zstd_version": re.search(r"\bv(\d+\.\d+\.\d+)\b", version).group(1), + "method": "Sorted GNU tar regular files; outputs/ prefix; mode 0644; uid/gid/mtime 0; zstd -3 -T1 stream", + "samples": results, + }, + indent=2, + ) + "\n" + ) + + +if __name__ == "__main__": + main() diff --git a/docs/rfcs/remote-cache-size-study/results.json b/docs/rfcs/remote-cache-size-study/results.json new file mode 100644 index 000000000..93fa5f257 --- /dev/null +++ b/docs/rfcs/remote-cache-size-study/results.json @@ -0,0 +1,355 @@ +{ + "measurement_date": "2026-09-07", + "zstd_version": "1.5.7", + "method": "Sorted GNU tar regular files; outputs/ prefix; mode 0644; uid/gid/mtime 0; zstd -3 -T1 stream", + "samples": [ + { + "id": "directus", + "source_sha256": "aeb56c7f70c6ba09782a5b649d0ab2cdf9cd746ab6dcaaa80bb3db9490f64f94", + "source_download_bytes": 7521159, + "prefix": "package/dist/", + "files": 401, + "output_bytes": 20806766, + "tar_zstd": { + "bytes": 6967198, + "sha256": "bf7b16909bfb430f6c31605e2026b5ae0b6f458040943fcdf9b3a60178cd51ae" + }, + "without_maps": { + "files": 401, + "output_bytes": 20806766, + "tar_zstd": { + "bytes": 6967198, + "sha256": "bf7b16909bfb430f6c31605e2026b5ae0b6f458040943fcdf9b3a60178cd51ae" + } + }, + "by_extension": { + ".css": { + "files": 5, + "bytes": 719721 + }, + ".html": { + "files": 1, + "bytes": 1694 + }, + ".ico": { + "files": 1, + "bytes": 1150 + }, + ".js": { + "files": 369, + "bytes": 17819785 + }, + ".png": { + "files": 1, + "bytes": 14915 + }, + ".svg": { + "files": 1, + "bytes": 155481 + }, + ".woff": { + "files": 11, + "bytes": 972248 + }, + ".woff2": { + "files": 12, + "bytes": 1121772 + } + }, + "largest_files": [ + { + "path": "assets/index.C9zFwJTK.entry.js", + "bytes": 6724943 + }, + { + "path": "assets/shader-background-B-taT_fk.js", + "bytes": 1449199 + }, + { + "path": "assets/index-BMHQmbsG.css", + "bytes": 699610 + }, + { + "path": "assets/dist-chJWdeb_.js", + "bytes": 657240 + }, + { + "path": "assets/social-icon-Dxox5INO.js", + "bytes": 570996 + } + ] + }, + { + "id": "docmost", + "source_sha256": "4e7fffb4a1e8feb4e7213d2e8701c09df286de364177860e1c230e2374a25190", + "source_download_bytes": 5015721, + "prefix": "app/apps/client/dist/", + "files": 230, + "output_bytes": 14828770, + "tar_zstd": { + "bytes": 4769432, + "sha256": "cad27b93f705537b0abf7cfe7cc47dbb416c6e2120bd91dfce7316cc54a00157" + }, + "without_maps": { + "files": 230, + "output_bytes": 14828770, + "tar_zstd": { + "bytes": 4769432, + "sha256": "cad27b93f705537b0abf7cfe7cc47dbb416c6e2120bd91dfce7316cc54a00157" + } + }, + "by_extension": { + ".css": { + "files": 3, + "bytes": 660300 + }, + ".html": { + "files": 1, + "bytes": 1504 + }, + ".js": { + "files": 145, + "bytes": 11997158 + }, + ".json": { + "files": 13, + "bytes": 994411 + }, + ".png": { + "files": 4, + "bytes": 19808 + }, + ".svg": { + "files": 1, + "bytes": 1497 + }, + ".ttf": { + "files": 20, + "bytes": 513664 + }, + ".woff": { + "files": 20, + "bytes": 303116 + }, + ".woff2": { + "files": 23, + "bytes": 337312 + } + }, + "largest_files": [ + { + "path": "assets/index-BgC9hh6t.js", + "bytes": 3469981 + }, + { + "path": "assets/chunk-Z5NKEFVG-B3tsbN9K.js", + "bytes": 1821041 + }, + { + "path": "assets/excalidraw-utils-DacaKd1Y.js", + "bytes": 1350729 + }, + { + "path": "assets/chunk-NNHCCRGN-DlpIbxXb.js", + "bytes": 593667 + }, + { + "path": "assets/chunk-LZXEDZCA-4GiMzlKw.js", + "bytes": 536756 + } + ] + }, + { + "id": "hoppscotch", + "source_sha256": "9823634d487dac47140c008685e31f548e33644ba890c8bba66ba8b776aea98e", + "source_download_bytes": 33455805, + "prefix": "site/selfhost-web/", + "files": 471, + "output_bytes": 127526484, + "tar_zstd": { + "bytes": 32182975, + "sha256": "958a98d1aba6d70f315d593c6549898c803670d995513fb242d3958405bad2e0" + }, + "without_maps": { + "files": 279, + "output_bytes": 37709018, + "tar_zstd": { + "bytes": 12175754, + "sha256": "ed31d530071727007670fbd42e1009091cea063f0df6dc44ad0b82e7a8740c0c" + } + }, + "by_extension": { + "(none)": { + "files": 1, + "bytes": 13 + }, + ".css": { + "files": 13, + "bytes": 305947 + }, + ".html": { + "files": 1, + "bytes": 5219 + }, + ".ico": { + "files": 1, + "bytes": 15086 + }, + ".js": { + "files": 192, + "bytes": 33464992 + }, + ".map": { + "files": 192, + "bytes": 89817466 + }, + ".png": { + "files": 12, + "bytes": 2105842 + }, + ".svg": { + "files": 41, + "bytes": 372789 + }, + ".ttf": { + "files": 1, + "bytes": 121972 + }, + ".txt": { + "files": 1, + "bytes": 66 + }, + ".webmanifest": { + "files": 1, + "bytes": 845 + }, + ".woff2": { + "files": 14, + "bytes": 1313348 + }, + ".xml": { + "files": 1, + "bytes": 2899 + } + }, + "largest_files": [ + { + "path": "assets/index-DSckl1TP.js.map", + "bytes": 37094987 + }, + { + "path": "assets/ts.worker-BAgXcSxE.js.map", + "bytes": 18859801 + }, + { + "path": "assets/index-DSckl1TP.js", + "bytes": 12071781 + }, + { + "path": "assets/har-DRQ_daXH.js.map", + "bytes": 8016993 + }, + { + "path": "assets/ts.worker-BAgXcSxE.js", + "bytes": 7091224 + } + ] + }, + { + "id": "n8n", + "source_sha256": "14636be99659fca25eaa19ed20ec52c5286745eb3e1cef80b699adb23144eb1f", + "source_download_bytes": 37936612, + "prefix": "package/dist/", + "files": 1477, + "output_bytes": 162756849, + "tar_zstd": { + "bytes": 34705298, + "sha256": "7c916ddcdbb090123824840e55669de6500e294d9d419a6cb91cba5c7dc77cac" + }, + "without_maps": { + "files": 875, + "output_bytes": 44092939, + "tar_zstd": { + "bytes": 13146351, + "sha256": "4c1473f0d932be64dd7ee412b7ef1aa9ec0921a02010dc82a61bda7e9ebef21f" + } + }, + "by_extension": { + ".css": { + "files": 149, + "bytes": 1098799 + }, + ".gif": { + "files": 1, + "bytes": 158971 + }, + ".html": { + "files": 1, + "bytes": 18822 + }, + ".ico": { + "files": 1, + "bytes": 15086 + }, + ".js": { + "files": 626, + "bytes": 35334179 + }, + ".map": { + "files": 602, + "bytes": 118663910 + }, + ".png": { + "files": 24, + "bytes": 820355 + }, + ".svg": { + "files": 3, + "bytes": 367325 + }, + ".ttf": { + "files": 21, + "bytes": 569620 + }, + ".wasm": { + "files": 5, + "bytes": 3980250 + }, + ".webp": { + "files": 1, + "bytes": 315064 + }, + ".woff": { + "files": 21, + "bytes": 331316 + }, + ".woff2": { + "files": 22, + "bytes": 1083152 + } + }, + "largest_files": [ + { + "path": "assets/typescript.worker-C3GNzKj8.js.map", + "bytes": 18592487 + }, + { + "path": "assets/constants-j6z_fOkc.js.map", + "bytes": 5567385 + }, + { + "path": "assets/worker-FLvH_Wit.js.map", + "bytes": 5457892 + }, + { + "path": "assets/constants-legacy-i87_3HzL.js.map", + "bytes": 5432841 + }, + { + "path": "assets/typescript.worker-C3GNzKj8.js", + "bytes": 5049634 + } + ] + } + ] +} diff --git a/docs/rfcs/remote-cache-size-study/sources.json b/docs/rfcs/remote-cache-size-study/sources.json new file mode 100644 index 000000000..02cc4c162 --- /dev/null +++ b/docs/rfcs/remote-cache-size-study/sources.json @@ -0,0 +1,71 @@ +{ + "measurement_date": "2026-09-07", + "samples": [ + { + "id": "directus", + "project": "Directus", + "version": "@directus/app 17.1.1 (Directus v12.3.1)", + "url": "https://registry.npmjs.org/@directus/app/-/app-17.1.1.tgz", + "sha256": "aeb56c7f70c6ba09782a5b649d0ab2cdf9cd746ab6dcaaa80bb3db9490f64f94", + "prefix": "package/dist/", + "repository": "https://github.com/directus/directus", + "source_commit": "973be10df8b0305569dc0dc53e187c648133c8d6", + "vite_config": "app/vite.config.js", + "build_script": "app/package.json", + "provenance": "https://registry.npmjs.org/-/npm/v1/attestations/@directus%2fapp@17.1.1", + "stars": 37786 + }, + { + "id": "docmost", + "project": "Docmost", + "version": "v0.95.0", + "url": "https://registry-1.docker.io/v2/docmost/docmost/blobs/sha256:4e7fffb4a1e8feb4e7213d2e8701c09df286de364177860e1c230e2374a25190", + "sha256": "4e7fffb4a1e8feb4e7213d2e8701c09df286de364177860e1c230e2374a25190", + "prefix": "app/apps/client/dist/", + "repository": "https://github.com/docmost/docmost", + "source_commit": "4132dd597c956a27423607d008708c0e214690da", + "vite_config": "apps/client/vite.config.ts", + "build_script": "apps/client/package.json", + "docker_repository": "docmost/docmost", + "image": "docmost/docmost:0.95.0", + "image_manifest": "sha256:2b0a3f73e57951b726bf67259c4e8cb5269eb24530cde521003ff382d7ad8ab6", + "image_platform": "linux/amd64", + "layer_index": 9, + "dockerfile": "Dockerfile", + "stars": 21602 + }, + { + "id": "hoppscotch", + "project": "Hoppscotch", + "version": "2026.8.0", + "url": "https://registry-1.docker.io/v2/hoppscotch/hoppscotch-frontend/blobs/sha256:9823634d487dac47140c008685e31f548e33644ba890c8bba66ba8b776aea98e", + "sha256": "9823634d487dac47140c008685e31f548e33644ba890c8bba66ba8b776aea98e", + "prefix": "site/selfhost-web/", + "repository": "https://github.com/hoppscotch/hoppscotch", + "source_commit": "ac145e7f758151b41fd46d3e5f513886ce9068ba", + "vite_config": "packages/hoppscotch-selfhost-web/vite.config.ts", + "build_script": "packages/hoppscotch-selfhost-web/package.json", + "docker_repository": "hoppscotch/hoppscotch-frontend", + "image": "hoppscotch/hoppscotch-frontend:2026.8.0", + "image_manifest": "sha256:c5acdcfa5e00d3500ff809a33d25b10d36eb2fe2b8ef23e0fd74a157c6abc362", + "image_platform": "linux/amd64", + "layer_index": 18, + "dockerfile": "prod.Dockerfile", + "stars": 80228 + }, + { + "id": "n8n", + "project": "n8n", + "version": "n8n-editor-ui 2.16.2", + "url": "https://registry.npmjs.org/n8n-editor-ui/-/n8n-editor-ui-2.16.2.tgz", + "sha256": "14636be99659fca25eaa19ed20ec52c5286745eb3e1cef80b699adb23144eb1f", + "prefix": "package/dist/", + "repository": "https://github.com/n8n-io/n8n", + "source_commit": "9bdde69954a4d7d1569d37b6fd3a3f55f55b297a", + "vite_config": "packages/frontend/editor-ui/vite.config.mts", + "build_script": "packages/frontend/editor-ui/package.json", + "provenance": "https://registry.npmjs.org/-/npm/v1/attestations/n8n-editor-ui@2.16.2", + "stars": 203576 + } + ] +} From 9fa2a38c6143ef0638bfb448c2da59a0cd1b5a70 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 9 Sep 2026 16:42:13 +0800 Subject: [PATCH 3/6] docs: define public remote cache with GitHub OIDC Co-authored-by: GPT-6 Codex --- docs/rfcs/0001-remote-cache.md | 756 +++++++++----------- docs/rfcs/remote-cache-size-study/README.md | 14 +- 2 files changed, 364 insertions(+), 406 deletions(-) diff --git a/docs/rfcs/0001-remote-cache.md b/docs/rfcs/0001-remote-cache.md index c3929fd8a..e487e8ae5 100644 --- a/docs/rfcs/0001-remote-cache.md +++ b/docs/rfcs/0001-remote-cache.md @@ -1,571 +1,529 @@ -# RFC: Self-hosted remote cache for `vp run` +# RFC: Public remote cache for GitHub projects with `vp run` -Status: Proposed. This document specifies the first version; the APIs and configuration below do not exist yet. +Status: Draft design. -Date: 2026-09-07. Repository baseline: `9a1d32cf`. +Updated: 2026-09-09. Repository baseline: `9a1d32cf`. API baseline: [PR #713](https://github.com/voidzero-dev/vite-task/pull/713), commit [`362f5bd9`](https://github.com/voidzero-dev/vite-task/blob/362f5bd91bb32806b512d3d9a5339ff435bf5f0a/docs/remote-cache-server-api.md). The API proposal remains a draft; check its final contract before implementation. ## 1. Motivation -Developers and CI should reuse successful task results without copying a whole local cache directory between machines. Users must be able to deploy the service into their own Cloudflare account, retain control of its data and credentials, and operate it without a Vite+ hosted account or license service. +Open-source maintainers should publish successful main-branch task results from GitHub Actions to a service in their own Cloudflare account. Developers and fork contributors should reuse these public results without signing in. The service needs no Vite+ hosted account or license service. Local caching remains the first tier; remote read failures fall back to task execution. -The current [docs deployment action](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/.github/actions/deploy-docs/action.yml) restores and saves `docs/node_modules/.vite/task-cache` through GitHub Actions Cache. Its keys include OS, architecture, ref, and commit. Restore prefixes select the latest cache for the ref, then fall back to `main`; task fingerprints determine whether its contents can actually be reused. This is a useful CI bootstrap, but developers cannot use it as a native shared task cache, and each save transfers a directory snapshot. +The current [docs deployment action](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/.github/actions/deploy-docs/action.yml) transfers a whole task-cache directory through GitHub Actions Cache. A native remote cache can transfer one task's metadata and output blob, and make those results available to compatible developer machines. -This RFC proposes an optional second cache tier: check local results, check remote results, then execute. A successful execution remains available locally even if the remote service fails. Remote storage holds individual execution results, including their observed inputs, output files, and terminal output. +This RFC implements PR #713 on Workers, D1, and R2, with anonymous reads and GitHub Actions OIDC authorization for writes. It also sets operational defaults and measures the conditions under which an individual or small team can stay within Cloudflare's free allowances. -## 2. Decisions and scope +## 2. Contract and scope -| Area | Version 1 decision | -| ----------- | -------------------------------------------------------------------------------------------------------- | -| Hosting | Open-source TypeScript Worker, private R2 Standard bucket, D1 database, and a Cron Trigger | -| Ownership | One deployment per organization or team; explicit project and trust namespaces within it | -| Client | Native Rust integration in the execution engine; provider-independent versioned HTTPS protocol | -| Discovery | Bounded candidate lookup followed by local validation of explicit and inferred inputs | -| Identity | SHA-256, canonical portable metadata, and an explicit platform/toolchain compatibility identity | -| Publication | Immutable results; upload all bytes before an atomic D1 transition makes a result visible | -| Trust | Scoped bearer tokens and mandatory publisher signatures; CI and developer write permissions are separate | -| Transfer | Streaming, retryable chunks of at most 64 MiB; at most 1 GiB compressed per result | -| Retention | Seven-day retention by default; optional 30-day history, storage budgets, and incremental cleanup | -| Failure | Remote failures become misses or skipped uploads, with bounded waits and diagnostics | +PR #713 owns the HTTP contract. This RFC owns the Cloudflare storage, authorization, limits, deployment, and cleanup choices. Version 1 requires public reads, repository-scoped write authorization, and namespace isolation for all three endpoints. Client fingerprint formats and cache-validation policy remain client responsibilities. -Version 1 includes local-to-CI, CI-to-local, and CI-to-CI reuse on compatible machines, on macOS, Linux, and Windows. A fresh checkout with an empty local cache must be able to hit an automatically inferred remote result. No Git commit, branch, absolute checkout root, or local database identity is required for a hit. +| Area | Decision | +| -------------- | ------------------------------------------------------------------------------------------------------- | +| Hosting | Open-source TypeScript Worker, private R2 Standard bucket, D1 database, five-minute Cron Trigger | +| Endpoints | `POST /fetch`, `GET /blob/{blob_id}`, `POST /store`, relative to a configured namespace endpoint | +| Data | CBOR envelope; opaque binary keys and values; optional opaque blob | +| Lookup | Exact key first, then one secondary-key association | +| Store | Replace the entry and secondary association together after object storage succeeds | +| Access control | Anonymous reads; GitHub OIDC writes restricted to the registered repository and main-branch push events | +| Transfer | One multipart HTTP store request; internal R2 multipart upload for larger blobs | +| Defaults | Seven-day retention, 8 GB total R2 budget, 64 MiB maximum blob | +| Failure | Bounded waits; read failures become misses; explicit push reports publication failures | -Cross-OS and cross-architecture reuse are outside version 1. For example, a macOS developer can share with macOS CI, or use a Linux development container to share with matching Linux CI. A later portable-task mode needs a separate correctness contract. Also excluded are remote execution, a hosted SaaS, anonymous/public caches, OIDC federation, a web dashboard, cross-project content deduplication, and direct compatibility with Nx or Turborepo clients. +The first delivery includes a self-deployment template and a native Rust client adapter. Cross-platform client behavior must work on macOS, Linux, and Windows. Reuse between different OS/architecture combinations requires a separate client compatibility agreement. Remote execution, a hosted SaaS, anonymous writes, a web dashboard, and cross-project deduplication are outside this plan. Private caches and Cloudflare One authorization are version 2 work. -## 3. Current implementation and implications +## 3. Relationship to the current local cache -The following are source identifiers, not proposed public API names: +The current engine already separates exact lookup from a task association used to explain misses: -| Evidence at the baseline | Consequence for this design | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`ExecutionCache` and `CacheEntryKey`](../../crates/vt/src/session/cache/mod.rs) store SQLite rows keyed by spawn fingerprint and resolved input/output configuration. Input contents are in the value. | The existing key is a discovery key, not a complete immutable result key. Simply exposing `GET`/`PUT` for that key would overwrite results from other revisions. | -| [`ExecutionCache::try_hit`](../../crates/vt/src/session/cache/mod.rs) compares explicit glob inputs and then validates the post-run fingerprint. | A remote hit must do both checks on the receiving machine. A server lookup alone cannot establish a hit. | -| [`PostRunFingerprint`](../../crates/vt/src/session/execute/fingerprint.rs) records file contents, missing paths, directories and their optional entry lists, tracked environment variables, and bulk environment queries. | A remote manifest must preserve these observations, including negative dependencies and environment match sets. A plain output archive is insufficient. | -| [`ExecutionCacheKey` and `SpawnFingerprint`](../../crates/vt_plan/src/cache_metadata.rs) distinguish task identity from execution configuration. Outside-workspace programs are identified by name. | Keep execution-based sharing between equivalent tasks, but add toolchain identity before sharing between machines. | -| [`TrackedPathAccesses::from_raw`](../../crates/vt/src/session/execute/tracked_accesses.rs) drops outside-workspace and `.git` accesses. | Existing inference is not a hermetic build guarantee. External dependencies need a declared environment contract. | -| [`update_cache`](../../crates/vt/src/session/execute/cache_update.rs) rejects failures, cancellation, incomplete tracking, inferred read/write overlap, and tool-requested cache disabling. | Remote publication must inherit every eligibility check. It must not export arbitrary successful process output. | -| [`archive`](../../crates/vt/src/session/cache/archive.rs) writes regular files as `tar.zst`; [`replay_cache_hit`](../../crates/vt/src/session/execute/mod.rs) replays terminal output before extraction. | Remote import needs a bounded, validated staging path and must complete restoration before reporting a hit or replaying output. | -| [`cache_schema_dir_name`](../../crates/vt/src/session/cache/mod.rs) selects local schema `v18`; file hashes use `xxHash3_64`, while [`EnvValueHash`](../../crates/vt_plan/src/envs.rs) uses SHA-256. | Do not publish SQLite files, Rust memory layouts, `wincode` values, or existing non-cryptographic input hashes as the wire format. | +| Source evidence | Client integration consequence | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [`ExecutionCache::try_hit` and `CacheEntryKey`](../../crates/vt/src/session/cache/mod.rs) use the spawn fingerprint and resolved input/output configuration for exact lookup. | Construct `key` before execution. Input changes can replace the value at the same key. | +| [`ExecutionCacheKey`](../../crates/vt_plan/src/cache_metadata.rs) identifies the task for the diagnostic association. | Supply a corresponding `secondary_key`; a fallback result can explain what changed. | +| [`PostRunFingerprint`](../../crates/vt/src/session/execute/fingerprint.rs) and explicit glob checks validate observed inputs. | An exact server response still needs local validation before reuse. | +| [`update_cache`](../../crates/vt/src/session/execute/cache_update.rs) rejects failed, cancelled, incompletely traced, or otherwise ineligible executions. | Apply the same eligibility checks before remote storage. | +| [`archive`](../../crates/vt/src/session/cache/archive.rs) and [`replay_cache_hit`](../../crates/vt/src/session/execute/mod.rs) handle output files and terminal replay. | Import verified data through a bounded staging path before reporting a hit. | -The first implementation must extract a common validation layer with a versioned digest representation. Preserve local mismatch explanations and existing task scheduling. The additional SHA-256 work is required for remote-capable executions; local-only execution can retain its fast hash path. +An exact response means that the server found identical key bytes. It does not establish that current input files, inferred dependencies, or tracked environment values match. In the current engine, fallback data explains a miss; the client does not reuse its outputs. Preserve that distinction when adding the remote adapter. An exact entry that fails validation is a cache miss. -## 4. Lessons from Nx and Turborepo +The client must define a portable, versioned encoding for its opaque fields before cross-machine reuse ships. It must preserve negative file dependencies, directory observations, tracked environment queries, and explicit glob membership. Schema, toolchain, and platform compatibility belong in client-controlled identity or validation data. PR #713 does not prescribe that encoding, and the Worker must not decode it. Existing local schema `v18` or a serialized SQLite directory is not a portable wire-format agreement. -Nx's [cache client](https://github.com/nrwl/nx/blob/fc41a1b479677fdb4a5166c85053c152ea3abb6a/packages/nx/src/tasks-runner/cache.ts) checks local storage, retrieves a remote result on a miss, and imports that result into the local cache. Its [self-hosted protocol](https://nx.dev/docs/kb/self-hosted-caching) defines authenticated archive upload/download at `/v1/cache/{hash}`, including `403` for forbidden access and `409` for attempted overwrite. Adopt local promotion and immutable publication. Nx's [deprecation guidance](https://nx.dev/docs/reference/deprecated/self-hosted-cache-packages) also identifies poisoning risks in shared bucket caches. Immutability prevents replacement, but cannot prevent an untrusted writer from publishing a bad result first. +Client flow: validate local cache; on a miss call `/fetch`; validate an exact response; fetch its blob only if reuse is possible; restore and promote to local storage. A fallback or `not_found` response leads to execution. A successful eligible execution updates local storage only. `vp cache push` publishes selected results through `/store`. A local hit makes no remote request during `vp run`. -Turborepo publishes an [HTTP API specification](https://github.com/vercel/turborepo/blob/f6d5f18dfdcea4070ae8dc07300e5a735d529eb7/apps/docs/lib/remote-cache-openapi.json) with artifact existence, download, upload, query, and event operations. Its [HTTP cache implementation](https://github.com/vercel/turborepo/blob/f6d5f18dfdcea4070ae8dc07300e5a735d529eb7/crates/turborepo-cache/src/http.rs) integrates archive transfer and signature verification. Its [remote caching guide](https://turborepo.dev/docs/core-concepts/remote-caching) describes optional HMAC-SHA256 artifact signatures and treating invalid signatures as misses. Adopt an open protocol and verification before use. Use asymmetric publisher signatures here so readers need no signing secret. +## 4. HTTP API mapping -Neither protocol directly solves this engine's post-execution dependency discovery. Our separate candidate index is intentional. An adapter for another client would need its own namespace and artifact semantics; sharing a hash spelling does not make results interchangeable. +All paths are relative to an endpoint such as `https://cache.example.com/projects/docs-trusted-v1`. The endpoint includes the namespace. The following notation describes CBOR fields: `bytes` is a binary byte string, `string` is text, and nullable fields must be present with CBOR `null` when absent. -## 5. User configuration and trust setup +### Fetch metadata -Add a workspace-root `run.remoteCache` setting in `vite.config.*`. Package configurations cannot replace the endpoint or credential source. Proposed configuration: +```text +POST {endpoint}/fetch +Content-Type: application/cbor -```ts -export default { - run: { - remoteCache: { - url: 'https://cache.example.com', - project: 'docs', - namespace: 'trusted', - epoch: '1', - mode: 'read', - environment: 'node-toolchain-2026-09', - trustedKeys: { - 'ci-2026-09': '', - }, - }, - tasks: { - 'build:site': { - command: 'vitepress build', - env: ['DOCS_SITE_ORIGIN'], - output: ['.vitepress/dist/**'], - }, - 'private-report': { - command: 'node private-report.mjs', - remoteCache: false, - }, - }, - }, -}; +{ key: bytes, secondary_key: bytes } ``` -`remoteCache` is absent by default. A configured service defaults to `read`. Modes are `off`, `read`, `write`, and `read-write`; they control only the remote tier. A task-level `remoteCache: false` disables both remote directions for that task but permits local caching. Existing task `cache: false`, `--no-cache`, and tool-requested cache disabling take precedence over all remote settings. +Return HTTP `200`, `Content-Type: application/cbor`, with one of: -Credentials come only from the host environment or a user-owned credential file outside the checkout. `VP_REMOTE_CACHE_TOKEN` supplies the scoped bearer token. Writers additionally provide `VP_REMOTE_CACHE_SIGNING_KEY` and `VP_REMOTE_CACHE_SIGNING_KEY_ID`. The private key is base64-encoded PKCS#8 Ed25519; trusted public keys are base64-encoded raw 32-byte keys. No token or private key is accepted in checked-in configuration or a command-line flag. Tokens contain a public lookup ID and a cryptographically random 256-bit secret; store only the secret's SHA-256 digest and compare it in constant time. Give tokens explicit expiry dates and support overlapping old/new credentials during rotation. +```text +{ kind: "exact", value: bytes, blob_id: string | null } +{ kind: "fallback", key: bytes, value: bytes, blob_id: string | null } +{ kind: "not_found" } +``` -Support `VP_REMOTE_CACHE_URL`, `VP_REMOTE_CACHE_PROJECT`, `VP_REMOTE_CACHE_NAMESPACE`, `VP_REMOTE_CACHE_EPOCH`, `VP_REMOTE_CACHE_MODE`, `VP_REMOTE_CACHE_ENVIRONMENT`, and `VP_REMOTE_CACHE_TRUSTED_KEYS` as explicit host overrides. `TRUSTED_KEYS` is a JSON key-ID-to-public-key map that replaces the configured map. Host values override workspace values. `--no-remote-cache` overrides both and leaves local caching enabled. Missing credentials or required trust settings disable the remote tier with one diagnostic. Invalid configuration is reported before task execution. No automatic fallback to another service or namespace is allowed. +Check `key` first. If no live entry exists, resolve `secondary_key` to a stored key and check that entry. Include the stored key only in the fallback variant. If neither resolves, return `not_found`. Fetch does not change entries or associations. -Remove all `VP_REMOTE_CACHE_*` variables from child environments, environment fingerprints, runner-aware environment APIs, serialized plans, and debug output, even when a task requests `env: ["*"]`. The client must not expose these control credentials to tools through normal environment propagation. This does not isolate credentials from malicious code running under the same OS account; CI secret placement must respect the job's trust boundary. +### Download a blob -`environment` is an operator-maintained identifier for external build dependencies, such as an image digest or a pinned development-toolchain revision. It is required in version 1. Matching labels assert equivalent external dependencies; they do not measure them. Documentation must explain when to change this value and when to disable remote caching. +```text +GET {endpoint}/blob/{blob_id} +``` -### Recommended permissions +Return HTTP `200`, `Content-Type: application/octet-stream`, with the raw blob. An unavailable blob returns `404`. The blob ID is an opaque server-generated reference, scoped by the endpoint. It is not an R2 URL or an authorization credential. -| Principal | Namespace | Permission and signing keys | -| --------------------------------------------------- | --------------- | ---------------------------------------------------------------- | -| Protected CI jobs | `trusted` | Read/write token and a CI publisher private key | -| Developer reading CI results | `trusted` | Read-only token and trusted public keys | -| Developer publishing shared local results | `team` | Individual read/write token and individual publisher private key | -| CI job that intentionally accepts developer results | `team` | Read-only token and an explicit approved publisher key set | -| Untrusted fork pull request | None by default | No tokens or signing keys | +### Store an entry -This supports both directions of reuse without silently making release builds trust every laptop. Each invocation selects one namespace. Automatic fallback from `trusted` to `team` is forbidden. A team that wants full bidirectional sharing can explicitly use `team` in both developer and CI environments. +```text +POST {endpoint}/store +Content-Type: multipart/form-data; boundary=... -For jobs that run code from a pull request, endpoint, namespace, epoch, mode, and trusted keys must come from protected workflow settings if credentials are supplied. Do not give shared write credentials to arbitrary PR code, including via `pull_request_target`. A read token also grants access to potentially private artifacts and logs. A cache signature proves publisher identity and byte integrity, not that the publisher ran trustworthy code. +metadata part (required), Content-Type: application/cbor: + { key: bytes, secondary_key: bytes, value: bytes } -## 6. Cache identity and discovery +blob part (optional), Content-Type: application/octet-stream: + raw blob bytes +``` -### Canonical encoding +Accept either part order. Return HTTP `200`, `Content-Type: application/cbor`: -Define `vp-cache-v1` metadata independently of local storage. Use UTF-8 JSON with RFC 8785 JSON Canonicalization Scheme (JCS). Reject duplicate object keys, unsupported fields for the selected format, invalid UTF-8, and numbers outside the schema's safe integer range. Encode digests as lowercase hexadecimal, bytes as base64, and paths as workspace-relative strings with `/` separators. Normalize neither path case nor Unicode. Sort semantic sets before encoding; preserve command argument and terminal event order. +```text +{ blob_id: string | null } +``` -Use SHA-256 over file bytes and canonical observations. Hash each domain as the canonical array `[domain, value]` to avoid ambiguous concatenation. An empty value, an absent environment variable, and a missing file have different tagged encodings. Normative schemas and Rust/TypeScript golden vectors must ship before protocol implementation is considered complete. The [JCS specification](https://www.rfc-editor.org/rfc/rfc8785) defines the byte-level canonicalization. +Omitting the blob returns `null`. A present zero-byte blob receives a non-null ID and downloads as an empty body. -### Two keys +Each successful store replaces `entries[key]` and sets `associations[secondary_key] = key`. For example: -```text -compatibility = { - fingerprint_format, artifact_format, engine_cache_abi, - os, arch, platform_abi, filesystem_semantics, - runtime_identity, executable_identity, lockfile_digests, environment -} - -lookup_key = SHA256(JCS(["vp-cache-lookup-v1", { - compatibility, spawn_fingerprint, input_config, output_config -}])) - -result_key = SHA256(JCS(["vp-cache-result-v1", { - lookup_key, explicit_inputs, inferred_inputs, - tracked_envs, tracked_env_queries -}])) +| Operation | Entries afterward | Association afterward | +| ------------------- | ----------------------------------------------- | --------------------- | +| Store `(A, S, VA)` | `A → VA` | `S → A` | +| Store `(B, S, VB)` | `A → VA`, `B → VB` | `S → B` | +| Fetch `(A, S)` | Unchanged; returns exact `VA` | Still `S → B` | +| Fetch `(C, S)` | Unchanged; returns fallback key `B`, value `VB` | Still `S → B` | +| Store `(A, T, VA2)` | `A → VA2`, `B → VB` | `S → B`, `T → A` | + +Other secondary keys that already point to `A` also resolve to `VA2`. Changing `S` does not evict entry `A`. + +### Errors + +API errors use `Content-Type: text/plain; charset=utf-8`. Clients use the status code, not the human-readable message, to classify them. + +| Status | Meaning | +| ------ | ------------------------------------------------------------------------ | +| `400` | Malformed request or invalid field types | +| `404` | Blob unavailable | +| `413` | Request exceeds configured size limits | +| `500` | Operation could not complete | +| `503` | Service temporarily unavailable, including exhausted application budgets | + +Normal metadata absence returns `200` with `not_found`. Version 1 reads require no credentials. For `/store`, this deployment adds `401` for a missing, invalid, or expired JWT and `403` for a verified token that fails the namespace write policy. Return `503` if authorization cannot be established because D1 or required signing keys are unavailable. Keep these errors generic and plain-text; authentication is outside PR #713. Rate limiting can return `429` with `Retry-After`. Cloudflare may reject requests before Worker code runs, so clients must tolerate non-protocol error bodies and avoid authentication redirects. + +## 5. Public reads, GitHub OIDC writes, and explicit publication + +Version 1 serves public cache data for open-source repositories on GitHub.com. Anyone can call `/fetch` and `/blob/{blob_id}` without credentials. Only an authorized GitHub Actions job can call `/store`. Developers use the checked-in endpoint without login, secrets, or individual permission setup. Version 2 covers private projects with Cloudflare One authorization. + +### Client configuration and `vp cache push` + +```ts +export default { + run: { + remoteCache: { + url: 'https://cache.example.com/projects/docs-trusted-v1', + }, + }, +}; ``` -Every storage/API identity also includes `(project, namespace, epoch)`. These values are bound by the signature. Branch and commit may appear in optional diagnostics, but never determine a hit. `ExecutionCacheKey` remains a local diagnostic association; equivalent execution configurations can still share results. +An endpoint enables public remote reads during `vp run`. Successful eligible tasks save their results locally. Publication is explicit: `vp cache push` sends selected local results through one `/store` request per entry. -`lookup_key` is available before a task runs. `result_key` identifies a particular observed input state. Output bytes, execution duration, and terminal output are excluded from `result_key`; two executions with the same inputs compete for one immutable result. +Use `VP_REMOTE_CACHE_URL` to override the endpoint on a host and `--no-remote-cache` to disable remote use for an invocation. Task-level `remoteCache: false` excludes both remote reads and publication, while retaining local caching. Existing `cache: false`, `--no-cache`, and tool-requested cache disabling also exclude results from publication. No endpoint means no remote reads; an explicit push without an endpoint reports a configuration error. -Compatibility includes the engine cache ABI, initially tied to the exact released engine version and build features. It also includes OS/architecture, Linux libc family and version where applicable, OS release, and filesystem case-sensitivity behavior. Managed Node.js uses its full version and module ABI. Hash the resolved executable's bytes; if it is a script or shim, include its interpreter identity. Include the workspace package-manager identity and lockfile paths/content digests. Resolve these values once per invocation where possible, and invalidate memoized file digests on change. Do not invoke arbitrary project commands merely to discover a version. +By default, push selects eligible results produced by the latest completed `vp run` invocation in the current workspace and CI job/commit. Starting a new run replaces that selection; a failed or cancelled run does not leave an older successful run selected. Do not upload the whole local cache, entries imported from remote, or restored caches from other jobs by default. -These conservative dimensions can cause misses between machines that would have produced identical output. That is preferable to reusing a native binary or tool result under an incompatible runtime. Unknown required compatibility information disables remote reuse for that execution. Child tools and external libraries that the engine cannot identify automatically are part of the declared `environment` contract. +Record the selection locally and pin its exact metadata/archive snapshot until publication or bounded cleanup. The current [cache update](../../crates/vt/src/session/execute/cache_update.rs) archives outputs, while [entry replacement](../../crates/vt/src/session/cache/mod.rs) can remove the previous archive. A delayed push therefore needs a snapshot or lease; it must not rebuild the archive from the later working tree or silently upload a replacement entry. Missing or inconsistent snapshots fail publication without modifying remote mappings. -Keep raw command arguments and tracked environment values semantically unchanged when hashing; hash environment values rather than serializing their plaintext. Do not replace arbitrary occurrences of an absolute path inside strings. Such values can cause a miss across checkout roots. Tools that embed absolute roots in output must be configured for relocatable output or excluded from remote caching. Logs can contain the original checkout path; do not rewrite them. +`vp cache push` reports published, skipped, and failed entries. A remote publication failure returns a nonzero exit status while preserving local results; CI can mark this cache-only step `continue-on-error`. Entries commit separately, so partial success is possible. Repeated pushes follow PR #713 replacement semantics, not exactly-once delivery. No eligible entries is a successful no-op and needs no OIDC token. -### Candidate lookup and validation +### One-time repository binding -1. Validate a compatible local entry first. On a local hit, make no remote request and do not backfill old entries into remote storage. -2. Request candidates for `lookup_key`. D1 returns only committed, unexpired entries from active publishers in the authorized scope, newest first with `result_key` as a stable tie-breaker. Return eight references per page, with an opaque cursor bound to the scope, lookup key, and first-page publication watermark. -3. Verify the small signed descriptors returned with candidates, then fetch their manifests and verify the signed manifest digests. Require scope, formats, and lookup key to match locally computed values. Never let a manifest replace local command/configuration data. -4. Re-enumerate explicit globs using the current resolved configuration; compare the full path set and SHA-256 file digests. Validate every inferred observation: file content, absence, directory existence, and a sorted entry-name/type set when enumeration was observed. Validate input symlink chains as specified in section 7. Re-evaluate tracked environment queries against the same planning context used by runner-aware APIs. Compare missing values and complete query match sets. -5. Recompute `result_key` from the current observations. A candidate is eligible only if every observation matches and the result key matches. Then fetch and restore its artifact as described below. -6. Stop after 32 candidates, 16 MiB of manifest metadata, or the lookup deadline, whichever comes first. No valid candidate means execute locally. A bounded search may miss an older valid result; it cannot turn a mismatch into a hit. +At deployment, the maintainer selects a public GitHub repository. Setup resolves and records its immutable `repository_id` and `repository_owner_id`, binds them to the namespace, and sets the allowed branch to `refs/heads/main`. A project whose main branch has another name needs that one server-side adjustment. Repository names are display data; names alone must not authorize writes. Do not let the first incoming token claim an unregistered namespace. -The service keeps all unexpired results subject to project quotas; the 32-candidate limit bounds each lookup rather than overwriting older entries. The client can retain authenticated manifest metadata to avoid repeated downloads, but must revalidate inputs and current local trust policy before reuse. There is no local-history requirement for cold lookup. +Use the configured namespace endpoint, without a trailing slash, as the exact OIDC audience. Store it in the server policy and derive it from the configured client URL. An alias or namespace change needs a matching policy update; do not infer the expected audience from an untrusted Host header. A repository transfer requires maintainer review and an owner-ID update. No per-developer registration or reusable cache secret is required. -For example, two branches run the same `build` command. Both have the same lookup key, but changed source bytes produce different result keys. Both results can coexist. A fresh checkout evaluates each candidate against its own source tree. Creating a previously missing import or adding a directory entry invalidates the corresponding candidate. +### GitHub Actions token acquisition -Input inference retains its existing limits: ignored inputs, untracked environment values, time, network responses, and unobserved external files can affect output. Remote caching does not make non-deterministic tasks safe. Preserve complete raw tracking long enough to flag outside-workspace writes and unsupported path representations; these make a result ineligible for remote publication. External reads need the documented environment contract. Compute explicit input SHA-256 values before execution, then create inferred observations after the existing tracking/overlap checks. Recheck observed inputs before publication and immediately before restoration; a detected change cancels reuse/publication. This detects ordinary concurrent edits, not arbitrary mutation-and-reversion races. Do not claim filesystem snapshot isolation. +The publishing job grants `permissions: id-token: write`. This lets a job request an OIDC token; the Worker decides whether it grants write access. The native client uses GitHub's `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` to request a token with the namespace audience. See GitHub's [OIDC workflow configuration](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-cloud-providers). -## 7. Wire artifact and safe restoration +Only `vp cache push` requests the token. Send the returned JWT as `Authorization: Bearer ` to `/store`; the Worker verifies it directly, without a custom token exchange endpoint. Keep it in process memory, reuse only while valid for the same audience, and obtain a fresh token before expiry. The runner's request token is only for GitHub's token endpoint and must never be sent to the cache Worker. Outside Actions, push reports that GitHub OIDC is unavailable. -Each result consists of a small signed descriptor, a validation manifest, and one canonical `tar.zst` stream split into numbered transport chunks. The archive contains regular output files under `outputs/` and one `stdio.json` with ordered stdout/stderr byte events. No-output tasks still have a valid archive for terminal output. Duration is metadata; the only publishable exit status is success. +Do not forward either token across redirects or write it to config, command arguments, output, or the cache. Strip the OIDC request variables and remote-cache controls from task child environments, fingerprints, runner-aware environment APIs, serialized plans, and debug output, including wildcard environment selection. This does not isolate a privileged job from other code running as the same OS user; publication jobs execute trusted main-branch code. -The descriptor is bounded to 8 KiB so server-side authentication and signature verification do not scale with the number of inferred inputs. It contains: +### Worker write policy -| Field group | Contents | -| ----------- | ----------------------------------------------------------------------------------------------- | -| Identity | Protocol/format versions, scope, lookup key, result key, compatibility digest, publisher key ID | -| Manifest | SHA-256 and byte size of the canonical validation manifest | -| Artifact | Compressed SHA-256 and size; ordered chunk sizes and SHA-256 digests; expanded size | -| Bounds | Output-file count and terminal-output byte count | +Before consuming a store body or reserving quota, verify the JWT signature with a maintained library such as [`jose`](https://github.com/panva/jose), which supports Workers and remote JWKS. Use GitHub's fixed [OIDC issuer metadata](https://token.actions.githubusercontent.com/.well-known/openid-configuration) and HTTPS JWKS endpoint; allow only its supported signing algorithm (`RS256` initially). Do not accept `none`, symmetric algorithms, token-supplied key URLs, or decoded claims without verification. Bound token size, JWKS response size, fetch time, cache lifetime, and refresh frequency; unknown key IDs must not trigger unlimited outbound requests. If no usable cached key exists and key retrieval fails, return `503`; do not skip verification. -The descriptor envelope contains `body` and a base64 Ed25519 `signature` over `JCS(["vp-cache-descriptor-v1", body])`. Its digest is SHA-256 of the canonical envelope. D1 stores this small envelope and returns it inline with candidate references, so discovery does not add a separate descriptor download. Public keys are pinned by the consumer's configuration, not accepted from a download. The Worker verifies that the publisher key is active and permitted for the token's scope; consumers verify independently. Cloudflare supports Ed25519 through its [Web Crypto API](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/). +After cryptographic verification, require these signed claims and the enabled namespace policy: -The validation manifest contains the matching identity, full explicit/inferred input observations, tracked environment hashes and queries, exact output-file inventory with SHA-256/size/executable bits, terminal-output digest, success status, and elapsed milliseconds. The client verifies its signed digest before parsing, then checks that all duplicated identity and bound fields match the descriptor. Changing any manifest byte invalidates the descriptor's signature binding. The full input and result-key checks remain on the consuming client. +| Claim | Required value | +| ----------------------- | --------------------------------------------------- | +| `iss` | `https://token.actions.githubusercontent.com` | +| `aud` | Exact configured namespace endpoint | +| `repository_id` | Namespace's registered repository ID | +| `repository_owner_id` | Namespace's registered owner ID | +| `repository_visibility` | `public` | +| `ref` / `ref_type` | Configured main-branch ref / `branch` | +| `event_name` | `push` | +| `exp`, `nbf`, `iat` | Present and valid under a bounded clock-skew policy | -The Worker treats the manifest as opaque bytes and uses R2's checksum verification on upload. It never parses or canonicalizes a multi-megabyte manifest or decompresses an artifact. This preserves the 4 MiB manifest limit for Free users while moving expensive validation to the clients, which already need to perform it. All hosting profiles use the same protocol and mandatory signatures; Free support does not reduce validation or task input coverage. +These are this deployment's trust conditions over [GitHub's documented claims](https://docs.github.com/en/actions/reference/security/oidc). Require exact types and values. Do not authorize from `actor`, a repository URL supplied by the client, a branch environment variable, or `sub` substring matching. GitHub supports different subject formats; explicit repository IDs and branch/event claims avoid relying on one textual `sub` layout. No organization-wide or repository-wide write grant substitutes for the full predicate. -Archive creation sorts paths and removes host-specific owner/group names, timestamps, and unnecessary metadata. Preserve file bytes and the executable bit; never preserve ownership, ACLs, setuid/setgid bits, or arbitrary extended attributes. Version 1 rejects symlink/hardlink outputs, devices, FIFOs, sparse files, and other special entries for remote publication. Workspace input symlinks must resolve within the workspace, and the remote fingerprint includes every link in the resolution chain, its relative target, and the final resolved observation; loops or outside-workspace targets disable remote reuse. Validate ancestor links as well as the final path. This is stricter than the current local archive path. +A `pull_request_target` job can run in the base repository's default-branch context. Therefore `ref=refs/heads/main` alone is insufficient: deny `pull_request`, `pull_request_target`, `workflow_run`, tags, non-main branches, and other event types in v1. Fork repositories have different IDs and cannot write to the upstream namespace, even from their own `main` branch. Reusable workflows must retain matching caller-repository, branch, and event claims; the callee's identity alone grants no permission. See GitHub's [workflow event behavior](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target). -Client and server enforce these protocol maxima; deployments may lower them and advertise effective limits: +| Caller | `POST /fetch` | `GET /blob/{blob_id}` | `POST /store` | +| --------------------------------------------------------------------------------- | ----------------------------- | ----------------------------- | ------------- | +| Anonymous developer or fork contributor | Allow | Allow | `401` | +| Registered repository, public, main-branch push, valid audience/token | Allow | Allow | Allow | +| Valid GitHub token with wrong repo, owner, visibility, branch, event, or audience | Allow | Allow | `403` | +| Invalid, expired, or forged token | Allow without using the token | Allow without using the token | `401` | -| Limit | Maximum | -| ------------------------------------------ | ---------------------------------------------- | -| Signed descriptor | 8 KiB | -| Canonical validation manifest | 4 MiB | -| Chunk | 64 MiB; all but the final chunk have this size | -| Compressed archive | 1 GiB, at most 16 chunks | -| Expanded archive including terminal output | 8 GiB | -| Output files | 100,000 | -| Terminal output bytes | 16 MiB | -| Metadata nesting | 32 levels | +A local command cannot obtain write permission by setting environment variables. Unknown or disabled namespaces expose no cache data. Scope every exact/fallback/blob lookup and mutation; a blob ID requested through the wrong namespace returns `404`. This separation prevents mixed project results, not discovery by an authorized reader: all enabled v1 namespaces are public. Keep the R2 bucket private so reads pass through Worker routing, retention, and budgets. Apply the same store verifier on all exposed routes and aliases. -An oversized result remains local and produces a remote-skip diagnostic. Do not silently truncate logs or omit output files to fit limits. Compress and hash to a temporary disk file, then stream its chunks with bounded memory. The Worker never decompresses an archive or buffers artifact chunks. Multiple chunk requests avoid the account-plan [request size limit of 100 MB on Free/Pro](https://developers.cloudflare.com/workers/platform/limits/) and the runtime's 128 MB per-isolate memory limit. +### Publication trust and revocation -The receiving client must complete the following before a cache hit is observable: +A GitHub token proves the job's identity, not that uploaded bytes match its commit or contain no secrets. The trusted publishing workflow builds the triggering main-branch commit and selects only results intended for public release. Values, input metadata, terminal logs, source maps, and blobs are all public; tasks that use private inputs or produce sensitive output must opt out. The Worker treats them as opaque and cannot redact them. Client validation still determines whether a public result can be reused. -1. Download into an invocation-owned staging directory, verify each chunk digest/size and the complete compressed digest, then decompress with explicit byte, file-count, path-length, nesting, and disk-space bounds. A manifest's stated expanded size is not a substitute for counting actual bytes. -2. Validate every archive entry against the signed output inventory. Reject duplicates, undeclared entries, missing entries, absolute paths, `..`, NUL, drive/UNC paths, Windows alternate data streams and reserved names, and case aliases on a case-insensitive destination. Reject `.git` and the runner's own cache/staging directories. Interpret `/` consistently on all platforms; never treat a backslash as a permitted escape route. -3. Require all output paths to satisfy the current explicit output rules or the signed auto-output inventory under the matching configuration. Verify each extracted file's digest and executable bit. Auto-output inventories are publisher-authorized filesystem writes, which is another reason writers must be trusted. -4. Restore regular files with workspace-root-anchored operations that reject symlink/reparse-point ancestors. Use temporary files on the destination filesystem and atomic replacement per file. Journal replacements and retain backups until the whole restore succeeds. If restore fails, roll back before running the task; if rollback cannot complete, fail with a local filesystem error. Never execute a task over a partly restored workspace and call that a remote miss. -5. Commit the local cache record and artifact reference, then replay terminal output and report a remote hit. Recover or clean incomplete restore journals before later invocations use affected paths. Concurrent invocations must coordinate overlapping restore paths; the existing scheduler still orders task dependencies. +At publication, the guarded D1 transaction rechecks the token expiry against server time, the scope's enabled/write-enabled state and unchanged policy version, lease, and quotas. An expired token or changed policy leaves existing mappings unchanged and stages objects for cleanup. No GitHub API call belongs inside that transaction. Tokens are short-lived bearer credentials and can be reused within their validity period; v1 does not maintain a per-token revocation or single-use ledger. Cancelling a job is not immediate token revocation. The maintainer can disable writes or change the namespace policy in primary D1; disabling the whole scope also stops subsequent reads. -This gives atomic publication of local cache metadata and recoverable multi-file restoration, not a filesystem-wide atomic transaction. No staging file is promoted to the workspace before all downloaded bytes and paths are verified. Existing files outside the output inventory are left in place, matching current restoration behavior; tasks that require deletion side effects are not eligible for remote caching in version 1. +Making a repository private does not remove previously published cache data. Maintainers must disable the public namespace and remove its objects when withdrawing publication; downloaded copies cannot be recalled. Private-cache access control belongs to v2. -## 8. Cloudflare service architecture +## 6. Cloudflare storage model -```mermaid -flowchart LR - Dev[Developer: vp run] --> LocalDev[Local cache] - CI[CI: vp run] --> LocalCI[Local cache] - Dev -->|HTTPS and scoped token| API[Cloudflare Worker] - CI -->|HTTPS and scoped token| API - API -->|authorization and result index| DB[(D1)] - API -->|stream manifests and chunks| Objects[(Private R2 bucket)] - Cron[Cron Trigger] -->|incremental cleanup| API -``` +Use D1 for binary-key indexes and transactions, and R2 for opaque values and blobs. D1's [2 MB row limit](https://developers.cloudflare.com/d1/platform/limits/) makes storing potentially larger values inline unsuitable. Store even small values in R2 initially to keep one storage path. -The Worker performs authentication, bounded descriptor-schema checks, publisher-signature validation, indexed lookup, upload coordination, and streaming. R2 holds artifact chunks and manifests through a binding. Disable public bucket access and do not expose S3 credentials, public object URLs, or presigned URLs to clients. +Conceptual tables, with names subject to implementation review: -D1 holds compact metadata and signed descriptors; large manifests remain in R2. This avoids depending on D1's [2 MB row limit](https://developers.cloudflare.com/d1/platform/limits/). Use prepared statements, indexes, conditional writes, and transactional `batch()` calls. [D1 batches roll back if a statement fails](https://developers.cloudflare.com/d1/worker-api/d1-database/); a conditional update affecting zero rows is not an exception, so dependent changes must be guarded in SQL as well. Do not perform a read in JavaScript and assume a later write is still exclusive. +| Table | Identity and contents | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `scopes` | Public endpoint, enabled/write-enabled state, GitHub repo/owner IDs, branch, OIDC audience, policy version, retention, budgets, counters | +| `entries` | Primary key `(scope_id, key BLOB)`; current generation ID | +| `associations` | Primary key `(scope_id, secondary_key BLOB)`; target key as BLOB | +| `generations` | Random generation ID; scope, R2 object keys, optional blob ID, actual sizes, state, lease/expiry/retirement timestamps | -Keep authorization, reservation, commit, and deletion on the D1 primary. Version 1 also reads candidates from the primary, with read replication disabled. If replication is introduced later, [Sessions and bookmarks](https://developers.cloudflare.com/d1/best-practices/read-replication/) must preserve read-after-publish behavior, and replica lag must not delay credential revocation. +Use bound binary parameters and byte equality. Accept empty and non-UTF-8 keys within the size limits. Do not stringify, normalize, or interpret them as hex hashes. Index exact lookups, secondary lookups, blob IDs, and cleanup eligibility. Limit associations separately: many secondary keys can point to one entry. -### Logical data model +Each store gets a fresh generation. Its R2 value object and optional blob object have unique immutable object names under an internal scope/generation prefix. The D1 entry pointer is mutable. This prevents concurrent stores from mixing one execution's value with another execution's blob. -| Table | Key data and indexes | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `deployment_budget` | Singleton byte/entry limits and charged totals shared by every namespace and epoch | -| `namespaces` | `(project, namespace, epoch)`; active flag, retention, byte/entry quotas, charged bytes/entries | -| `tokens` | Public token ID, SHA-256 of a random secret, principal ID, allowed scope/modes, expiry, revocation; lookup by token ID | -| `publisher_keys` | Scope, key ID, Ed25519 public key, allowed principal IDs, active flag | -| `entries` | Unique `(scope, lookup_key, result_key)`; upload ID/generation, owner principal, immutable signed descriptor and its digest, manifest receipt, total size, state, lease deadline, publish/expiry timestamps | -| `chunks` | Unique `(upload_id, ordinal)`; expected size/digest and upload receipt | +Generation states cover `uploading`, `ready`, `retired`, and `deleting`. Persist object names and an internal upload lease before R2 writes. For a multipart upload, also record its R2 upload ID for abort/recovery. These records are backend bookkeeping; clients never receive an upload-session API. -Add indexes on `(scope, lookup_key, state, published_at DESC, result_key)` and `(state, lease_deadline)` / `(state, expires_at)` for discovery and cleanup. A monotonically increasing publication sequence supplies the page watermark. Never store source file contents or plaintext tracked environment values in D1. Use an unpredictable upload ID; R2 keys are server-derived: +Keep D1 operations on the primary in version 1. Read replication without a cross-request consistency agreement could return old mappings after a successful store or scope disable. Version 1 serves public data through the Worker without an additional CDN cache. Revisit edge caching separately, including namespace withdrawal and expiry behavior. -```text -v1/////manifest.json -v1/////chunks/0000 -``` +## 7. Fetch and download implementation -No cross-entry or cross-project blob sharing occurs in version 1. Deleting one generation cannot break another result, and no reference-counted garbage collector is needed. +After rate limiting, the enabled-public-scope check, and bounded CBOR decoding, use a single indexed D1 query to select the exact live entry, or its secondary fallback if exact is absent. Select the response kind, stored key, and generation references from one database snapshot. Do not perform independent exact/fallback reads that can observe different commits. -R2 provides [strong consistency for writes and deletes](https://developers.cloudflare.com/r2/reference/consistency/). Its [Worker API](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/) supports conditional writes and a supplied SHA-256 checksum. Use create-only object writes and checksum verification for manifests/chunks. A successful D1 commit is the visibility boundary across the two stores; there is no distributed D1/R2 transaction. +Read the selected generation's value from R2 and return the corresponding CBOR variant. Return `503` if D1 identifies a live generation but its value object is missing or unreadable; that is a storage failure, not a normal cache miss. Expired or deleted entries count as absent. The client still validates the exact value before downloading outputs. -## 9. HTTP protocol and publication state machine +For `/blob/{blob_id}`, check the enabled public scope without authentication, resolve the blob ID in D1, and stream the R2 object to the response. A blob ID from another scope must not expose data. Ready blobs remain available until expiry; replaced blobs remain available during the retirement grace period described below. Return `404` for unavailable IDs, including IDs whose R2 object is gone. -All cache endpoints start with `/v1/projects/{project}/namespaces/{namespace}/epochs/{epoch}` (abbreviated `S` below). Project, namespace, epoch, and publisher key IDs match `[a-z0-9][a-z0-9_-]{0,63}`; keys are 64 lowercase hex characters. Upload IDs are server-generated random 128-bit values encoded as 32 lowercase hex characters. Reject non-canonical or multiply encoded path segments. Authorize the complete scope before looking up entries or accessing R2. A token never gains permissions from caller-supplied project or namespace headers. Read-only principals can use discovery/download endpoints; write-only principals can use capabilities and their own upload lifecycle, without general artifact access. +No read writes last-access timestamps, extends retention, changes associations, or creates per-request analytics rows. Fixed retention and a short replacement grace period let fetch and download remain read-only. A blob may expire between fetch and download; the client handles `404` as a miss and executes. -All responses use `Cache-Control: private, no-store`. Require verified HTTPS in production, with no redirects. Local development permits HTTP only on explicit loopback URLs. The server returns paths relative to its own origin; the client rejects arbitrary artifact URLs. Do not forward credentials across hosts or disable certificate validation. No CORS access is enabled by default. +## 8. Store implementation and concurrency -| Request | Behavior | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /healthz` | Public liveness only; no configuration or dependency details | -| `GET S/capabilities` | Authenticated formats, effective size limits, and retention | -| `GET S/lookups/{lookup_key}?cursor=...` | `200` with candidate references, signed descriptors, result keys, and next cursor; an empty list is a miss | -| `GET S/results/{lookup_key}/{result_key}/manifest` | `200` streamed validation manifest for a ready result; otherwise `404` | -| `GET S/results/{lookup_key}/{result_key}/chunks/{ordinal}` | Stream one immutable chunk of a ready result; otherwise `404` | -| `POST S/uploads` | Body is the canonical signed descriptor, with `Idempotency-Key`; reserve the result and quota; `201` with upload ID, deadline, and relative manifest/chunk paths | -| `PUT S/uploads/{upload_id}/manifest` | Require `Content-Length`; stream and verify the reserved manifest digest/size; `204` on success or an identical retry | -| `PUT S/uploads/{upload_id}/chunks/{ordinal}` | Require `Content-Length`; verify expected digest and length while writing the stream; `204` on verified success or an identical retry | -| `POST S/uploads/{upload_id}/commit` | Confirm all bytes and permissions, then publish; `201` for the winning commit, `200` for its identical retry | -| `DELETE S/uploads/{upload_id}` | Owner can abandon a pending upload; idempotent `204`; cannot delete a published result | +1. Verify the GitHub OIDC JWT and the namespace's write policy from section 5, capture its policy version and token expiry, then reserve storage capacity in D1. Use a bounded `Content-Length` when supplied; reserve the total request limit otherwise. Do not require that header. Create the generation and a 15-minute internal lease. Count concurrent reservations against scope and deployment budgets. +2. Parse multipart input incrementally with backpressure. Bound headers, part count, metadata bytes, blob bytes, and total bytes. Reject duplicate metadata or blob parts, invalid types, missing metadata, and truncated bodies. Accept metadata-first and blob-first bodies. Do not buffer the whole request with `formData()` or `arrayBuffer()`. +3. Buffer the bounded metadata part, decode its outer CBOR map, and preserve its byte-string fields. Write `value` to its generation-specific R2 object. The Worker does not inspect nested client data. +4. For a blob up to 5 MiB, use one R2 PUT after buffering that bounded amount. For a larger blob, use internal R2 multipart upload with 5 MiB parts and a smaller final part. Upload one part at a time, releasing buffers as progress allows. A present empty blob still requires an R2 object. Cloudflare documents the [multipart minimum and API](https://developers.cloudflare.com/r2/objects/multipart-objects/). +5. Await completion of both R2 objects and the full multipart request, including its closing boundary. Record actual sizes. In one guarded D1 batch, verify token expiry against server time, the lease, captured scope and unchanged policy version, enabled/write-enabled state, and budgets; mark the generation ready; replace `entries[key]`; set `associations[secondary_key] = key`; retire the old generation of that same key; and reconcile reserved bytes with actual bytes. +6. Return the new blob ID, or `null`. Publication must finish before the response; `waitUntil()` is reserved for best-effort cleanup or observations. -Each chunk response includes `Content-Length` and its SHA-256 in `VP-Cache-SHA256`. The signed descriptor and its manifest digest remain the client's authority for integrity; an R2 ETag is not a content checksum contract. JSON errors have `{ "code": "...", "requestId": "..." }`, without arbitrary reflected request text. `401` means missing/expired/revoked credentials; `403` means insufficient scope or publisher permission. `400` covers malformed data, `413` size limits, `422` checksum/signature/schema failure, `429` quota/rate limits, and `503` transient storage failure. `409` distinguishes `already_exists`, `upload_in_progress`, `descriptor_conflict`, and `lease_expired`. +D1 [batches provide transaction rollback on statement failure](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch). A conditional update affecting zero rows is not itself a SQL failure. Guard all publication mutations with the same valid-generation condition, inspect their results, and ensure a failed guard cannot leave one mapping changed. Prove this with concurrent-store and lease-expiry tests. -### Publication +Readers see either the previous complete entry or the new complete entry. Concurrent stores follow the order of successful D1 commits; the last commit determines each affected mapping. Reassigning a secondary key does not retire the different entry it previously referenced. Replacing an entry changes what all associations to that key resolve to. -```text -absent -> uploading -> ready -> deleting -> absent - \------------> deleting -> absent -``` +If R2 or parsing fails before publication, leave existing mappings unchanged and clean up the staged generation. If the response is lost after commit, the client cannot know whether storage succeeded. A retry is another store and may return a different blob ID or overwrite a newer concurrent store. PR #713 supplies no idempotency key or exactly-once guarantee. -1. Authenticate and validate the small signed descriptor, scope, digest syntax, and size/chunk bounds. The service treats lookup and result keys as opaque; it cannot validate the publisher's actual command or source tree. In one D1 transaction, reserve the full manifest-plus-archive byte count and entry slot, insert an `uploading` row, and create its expected chunk rows. Quota checks and counter updates must be enforced in the same transaction, using SQL constraints/triggers or guarded statements. Return no usable upload location until reservation succeeds. -2. Upload the canonical validation manifest and chunks under the reserved upload ID. Each PUT must match its reserved size/digest and owner, and the upload must still be active. Stream directly to an R2 create-only write with the expected SHA-256. After success, conditionally record its receipt in D1. A lost response or failed receipt write is recovered by checking the existing object's checksum and size on retry; never overwrite different bytes. -3. At commit, verify the manifest receipt and all expected chunk receipts against R2 HEAD checks for size/checksum. Do not read and parse the manifest. Bound concurrent HEAD calls and keep the entire request within the Free profile's query/subrequest budget. In a single D1 transaction, require the token, publisher key, and namespace to still be active, compare the upload ID/state/deadline, and transition to `ready`, setting publication sequence/time and `expires_at`. Authorization predicates must be part of the guarded transition, so concurrent revocation cannot be bypassed by an earlier JavaScript check. Return success only after that transaction commits. Retention starts at publication; there is no background publication through `waitUntil()`. -4. Readers select only `ready` entries and check expiry/authorization again for manifest and chunk reads. If an object is missing despite a ready row, treat it as a miss, record an integrity error, and schedule that entry for cleanup. Do not replay logs or partial files. +R2 multipart parts are an implementation detail inside one incoming HTTP request. A client cannot resume them after disconnecting. Cancellation, worker termination, and failure between creating an R2 multipart upload and recording its ID require cleanup and a bucket lifecycle backstop. -Idempotency keys are random 128-bit values, bound to principal, scope, and descriptor digest for the upload lifetime. Persist this binding in `entries` with a unique `(principal, scope, idempotency_key)` constraint. Repeating the same reservation returns its upload ID, manifest receipt status, and missing chunks; interrupted manifest uploads resume through the idempotent manifest PUT. Reusing the key with different bytes returns `409`. Another writer for an `uploading` result receives `409 upload_in_progress` and need not wait. After an abandoned generation is reclaimed, a new reservation always gets a new upload ID. +## 9. Size limits and runtime budgets -A published result cannot be replaced. A new reservation for it receives `409 already_exists` with the winning descriptor digest. The client treats this as upload deduplication, not a failed task. Compare output-file inventory/content digests if both manifests are available: different output files under the same result key produce a nondeterminism diagnostic. Duration or log timing differences alone are not proof of incorrect output. An identical commit retry is successful only for the original upload ID. +PR #713 defines no fixed maximum length for client-supplied keys, values, or blobs. Its [size guidance](https://github.com/voidzero-dev/vite-task/blob/362f5bd91bb32806b512d3d9a5339ff435bf5f0a/docs/remote-cache-server-api.md#sizes) permits servers to impose resource limits and return `413`. The following are configurable defaults for this deployment, not protocol-wide limits: -An upload lease lasts 15 minutes and is not extended in version 1. Server-side body transfer deadlines are two minutes per chunk. On a late write after lease loss, reject the receipt and remove that generation's object where possible; it can never become visible. Cleanup may safely retry removal because upload IDs are never reused. +| Resource | Initial limit | +| ------------------------------------------------------ | --------------------- | +| Each `key` or `secondary_key` | 16 KiB | +| Opaque `value` | 4 MiB | +| Store metadata part | 5 MiB | +| Fetch request body | 40 KiB | +| Blob | 64 MiB | +| Entire store request, including multipart overhead | 72 MiB | +| Internal R2 part buffer | 5 MiB | +| Upload lease | 15 minutes | +| Store request deadline / client blob-download deadline | 2 minutes / 5 minutes | -## 10. Failure handling and local integration +Return `413` when a field or request exceeds its configured byte limit, including when streaming discovers the excess. Reserve `400` for malformed input. Do not truncate or transform opaque fields to make them fit. Publish the configured limits in the deployment guide; changes must keep individual fields, envelope sizes, total request size, and measured runtime budgets consistent. The client treats a rejected store as skipped remote publication and retains its local result. -Remote lookup begins only after task dependencies finish and the existing local lookup misses. Avoid network I/O while holding a SQLite lock or an output-restoration lock. Configure the session once and share an HTTP connection pool, per-invocation manifest/digest memoization, and a bounded transfer scheduler across tasks. +Bound multipart headers and CBOR container depth before allocation; reject ambiguous duplicate envelope fields. Support valid CBOR byte strings without requiring a canonical encoding. Test streaming boundaries and unknown-length request bodies. Envelope limits do not authorize decoding the opaque `value`. -Default budgets are 2 seconds to connect, 5 seconds for discovery including manifest validation, 2 minutes per artifact request, and 5 minutes for a complete artifact transfer. The receiving client rechecks inputs after download before restoration. Permit at most four transfers and two local compression/decompression jobs per invocation. End-to-end deadlines include queueing and retries. Use at most two jittered retries for connection failures, `408`, `429`, and `5xx`, respecting `Retry-After` within the deadline. Do not retry invalid credentials or invalid artifacts. After three transport/service failures, stop new remote work for the invocation; a new invocation can try again. +Cloudflare's [request-body limits](https://developers.cloudflare.com/workers/platform/limits/#request-limits) depend on the Cloudflare account plan: Free and Pro allow 100 MB, Business 200 MB. Buying Workers Paid alone does not raise the Free account's 100 MB body limit. The 72 MiB cap leaves space below that limit. Larger transfers require compatible field and request limits, account allowances, and measured Worker settings. -| Condition | Client behavior | -| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| Missing result, no matching candidate, or bounded search exhausted | Execute normally | -| Timeout, unavailable service, or exhausted quota | Execute locally or keep the local result; summarize once | -| `401` / `403` | Disable the affected remote direction for this invocation and report setup information without secrets | -| Invalid signature, digest, manifest, or archive | Discard and quarantine the entry ID for the invocation; report rejected remote data; execute only if the workspace is untouched or fully rolled back | -| Failed/cancelled/non-cacheable task | Never upload | -| Upload conflict | Keep local result; use the immutable-winner rules above | -| Local extraction, disk, or rollback failure | Surface a local error when safe execution cannot continue | -| Unsupported protocol/format | Disable remote use and state the compatibility reason; local execution continues | +Workers provides 128 MB per isolate, shared by concurrent requests. Budget metadata, CBOR copies, stream buffers, and concurrency together. Streaming reduces memory use; it does not make multipart parsing constant-cost. Workers Free allows 10 ms CPU per HTTP or Cron invocation. Include JWT signature verification and key loading in store measurements, with both cold and warm JWKS caches. Measure CBOR decoding and fetch encoding with 250 KB values and values up to the configured 4 MiB maximum, independently of blob size. Measure stores at 5, 20, 50 MB and the configured maximum, including concurrent uploads. The number of output files does not bound input metadata size. A Free deployment is a release target only for the sizes that pass those measurements. Lower limits or select Workers Paid if the implementation cannot meet them. -After an eligible execution, save the local result first and enqueue its immutable export with owned file handles or a pinned spool copy. Do not let a subsequent local cache update delete an archive that is still being uploaded. Drain uploads before normal process exit, for at most 30 seconds after the last task finishes; then cancel remaining uploads and report skipped publication. Cancellation aborts remote work promptly. The task's success never depends on an upload finishing. +Keep no more than six external connections open, with sequential R2 part uploads. Size SQL batches and cleanup work to the Free plan's per-invocation limits. Do not interpret an average CPU estimate in the cost table as proof that large stores fit Free. -The new local schema must distinguish locally executed results from remote imports and persist compatibility, strong input digests, signed descriptor and manifest, artifact digest, and source scope. When remote mode is enabled, local hits must match the current compatibility and consumer trust policy. Remote imports must still have an accepted publisher key and epoch; changing either invalidates those imports. Locally executed records belong to the current user's local trust boundary and are never automatically signed and republished on a hit. +## 10. Retention, quotas, and cleanup -Legacy entries do not contain the required strong fingerprints or provenance. Do not derive a new remote result by rehashing the current tree beside an old archive. Keep local-only behavior available; the first remote-enabled run of such a task must execute again to create a verified export. Bump the local schema when implementing this, independently of the remote wire version. +Retain current entries for seven days from successful store commit by default. Replacing a key starts a new retention interval for its new generation. Fetch does not refresh it. An association follows its target entry's lifetime; changing an association does not shorten the old target's retention. -Extend cache events with `local_hit`, `remote_hit`, `remote_miss`, `remote_rejected`, and `remote_upload_skipped`, preserving existing miss explanations. Report aggregate transferred bytes and remote wait time, plus individual reasons in debug output. `vp cache clean` continues to affect only local storage. A future explicit admin operation may purge a remote namespace; a normal client token cannot do so. +Retain a replaced generation's value and blob for ten minutes after replacement to cover an in-flight fetch/download sequence. The former blob ID continues to identify the former bytes during that grace period. It must never return the replacement blob. -## 11. Retention, quotas, and operations +The Free profile reserves 8 GB across live, pending, retired, and deleting objects, plus limits of 20,000 live entries and 20,000 associations. Reserve space for new associations and entries during publication; updates of existing identities do not consume new slots. Warn at 400 MB of actual D1 storage. Maximum-sized keys and many associations can exhaust the database before the entry count limit. -Each namespace has an operator-defined storage-byte quota, entry quota, request-rate limit, and maximum active uploads per principal. Add a deployment-wide byte/entry budget so separate projects and epochs cannot each consume the entire free allowance. Reservations must satisfy both budgets atomically. Pending and ready entries both consume reserved bytes. All upload/retry paths enforce the same limits. Use primary-backed counters for exact reservations; an edge-local rate limiter can be an additional load-shedding measure, not a global quota guarantee. Request and CPU charges can still occur for rejected traffic, so these are storage/admission controls, not a hard Cloudflare bill cap. +A five-minute Cron invocation claims a bounded batch of expired, retired, or abandoned generations in D1, then deletes their known R2 objects or aborts uploads, and finally releases charged bytes. Use generation IDs and conditional state transitions so GC cannot remove a replacement entry or an association whose target has been recreated. Prune dangling associations in bounded indexed batches. Keep deleting objects charged until deletion succeeds. -Results expire seven days after publication in the default `free` hosting profile. Operators can select 30-day history or another explicit retention in an explicit custom hosting profile; retention is an operational choice, not a protocol or billing-plan difference. A custom profile can still use Workers Free. Reads and duplicate uploads do not extend the lifetime. Expired rows stop being discoverable immediately. A Cron Trigger runs every five minutes and persists a cursor. Each Free invocation processes at most 16 entries in one bounded batch; Paid can process 256. Group known object keys into bounded R2 delete calls instead of listing the bucket or looping until empty: +An expired upload lease prevents publication. Allow an additional cleanup grace period for late R2 operations, and retry deletion until the generation is gone. Configure [R2 lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/) as a backstop: abort unfinished multipart uploads after one day; expire generation objects after nine days for seven-day retention, or 32 days for 30-day retention. Lifecycle age starts at object creation, so its margin must cover upload leases and retirement grace. Lifecycle deletion is asynchronous; it does not replace D1 accounting or prompt cleanup. -1. Atomically claim expired ready entries or abandoned uploads as `deleting`. No commit can win after this transition. -2. Delete only the claimed upload generation's manifest/chunks. Allow a five-minute grace after an upload lease expires to cover in-flight requests, then repeat cleanup of deleting generations. Treat missing objects as successful deletion. -3. Remove chunk rows and release quota only after cleanup succeeds. Retain a short-lived tombstone for late writes and retry cleanup when a receipt arrives for an abandoned upload. A failed delete keeps the row and quota charged until retried. +Start with a maximum of 16 generations per Free Cron run and 256 on Paid, subject to measured CPU, query, and subrequest limits. The theoretical Free ceiling is 4,608 generations/day; actual cleanup can be lower. Both overwritten and expired generations contribute to the backlog. Pause stores with `503` before cleanup lag threatens the byte budget. Normal cleanup does not need an R2 LIST per entry. -Configure an R2 lifecycle backstop longer than the maximum published retention plus upload lifetime and grace: nine days from object creation for seven-day retention, or 32 days for 30-day retention. Use separate prefixes or the longest applicable lifetime if policies share a bucket. Do not refresh object ages by overwriting them. [R2 lifecycle deletion is asynchronous](https://developers.cloudflare.com/r2/buckets/object-lifecycles/), so D1 expiry governs visibility. The backstop removes objects left by crashes, lost metadata, or exceptionally late writes. If retention changes, the deployment tooling must update and validate this relationship. Lifecycle alone must not delete still-live artifacts. +Public reads need resource limits before D1/R2 work. Use a [Workers rate-limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) with bounded keys for namespace/operation and stricter unauthenticated store admission; return `429` with `Retry-After`. Configure a catch-all key for unknown paths so callers cannot create unlimited limiter identities. The binding is approximate and local to each Cloudflare location, not a global billing cap. Rejected requests still invoke the Worker. Track anonymous miss traffic and denied writes separately; public traffic can exhaust the Free daily allowance even when storage fits. Keep a deployment/namespace disable control and avoid per-read D1 counter writes. -Log request ID, principal ID, operation, status, bytes, duration, and error class. Do not log bearer tokens, private keys, raw request bodies, environment names/values, archive contents, or full source paths by default. Track hit/miss/rejection rates, candidate count, transferred bytes, upload conflicts, active reservations, D1 latency/errors, R2 errors, and cleanup backlog. Alerts should cover sustained authorization failures, signature rejection, storage thresholds, and cleanup lag. Worker Logs and Cloudflare's D1/R2 metrics are sufficient initially; product telemetry must not require a central collection service. Sample successful request logs at 1% by default and bound error logging; no paid analytics or Logpush service is required. Read hits must not write last-access timestamps, token-usage rows, or analytics rows to D1. Exact byte/entry accounting happens at reservation and deletion; approximate edge rate limits and provider usage metrics cover request traffic without a database write on every read. +Application budgets prevent ordinary storage growth beyond the configured allowance. They do not guarantee a zero bill under arbitrary traffic, shared-account usage, failed uploads, or delayed lifecycle cleanup. Alert at 80% of provider allowances; leave hard admission limits and cleanup headroom in place. -Artifacts and logs may contain source maps, generated source, secrets printed by tasks, and private filenames. They are private project data. Hashing environment values is not encryption and can expose low-entropy values to guessing. Operators choose who can read data and where Cloudflare stores it; signing does not hide it. The public documentation must include task exclusions and credential rotation procedures. +## 11. Failure handling and operations -Token revocation takes effect on the next API request through primary D1 authorization, including chunk requests and commits. Key revocation blocks new publication and server reads from that publisher. For a compromised publisher, rotate the epoch and distribute updated client trust settings, then clean local imports. Server-side revocation cannot erase already downloaded bytes or revoke an offline client's local trust configuration. +The client preserves local results if remote reads, validation, downloads, or explicit publication fail. `vp run` falls back to execution after a failed read; `vp cache push` reports failures through its own exit status. Use short metadata deadlines, bounded transfer deadlines, cancellation, a small concurrency limit, and an invocation-wide circuit breaker after repeated failures. Authentication and size failures should produce one actionable diagnostic rather than repeated attempts for every task. The client may retry transient reads within its time budget. Retrying an uncertain store repeats its replacement semantics. -Cache artifacts need no backup for correctness; losing them causes recomputation. Back up operator policy separately, and document D1 migration/restore procedures. After a partial D1/R2 restore, reconcile references or start a fresh epoch; do not assume D1 recovery also restores deleted R2 bytes. Upgrade schemas additively before upgrading the Worker, retain previous protocol support during rollout, and roll back Worker code only while the database remains compatible. +Before extracting a remote blob, the client must validate its own format, compatibility, integrity information, output paths, and decompression/file-count limits. Reject path traversal, absolute paths, unsafe links, and malformed archives. Stage restoration before terminal replay or local promotion. The Worker stores opaque bytes and cannot perform these task-specific checks. -## 12. Self-deployment and GitHub Actions migration +Log request ID, scope, operation, status, bytes, duration, and error class. For verified writes, include repository ID, workflow ref, run ID/attempt, and commit SHA from signed claims. Reads have no authenticated principal; do not add an identity API call. Do not log credentials or opaque request contents. Observe exact/fallback/not-found rates, client-validated hits, transferred bytes, D1 rows and latency, R2 operations, pending bytes, and cleanup lag. Sample successful Worker logs and bound error logging; no central telemetry service or paid analytics dependency is required. Client hit metrics must distinguish an exact lookup from successful reuse. -Deliver a `packages/remote-cache` template in this repository, with TypeScript sources, pinned dependencies, a lockfile, `wrangler.jsonc`, D1 migrations, OpenAPI/JSON schemas, and an operator guide. The deployment must require only the user's Cloudflare account and local Node.js tooling. No external SaaS is part of the request path. +The Worker verifies GitHub tokens on writes and checks scope state in primary D1. Follow the policy-change and token-expiry semantics in section 5. Back up repository bindings and namespace policy separately from disposable cache data. D1 restoration does not restore deleted R2 objects; reconcile references or create a fresh namespace after partial recovery. Use additive migrations and document rollback compatibility. -The template's setup flow must: +## 12. Self-deployment and GitHub Actions migration -1. Create a private R2 Standard bucket and D1 database, then bind them as `ARTIFACTS` and `INDEX`. Give each environment separate resources. -2. Apply D1 migrations, install the lifecycle backstop and Cron Trigger, and configure retention/quotas. Pin a tested Worker compatibility date and enforce streaming/CPU bounds. -3. Bootstrap project/namespace/epoch, token hashes, and publisher public keys through an operator-only CLI using Cloudflare credentials. Generate individual random tokens and signing keys locally; never send publisher private keys to the Worker. No public administration endpoint is required. -4. Deploy to `workers.dev` or an optional custom domain with HTTPS, keep R2 public access disabled, and verify that unauthenticated reads/writes fail. -5. Print non-secret client configuration and a credential-storage guide. Run a small write/read/revoke/delete smoke test with temporary scoped credentials and isolated test data. +Deliver `packages/remote-cache` with TypeScript sources, pinned dependencies and lockfile, `wrangler.jsonc`, D1 migrations, protocol fixtures, and an operator CLI/guide. Setup creates a private R2 Standard bucket and D1 database, binds them as `ARTIFACTS` and `INDEX`, and installs lifecycle/Cron settings. The maintainer supplies the public GitHub repository; setup resolves its IDs through the [GitHub repository API](https://docs.github.com/en/rest/repos/repos#get-a-repository), stores its namespace write policy, and prints the public endpoint. Namespace creation remains an operator action, not open registration. -Publish exact tested commands with the implementation. The setup tooling should be idempotent, name every resource it creates, and support upgrades plus explicit teardown. Removing the Worker alone must not be described as removing stored data or ending storage charges. Include a local Miniflare workflow and a Cloudflare deployment smoke test; local emulation alone does not prove production consistency or limits. +Deploy to `workers.dev` or an optional custom domain. Every exposed route must permit public reads and enforce the same GitHub JWT policy for stores; disable unused aliases. Keep administration behind the operator's Cloudflare credentials. Provide idempotent setup, policy changes, write/scope disable, upgrades, isolated smoke tests, and explicit teardown of stored data and Worker resources. Pin tested JWT-library, tooling, and compatibility versions. No cache secret needs to be generated or added to GitHub. -Workers Free is the default production target for individuals and small teams. Setup selects the `free` hosting profile: seven-day retention, an 8 GB deployment-wide R2 byte budget including pending/deleting objects, and a 20,000-entry budget. Warn at 400 MB of actual D1 storage and at 80% of daily/monthly operation allowances. The `paid` profile changes operational budgets only after the operator selects them; subscribing to Workers Paid does not silently raise storage limits or retention. Section 13 quantifies when staying free is practical and when an upgrade helps. Real Free-plan CPU, cleanup, and usage measurements are a release gate, rather than a reason to require Paid in advance. +Default to the Free profile in section 10. The Paid profile changes operational budgets only after the operator selects them. Increasing retention or R2 storage remains an explicit choice, independent of the Workers subscription. -For the docs action, keep dependency installation and package-manager caching. Add remote credentials only to the `vp run build` step. A protected CI step would supply these proposed values: +The following workflow excerpt shows the client flow. Retain the existing checkout, Vite+ setup, and dependency-installation steps. The endpoint can be checked into `vite.config.*`; the example uses a non-secret repository variable as a protected CI override. The build saves local results, then a separate step publishes them: ```yaml -- run: vp run build - working-directory: docs - env: - DOCS_SITE_ORIGIN: ${{ inputs.site-origin }} - VP_REMOTE_CACHE_URL: ${{ vars.VP_REMOTE_CACHE_URL }} - VP_REMOTE_CACHE_PROJECT: docs - VP_REMOTE_CACHE_NAMESPACE: trusted - VP_REMOTE_CACHE_EPOCH: '1' - VP_REMOTE_CACHE_MODE: read-write - VP_REMOTE_CACHE_ENVIRONMENT: ${{ vars.VP_REMOTE_CACHE_ENVIRONMENT }} - VP_REMOTE_CACHE_TRUSTED_KEYS: ${{ vars.VP_REMOTE_CACHE_TRUSTED_KEYS }} - VP_REMOTE_CACHE_TOKEN: ${{ secrets.VP_REMOTE_CACHE_CI_TOKEN }} - VP_REMOTE_CACHE_SIGNING_KEY_ID: ci-2026-09 - VP_REMOTE_CACHE_SIGNING_KEY: ${{ secrets.VP_REMOTE_CACHE_CI_SIGNING_KEY }} +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + VP_REMOTE_CACHE_URL: ${{ vars.VP_REMOTE_CACHE_URL }} + steps: + # Existing checkout, Vite+ setup, and dependency installation steps. + - run: vp run build + working-directory: docs + env: + DOCS_SITE_ORIGIN: ${{ vars.DOCS_SITE_ORIGIN }} + - run: vp cache push + working-directory: docs + if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + continue-on-error: true ``` -This excerpt belongs only in a job authorized to receive the writer credentials. Other jobs use a separate read-only setup or no remote credentials. The existing [docs task configuration](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/docs/vite.config.ts) fingerprints `DOCS_SITE_ORIGIN`; preserve this distinction so production and preview outputs cannot be confused. +The workflow trigger and step condition avoid unnecessary upload attempts; the Worker independently checks the signed repository, branch, and event claims. A PR workflow uses the same public endpoint for reads, without `id-token: write` or a push step. Preserve the existing [`DOCS_SITE_ORIGIN` input tracking](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/docs/vite.config.ts) and the workflow's configured site-origin value. Keep dependency installation and package-manager caching. -During a canary, the existing task-directory restore/save can remain, but it must stay within the same CI trust boundary and cannot be treated as signed provenance. Once remote-enabled clients and a fresh-run population are verified, remove the task-cache key computation and task-directory restore/save steps. Keep the dependency cache managed by `setup-vp`. To roll back, set remote mode to `off`; builds continue with local caching and can retain/reintroduce the old task-directory optimization. +During a canary, retain existing task-directory restore/save within its trust boundary, but do not republish restored entries by default. Remove those steps after compatible clients pass anonymous fresh-checkout reuse and explicit-publication tests. Roll back publication by removing the push step or disabling writes on the server; disable remote reads by removing the endpoint or using `--no-remote-cache`. ## 13. Free and Paid capacity comparison -The default should stay within free allowances for representative individual and small-team workloads. This section separates provider limits from workload estimates. All prices are USD before tax, checked on 2026-09-07; all allowances assume no other applications consume them. Use a 30-day month and decimal MB/GB for the estimates. Team size alone is not a capacity measure: unique result size, publication frequency, remote hit rate, and retention matter more. +Prices below are USD before tax, checked on 2026-09-09. Estimates use a 30-day month and decimal MB/GB. Allowances assume this deployment is the account's only consumer. Workloads are illustrative; the frontend samples establish sizes, not typical user traffic. + +### Provider allowances + +A Cloudflare account plan, Workers Free/Paid, and R2 billing are separate choices. The service can use `workers.dev` without a Pro website plan. Users must [enable R2](https://developers.cloudflare.com/r2/get-started/); R2 overages can incur charges while Workers remains Free. Upgrading Workers does not increase R2's free allowance. See Cloudflare's [billing model](https://developers.cloudflare.com/billing/understand/how-billing-works/). -### Provider allowances and limits +| Workers resource | Free | Paid Standard | +| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------- | +| Subscription | $0 | $5/month minimum | +| Dynamic requests | 100,000/day | 10 million/month included; then $0.30/million | +| HTTP CPU | 10 ms/invocation | 30 million CPU ms/month included; then $0.02/million CPU ms; 30 s default per invocation, configurable to 5 min | +| Five-minute Cron CPU | 10 ms/invocation | 30 s/invocation | +| Memory | 128 MB/isolate | 128 MB/isolate | -A Free website/account plan, Workers Free/Paid, and R2 usage billing are separate choices. This service needs no Pro/Business website plan and can use `workers.dev`. [R2 must be enabled as a subscription](https://developers.cloudflare.com/r2/get-started/), even when usage is covered by its free allowance. R2 overages can produce charges while Workers remains Free; upgrading Workers does not increase the R2 free allowance. The setup guide must explain this distinction using Cloudflare's [billing model](https://developers.cloudflare.com/billing/understand/how-billing-works/). +Sources: [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) and [limits](https://developers.cloudflare.com/workers/platform/limits/). Storage/network wait time does not consume Worker CPU. Free request allowance is daily, and CPU eligibility applies to individual operations. -| Workers resource | Free | Paid Standard | -| ---------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------- | -| Subscription | $0 | $5/month minimum | -| Dynamic requests | 100,000/day; daily hard limit | 10 million/month included; then $0.30/million; no equivalent daily quota | -| HTTP CPU | 10 ms per invocation | 30 million CPU ms/month included; then $0.02/million CPU ms; 30 s per-invocation default, configurable to 5 min | -| Cron CPU at this design's five-minute interval | 10 ms per invocation | 30 s per invocation | -| Memory | 128 MB per isolate | 128 MB per isolate | +| D1 resource | Free | Paid Standard | +| ----------------------------- | ----------------------------- | -------------------------------------------------- | +| Rows read | 5 million/day | 25 billion/month; then $0.001/million | +| Rows written | 100,000/day | 50 million/month; then $1/million | +| Storage | 5 GB/account; 500 MB/database | 5 GB included, then $0.75/GB-month; 10 GB/database | +| Queries per Worker invocation | 50 | 1,000 | -Sources: [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) and [runtime limits](https://developers.cloudflare.com/workers/platform/limits/). Network/storage wait time does not consume Worker CPU. A monthly average below 3 million requests does not prove Free eligibility: one busy day can exceed the daily limit. The 10 ms CPU limit applies to each request and each Cron invocation, not an average across them. +Sources: [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) and [limits](https://developers.cloudflare.com/d1/platform/limits/). Count index maintenance and deletion as writes. Account-wide Free daily exhaustion interrupts database work until reset. -| D1 resource | Free | Paid Standard | -| ----------------------------- | ------------------------------------- | ---------------------------------------------------------- | -| Rows read | 5 million/day | 25 billion/month included; then $0.001/million | -| Rows written | 100,000/day | 50 million/month included; then $1/million | -| Stored data | 5 GB/account; **500 MB per database** | 5 GB included; then $0.75/GB-month; **10 GB per database** | -| Queries per Worker invocation | 50 | 1,000 | +| R2 Standard resource | Included with either Workers plan | Overage | +| ------------------------------- | --------------------------------- | --------------- | +| Storage | 10 GB-month/month | $0.015/GB-month | +| Class A | 1 million/month | $4.50/million | +| Class B | 10 million/month | $0.36/million | +| Egress, DELETE, multipart abort | Free | Free | -Sources: [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) and [D1 limits](https://developers.cloudflare.com/d1/platform/limits/). Count index maintenance and eventual deletion as writes, not just inserted result rows. Free daily exhaustion prevents further database work until reset; it does not automatically convert the service to Paid. Multiple deployments in one account share the daily allowance. +[R2 pricing](https://developers.cloudflare.com/r2/pricing/) counts multipart creation, each part, completion, and PUT as Class A; GET/HEAD as Class B. Storage billing averages daily peaks. Billable units round up to whole GB-months and million-operation units. Include unfinished and retired objects in observed peaks. -| R2 Standard resource | Included with either Workers plan | Beyond the included allowance | -| -------------------------------------- | --------------------------------- | ----------------------------- | -| Storage | 10 GB-month/month | $0.015/GB-month | -| Class A operations, including PUT/LIST | 1 million/month | $4.50/million | -| Class B operations, including GET/HEAD | 10 million/month | $0.36/million | -| Internet egress; object DELETE | No charge | No charge | +### Version 1 authorization cost -Source: [R2 pricing](https://developers.cloudflare.com/r2/pricing/). Included usage is account-wide, not per bucket. R2 storage billing uses the monthly average of daily peak usage; include pending uploads, expired objects awaiting deletion, and abandoned objects. R2 rounds billable storage/operation units up, so a small operation overage can cost a whole additional million-operation unit. The reference service uses Standard storage; Infrequent Access does not have the same free tier. +Public reads require no identity subscription. GitHub issues publishing-job tokens. GitHub Actions compute billing is separate from this cache estimate. JWT checks run in the Worker, and bounded JWKS refreshes add subrequests and latency. Benchmark both cold and warm verification with store parsing before claiming Workers Free eligibility. -### Model: translate task activity into billable work +### Usage model -Let `L` be daily remote lookups **after local misses**, `H` remote hits, `U` new successful publications, `M` average manifests downloaded per lookup, `C` chunks per artifact, `S` mean stored MB per newly published result, and `R` retention days. For `S`, count the compressed `tar.zst` bytes (outputs and terminal events) plus the validation manifest's bytes in R2. Account for the signed descriptor in D1. Local hits make no service request. Retried or conflicting publications still consume operations, even though they do not add retained results. +Let `L` be daily public fetches after local misses, `F` fetches returning a value, `H` blob downloads after successful client validation, and `P` successfully published entries from explicit main-branch CI pushes, including overwrites. `P` counts entries, not command invocations, and is independent of developer misses. GitHub token requests are outside cache-Worker request counts; JWT verification and JWKS fetching belong in measured Worker CPU/subrequest budgets. Let `B` be mean blob bytes, `V` mean value bytes, `S = (B + V) / 1,000,000`, `E` live exact keys, and `R` retention days. -A publication takes one descriptor reservation, one manifest PUT, `C` chunk PUTs, and one commit. A hit takes discovery, its candidate manifest downloads, and `C` chunk GETs. Unmatched candidates also cause manifest reads. Use these planning equations: +Each store takes one Worker request. Each metadata fetch takes one request, followed by one blob request only when needed. The Worker also reads the opaque value from R2. Internal R2 multipart upload changes storage operations, not client request counts: ```text -Worker invocations/day = ceil(1.10 * (L * (1 + M) + H * C + U * (C + 3))) + 300 -R2 Class A/month = ceil(30 * 1.10 * U * (C + 1)) -R2 Class B/month = ceil(30 * 1.10 * (M * L + C * H + (C + 1) * U)) -Retained R2 GB = U * S * R / 1000 +A per store = 1 # no blob: value PUT + = 2 # blob <= 5 MiB: value PUT + blob PUT + = 3 + ceil(B / 5 MiB) # larger: value PUT + create + parts + complete + +Worker invocations/day = ceil(1.10 * (L + H + P)) + 300 +R2 Class A/month = ceil(30 * 1.10 * P * A) +R2 Class B/month = ceil(30 * 1.10 * (F + H)) +Current R2 GB = E * S / 1000 +Total R2 GB = current + pending + retired + awaiting deletion ``` -The 10% reserve covers ordinary retries, capabilities, conflicts, and cleanup overhead; 300 extra daily invocations conservatively cover the 288 Cron runs plus routine management. Normal GC deletes known object keys without an R2 LIST per result. R2 HEAD checks at commit are included. Reserve more for outages, deep candidate searches, or many small invocations; these formulas are not an upper bound against arbitrary traffic. +Use per-size buckets or sum per-store operations for a mixed workload; `ceil(mean size)` can undercount multipart operations. The 10% operation reserve covers ordinary retries and maintenance; 300 daily invocations cover 288 Cron runs and routine management. It is not a bound against outages or arbitrary traffic. R2 completion/PUT success precedes publication; no extra HEAD per object is planned. -D1 costs depend on the actual schema and query plans. Until the implementation is measured, budget **64 rows read per lookup plus 32 per publication**, and **40 rows written over the complete life of a one-chunk result**, including indexes, receipts, quota counters, publication, GC, and tombstones. Reserve another 10% plus 5,000 daily reads/1,000 daily writes for maintenance. These are engineering budgets, not Cloudflare per-request rates: +Only when each store creates a different retained exact key at a steady rate does `E = P * R`, giving `P * S * R / 1000` GB. With repeated stores to one key, retain its current generation and a short retirement backlog. Input changes do not necessarily create a different exact key. Do not multiply all stores by seven days and describe the result as actual storage usage. Overwrites still consume Worker, R2, D1, and cleanup operations. + +For planning, budget 64 D1 rows read per fetch/download cycle plus 32 per store, and 40 rows written per store over its full lifecycle. Include scope lookups, indexes, accounting, association replacement, and cleanup. GitHub token verification needs no D1 credential table; retain conservative row budgets for repository-policy checks until measurements justify reducing them. Add 10% plus 5,000 reads/1,000 writes per day for maintenance: ```text -D1 reads/day = ceil(1.10 * (64 * L + 32 * U)) + 5000 -D1 writes/day = ceil(1.10 * 40 * U) + 1000 -D1 storage MB = retained_results * 4096 / 1000000 # provisional one-chunk mean +D1 reads/day = ceil(1.10 * (64 * L + 32 * P)) + 5000 +D1 writes/day = ceil(1.10 * 40 * P) + 1000 +D1 storage MB = E * 4096 / 1000000 # provisional, ordinary small keys ``` -The 4 KiB metadata estimate includes an ordinary small descriptor and indexes; an 8 KiB maximum descriptor or a 16-chunk result costs more. Measure `rows_read`, `rows_written`, and actual database bytes, including migrations and cleanup, before claiming these budgets. Additional chunks add receipt/index work; the one-chunk D1 equation must not be applied unchanged to large artifacts. +These are engineering budgets, not measured costs. The storage estimate assumes roughly one association and current generation per key; account for extra associations, pending/retired generations, and large keys separately. R2 part receipts need not create a D1 row per part. Confirm `rows_read`, `rows_written`, index plans, actual database bytes, and cleanup costs before claiming these capacities. + +### Key and value size evidence -### Result-size assumption and measurement +The [PR #713 size example](https://github.com/voidzero-dev/vite-task/blob/362f5bd91bb32806b512d3d9a5339ff435bf5f0a/docs/remote-cache-server-api.md#sizes) reports a build tracking about 4,200 input paths and producing eight output files: -Use **5 MB for a small-output scenario**, not a measured average or artifact-size limit. Measurements of four established products' published Vite frontend outputs on 2026-09-07 give the following comparison. The [artifact study](remote-cache-size-study/README.md) includes pinned sources, exact byte counts, and a reproduction script. +| Field | Reported size | +| --------------- | -------------: | +| `key` | About 1 KB | +| `secondary_key` | About 50 bytes | +| `value` | About 250 KB | +| `blob` | About 250 KB | -| Product and sampled release | Output files MB | `tar.zst` MB | `tar.zst` MB without source maps | -| ------------------------------- | --------------: | -----------: | -------------------------------: | -| Directus `@directus/app@17.1.1` | 20.81 | 6.97 | 6.97 | -| Docmost `v0.95.0` | 14.83 | 4.77 | 4.77 | -| Hoppscotch `2026.8.0` | 127.53 | 32.18 | 12.18 | -| n8n `n8n-editor-ui@2.16.2` | 162.76 | 34.71 | 13.15 | +This is an upstream-reported example, not a measurement from our frontend artifact study or a population average. The client can record many input paths and hashes even when a task produces few output files. Here the value is as large as the blob. Budget and measure them separately; the Worker continues to treat their contents as opaque bytes. -These measurements isolate frontend output files from official npm releases or Docker build COPY layers and recompress them with zstd level 3. They exclude backend code, dependencies, terminal events, and the proposed validation manifest. We did not rebuild the projects or measure their private cloud deployments. The map-free column is a sensitivity calculation; the cache must preserve source maps when the task requires them. +For planning, interpret the approximate KB values as decimal: `V = 250,000` bytes and `B = 250,000` bytes give `S = 0.5 MB`. At 200 new distinct keys/day and seven-day retention, value plus blob storage is about 0.7 GB before staging and cleanup headroom. Both objects remain in R2. D1's provisional 4 KiB per-key estimate covers index and generation records only; validate it with the reported key sizes, including repeated key bytes in indexes and associations. -Three of these four releases exceed 5 MB after compression. Add **50 MB as a complete-result planning case for mature SaaS frontends**, with room above the sampled frontend archives, and 100 MB as a stress case. Neither value is a measured population average or a guaranteed upper bound. At 200 new results/day and seven-day retention, a 50 MB mean needs 70 GB, so the earlier 7 GB estimate applies only to the 5 MB scenario. The Free-capacity estimates remain conditional on workload and measured service CPU. +Every exact or fallback response transfers the value even if client validation prevents a blob download. Before protocol overhead and retries, daily response payload is `F * V + H * B` bytes. With 1,000 value-bearing fetches and 800 blob downloads at the example's sizes, that is about 450 MB/day, including 250 MB of values. This affects transfer time, CBOR work, and memory; request and R2 operation counts follow the existing equations. -Measure one cached task execution at a time. A monorepo run can publish several task results. A GitHub Actions archive of the whole local cache can contain many tasks and historical versions, so its size cannot substitute for `S`. Tasks with only terminal output, library builds, and application or documentation builds need separate samples; file inventories and compressibility differ. +### Artifact-size evidence -Before using these scenarios to support a claim about average users: +Use 5 MB for a small-output scenario. Measurements of four established products' published Vite frontend outputs on 2026-09-07 provide broader size evidence. The [artifact study](remote-cache-size-study/README.md) records pinned sources, exact bytes, and a reproduction script. -1. Sample small repositories and monorepos across normal developer and CI changes. Include the docs canary, builds with source maps and static assets, and tasks with no output files. Record task type, compatibility identity, and observation period. -2. Measure the proposed remote archive and manifest bytes for each new stored result. Existing local output archives provide preliminary output-size data; add the proposed terminal-event encoding and validation manifest before estimating remote storage. Count separate stored copies across namespaces or compatibility identities, while excluding read hits and retries that create no new copy. -3. Compute `S = total newly stored bytes / new stored results / 1,000,000`. Report the mean, median, p95, maximum, and sample counts by task type and repository. Weight the storage mean by new publications; an average of repository averages can hide frequently rebuilt large tasks. -4. Record daily new bytes and peak retained bytes over at least two retention windows. Include pending uploads and cleanup lag. Report the fraction of sampled deployments that fit the default budget and the sample's limits before extrapolating to most users. +| Product/release | Output MB | `tar.zst` MB | `tar.zst` MB without source maps | +| ------------------------------- | --------: | -----------: | -------------------------------: | +| Directus `@directus/app@17.1.1` | 20.81 | 6.97 | 6.97 | +| Docmost `v0.95.0` | 14.83 | 4.77 | 4.77 | +| Hoppscotch `2026.8.0` | 127.53 | 32.18 | 12.18 | +| n8n `n8n-editor-ui@2.16.2` | 162.76 | 34.71 | 13.15 | -The release-artifact sample supplies size evidence for four frontend builds. It does not measure publication rates, hit rates, or the mix of tasks across users. At 200 new results/day and seven-day retention, the 1, 5, 20, 50, and 100 MB sensitivity cases require 1.4, 7, 28, 70, and 140 GB respectively, before pending uploads and cleanup lag. +These are official npm/Docker frontend outputs recompressed with zstd level 3, not local rebuilds or private cloud deployment measurements. They exclude backend tasks, dependencies, terminal events, and client validation metadata. Keep source maps when the task requires them. All four sampled archives fit the 64 MiB blob limit. -### Illustrative workloads +Three samples exceed 5 MB. Use 50 MB as an additional complete-result planning case for mature frontends, with room above these sampled archives. It is not a measured population average or an upper bound. -Assume 80% of remote lookups hit (`H = 0.8 * L`), every remaining lookup publishes one new result (`U = 0.2 * L`), one manifest is read per lookup even on misses (`M = 1`), and every artifact fits one chunk (`C = 1`). These are illustrative user profiles, not measured usage statistics. Averaging 5 ms Worker CPU per modeled invocation is a **Paid-cost assumption**; Free eligibility still requires every operation class to stay within its 10 ms limit. +Measure one task at a time. A whole local-cache directory can contain several tasks and keys. During the canary, record compressed blob/value bytes, store and overwrite counts, live distinct keys, retention, pending peaks, task types, and lookup/validated-hit rates. Report mean, median, p95, maximum, and sample count by task type over at least two retention windows. Measure the fraction of sampled deployments that stay free before claiming support for most average users. -| Workload | Remote lookups/day | New results/day | Mean result | Retention | Retained R2 | Worker invocations/day | D1 writes/day | -| -------------------------------- | -----------------: | --------------: | ----------: | --------: | ----------: | ---------------------: | ------------: | -| Individual | 100 | 20 | 1 MB | 7 days | 0.14 GB | 696 | 1,880 | -| Small-team scenario | 500 | 100 | 5 MB | 7 days | 3.5 GB | 2,280 | 5,400 | -| Active small-team scenario | 1,000 | 200 | 5 MB | 7 days | 7 GB | 4,260 | 9,800 | -| Same active team, longer history | 1,000 | 200 | 5 MB | 30 days | 30 GB | 4,260 | 9,800 | -| Same active team, larger outputs | 1,000 | 200 | 20 MB | 7 days | 28 GB | 4,260 | 9,800 | -| Mature SaaS frontend scenario | 1,000 | 200 | 50 MB | 7 days | 70 GB | 4,260 | 9,800 | -| Growing team | 20,000 | 4,000 | 5 MB | 7 days | 140 GB | 79,500 | 177,000 | -| High volume | 100,000 | 20,000 | 5 MB | 7 days | 700 GB | 396,300 | 881,000 | +### Public-read workloads with explicit CI publication -| Workload | Workers Free plus R2 | Workers Paid plus R2 | Recommended choice | -| -------------------- | ------------------------------------------ | -------------------- | ------------------------------------------------------------------------- | -| Individual | $0 | About $5/month | Default Free profile | -| Small team | $0 | About $5/month | Default Free profile | -| Active small team | $0 | About $5/month | Default Free profile; monitor storage | -| Longer history | About $0.30/month in R2 | About $5.30/month | Keep Workers Free; explicitly allow more R2 storage if 30 days are useful | -| Larger outputs | About $0.27/month in R2 | About $5.27/month | Keep Workers Free with a larger paid storage budget, or shorten retention | -| Mature SaaS frontend | About $0.90/month in R2 | About $5.90/month | Keep Workers Free if CPU fits; shorten retention or allow more R2 storage | -| Growing team | Cannot sustain the modeled D1 daily writes | About $6.95/month | Paid compute/D1 plus an explicit storage budget | -| High volume | Exceeds Free Workers/D1 limits | About $21.01/month | Paid, with load testing and larger operational budgets | +In v1, many developer reads can share a small main-branch publication stream. The examples below use `H = 0.8 * L`, `F = L`, 5 MB per published entry, seven-day retention, and distinct keys for all stores. Publication counts are independent inputs: -The $0 rows fit the default 8 GB object budget and provider operation allowances, subject to measured CPU and cleanup behavior. The other rows assume the operator raises application budgets; the default profile would stop accepting more data. D1 read estimates range from 12,744/day for the individual to 82,440/day for the active small team, well below 5 million. The active team retains about 5.7 MB of modeled D1 data, below the 500 MB database limit. Its monthly R2 workload is 13,200 Class A and 72,600 Class B operations. This is why storage is the first expected limit for these profiles. +| Public-cache workload | Fetches/day | Published entries/day | Current R2 | Worker/day | D1 reads/day | D1 writes/day | Free monthly estimate | Paid monthly estimate | +| --------------------- | ----------: | --------------------: | ---------: | ---------: | -----------: | ------------: | --------------------: | --------------------: | +| Public project | 1,000 | 20 | 0.7 GB | 2,302 | 76,104 | 1,880 | $0 | $5.00 | +| High read traffic | 40,000 | 100 | 3.5 GB | 79,610 | 2,824,520 | 5,400 | $0 | $5.00 | -For the high-volume Paid row, 11.889 million monthly Worker invocations at 5 ms use 59.445 million CPU ms. Worker subscription plus overages is about $6.16. R2 storage is `(700 - 10) * $0.015 = $10.35`; 1.32 million Class A operations cost $4.50 after the free allowance and rounding; 7.26 million Class B operations remain included. Modeled D1 usage is 232.47 million reads, 26.43 million writes, and roughly 573 MB stored, within Paid's included allowances. Total: about $21.01. Other account use, tax, logs beyond included allowances, and higher CPU/retry rates can change the bill. +Both fit the modeled request, row, storage, and R2-operation allowances, subject to per-request CPU qualification and operational headroom. The second case uses 2.3883 million Worker requests, 6,600 R2 Class A operations, and 2.376 million Class B operations per 30-day month. Under the same provisional 5 ms CPU assumption, Workers Paid stays at its $5 base charge. No identity-seat fee scales with the number of readers. These are workload examples, not measurements of typical projects or a guarantee against arbitrary public traffic. -### How much fits, and what should trigger payment? +### Illustrative workloads and prices -With the same 80% hit rate and seven-day retention, the 8 GB application budget supports: +These scenarios compare storage and operation costs with `H = 0.8 * L`, `P = 0.2 * L`, and `F = L`. Only main-branch CI publications count toward `P`; the chosen ratio is a modeling assumption. Assume each store creates a distinct key. Set `V = 250 KB` (250,000 bytes), based on the upstream example, and include it in `S`. Calculate multipart counts from the remaining blob bytes. Average CPU of 5 ms per invocation is a Paid-cost assumption; Free eligibility requires per-request measurements. -| Mean stored result | New results/day within 8 GB | Remote lookups/day at 80% hits | New results/day if keeping 30 days | -| ------------------ | --------------------------: | -----------------------------: | ---------------------------------: | -| 1 MB | 1,142 | 5,710 | 266 | -| 5 MB | 228 | 1,140 | 53 | -| 20 MB | 57 | 285 | 13 | -| 50 MB | 22 | 110 | 5 | -| 100 MB | 11 | 55 | 2 | +| Workload | Fetches/day | Stores/day | Mean size | Retention | Current R2 | Worker/day | D1 writes/day | +| -------------------- | ----------: | ---------: | --------: | --------: | ---------: | ---------: | ------------: | +| Individual | 100 | 20 | 1 MB | 7 days | 0.14 GB | 520 | 1,880 | +| Small team | 500 | 100 | 5 MB | 7 days | 3.5 GB | 1,400 | 5,400 | +| Active small team | 1,000 | 200 | 5 MB | 7 days | 7 GB | 2,500 | 9,800 | +| Longer retention | 1,000 | 200 | 5 MB | 30 days | 30 GB | 2,500 | 9,800 | +| Larger outputs | 1,000 | 200 | 20 MB | 7 days | 28 GB | 2,500 | 9,800 | +| Mature frontend case | 1,000 | 200 | 50 MB | 7 days | 70 GB | 2,500 | 9,800 | +| Busy team | 20,000 | 4,000 | 5 MB | 7 days | 140 GB | 44,300 | 177,000 | +| Large organization | 100,000 | 20,000 | 5 MB | 7 days | 700 GB | 220,300 | 881,000 | -These are steady-state storage ceilings rounded down, not recommended operating targets. Retention lag and upload reservations also consume the budget. The 100 MB case concerns storage only; its archive can require multiple chunks, so use the general operation equations with the actual `C`. With 5 MB results, changing remote hit rate from 80% to 50% reduces the supported lookups from 1,140 to 456/day; raising it to 95% increases them to 4,560/day. A large number of reads can be cheap when few new artifacts are stored. Large output sets or low reuse can fill a free cache even for one developer. +| Workload | Workers Free + R2 monthly estimate | Workers Paid + R2/D1 monthly estimate | +| ------------------------------------------- | --------------------------------------------------------------- | ------------------------------------: | +| Individual / small team / active small team | $0 within modeled allowances | $5.00 | +| Longer retention | $0.30; requires higher storage budget | $5.30 | +| Larger outputs | $0.27; requires higher storage budget | $5.27 | +| Mature frontend case | $0.90; requires higher storage budget and Free CPU verification | $5.90 | +| Busy team | Does not fit D1 Free writes | $6.95 | +| Large organization | Exceeds Free requests, writes, and single-database storage | $19.91 | -Ignoring storage, the common-mix model reaches Free's daily Workers quota at about 25,176 lookups, D1's read quota at about 64,501, and D1's write quota at about 11,250. Use approximately 8,977 lookups/day as the 80%-of-write-allowance planning threshold, not the hard ceiling; the 20,000-entry budget and storage budget can bind earlier. Actual CPU or burst load may bind earlier. For tiny artifacts, D1 writes can therefore become the first limit; a paid upgrade is not determined by user count alone. +The public cache has no per-reader subscription charge. These steady-state prices exclude pending/retired storage, other account usage, domain costs, GitHub Actions compute, and unusual or abusive traffic. Free eligibility still requires CPU measurements, including JWT verification on stores. The default 8 GB profile refuses excess stores; it does not incur the higher-storage scenario by itself. R2 Class A remains within one million monthly operations in the 20/50 MB rows: 46,200/85,800 operations under the chosen multipart model. -At the assumed 5 ms average, Paid's included 30 million CPU ms cover about 6 million invocations/month, equivalent to roughly 50,000 daily lookups in this model. R2 storage for that workload is additional; $5 is the compute subscription, not an all-inclusive 350 GB cache plan. Beyond included usage, charges grow with operations and retained bytes. Paid has no single maximum number of builds: per-request limits, D1 database size, query latency, and concurrency still apply. D1 executes queries serially per database; test peaks and partition projects into separate databases/deployments when needed, rather than inferring throughput from a monthly allowance. +For the large-organization row: 6.609 million Worker requests/month and 33.045 million CPU ms cost about $5.06; 700 GB R2 storage costs $10.35; 1.32 million Class A operations cost $4.50 after free allowance and unit rounding; 5.94 million Class B operations remain included. Modeled D1 use is 232.47 million reads, 26.43 million writes, and about 573 MB of current-key metadata, within Paid allowances. Cache infrastructure subtotal: about $19.91/month. Bursts, CPU outliers, and D1 throughput still need load tests; monthly allowances do not promise a request rate. -### Decisions to keep normal use free +### How much can remain free? -1. Default to Workers Free, seven-day retention, 8 GB of reserved object bytes, and 20,000 retained/pending entries across the deployment. Storage is Standard; custom domains, Queues, Durable Objects, and paid analytics are not prerequisites. A full cache rejects new reservations and preserves existing results until expiry; it never drops verification or silently buys capacity. -2. Parse and verify only the 8 KiB signed descriptor in the Worker. Stream the full manifest and artifact to R2 with checksum verification. Keep cryptographic file hashing, input validation, archive work, and decompression on the client. Do not solve Free CPU limits by disabling signatures or excluding inferred inputs. -3. Avoid D1 writes on read hits. Use indexed candidate queries, page/transfer bounds, local promotion, and per-invocation memoization. Charge storage reservations exactly; track request usage through provider metrics rather than a global SQL update per GET. -4. Keep Free cleanup to one batch of at most 16 entries every five minutes: at most 4,608 reclaimed entries/day before retries. Bound D1 queries and R2 calls per invocation, coalesce deletes, and retain the cursor on CPU/error interruption. Pause new publications when cleanup falls behind. Paid's 256-entry batches provide more cleanup headroom without changing cache semantics. -5. Alert at 80% of service allowances and expose actual storage, publication count, average result size, retention, and quota skips through the operator CLI. Include all projects, environments, abandoned uploads, and other Cloudflare applications in the account budget. R2's free allowance is not a hard spending cap; delayed usage metrics, exceptional orphaned bytes, and abusive traffic prevent a guarantee of a zero bill. -6. Offer the cheapest relevant next step: shorten retention or exclude low-value outputs first; permit a small R2 storage charge if storage alone is limiting; select Workers Paid when CPU, daily requests, D1 writes, or the database limit requires it. Never recommend the $5 subscription solely because stored artifacts exceed 10 GB. +For distinct keys, the 8 GB application budget gives these storage-only ceilings before pending bytes and cleanup headroom: -Free support is a release requirement. Measure the individual and both small-team profiles on a real Workers Free deployment, with p99 CPU below 8 ms and no CPU-limit errors for descriptor verification, maximum-size streamed manifest/chunks, candidate pages, and GC. Verify quota behavior and recovery across daily resets, and ensure GC sustains the tested publication rate. If a path exceeds budget, optimize or split that path before claiming Free compatibility. A low average CPU value alone is insufficient. +| Mean stored size | New distinct keys/day, 7 days | New distinct keys/day, 30 days | +| ---------------- | ----------------------------: | -----------------------------: | +| 1 MB | 1,142 | 266 | +| 5 MB | 228 | 53 | +| 20 MB | 57 | 13 | +| 50 MB | 22 | 5 | -Record artifacts from representative small repositories and monorepos, their actual average/p95 sizes, manifest sizes, publication rates, hit rates, and SQL counters during the canary. The RFC supports a plausible zero-cost target for individuals and small teams; claiming that **most average users** fit it requires those workload measurements. Publish the measured coverage and the formulas so users can estimate their own costs rather than relying on a universal free-user count. +Compute `floor(8000 / (S * R))`; other limits may bind first. If 200 daily stores repeatedly replace the same ten 50 MB keys, current storage is about 0.5 GB, plus temporary old generations and staging. If they create 200 different keys daily, seven-day current storage is 70 GB. The client's actual key reuse therefore matters as much as archive size. -## 14. Alternatives and tradeoffs +In the stress comparison where `P = 0.2 * L`, with 80% blob downloads and no storage constraint, Free Worker requests allow about 45,318 fetches/day. The provisional D1 write budget binds earlier at 11,250 fetches/day, or about 8,977 at an 80% write alert threshold. Cleanup and CPU can reduce these numbers. For read-only workloads, D1 reads and Worker requests bind instead; apply the equations with `P = 0`. -| Alternative | Reason not selected for version 1 | -| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Keep GitHub Actions Cache as the remote backend | Remains a CI directory-snapshot workflow and does not give developers a native deployable task-cache service | -| Expose the local SQLite database/archive directory in R2 | Couples machines to mutable local state, weak input digests, and local schema/layout; cannot safely merge concurrent results | -| Workers plus R2 only | Viable for an already-complete immutable key, but bounded inferred-input discovery, quota reservations, revocation, and upload cleanup need coordinated metadata; rebuilding them with object lists and CAS adds complexity | -| Workers KV as the authoritative index or token store | Its eventual consistency is unsuitable for prompt revocation and publication coordination; see [KV consistency](https://developers.cloudflare.com/kv/concepts/how-kv-works/) | -| Durable Objects for each lookup key | Can coordinate publication, but project-wide authorization, quota accounting, discovery, and cleanup still need a design across objects; D1 SQL suits the first deployment scale | -| Direct presigned R2/S3 access | Adds credential/signing and upload-finalization complexity; Worker-proxied chunks give the same authorization and bounds on each request | -| One unbounded HTTP archive | Exceeds account upload limits for large artifacts; fixed chunks keep memory and retry work bounded | -| Require explicit input lists or hash the whole checkout | Removes default inferred-input reuse or adds unrelated invalidations; neither is required by the existing cache model | -| Global content-addressed blob store | Adds reference tracking and deletion races for limited initial benefit; per-result chunks can be reclaimed independently | -| Optional symmetric signatures | Simpler key setup, but readers must share a signing secret; asymmetric signatures separate reader and publisher capabilities | +The plan targets free operation through local-first public reads, one metadata fetch, blob downloads only after validation, explicit main-branch publication, replacement semantics, no D1 writes on reads, bounded retention, and private Standard R2. Read traffic can grow without adding writers or user-seat fees. The modeled examples fit free allowances only while traffic, storage, and per-request CPU remain within their budgets. Production measurements remain necessary to substantiate a claim about typical users. -## 15. Implementation sequence and acceptance criteria +## 14. Storage tradeoffs -This RFC does not implement or deploy the service. Approval starts the following work; each stage has a concrete release gate. +D1 plus R2 preserves atomic replacement of two indexes while streaming object data. An R2-only implementation would need another coordination mechanism for those mappings. Workers KV would require a separate consistency design for replacement and scope-state changes. Durable Objects could serialize writes and accounting, but would add another state service; consider them only if D1 concurrency measurements show a need. -1. **Portable identity and validation.** Add canonical schemas, SHA-256 observations, compatibility identity, and an export/import representation in the engine. Cover the existing fingerprint variants exhaustively, including environment-query match sets. Keep local-only behavior and cache miss explanations. Publish golden vectors shared by Rust and TypeScript; schema changes require explicit format/version decisions. -2. **Untrusted artifact handling.** Implement bounded canonical archive creation, signature verification, staged extraction, guarded replacement, journal recovery, and local provenance. Test hostile/truncated archives, digest failures, symlink/reparse escapes, case collisions, executable bits, permissions/disk failures, rollback, and cancellation. Fuzz manifest parsing and extraction boundaries before enabling remote downloads. -3. **Cloudflare reference service.** Implement the versioned API, D1 migrations, R2 chunk transfer, permissions, immutable commit, quotas, and GC. Ship a machine-readable OpenAPI contract and conformance suite. Verify duplicate writers, identical retries, different-body retries, failed uploads/receipts/commits, expired leases, late writes, missing objects, revoked principals/keys, concurrent quota reservations, and cleanup/commit races against a real isolated Cloudflare deployment. -4. **Native client and configuration.** Integrate after local misses and after successful local updates, add transfer deadlines/concurrency, secret filtering, provenance policy, upload draining, and diagnostics. Cover task disables, CLI precedence, environment overrides, credential absence, `401`/`403`/`429`/`5xx`, service outage, cancellation, and normal process exit while uploads remain pending. -5. **Deployment and canary.** Ship the template and operator guide, exercise clean-account Free setup and explicit Paid upgrade/rollback/teardown, and migrate the docs action in a separate change. Verify section 13's Free CPU and usage budgets, cleanup throughput, and representative workload coverage. Measure latency, transfer sizes, CPU, D1 rows, storage, and recomputation avoided with actual docs builds before declaring the default limits suitable. +Storing small values inline in D1 could remove one R2 read/write for many tasks. Defer this optimization until value-size measurements justify a split storage path; values above D1's row limit would still need R2. Cross-result content deduplication could reduce repeated asset storage but adds reference accounting and changes garbage collection. -The end-to-end suite must prove the user outcome, not just successful API round trips: +## 15. Delivery stages and acceptance -- Machine A executes and publishes; machine B, with a different absolute checkout root and empty local cache, restores identical output bytes and terminal events without executing. Repeat in both developer-to-CI and CI-to-developer directions using the configured trust namespace. -- Repeat compatible-machine tests on Windows, macOS, and Linux. Do not skip a platform. Any essential-capability exception is limited to musl, with its unavailable requirement documented. -- Changing explicit content, adding/removing a glob match, changing an inferred input, creating a missing path, changing a directory listing, changing tracked env/query membership, changing command/config, or changing toolchain/platform/environment must prevent stale reuse. Reverting source inputs should recover an older candidate within the discovery window. -- Exercise a real artifact larger than 100 MB to prove chunked transfer works through the configured Cloudflare endpoint. Enforce compressed/expanded/log/file limits and verify that rejected artifacts leave the workspace intact. -- A reader token cannot upload, another project cannot discover/read artifacts, an untrusted publisher cannot populate the trusted namespace, a revoked publisher cannot commit, and no remote credentials reach child processes or logs through wildcard env requests. -- Network loss before and during commit never exposes a partial result. Concurrent writers expose one immutable winner. GC cannot delete a newly published generation, and failures do not release quota before cleanup. -- With the remote service unavailable, an unchanged task uses a valid local result, and a local miss executes successfully within the configured network budget. Turning remote caching off restores local-only behavior. -- The docs canary retains `DOCS_SITE_ORIGIN` separation, removes only the task-directory cache steps after proving cold remote hits, and demonstrates a rollback with remote mode disabled. +1. **Public reads and OIDC write policy.** Exercise every cell of section 5's permission matrix, including anonymous fresh-checkout reads. Test wrong issuer/audience, forged signatures, missing claims, expired/future tokens, wrong repository/owner/visibility, forks on their own main branch, PR and `pull_request_target` events, `workflow_run`, tags, non-main refs, and reusable workflows. Cover JWKS rotation, unknown-key refresh bounds, outages, policy changes, namespace disable, same-key/blob-ID isolation, and route/alias bypass attempts. Stores rejected at admission must reserve no quota and perform no R2 operation. +2. **Protocol and storage foundation.** Pin the final PR #713 contract; add CBOR/multipart fixtures and schema migrations. Test exact/fallback/not-found, required nullable fields, arbitrary binary/empty keys, replacement, and associations shared across keys. Add size fixtures for 1 KB keys, 50-byte secondary keys, and 250 KB values/blobs, plus configured maxima and just-over-limit `413` cases. Fetch must leave both mappings unchanged. Verify absent versus empty blobs and plain-text errors. +3. **Streaming and atomic publication.** Test both part orders, unknown content length, boundaries split across stream chunks, malformed/truncated bodies, maximum sizes, and cancellation. Exercise concurrent same-key and same-secondary-key stores, lease/token expiry, changed write policy before commit, lost responses, R2 failures, and rollback guards. No visible entry may reference an incomplete generation or change one mapping after a failed publication guard. +4. **Explicit client publication and canary.** Agree on the push command, selection semantics, and portable client encoding. Prove that `vp run` never stores remotely and only explicit push acquires OIDC credentials. Test snapshot pinning, local replacement/eviction, changed working-tree files, failed-run selection reset, opt-outs, exclusion of imported/restored entries, no-op push, partial failure, and retry behavior. On Unix and Windows, verify token acquisition/redaction, credential handling across redirects, exact validation, diagnostics-only fallback, inferred/missing inputs, tracked environment queries, safe restoration, local promotion, and failed-read fallback. Demonstrate anonymous reuse in a compatible fresh checkout after a main-branch push. +5. **Cleanup and self-deployment.** Prove concurrent quota reservation, old-blob grace, current-pointer protection during GC, bounded association cleanup, abandoned multipart cleanup, and lifecycle margins. Ship setup/policy-change/disable/upgrade/teardown guides and a real GitHub Actions-to-Worker smoke test. Test anonymous rate limits and namespace withdrawal; local emulation does not establish production OIDC or provider-limit behavior. +6. **Capacity qualification.** Measure operation counts, actual D1 storage, JWT/JWKS cold and warm costs, Free CPU across value/blob sizes, concurrent memory, and sustainable cleanup. Update cost tables with independent public-read and main-branch publication rates. Sample task mixes and key replacement over two retention windows before claiming broad free coverage. -Performance targets for the canary are p95 metadata lookup below 500 ms from representative developer/CI locations and less than 5% added task time when a result is ineligible for remote use. These are targets to measure, not asserted Cloudflare guarantees. Investigate misses caused by compatibility partitioning, candidate caps, or transfer deadlines before widening any correctness boundary. +## 16. Version 2 and implementation questions -## 16. Review questions +Version 2 can add private projects using Cloudflare One: Access policies protect reads and writes, service tokens serve automation that cannot use GitHub OIDC, and Managed OAuth provides developer login. Retain namespace isolation and explicit publication. Separate private namespaces or deployments from public v1 endpoints; a private authorization failure must never fall back to public access. Revisit [Workers Access integration](https://developers.cloudflare.com/workers/configuration/cloudflare-access/), [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/), revocation semantics, and [Access pricing](https://www.cloudflare.com/sase/products/access/) when designing that version. None is a v1 dependency or cost. -The proposal makes implementable defaults, but these product tradeoffs deserve explicit RFC review: - -- Is mandatory publisher signing with individual keys an acceptable setup cost for the first release? The proposed default is yes, with key generation and configuration handled by the setup CLI. -- Is same-platform reuse sufficient for the first release? The proposed default is yes; cross-platform reuse requires an explicit portable-task contract and separate fixtures. -- Should developer-to-release-CI sharing be enabled by default? The proposed default is no; teams can opt into the shared `team` namespace and its publisher policy. -- Do measured docs and representative monorepo workloads fit the default Free profile and the proposed discovery, metadata, archive, and timeout limits? Keep the bounds in version 1, and adjust defaults from canary evidence without weakening validation. Workers Paid is an explicit scale/capacity option, not a deployment prerequisite. +Implementation questions include additional push selection flags, snapshot lifetime, portable client encoding and compatibility, JWT time tolerances and key-cache limits, measured Free CPU and D1 costs, and representative public traffic/publication rates. Verify compatibility with the merged version of PR #713 before release. diff --git a/docs/rfcs/remote-cache-size-study/README.md b/docs/rfcs/remote-cache-size-study/README.md index 64a7f1f24..4206d7879 100644 --- a/docs/rfcs/remote-cache-size-study/README.md +++ b/docs/rfcs/remote-cache-size-study/README.md @@ -30,7 +30,7 @@ The pinned source confirms Vite usage: ## Measured sizes -All sizes use decimal MB (`1 MB = 1,000,000 bytes`). “Output” sums regular-file bytes, including static assets and source maps present in the selected directory. “Archive” measures a single sorted GNU tar stream compressed with `zstd -3 -T1`, using zstd `1.5.7`. We normalize tar paths and metadata; archive sizes approximate the proposed cache format rather than reproducing an implemented remote-cache archive byte for byte. +All sizes use decimal MB (`1 MB = 1,000,000 bytes`). “Output” sums regular-file bytes, including static assets and source maps present in the selected directory. “Archive” measures a single sorted GNU tar stream compressed with `zstd -3 -T1`, using zstd `1.5.7`. We normalize tar paths and metadata. Archive sizes estimate the compressed output payload; a complete cache entry also includes task metadata. | Project | Files | Output MB | Archive MB | Source-map MB before compression | Archive MB with `.map` files omitted | | ---------- | ----: | --------: | ---------: | -------------------------------: | -----------------------------------: | @@ -43,11 +43,11 @@ Source maps comprise about 70% of Hoppscotch's uncompressed output and 73% of n8 The last column is a controlled sensitivity calculation on the same files, not another build. Removing maps reduces the compressed archives to 12.18 MB and 13.15 MB respectively, still above 5 MB. A cache must preserve the outputs required by its task; these measurements do not justify silently dropping source maps. For Directus and Docmost, the published frontend directories contain no `.map` files. -These archives contain frontend output files only. The proposed cache also stores terminal events and an inferred-input validation manifest. A real cached task can produce additional files outside `dist/`; release packaging can omit such files. Measure those bytes during implementation before assigning a complete per-result size. Backend and shared-package builds are separate task results unless the operator caches them as one task. +These archives contain frontend output files only. A complete cached task also needs terminal events and client validation metadata. Under [PR #713](https://github.com/voidzero-dev/vite-task/pull/713), the client encodes these in an opaque value and optional blob; the Worker does not prescribe their internal format. A real cached task can produce additional files outside `dist/`; release packaging can omit such files. Measure those bytes during implementation before assigning a complete per-result size. Backend and shared-package builds are separate task results unless the operator caches them as one task. ## Effect on the free storage budget -Using the RFC's 8 GB application budget and seven-day retention, the measured output archives alone give these steady-state ceilings: +Using the RFC's 8 GB application budget and seven-day retention, the measured output archives alone give these steady-state ceilings when each store creates a different exact key: | Project-sized result | New results/day within 8 GB | Storage at 200 new results/day, seven days | | -------------------- | --------------------------: | -----------------------------------------: | @@ -56,13 +56,13 @@ Using the RFC's 8 GB application budget and seven-day retention, the measured ou | Hoppscotch | 35 | 45.06 GB | | n8n | 32 | 48.59 GB | -Calculate the ceiling as `floor(8,000,000,000 / (archive_bytes * 7))`. These are artifact-only upper bounds; manifests, logs, pending uploads, and delayed deletion lower them. A new cache key stores another complete archive in the proposed version 1 service, even when many assets match the previous build. Read hits do not create another copy. Count separate namespaces and compatibility identities where the service stores separate results. +Calculate the ceiling as `floor(8,000,000,000 / (archive_bytes * 7))`. These are artifact-only upper bounds; client metadata, logs, pending uploads, retired generations, and delayed deletion lower them. A new exact key stores another complete archive even when many assets match the previous build. Repeated stores to the same key replace its value and blob, keeping the old generation only for a short download grace period. Input changes do not necessarily create a different exact key. Read hits do not create another copy. Count separate namespaces and client compatibility identities where the service stores separate results. -For a **50 MB complete-result planning case**, the same budget supports at most **22 new results/day** with seven-day retention, before operational headroom. At 200 new results/day, storage reaches 70 GB. With other usage assumptions unchanged, raising the storage budget would cost about **$0.90/month in R2 storage** beyond the 10 GB included allowance; Workers could remain Free. This uses the RFC's steady-state 30-day billing model and [R2 Standard pricing](https://developers.cloudflare.com/r2/pricing/), checked on the measurement date. The default profile would reject additional publications instead of raising its budget. +For a **50 MB complete-result planning case**, the same budget supports at most **22 new distinct keys/day** with seven-day retention, before operational headroom. At 200 new distinct keys/day, storage reaches 70 GB. Under the RFC's operation assumptions, raising the storage budget would cost about **$0.90/month in R2 storage** beyond the 10 GB included allowance. Workers could remain Free if the streaming implementation meets its CPU limit. This uses the RFC's steady-state 30-day billing model and [R2 Standard pricing](https://developers.cloudflare.com/r2/pricing/), rechecked on 2026-09-09. The default profile would reject additional stores instead of raising its budget. -Keep 5 MB for a small-output scenario, add 50 MB for mature frontend builds, and retain a 100 MB stress case. These values are planning inputs, not estimates of population averages. A few full-frontend publications per day can fit the free budget; hundreds of publications per day need smaller results, shorter retention, or additional storage. The number of developers does not determine which case applies. +Keep 5 MB for a small-output scenario and 50 MB for mature frontend builds. All four measured archives fit the RFC's 64 MiB blob limit. These values are planning inputs, not estimates of population averages. A few new full-frontend keys per day can fit the free budget; hundreds of different keys per day need smaller results, shorter retention, or additional storage. Frequent overwrites can retain much less data but still consume operations. The number of readers does not determine this storage case. In v1, only explicit main-branch CI publication adds remote results; public readers need no credentials or per-user subscription. -This sample covers one release per product and favors mature applications. It does not measure a publication-weighted average, daily change rate, cache hit rate, or the fraction of ordinary users that stay free. The RFC's canary still needs those measurements across task types and successive changes. +This sample covers one release per product and favors mature applications. It does not measure a store-weighted average, daily change rate, exact-key replacement rate, cache hit rate, or the fraction of ordinary users that stay free. The RFC's canary still needs those measurements across task types and successive changes. ## Reproduce From d09e1fd9a02bffa984029a387126d9d22cbec096 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 9 Sep 2026 23:49:19 +0800 Subject: [PATCH 4/6] docs: simplify remote cache RFC and add diagrams Co-authored-by: GPT-6 Codex --- docs/rfcs/0001-remote-cache.md | 522 ++++++++++++++----- docs/rfcs/images/repository-binding-form.svg | 47 ++ docs/rfcs/remote-cache-size-study/README.md | 58 ++- 3 files changed, 489 insertions(+), 138 deletions(-) create mode 100644 docs/rfcs/images/repository-binding-form.svg diff --git a/docs/rfcs/0001-remote-cache.md b/docs/rfcs/0001-remote-cache.md index e487e8ae5..fba252d96 100644 --- a/docs/rfcs/0001-remote-cache.md +++ b/docs/rfcs/0001-remote-cache.md @@ -6,15 +6,17 @@ Updated: 2026-09-09. Repository baseline: `9a1d32cf`. API baseline: [PR #713](ht ## 1. Motivation -Open-source maintainers should publish successful main-branch task results from GitHub Actions to a service in their own Cloudflare account. Developers and fork contributors should reuse these public results without signing in. The service needs no Vite+ hosted account or license service. Local caching remains the first tier; remote read failures fall back to task execution. +Open-source maintainers should publish successful task results from the main branch through GitHub Actions. They should use a service in their own Cloudflare account. Developers and fork contributors should reuse these public results without login. The service needs no Vite+ hosted account or license service. -The current [docs deployment action](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/.github/actions/deploy-docs/action.yml) transfers a whole task-cache directory through GitHub Actions Cache. A native remote cache can transfer one task's metadata and output blob, and make those results available to compatible developer machines. +The client checks the local cache first. If a remote read fails, the client executes the task. -This RFC implements PR #713 on Workers, D1, and R2, with anonymous reads and GitHub Actions OIDC authorization for writes. It also sets operational defaults and measures the conditions under which an individual or small team can stay within Cloudflare's free allowances. +The current [docs deployment action](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/.github/actions/deploy-docs/action.yml) transfers a whole task-cache directory through GitHub Actions Cache. A native remote cache can transfer one task's metadata and output blob. Compatible developer machines can reuse these results. + +This RFC describes an implementation of PR #713 on Workers, D1, and R2. Reads need no authentication. Writes use GitHub Actions OpenID Connect (OIDC), which supplies signed tokens that identify jobs. The RFC sets operational defaults and estimates when an individual or small team can stay within Cloudflare's free allowances. ## 2. Contract and scope -PR #713 owns the HTTP contract. This RFC owns the Cloudflare storage, authorization, limits, deployment, and cleanup choices. Version 1 requires public reads, repository-scoped write authorization, and namespace isolation for all three endpoints. Client fingerprint formats and cache-validation policy remain client responsibilities. +PR #713 defines the HTTP contract. This RFC defines the Cloudflare storage, authorization, limits, deployment, and cleanup. A namespace is a project's cache scope at a configured endpoint. Version 1 requires public reads and write authorization for each registered repository. All three endpoints must keep namespace data separate. The client defines its fingerprint format and cache-validation policy. | Area | Decision | | -------------- | ------------------------------------------------------------------------------------------------------- | @@ -28,11 +30,13 @@ PR #713 owns the HTTP contract. This RFC owns the Cloudflare storage, authorizat | Defaults | Seven-day retention, 8 GB total R2 budget, 64 MiB maximum blob | | Failure | Bounded waits; read failures become misses; explicit push reports publication failures | -The first delivery includes a self-deployment template and a native Rust client adapter. Cross-platform client behavior must work on macOS, Linux, and Windows. Reuse between different OS/architecture combinations requires a separate client compatibility agreement. Remote execution, a hosted SaaS, anonymous writes, a web dashboard, and cross-project deduplication are outside this plan. Private caches and Cloudflare One authorization are version 2 work. +The first delivery includes a template for self-deployment and a native Rust client adapter. The client must work on macOS, Linux, and Windows. Reuse across different operating systems or architectures requires a separate agreement about client compatibility. + +This plan excludes remote execution, a hosted SaaS, anonymous writes, a web dashboard, and deduplication across projects. Version 2 covers private caches and Cloudflare One authorization. ## 3. Relationship to the current local cache -The current engine already separates exact lookup from a task association used to explain misses: +The current engine separates exact lookup from a task association that explains misses: | Source evidence | Client integration consequence | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | @@ -42,15 +46,29 @@ The current engine already separates exact lookup from a task association used t | [`update_cache`](../../crates/vt/src/session/execute/cache_update.rs) rejects failed, cancelled, incompletely traced, or otherwise ineligible executions. | Apply the same eligibility checks before remote storage. | | [`archive`](../../crates/vt/src/session/cache/archive.rs) and [`replay_cache_hit`](../../crates/vt/src/session/execute/mod.rs) handle output files and terminal replay. | Import verified data through a bounded staging path before reporting a hit. | -An exact response means that the server found identical key bytes. It does not establish that current input files, inferred dependencies, or tracked environment values match. In the current engine, fallback data explains a miss; the client does not reuse its outputs. Preserve that distinction when adding the remote adapter. An exact entry that fails validation is a cache miss. +An exact response means that the server found identical key bytes. It does not prove that current input files, inferred dependencies, or tracked environment values match. In the current engine, fallback data explains a miss. The client does not reuse fallback outputs. Keep this distinction in the remote adapter. An exact entry that fails validation is a cache miss. + +The client must define a portable, versioned encoding before it supports reuse across machines. An opaque field contains bytes that the Worker does not interpret. The encoding must preserve these observations: + +- Negative file dependencies, which record that a file does not exist. +- Directory observations. +- Tracked environment queries. +- Explicit glob membership, which records the files that match each pattern. + +The client must include schema, toolchain, and platform compatibility in its identity or validation data. PR #713 does not define this encoding. The Worker must not decode it. Local schema `v18` and a serialized SQLite directory do not define a portable wire format. + +The client follows this sequence: -The client must define a portable, versioned encoding for its opaque fields before cross-machine reuse ships. It must preserve negative file dependencies, directory observations, tracked environment queries, and explicit glob membership. Schema, toolchain, and platform compatibility belong in client-controlled identity or validation data. PR #713 does not prescribe that encoding, and the Worker must not decode it. Existing local schema `v18` or a serialized SQLite directory is not a portable wire-format agreement. +1. Validate the local cache. A local hit makes no remote request during `vp run`. +2. On a local miss, call `/fetch`. +3. Validate an exact response. Download its blob only if the result passes validation. +4. Restore the outputs. Save the result in local storage. -Client flow: validate local cache; on a miss call `/fetch`; validate an exact response; fetch its blob only if reuse is possible; restore and promote to local storage. A fallback or `not_found` response leads to execution. A successful eligible execution updates local storage only. `vp cache push` publishes selected results through `/store`. A local hit makes no remote request during `vp run`. +A fallback or `not_found` response leads to task execution. A successful eligible execution updates local storage only. `vp cache push` publishes selected results through `/store`. ## 4. HTTP API mapping -All paths are relative to an endpoint such as `https://cache.example.com/projects/docs-trusted-v1`. The endpoint includes the namespace. The following notation describes CBOR fields: `bytes` is a binary byte string, `string` is text, and nullable fields must be present with CBOR `null` when absent. +All paths are relative to an endpoint such as `https://cache.example.com/projects/docs-trusted-v1`. The endpoint includes the namespace. The examples describe fields in Concise Binary Object Representation (CBOR), a binary data format. `bytes` means a binary byte string. `string` means text. Nullable fields must remain present with CBOR `null` when they have no value. ### Fetch metadata @@ -69,7 +87,7 @@ Return HTTP `200`, `Content-Type: application/cbor`, with one of: { kind: "not_found" } ``` -Check `key` first. If no live entry exists, resolve `secondary_key` to a stored key and check that entry. Include the stored key only in the fallback variant. If neither resolves, return `not_found`. Fetch does not change entries or associations. +Check `key` first. If no live entry exists, resolve `secondary_key` to a stored key. Check that entry. Include the stored key only in the fallback variant. If neither resolves, return `not_found`. Fetch does not change entries or associations. ### Download a blob @@ -77,7 +95,7 @@ Check `key` first. If no live entry exists, resolve `secondary_key` to a stored GET {endpoint}/blob/{blob_id} ``` -Return HTTP `200`, `Content-Type: application/octet-stream`, with the raw blob. An unavailable blob returns `404`. The blob ID is an opaque server-generated reference, scoped by the endpoint. It is not an R2 URL or an authorization credential. +Return HTTP `200`, `Content-Type: application/octet-stream`, with the raw blob. Return `404` if the blob is unavailable. The server generates an opaque blob ID within the endpoint's namespace. The ID is neither an R2 URL nor an authorization credential. ### Store an entry @@ -98,7 +116,7 @@ Accept either part order. Return HTTP `200`, `Content-Type: application/cbor`: { blob_id: string | null } ``` -Omitting the blob returns `null`. A present zero-byte blob receives a non-null ID and downloads as an empty body. +If the request omits the blob, return `null`. A present zero-byte blob receives a non-null ID. Its download has an empty body. Each successful store replaces `entries[key]` and sets `associations[secondary_key] = key`. For example: @@ -110,7 +128,7 @@ Each successful store replaces `entries[key]` and sets `associations[secondary_k | Fetch `(C, S)` | Unchanged; returns fallback key `B`, value `VB` | Still `S → B` | | Store `(A, T, VA2)` | `A → VA2`, `B → VB` | `S → B`, `T → A` | -Other secondary keys that already point to `A` also resolve to `VA2`. Changing `S` does not evict entry `A`. +Other secondary keys that already point to `A` also resolve to `VA2`. A change to `S` does not evict entry `A`. ### Errors @@ -124,7 +142,13 @@ API errors use `Content-Type: text/plain; charset=utf-8`. Clients use the status | `500` | Operation could not complete | | `503` | Service temporarily unavailable, including exhausted application budgets | -Normal metadata absence returns `200` with `not_found`. Version 1 reads require no credentials. For `/store`, this deployment adds `401` for a missing, invalid, or expired JWT and `403` for a verified token that fails the namespace write policy. Return `503` if authorization cannot be established because D1 or required signing keys are unavailable. Keep these errors generic and plain-text; authentication is outside PR #713. Rate limiting can return `429` with `Retry-After`. Cloudflare may reject requests before Worker code runs, so clients must tolerate non-protocol error bodies and avoid authentication redirects. +If metadata is absent, return `200` with `not_found`. Version 1 reads require no credentials. For `/store`, this deployment adds these errors: + +- `401`: The JSON Web Token (JWT) is missing, invalid, or expired. +- `403`: The verified JWT fails the namespace's write policy. +- `503`: The Worker cannot establish authorization because D1 or required signing keys are unavailable. + +Keep these errors generic. Use plain text. PR #713 does not define authentication. Rate limiting can return `429` with `Retry-After`. Cloudflare can reject requests before the Worker runs. Clients must handle error bodies outside the protocol without authentication redirects. ## 5. Public reads, GitHub OIDC writes, and explicit publication @@ -144,33 +168,61 @@ export default { An endpoint enables public remote reads during `vp run`. Successful eligible tasks save their results locally. Publication is explicit: `vp cache push` sends selected local results through one `/store` request per entry. -Use `VP_REMOTE_CACHE_URL` to override the endpoint on a host and `--no-remote-cache` to disable remote use for an invocation. Task-level `remoteCache: false` excludes both remote reads and publication, while retaining local caching. Existing `cache: false`, `--no-cache`, and tool-requested cache disabling also exclude results from publication. No endpoint means no remote reads; an explicit push without an endpoint reports a configuration error. +Use `VP_REMOTE_CACHE_URL` to override the endpoint on a host. Use `--no-remote-cache` to disable remote use for one invocation. Task-level `remoteCache: false` excludes remote reads and publication but retains local caching. `cache: false`, `--no-cache`, and tool-requested cache disabling also exclude results from publication. + +Without an endpoint, the client makes no remote reads. An explicit push without an endpoint reports a configuration error. + +By default, push selects eligible results from the latest completed `vp run` invocation. The results must belong to the current workspace, CI job, and commit. A new run replaces the selection. A failed or cancelled run must not leave an older successful run selected. + +Push does not select the whole local cache by default. It excludes entries imported from remote and caches restored from other jobs. + +Record the selection locally. Preserve its exact metadata and archive snapshot until publication or bounded cleanup. The current [cache update](../../crates/vt/src/session/execute/cache_update.rs) archives outputs. [Entry replacement](../../crates/vt/src/session/cache/mod.rs) can remove the previous archive. A delayed push therefore needs a snapshot or lease to preserve the selected data. -By default, push selects eligible results produced by the latest completed `vp run` invocation in the current workspace and CI job/commit. Starting a new run replaces that selection; a failed or cancelled run does not leave an older successful run selected. Do not upload the whole local cache, entries imported from remote, or restored caches from other jobs by default. +Do not rebuild the archive from the later working tree. Do not silently upload a replacement entry. If a snapshot is missing or inconsistent, fail publication without changes to remote mappings. -Record the selection locally and pin its exact metadata/archive snapshot until publication or bounded cleanup. The current [cache update](../../crates/vt/src/session/execute/cache_update.rs) archives outputs, while [entry replacement](../../crates/vt/src/session/cache/mod.rs) can remove the previous archive. A delayed push therefore needs a snapshot or lease; it must not rebuild the archive from the later working tree or silently upload a replacement entry. Missing or inconsistent snapshots fail publication without modifying remote mappings. +`vp cache push` reports published, skipped, and failed entries. A remote publication failure returns a nonzero exit status and preserves local results. CI can set `continue-on-error` for this cache-only step. Each entry commits separately, so some entries can succeed while others fail. -`vp cache push` reports published, skipped, and failed entries. A remote publication failure returns a nonzero exit status while preserving local results; CI can mark this cache-only step `continue-on-error`. Entries commit separately, so partial success is possible. Repeated pushes follow PR #713 replacement semantics, not exactly-once delivery. No eligible entries is a successful no-op and needs no OIDC token. +Repeated pushes follow the replacement rules in PR #713. They do not guarantee exactly-once delivery. If no entries qualify, push succeeds without an upload or OIDC token. ### One-time repository binding -At deployment, the maintainer selects a public GitHub repository. Setup resolves and records its immutable `repository_id` and `repository_owner_id`, binds them to the namespace, and sets the allowed branch to `refs/heads/main`. A project whose main branch has another name needs that one server-side adjustment. Repository names are display data; names alone must not authorize writes. Do not let the first incoming token claim an unregistered namespace. +The operator binds a public repository during deployment. This form illustrates the setup inputs and automatic values: -Use the configured namespace endpoint, without a trailing slash, as the exact OIDC audience. Store it in the server policy and derive it from the configured client URL. An alias or namespace change needs a matching policy update; do not infer the expected audience from an untrusted Host header. A repository transfer requires maintainer review and an owner-ID update. No per-developer registration or reusable cache secret is required. +![Configuration form: repository, namespace, and main branch inputs; automatic IDs, access policy, and public endpoint.](images/repository-binding-form.svg) + +Setup saves both immutable IDs, the branch ref, and the audience in the server policy. The audience identifies the token's intended recipient. The client derives the same audience from its configured endpoint, without a trailing slash. + +After a repository transfer, review the binding. Update the owner ID. After an endpoint alias or namespace change, update the policy. ### GitHub Actions token acquisition -The publishing job grants `permissions: id-token: write`. This lets a job request an OIDC token; the Worker decides whether it grants write access. The native client uses GitHub's `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` to request a token with the namespace audience. See GitHub's [OIDC workflow configuration](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-cloud-providers). +The publishing job grants `permissions: id-token: write`. This permission lets the job request an OIDC token. The Worker decides whether the token grants write access. The native client uses GitHub's `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` to request a token with the namespace audience. See GitHub's [OIDC workflow configuration](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-cloud-providers). + +Only `vp cache push` requests the token. Send the returned JWT to `/store` as `Authorization: Bearer `. The Worker verifies it directly, without a custom endpoint for token exchange. -Only `vp cache push` requests the token. Send the returned JWT as `Authorization: Bearer ` to `/store`; the Worker verifies it directly, without a custom token exchange endpoint. Keep it in process memory, reuse only while valid for the same audience, and obtain a fresh token before expiry. The runner's request token is only for GitHub's token endpoint and must never be sent to the cache Worker. Outside Actions, push reports that GitHub OIDC is unavailable. +Keep the JWT in process memory. Reuse it only while it remains valid for the same audience. Obtain a fresh token before expiry. Send the runner's request token only to GitHub's token endpoint. Never send that request token to the cache Worker. Outside Actions, push reports that GitHub OIDC is unavailable. -Do not forward either token across redirects or write it to config, command arguments, output, or the cache. Strip the OIDC request variables and remote-cache controls from task child environments, fingerprints, runner-aware environment APIs, serialized plans, and debug output, including wildcard environment selection. This does not isolate a privileged job from other code running as the same OS user; publication jobs execute trusted main-branch code. +Do not forward either token across redirects. Do not write either token to config, command arguments, output, or the cache. Remove the OIDC request variables and remote-cache controls from these locations, including through wildcard environment selection: + +- Environments of task child processes. +- Fingerprints. +- Runner-aware environment APIs. +- Serialized plans. +- Debug output. + +These controls do not isolate a privileged job from other code that runs as the same OS user. Publication jobs execute trusted code from the main branch. ### Worker write policy -Before consuming a store body or reserving quota, verify the JWT signature with a maintained library such as [`jose`](https://github.com/panva/jose), which supports Workers and remote JWKS. Use GitHub's fixed [OIDC issuer metadata](https://token.actions.githubusercontent.com/.well-known/openid-configuration) and HTTPS JWKS endpoint; allow only its supported signing algorithm (`RS256` initially). Do not accept `none`, symmetric algorithms, token-supplied key URLs, or decoded claims without verification. Bound token size, JWKS response size, fetch time, cache lifetime, and refresh frequency; unknown key IDs must not trigger unlimited outbound requests. If no usable cached key exists and key retrieval fails, return `503`; do not skip verification. +Authorize only registered namespaces. Use the stored repository and owner IDs; names serve as display data. Do not let a token claim a namespace. Use the stored audience, never the request's `Host` header. + +Verify the JWT signature before the Worker reads a store body or reserves quota. Use a maintained library such as [`jose`](https://github.com/panva/jose), which supports Workers and remote JSON Web Key Sets (JWKS). A JWKS supplies public keys for signature verification. -After cryptographic verification, require these signed claims and the enabled namespace policy: +Use GitHub's fixed [OIDC issuer metadata](https://token.actions.githubusercontent.com/.well-known/openid-configuration) and HTTPS JWKS endpoint. Allow only its supported signing algorithm (`RS256` initially). Reject `none`, symmetric algorithms, key URLs from tokens, and claims without signature verification. + +Set limits for token size, JWKS response size, fetch time, cache lifetime, and refresh frequency. Unknown key IDs must not trigger unlimited outbound requests. Return `503` if key retrieval fails and the cache has no usable key. Do not skip signature verification. + +After signature verification, check these signed claims against the enabled namespace policy: | Claim | Required value | | ----------------------- | --------------------------------------------------- | @@ -183,9 +235,18 @@ After cryptographic verification, require these signed claims and the enabled na | `event_name` | `push` | | `exp`, `nbf`, `iat` | Present and valid under a bounded clock-skew policy | -These are this deployment's trust conditions over [GitHub's documented claims](https://docs.github.com/en/actions/reference/security/oidc). Require exact types and values. Do not authorize from `actor`, a repository URL supplied by the client, a branch environment variable, or `sub` substring matching. GitHub supports different subject formats; explicit repository IDs and branch/event claims avoid relying on one textual `sub` layout. No organization-wide or repository-wide write grant substitutes for the full predicate. +These conditions use [GitHub's documented claims](https://docs.github.com/en/actions/reference/security/oidc). Require exact types and values. Do not authorize writes from any of these inputs: + +- `actor`. +- A repository URL from the client. +- A branch environment variable. +- A substring match in `sub`. + +GitHub supports different subject formats. Repository IDs and branch/event claims avoid dependence on one text format for `sub`. A write grant for an organization or repository does not replace the complete set of checks. + +A `pull_request_target` job can run in the base repository's default-branch context. Thus, `ref=refs/heads/main` alone cannot authorize writes. Version 1 denies `pull_request`, `pull_request_target`, `workflow_run`, tags, branches other than main, and all other event types. -A `pull_request_target` job can run in the base repository's default-branch context. Therefore `ref=refs/heads/main` alone is insufficient: deny `pull_request`, `pull_request_target`, `workflow_run`, tags, non-main branches, and other event types in v1. Fork repositories have different IDs and cannot write to the upstream namespace, even from their own `main` branch. Reusable workflows must retain matching caller-repository, branch, and event claims; the callee's identity alone grants no permission. See GitHub's [workflow event behavior](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target). +Fork repositories have different IDs. They cannot write to the upstream namespace, even from their own `main` branch. Reusable workflows must have matching repository, branch, and event claims for the caller. The called workflow's identity alone grants no permission. See GitHub's [workflow event behavior](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target). | Caller | `POST /fetch` | `GET /blob/{blob_id}` | `POST /store` | | --------------------------------------------------------------------------------- | ----------------------------- | ----------------------------- | ------------- | @@ -194,67 +255,151 @@ A `pull_request_target` job can run in the base repository's default-branch cont | Valid GitHub token with wrong repo, owner, visibility, branch, event, or audience | Allow | Allow | `403` | | Invalid, expired, or forged token | Allow without using the token | Allow without using the token | `401` | -A local command cannot obtain write permission by setting environment variables. Unknown or disabled namespaces expose no cache data. Scope every exact/fallback/blob lookup and mutation; a blob ID requested through the wrong namespace returns `404`. This separation prevents mixed project results, not discovery by an authorized reader: all enabled v1 namespaces are public. Keep the R2 bucket private so reads pass through Worker routing, retention, and budgets. Apply the same store verifier on all exposed routes and aliases. +A local command cannot obtain write permission through environment variables alone. Unknown or disabled namespaces expose no cache data. Apply the namespace restriction to every exact, fallback, and blob lookup and every mutation. Return `404` for a blob ID from another namespace. + +This separation prevents results from different projects from mixing. All enabled version 1 namespaces remain public. Keep the R2 bucket private so reads pass through Worker routing, retention checks, and budgets. Apply the same store verifier to every exposed route and alias. ### Publication trust and revocation -A GitHub token proves the job's identity, not that uploaded bytes match its commit or contain no secrets. The trusted publishing workflow builds the triggering main-branch commit and selects only results intended for public release. Values, input metadata, terminal logs, source maps, and blobs are all public; tasks that use private inputs or produce sensitive output must opt out. The Worker treats them as opaque and cannot redact them. Client validation still determines whether a public result can be reused. +A GitHub token proves the job's identity. It does not prove that uploaded bytes match the commit or contain no secrets. The trusted publishing workflow builds the commit that triggered the main-branch job. It selects only results intended for public release. + +Values, input metadata, terminal logs, source maps, and blobs are public. Tasks that use private inputs or produce sensitive output must opt out. The Worker treats these fields as opaque and cannot redact them. The client still validates a public result before reuse. + +At publication, the guarded D1 transaction checks these conditions again: -At publication, the guarded D1 transaction rechecks the token expiry against server time, the scope's enabled/write-enabled state and unchanged policy version, lease, and quotas. An expired token or changed policy leaves existing mappings unchanged and stages objects for cleanup. No GitHub API call belongs inside that transaction. Tokens are short-lived bearer credentials and can be reused within their validity period; v1 does not maintain a per-token revocation or single-use ledger. Cancelling a job is not immediate token revocation. The maintainer can disable writes or change the namespace policy in primary D1; disabling the whole scope also stops subsequent reads. +- The token has not expired according to server time. +- The scope remains enabled for access and writes. +- The policy version has not changed. +- The lease and quotas remain valid. -Making a repository private does not remove previously published cache data. Maintainers must disable the public namespace and remove its objects when withdrawing publication; downloaded copies cannot be recalled. Private-cache access control belongs to v2. +If the token expires or the policy changes, keep existing mappings unchanged. Mark the staged objects for cleanup. Do not call the GitHub API inside that transaction. + +Tokens are short-lived bearer credentials. A caller can reuse a token within its validity period. Version 1 keeps no ledger for token revocation or single-use enforcement. Job cancellation does not immediately revoke a token. The maintainer can disable writes or change the namespace policy in primary D1. If the maintainer disables the whole scope, subsequent reads also stop. + +A repository visibility change to private does not remove previously published cache data. To withdraw publication, maintainers must disable the public namespace. They must also remove its objects. They cannot recall downloaded copies. Version 2 covers access control for private caches. ## 6. Cloudflare storage model -Use D1 for binary-key indexes and transactions, and R2 for opaque values and blobs. D1's [2 MB row limit](https://developers.cloudflare.com/d1/platform/limits/) makes storing potentially larger values inline unsuitable. Store even small values in R2 initially to keep one storage path. +D1 commits key mappings atomically. R2 holds opaque values and optional blobs. All values use R2 because they can exceed D1's [2 MB row limit](https://developers.cloudflare.com/d1/platform/limits/). + +### D1 relationships + +The diagram shows the logical relationships and key fields. `PK` marks primary-key columns; two marked columns form one composite key. Implementation defines the final schema and foreign-key constraints. + +```mermaid +erDiagram + direction LR + scopes ||--o{ entries : contains + scopes ||--o{ associations : contains + scopes ||..o{ generations : contains + entries |o..o{ associations : "is the target of" + entries o|..|| generations : "selects current" + + scopes { + ID scope_id PK + } + entries { + ID scope_id PK + BLOB key PK + ID generation_id + } + associations { + ID scope_id PK + BLOB secondary_key PK + BLOB target_key + } + generations { + ID generation_id PK + ID scope_id + TEXT state + } +``` -Conceptual tables, with names subject to implementation review: +All records and references stay within their namespace. An entry selects one current generation and can have many secondary-key associations. Unreferenced generations await publication or cleanup. Associations can remain after their targets disappear, until cleanup. -| Table | Identity and contents | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `scopes` | Public endpoint, enabled/write-enabled state, GitHub repo/owner IDs, branch, OIDC audience, policy version, retention, budgets, counters | -| `entries` | Primary key `(scope_id, key BLOB)`; current generation ID | -| `associations` | Primary key `(scope_id, secondary_key BLOB)`; target key as BLOB | -| `generations` | Random generation ID; scope, R2 object keys, optional blob ID, actual sizes, state, lease/expiry/retirement timestamps | +Other metadata: -Use bound binary parameters and byte equality. Accept empty and non-UTF-8 keys within the size limits. Do not stringify, normalize, or interpret them as hex hashes. Index exact lookups, secondary lookups, blob IDs, and cleanup eligibility. Limit associations separately: many secondary keys can point to one entry. +- `scopes`: Public endpoint, enabled/write-enabled state, GitHub repository and owner IDs, branch, audience, and policy version. It also stores retention, budgets, and counters. +- `generations`: R2 object keys, optional blob ID, actual sizes, and lease/expiry/retirement times. Its random ID identifies one store. Multipart uploads also record an R2 upload ID. -Each store gets a fresh generation. Its R2 value object and optional blob object have unique immutable object names under an internal scope/generation prefix. The D1 entry pointer is mutable. This prevents concurrent stores from mixing one execution's value with another execution's blob. +### R2 objects -Generation states cover `uploading`, `ready`, `retired`, and `deleting`. Persist object names and an internal upload lease before R2 writes. For a multipart upload, also record its R2 upload ID for abort/recovery. These records are backend bookkeeping; clients never receive an upload-session API. +Each store creates a generation with unique, immutable R2 object names. The paths below illustrate the scope/generation prefix: -Keep D1 operations on the primary in version 1. Read replication without a cross-request consistency agreement could return old mappings after a successful store or scope disable. Version 1 serves public data through the Worker without an additional CDN cache. Revisit edge caching separately, including namespace withdrawal and expiry behavior. +```mermaid +flowchart LR + subgraph D1["D1 · metadata"] + E["Entry
(scope_id, key)"] -->|current generation| G["Generation
random generation_id"] + end + subgraph R2["Private R2 · immutable objects"] + V["Value object
scope/generation/value"] + B["Blob object · optional
scope/generation/blob"] + end + G -->|value object key| V + G -.->|blob object key| B +``` + +Publish only after all required objects are complete. Switch the entry pointer atomically. Retire its previous generation for cleanup. This keeps the value and blob from the same execution together during concurrent stores. + +### Storage rules + +- Use bound binary parameters and byte equality. Accept empty and non-UTF-8 keys within the size limits. Do not convert keys to strings, normalize them, or interpret them as hex hashes. +- Index exact lookups, secondary lookups, blob IDs, and cleanup eligibility. Limit associations separately because many can target one entry. +- Use generation states `uploading`, `ready`, `retired`, and `deleting`. Record object names and a bounded upload lease before R2 writes. Record multipart upload IDs for abort or recovery. These records remain internal; clients receive no upload-session API. +- Use primary D1 in version 1. Read replicas need a consistency agreement to prevent old mappings after store commits or scope disable. +- Serve public data through the Worker without an additional CDN cache. Review edge caching separately, including namespace withdrawal and expiry behavior. ## 7. Fetch and download implementation -After rate limiting, the enabled-public-scope check, and bounded CBOR decoding, use a single indexed D1 query to select the exact live entry, or its secondary fallback if exact is absent. Select the response kind, stored key, and generation references from one database snapshot. Do not perform independent exact/fallback reads that can observe different commits. +First, apply rate limits. Check that the public scope is enabled. Decode CBOR within the configured limits. + +Use one indexed D1 query to select the exact live entry, or the secondary fallback if no exact entry exists. Select the response kind, stored key, and generation references from one database snapshot. Separate exact and fallback reads could observe different commits. -Read the selected generation's value from R2 and return the corresponding CBOR variant. Return `503` if D1 identifies a live generation but its value object is missing or unreadable; that is a storage failure, not a normal cache miss. Expired or deleted entries count as absent. The client still validates the exact value before downloading outputs. +Read the selected generation's value from R2. Return the corresponding CBOR variant. Return `503` if D1 identifies a live generation but its value object is missing or unreadable. This condition is a storage failure. Expired or deleted entries count as absent. The client still validates the exact value before it downloads outputs. -For `/blob/{blob_id}`, check the enabled public scope without authentication, resolve the blob ID in D1, and stream the R2 object to the response. A blob ID from another scope must not expose data. Ready blobs remain available until expiry; replaced blobs remain available during the retirement grace period described below. Return `404` for unavailable IDs, including IDs whose R2 object is gone. +For `/blob/{blob_id}`, check that the public scope is enabled, without authentication. Resolve the blob ID in D1. Stream the R2 object to the response. A blob ID from another scope must not expose data. -No read writes last-access timestamps, extends retention, changes associations, or creates per-request analytics rows. Fixed retention and a short replacement grace period let fetch and download remain read-only. A blob may expire between fetch and download; the client handles `404` as a miss and executes. +Ready blobs remain available until expiry. Replaced blobs remain available during the retirement grace period described below. Return `404` for unavailable IDs, including IDs whose R2 object is gone. + +Reads do not update last-access timestamps, extend retention, change associations, or create analytics rows for each request. Fixed retention and a short replacement grace period keep fetch and download read-only. A blob can expire between fetch and download. The client treats the resulting `404` as a miss and executes the task. ## 8. Store implementation and concurrency -1. Verify the GitHub OIDC JWT and the namespace's write policy from section 5, capture its policy version and token expiry, then reserve storage capacity in D1. Use a bounded `Content-Length` when supplied; reserve the total request limit otherwise. Do not require that header. Create the generation and a 15-minute internal lease. Count concurrent reservations against scope and deployment budgets. -2. Parse multipart input incrementally with backpressure. Bound headers, part count, metadata bytes, blob bytes, and total bytes. Reject duplicate metadata or blob parts, invalid types, missing metadata, and truncated bodies. Accept metadata-first and blob-first bodies. Do not buffer the whole request with `formData()` or `arrayBuffer()`. -3. Buffer the bounded metadata part, decode its outer CBOR map, and preserve its byte-string fields. Write `value` to its generation-specific R2 object. The Worker does not inspect nested client data. -4. For a blob up to 5 MiB, use one R2 PUT after buffering that bounded amount. For a larger blob, use internal R2 multipart upload with 5 MiB parts and a smaller final part. Upload one part at a time, releasing buffers as progress allows. A present empty blob still requires an R2 object. Cloudflare documents the [multipart minimum and API](https://developers.cloudflare.com/r2/objects/multipart-objects/). -5. Await completion of both R2 objects and the full multipart request, including its closing boundary. Record actual sizes. In one guarded D1 batch, verify token expiry against server time, the lease, captured scope and unchanged policy version, enabled/write-enabled state, and budgets; mark the generation ready; replace `entries[key]`; set `associations[secondary_key] = key`; retire the old generation of that same key; and reconcile reserved bytes with actual bytes. -6. Return the new blob ID, or `null`. Publication must finish before the response; `waitUntil()` is reserved for best-effort cleanup or observations. +1. Verify the GitHub OIDC JWT. Check the namespace's write policy from section 5. Record the policy version and token expiry. + + Reserve storage capacity in D1. If the request supplies `Content-Length`, use it within the request limit. Otherwise, reserve the total request limit. Do not require that header. Create the generation and a 15-minute internal lease. Count concurrent reservations against scope and deployment budgets. + +2. Parse multipart input incrementally with backpressure, so reads wait when the upload cannot accept more data. Limit headers, part count, metadata bytes, blob bytes, and total bytes. Reject duplicate metadata or blob parts, invalid types, missing metadata, and truncated bodies. Accept either part order. Do not buffer the whole request with `formData()` or `arrayBuffer()`. + +3. Buffer the metadata part within its limit. Decode its outer CBOR map. Preserve its byte-string fields. Write `value` to the generation's R2 object. Do not inspect nested client data. + +4. For a blob up to 5 MiB, buffer the blob. Upload it with one R2 PUT. + + For a larger blob, use internal R2 multipart upload with 5 MiB parts and a smaller final part. Upload one part at a time. Release buffers when the upload no longer needs them. A present empty blob still requires an R2 object. Cloudflare documents the [multipart minimum and API](https://developers.cloudflare.com/r2/objects/multipart-objects/). -D1 [batches provide transaction rollback on statement failure](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch). A conditional update affecting zero rows is not itself a SQL failure. Guard all publication mutations with the same valid-generation condition, inspect their results, and ensure a failed guard cannot leave one mapping changed. Prove this with concurrent-store and lease-expiry tests. +5. Wait for both R2 objects and the full multipart request to complete, including the closing boundary. Record actual sizes. Publish through one guarded D1 batch, as described below. -Readers see either the previous complete entry or the new complete entry. Concurrent stores follow the order of successful D1 commits; the last commit determines each affected mapping. Reassigning a secondary key does not retire the different entry it previously referenced. Replacing an entry changes what all associations to that key resolve to. +6. Return the new blob ID, or `null`. Complete publication before the response. Use `waitUntil()` only for cleanup or observations that do not require guaranteed completion. -If R2 or parsing fails before publication, leave existing mappings unchanged and clean up the staged generation. If the response is lost after commit, the client cannot know whether storage succeeded. A retry is another store and may return a different blob ID or overwrite a newer concurrent store. PR #713 supplies no idempotency key or exactly-once guarantee. +The publication batch checks token expiry against server time. It also checks the lease, captured scope, unchanged policy version, enabled/write-enabled state, and budgets. Under the same guard, the batch performs these changes atomically: -R2 multipart parts are an implementation detail inside one incoming HTTP request. A client cannot resume them after disconnecting. Cancellation, worker termination, and failure between creating an R2 multipart upload and recording its ID require cleanup and a bucket lifecycle backstop. +- Mark the generation ready. +- Replace `entries[key]`. +- Set `associations[secondary_key] = key`. +- Retire the old generation of the same key. +- Adjust reserved bytes to match actual bytes. + +D1 [batches roll back the transaction if a statement fails](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch). A conditional update that affects zero rows is not a SQL failure. Apply the same valid-generation guard to all publication mutations. Check their results. A failed guard must not leave either mapping changed. Test this condition with concurrent stores and lease expiry. + +Readers see either the previous complete entry or the new complete entry. Concurrent stores follow the order of successful D1 commits. The last commit determines each affected mapping. A secondary-key reassignment does not retire the different entry that it previously referenced. An entry replacement changes the result for all associations to that key. + +If R2 or parsing fails before publication, keep existing mappings unchanged. Clean up the staged generation. If the response is lost after commit, the client cannot know whether storage succeeded. A retry creates another store. It can return a different blob ID or overwrite a newer concurrent store. PR #713 supplies no idempotency key or exactly-once guarantee. + +R2 multipart uploads run inside one incoming HTTP request. A client cannot resume an upload after disconnection. Cancellation and Worker termination require cleanup. Cleanup must also cover failures between R2 multipart creation and upload-ID recording. Bucket lifecycle rules provide additional cleanup protection. ## 9. Size limits and runtime budgets -PR #713 defines no fixed maximum length for client-supplied keys, values, or blobs. Its [size guidance](https://github.com/voidzero-dev/vite-task/blob/362f5bd91bb32806b512d3d9a5339ff435bf5f0a/docs/remote-cache-server-api.md#sizes) permits servers to impose resource limits and return `413`. The following are configurable defaults for this deployment, not protocol-wide limits: +PR #713 defines no fixed maximum length for keys, values, or blobs from the client. Its [size guidance](https://github.com/voidzero-dev/vite-task/blob/362f5bd91bb32806b512d3d9a5339ff435bf5f0a/docs/remote-cache-server-api.md#sizes) permits servers to impose resource limits and return `413`. The following configurable defaults apply to this deployment. They do not limit the protocol as a whole: | Resource | Initial limit | | ------------------------------------------------------ | --------------------- | @@ -268,53 +413,113 @@ PR #713 defines no fixed maximum length for client-supplied keys, values, or blo | Upload lease | 15 minutes | | Store request deadline / client blob-download deadline | 2 minutes / 5 minutes | -Return `413` when a field or request exceeds its configured byte limit, including when streaming discovers the excess. Reserve `400` for malformed input. Do not truncate or transform opaque fields to make them fit. Publish the configured limits in the deployment guide; changes must keep individual fields, envelope sizes, total request size, and measured runtime budgets consistent. The client treats a rejected store as skipped remote publication and retains its local result. +Return `413` when a field or request exceeds its byte limit, including during streaming. Use `400` for malformed input. Do not truncate or transform opaque fields to make them fit. + +Publish the configured limits in the deployment guide. Keep field limits, envelope sizes, total request size, and measured runtime budgets consistent after changes. The client reports rejected stores as skipped remote publication and retains local results. -Bound multipart headers and CBOR container depth before allocation; reject ambiguous duplicate envelope fields. Support valid CBOR byte strings without requiring a canonical encoding. Test streaming boundaries and unknown-length request bodies. Envelope limits do not authorize decoding the opaque `value`. +Limit multipart headers and CBOR container depth before memory allocation. Reject ambiguous duplicate envelope fields. Support valid CBOR byte strings without a requirement for canonical encoding. Test streaming boundaries and request bodies of unknown length. Envelope limits do not authorize the Worker to decode the opaque `value`. -Cloudflare's [request-body limits](https://developers.cloudflare.com/workers/platform/limits/#request-limits) depend on the Cloudflare account plan: Free and Pro allow 100 MB, Business 200 MB. Buying Workers Paid alone does not raise the Free account's 100 MB body limit. The 72 MiB cap leaves space below that limit. Larger transfers require compatible field and request limits, account allowances, and measured Worker settings. +Cloudflare's [request-body limits](https://developers.cloudflare.com/workers/platform/limits/#request-limits) depend on the Cloudflare account plan. Free and Pro allow 100 MB. Business allows 200 MB. Workers Paid alone does not raise a Free account's 100 MB body limit. The 72 MiB cap leaves space below that limit. Larger transfers require compatible field limits, request limits, account allowances, and measured Worker settings. -Workers provides 128 MB per isolate, shared by concurrent requests. Budget metadata, CBOR copies, stream buffers, and concurrency together. Streaming reduces memory use; it does not make multipart parsing constant-cost. Workers Free allows 10 ms CPU per HTTP or Cron invocation. Include JWT signature verification and key loading in store measurements, with both cold and warm JWKS caches. Measure CBOR decoding and fetch encoding with 250 KB values and values up to the configured 4 MiB maximum, independently of blob size. Measure stores at 5, 20, 50 MB and the configured maximum, including concurrent uploads. The number of output files does not bound input metadata size. A Free deployment is a release target only for the sizes that pass those measurements. Lower limits or select Workers Paid if the implementation cannot meet them. +Workers provides 128 MB per isolate, which concurrent requests share. Budget metadata, CBOR copies, stream buffers, and concurrency together. Streaming reduces memory use, but parsing costs still depend on the multipart input. Workers Free allows 10 ms CPU per HTTP or Cron invocation. -Keep no more than six external connections open, with sequential R2 part uploads. Size SQL batches and cleanup work to the Free plan's per-invocation limits. Do not interpret an average CPU estimate in the cost table as proof that large stores fit Free. +Measure these operations before release: + +- Measure store costs with JWT signature verification and key loading. Test both cold and warm JWKS caches. +- Measure CBOR decoding and fetch encoding independently of blob size. Use 250 KB values and values up to the configured 4 MiB maximum. +- Measure stores at 5, 20, and 50 MB and at the configured maximum. Include concurrent uploads. + +The number of output files does not limit input metadata size. Release on Free only for sizes that pass these measurements. Lower the limits or select Workers Paid if the implementation cannot meet them. + +Keep no more than six external connections open. Upload R2 parts sequentially. Keep SQL batches and cleanup work within the Free plan's limits for each invocation. Average CPU estimates in the cost table do not prove that large stores fit Free. ## 10. Retention, quotas, and cleanup -Retain current entries for seven days from successful store commit by default. Replacing a key starts a new retention interval for its new generation. Fetch does not refresh it. An association follows its target entry's lifetime; changing an association does not shorten the old target's retention. +By default, retain current entries for seven days after a successful store commit. A key replacement starts a new retention interval for the new generation. Fetch does not refresh retention. An association follows its target entry's lifetime. An association change does not shorten the old target's retention. + +Retain a replaced generation's value and blob for ten minutes after replacement. This grace period covers fetch and download sequences already in progress. During this period, the old blob ID continues to identify the old bytes. It must never return the replacement blob. + +The Free profile reserves 8 GB across live, pending, retired, and deleting objects. It also limits live entries and associations to 20,000 each. Reserve space for new associations and entries during publication. Updates to existing identities do not consume new slots. + +Warn at 400 MB of actual D1 storage. Maximum-sized keys and many associations can fill the database before it reaches the entry count limit. + +A Cron invocation runs every five minutes. It performs cleanup in this order: + +1. Claim a bounded batch of expired, retired, or abandoned generations in D1. +2. Delete their known R2 objects or abort their uploads. +3. Release the charged bytes after successful deletion. + +Use generation IDs and conditional state transitions for garbage collection (GC). GC must not remove a replacement entry or an association whose target was recreated. Remove associations with no target in bounded, indexed batches. Keep objects charged while deletion remains incomplete. + +An expired upload lease prevents publication. Allow an additional cleanup grace period for late R2 operations. Retry deletion until the generation is gone. + +Configure [R2 lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/) as additional cleanup protection: -Retain a replaced generation's value and blob for ten minutes after replacement to cover an in-flight fetch/download sequence. The former blob ID continues to identify the former bytes during that grace period. It must never return the replacement blob. +- Abort unfinished multipart uploads after one day. +- For seven-day retention, expire generation objects after nine days. +- For 30-day retention, expire generation objects after 32 days. -The Free profile reserves 8 GB across live, pending, retired, and deleting objects, plus limits of 20,000 live entries and 20,000 associations. Reserve space for new associations and entries during publication; updates of existing identities do not consume new slots. Warn at 400 MB of actual D1 storage. Maximum-sized keys and many associations can exhaust the database before the entry count limit. +Lifecycle age starts at object creation. Its margin must cover upload leases and retirement grace. Lifecycle deletion is asynchronous. It does not replace D1 accounting or prompt cleanup. -A five-minute Cron invocation claims a bounded batch of expired, retired, or abandoned generations in D1, then deletes their known R2 objects or aborts uploads, and finally releases charged bytes. Use generation IDs and conditional state transitions so GC cannot remove a replacement entry or an association whose target has been recreated. Prune dangling associations in bounded indexed batches. Keep deleting objects charged until deletion succeeds. +Start with at most 16 generations per Free Cron run and 256 per Paid run. These limits depend on measured CPU, query, and subrequest costs. The theoretical Free ceiling is 4,608 generations/day. Actual cleanup can be lower. Both overwritten and expired generations add to the backlog. -An expired upload lease prevents publication. Allow an additional cleanup grace period for late R2 operations, and retry deletion until the generation is gone. Configure [R2 lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/) as a backstop: abort unfinished multipart uploads after one day; expire generation objects after nine days for seven-day retention, or 32 days for 30-day retention. Lifecycle age starts at object creation, so its margin must cover upload leases and retirement grace. Lifecycle deletion is asynchronous; it does not replace D1 accounting or prompt cleanup. +Pause stores with `503` before cleanup delays threaten the byte budget. Normal cleanup does not need an R2 LIST for each entry. -Start with a maximum of 16 generations per Free Cron run and 256 on Paid, subject to measured CPU, query, and subrequest limits. The theoretical Free ceiling is 4,608 generations/day; actual cleanup can be lower. Both overwritten and expired generations contribute to the backlog. Pause stores with `503` before cleanup lag threatens the byte budget. Normal cleanup does not need an R2 LIST per entry. +Apply resource limits to public reads before D1 or R2 work. Use a [Workers rate-limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/). Limit the set of keys for each namespace and operation. Apply stricter admission limits to unauthenticated stores. Return `429` with `Retry-After` when a request exceeds its rate limit. -Public reads need resource limits before D1/R2 work. Use a [Workers rate-limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) with bounded keys for namespace/operation and stricter unauthenticated store admission; return `429` with `Retry-After`. Configure a catch-all key for unknown paths so callers cannot create unlimited limiter identities. The binding is approximate and local to each Cloudflare location, not a global billing cap. Rejected requests still invoke the Worker. Track anonymous miss traffic and denied writes separately; public traffic can exhaust the Free daily allowance even when storage fits. Keep a deployment/namespace disable control and avoid per-read D1 counter writes. +Use one catch-all key for unknown paths. This prevents callers from creating unlimited limiter identities. The binding provides approximate limits at each Cloudflare location. It does not provide a global billing cap. Rejected requests still invoke the Worker. -Application budgets prevent ordinary storage growth beyond the configured allowance. They do not guarantee a zero bill under arbitrary traffic, shared-account usage, failed uploads, or delayed lifecycle cleanup. Alert at 80% of provider allowances; leave hard admission limits and cleanup headroom in place. +Track anonymous misses and denied writes separately. Public traffic can exhaust the Free daily allowance even when storage fits. Keep controls to disable a deployment or namespace. Do not write D1 counters for each read. + +Application budgets keep ordinary storage growth within the configured allowance. They cannot guarantee a zero bill for arbitrary traffic, shared-account usage, failed uploads, or delayed lifecycle cleanup. Alert at 80% of provider allowances. Keep hard admission limits and spare capacity for cleanup. ## 11. Failure handling and operations -The client preserves local results if remote reads, validation, downloads, or explicit publication fail. `vp run` falls back to execution after a failed read; `vp cache push` reports failures through its own exit status. Use short metadata deadlines, bounded transfer deadlines, cancellation, a small concurrency limit, and an invocation-wide circuit breaker after repeated failures. Authentication and size failures should produce one actionable diagnostic rather than repeated attempts for every task. The client may retry transient reads within its time budget. Retrying an uncertain store repeats its replacement semantics. +The client preserves local results if remote reads, validation, downloads, or explicit publication fail. `vp run` executes the task after a failed read. `vp cache push` reports failures through its own exit status. + +Use short metadata deadlines and bounded transfer deadlines. Support cancellation. Limit concurrency. After repeated failures, use a circuit breaker to stop remote attempts for the rest of the invocation. + +For authentication or size failures, report one diagnostic that explains the required action. Do not repeat the same failed attempt for every task. The client can retry transient read failures within its time budget. A retry after an uncertain store outcome follows the same replacement rules. + +Before archive extraction, the client must validate the remote blob's format, compatibility, integrity information, output paths, and decompression/file-count limits. Reject path traversal, absolute paths, unsafe links, and malformed archives. Restore into a staging area before terminal replay or promotion to local storage. The Worker stores opaque bytes and cannot perform these task-specific checks. + +Log the request ID, scope, operation, status, bytes, duration, and error class. For verified writes, also log repository ID, workflow ref, run ID/attempt, and commit SHA from signed claims. Reads have no authenticated identity. Do not add an identity API call. Do not log credentials or opaque request contents. + +Measure these operational values: -Before extracting a remote blob, the client must validate its own format, compatibility, integrity information, output paths, and decompression/file-count limits. Reject path traversal, absolute paths, unsafe links, and malformed archives. Stage restoration before terminal replay or local promotion. The Worker stores opaque bytes and cannot perform these task-specific checks. +- Exact, fallback, and not-found rates. +- Hits that pass client validation. +- Transferred bytes. +- D1 rows and latency. +- R2 operations. +- Pending bytes and cleanup delays. -Log request ID, scope, operation, status, bytes, duration, and error class. For verified writes, include repository ID, workflow ref, run ID/attempt, and commit SHA from signed claims. Reads have no authenticated principal; do not add an identity API call. Do not log credentials or opaque request contents. Observe exact/fallback/not-found rates, client-validated hits, transferred bytes, D1 rows and latency, R2 operations, pending bytes, and cleanup lag. Sample successful Worker logs and bound error logging; no central telemetry service or paid analytics dependency is required. Client hit metrics must distinguish an exact lookup from successful reuse. +Sample successful Worker logs. Limit error logging. The service needs no central telemetry service or paid analytics. Client hit metrics must distinguish exact lookup from successful reuse. -The Worker verifies GitHub tokens on writes and checks scope state in primary D1. Follow the policy-change and token-expiry semantics in section 5. Back up repository bindings and namespace policy separately from disposable cache data. D1 restoration does not restore deleted R2 objects; reconcile references or create a fresh namespace after partial recovery. Use additive migrations and document rollback compatibility. +The Worker verifies GitHub tokens on writes and checks scope state in primary D1. Follow section 5's rules for policy changes and token expiry. Back up repository bindings and namespace policy separately from disposable cache data. + +D1 restoration does not restore deleted R2 objects. After partial recovery, reconcile references or create a new namespace. Use additive migrations. Document rollback compatibility. ## 12. Self-deployment and GitHub Actions migration -Deliver `packages/remote-cache` with TypeScript sources, pinned dependencies and lockfile, `wrangler.jsonc`, D1 migrations, protocol fixtures, and an operator CLI/guide. Setup creates a private R2 Standard bucket and D1 database, binds them as `ARTIFACTS` and `INDEX`, and installs lifecycle/Cron settings. The maintainer supplies the public GitHub repository; setup resolves its IDs through the [GitHub repository API](https://docs.github.com/en/rest/repos/repos#get-a-repository), stores its namespace write policy, and prints the public endpoint. Namespace creation remains an operator action, not open registration. +Deliver `packages/remote-cache` with these files and tools: + +- TypeScript sources. +- Pinned dependencies and a lockfile. +- `wrangler.jsonc`. +- D1 migrations. +- Protocol fixtures. +- An operator CLI and guide. + +Setup creates a private R2 Standard bucket and D1 database. It binds them as `ARTIFACTS` and `INDEX` and installs lifecycle and Cron settings. The maintainer supplies the public GitHub repository. Setup resolves the repository's IDs through the [GitHub repository API](https://docs.github.com/en/rest/repos/repos#get-a-repository). It stores the namespace's write policy and prints the public endpoint. Only the operator can create namespaces. -Deploy to `workers.dev` or an optional custom domain. Every exposed route must permit public reads and enforce the same GitHub JWT policy for stores; disable unused aliases. Keep administration behind the operator's Cloudflare credentials. Provide idempotent setup, policy changes, write/scope disable, upgrades, isolated smoke tests, and explicit teardown of stored data and Worker resources. Pin tested JWT-library, tooling, and compatibility versions. No cache secret needs to be generated or added to GitHub. +Deploy to `workers.dev` or an optional custom domain. Every exposed route must permit public reads and enforce the same GitHub JWT policy for stores. Disable unused aliases. Require the operator's Cloudflare credentials for administration. -Default to the Free profile in section 10. The Paid profile changes operational budgets only after the operator selects them. Increasing retention or R2 storage remains an explicit choice, independent of the Workers subscription. +Provide setup that can run repeatedly without duplicate resources. Support policy changes, write/scope disable, upgrades, and isolated smoke tests. Provide explicit teardown of stored data and Worker resources. Pin tested versions of the JWT library, tools, and runtime compatibility settings. Setup needs no cache secret in GitHub. -The following workflow excerpt shows the client flow. Retain the existing checkout, Vite+ setup, and dependency-installation steps. The endpoint can be checked into `vite.config.*`; the example uses a non-secret repository variable as a protected CI override. The build saves local results, then a separate step publishes them: +Use the Free profile in section 10 by default. Change operational budgets for Paid only after the operator selects them. The operator must explicitly select more retention or R2 storage, independently of the Workers subscription. + +The following workflow excerpt shows the client flow. Keep the existing checkout, Vite+ setup, and dependency-installation steps. The repository can store the endpoint in `vite.config.*`. This example uses a non-secret repository variable as a protected CI override. The build saves local results. A separate step publishes them: ```yaml on: @@ -341,17 +546,21 @@ jobs: continue-on-error: true ``` -The workflow trigger and step condition avoid unnecessary upload attempts; the Worker independently checks the signed repository, branch, and event claims. A PR workflow uses the same public endpoint for reads, without `id-token: write` or a push step. Preserve the existing [`DOCS_SITE_ORIGIN` input tracking](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/docs/vite.config.ts) and the workflow's configured site-origin value. Keep dependency installation and package-manager caching. +The workflow trigger and step condition avoid unnecessary upload attempts. The Worker independently checks signed repository, branch, and event claims. A PR workflow reads from the same public endpoint without `id-token: write` or a push step. + +Preserve the existing [`DOCS_SITE_ORIGIN` input tracking](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/docs/vite.config.ts). Keep the workflow's configured site-origin value. Keep dependency installation and package-manager caching. -During a canary, retain existing task-directory restore/save within its trust boundary, but do not republish restored entries by default. Remove those steps after compatible clients pass anonymous fresh-checkout reuse and explicit-publication tests. Roll back publication by removing the push step or disabling writes on the server; disable remote reads by removing the endpoint or using `--no-remote-cache`. +During a limited production trial, or canary, keep the existing task-directory restore/save steps within their trust boundary. Do not republish restored entries by default. Remove those steps after compatible clients pass anonymous reuse tests from fresh checkouts and explicit publication tests. + +To roll back publication, remove the push step or disable server writes. To disable remote reads, remove the endpoint or use `--no-remote-cache`. ## 13. Free and Paid capacity comparison -Prices below are USD before tax, checked on 2026-09-09. Estimates use a 30-day month and decimal MB/GB. Allowances assume this deployment is the account's only consumer. Workloads are illustrative; the frontend samples establish sizes, not typical user traffic. +Prices below use USD before tax. We checked them on 2026-09-09. Estimates use a 30-day month and decimal MB/GB. Allowances assume that no other service uses the account. The workload examples illustrate costs. The frontend samples measure sizes and do not establish typical user traffic. ### Provider allowances -A Cloudflare account plan, Workers Free/Paid, and R2 billing are separate choices. The service can use `workers.dev` without a Pro website plan. Users must [enable R2](https://developers.cloudflare.com/r2/get-started/); R2 overages can incur charges while Workers remains Free. Upgrading Workers does not increase R2's free allowance. See Cloudflare's [billing model](https://developers.cloudflare.com/billing/understand/how-billing-works/). +A Cloudflare account plan, Workers Free/Paid, and R2 billing are separate choices. The service can use `workers.dev` without a Pro website plan. Users must [enable R2](https://developers.cloudflare.com/r2/get-started/). R2 usage above its free allowance can incur charges while Workers remains Free. A Workers upgrade does not increase R2's free allowance. See Cloudflare's [billing model](https://developers.cloudflare.com/billing/understand/how-billing-works/). | Workers resource | Free | Paid Standard | | -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------- | @@ -361,7 +570,7 @@ A Cloudflare account plan, Workers Free/Paid, and R2 billing are separate choice | Five-minute Cron CPU | 10 ms/invocation | 30 s/invocation | | Memory | 128 MB/isolate | 128 MB/isolate | -Sources: [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) and [limits](https://developers.cloudflare.com/workers/platform/limits/). Storage/network wait time does not consume Worker CPU. Free request allowance is daily, and CPU eligibility applies to individual operations. +Sources: [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) and [limits](https://developers.cloudflare.com/workers/platform/limits/). Storage and network wait time do not consume Worker CPU. The Free request allowance applies each day. CPU limits apply to each operation. | D1 resource | Free | Paid Standard | | ----------------------------- | ----------------------------- | -------------------------------------------------- | @@ -370,7 +579,7 @@ Sources: [Workers pricing](https://developers.cloudflare.com/workers/platform/pr | Storage | 5 GB/account; 500 MB/database | 5 GB included, then $0.75/GB-month; 10 GB/database | | Queries per Worker invocation | 50 | 1,000 | -Sources: [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) and [limits](https://developers.cloudflare.com/d1/platform/limits/). Count index maintenance and deletion as writes. Account-wide Free daily exhaustion interrupts database work until reset. +Sources: [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) and [limits](https://developers.cloudflare.com/d1/platform/limits/). Count index maintenance and deletion as writes. If the account exhausts a Free daily allowance, database work stops until that allowance resets. | R2 Standard resource | Included with either Workers plan | Overage | | ------------------------------- | --------------------------------- | --------------- | @@ -379,17 +588,32 @@ Sources: [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) an | Class B | 10 million/month | $0.36/million | | Egress, DELETE, multipart abort | Free | Free | -[R2 pricing](https://developers.cloudflare.com/r2/pricing/) counts multipart creation, each part, completion, and PUT as Class A; GET/HEAD as Class B. Storage billing averages daily peaks. Billable units round up to whole GB-months and million-operation units. Include unfinished and retired objects in observed peaks. +[R2 pricing](https://developers.cloudflare.com/r2/pricing/) counts multipart creation, each part, completion, and PUT as Class A operations. It counts GET and HEAD as Class B operations. Storage billing averages daily peaks. Billable units round up to whole GB-months and million-operation units. Include unfinished and retired objects in observed peaks. ### Version 1 authorization cost -Public reads require no identity subscription. GitHub issues publishing-job tokens. GitHub Actions compute billing is separate from this cache estimate. JWT checks run in the Worker, and bounded JWKS refreshes add subrequests and latency. Benchmark both cold and warm verification with store parsing before claiming Workers Free eligibility. +Public reads require no identity subscription. GitHub issues tokens for publishing jobs. GitHub Actions compute billing is separate from this cache estimate. JWT checks run in the Worker. Bounded JWKS refreshes add subrequests and latency. Benchmark cold and warm signature verification with store parsing before a claim of Workers Free support. ### Usage model -Let `L` be daily public fetches after local misses, `F` fetches returning a value, `H` blob downloads after successful client validation, and `P` successfully published entries from explicit main-branch CI pushes, including overwrites. `P` counts entries, not command invocations, and is independent of developer misses. GitHub token requests are outside cache-Worker request counts; JWT verification and JWKS fetching belong in measured Worker CPU/subrequest budgets. Let `B` be mean blob bytes, `V` mean value bytes, `S = (B + V) / 1,000,000`, `E` live exact keys, and `R` retention days. +Use these variables for daily traffic: + +- `L`: Public fetches after local misses. +- `F`: Fetches that return a value. +- `H`: Blob downloads after successful client validation. +- `P`: Entries successfully published through explicit CI pushes from the main branch, including overwrites. + +`P` counts entries, not command invocations. It is independent of developer misses. Exclude GitHub token requests from cache-Worker request counts. Include JWT verification and JWKS retrieval in measured Worker CPU and subrequest budgets. -Each store takes one Worker request. Each metadata fetch takes one request, followed by one blob request only when needed. The Worker also reads the opaque value from R2. Internal R2 multipart upload changes storage operations, not client request counts: +Use these variables for stored data: + +- `B`: Mean blob bytes. +- `V`: Mean value bytes. +- `S = (B + V) / 1,000,000`: Mean stored size in MB. +- `E`: Live exact keys. +- `R`: Retention days. + +Each store takes one Worker request. Each metadata fetch takes one request. The client makes a blob request only when it needs the blob. The Worker also reads the opaque value from R2. Internal R2 multipart upload changes storage operation counts but adds no client requests: ```text A per store = 1 # no blob: value PUT @@ -403,11 +627,21 @@ Current R2 GB = E * S / 1000 Total R2 GB = current + pending + retired + awaiting deletion ``` -Use per-size buckets or sum per-store operations for a mixed workload; `ceil(mean size)` can undercount multipart operations. The 10% operation reserve covers ordinary retries and maintenance; 300 daily invocations cover 288 Cron runs and routine management. It is not a bound against outages or arbitrary traffic. R2 completion/PUT success precedes publication; no extra HEAD per object is planned. +For mixed workloads, group stores by size or sum the operations for each store. `ceil(mean size)` can undercount multipart operations. The 10% operation reserve covers ordinary retries and maintenance. The 300 daily invocations cover 288 Cron runs and routine management. This reserve does not limit costs during outages or arbitrary traffic. + +R2 completion and PUT must succeed before publication. The plan adds no HEAD request for each object. -Only when each store creates a different retained exact key at a steady rate does `E = P * R`, giving `P * S * R / 1000` GB. With repeated stores to one key, retain its current generation and a short retirement backlog. Input changes do not necessarily create a different exact key. Do not multiply all stores by seven days and describe the result as actual storage usage. Overwrites still consume Worker, R2, D1, and cleanup operations. +If stores arrive steadily and each creates a different retained exact key, `E = P * R`. Current storage then equals `P * S * R / 1000` GB. Repeated stores to one key retain its current generation and a short retirement backlog. Input changes do not necessarily create a different exact key. -For planning, budget 64 D1 rows read per fetch/download cycle plus 32 per store, and 40 rows written per store over its full lifecycle. Include scope lookups, indexes, accounting, association replacement, and cleanup. GitHub token verification needs no D1 credential table; retain conservative row budgets for repository-policy checks until measurements justify reducing them. Add 10% plus 5,000 reads/1,000 writes per day for maintenance: +Do not multiply all stores by seven days and report the result as actual storage usage. Overwrites still consume Worker, R2, D1, and cleanup operations. + +Use these D1 planning budgets: + +- 64 rows read per fetch/download cycle. +- 32 rows read per store. +- 40 rows written per store over its full lifecycle. + +Include scope lookups, indexes, accounting, association replacement, and cleanup. GitHub token verification needs no D1 credential table. Keep conservative row budgets for repository-policy checks until measurements support a reduction. For daily maintenance, add 10% plus 5,000 reads and 1,000 writes: ```text D1 reads/day = ceil(1.10 * (64 * L + 32 * P)) + 5000 @@ -415,7 +649,9 @@ D1 writes/day = ceil(1.10 * 40 * P) + 1000 D1 storage MB = E * 4096 / 1000000 # provisional, ordinary small keys ``` -These are engineering budgets, not measured costs. The storage estimate assumes roughly one association and current generation per key; account for extra associations, pending/retired generations, and large keys separately. R2 part receipts need not create a D1 row per part. Confirm `rows_read`, `rows_written`, index plans, actual database bytes, and cleanup costs before claiming these capacities. +These budgets are estimates. They are not measured costs. The storage estimate assumes roughly one association and one current generation per key. Account separately for extra associations, pending and retired generations, and large keys. R2 part receipts do not require one D1 row per part. + +Check `rows_read`, `rows_written`, index plans, actual database bytes, and cleanup costs before a claim of these capacities. ### Key and value size evidence @@ -428,15 +664,17 @@ The [PR #713 size example](https://github.com/voidzero-dev/vite-task/blob/362f5b | `value` | About 250 KB | | `blob` | About 250 KB | -This is an upstream-reported example, not a measurement from our frontend artifact study or a population average. The client can record many input paths and hashes even when a task produces few output files. Here the value is as large as the blob. Budget and measure them separately; the Worker continues to treat their contents as opaque bytes. +This example comes from the upstream API proposal. It is neither a measurement from our frontend study nor a population average. The client can record many input paths and hashes even when a task produces few output files. Here the value is as large as the blob. Budget and measure each separately. The Worker treats both as opaque bytes. + +For planning, interpret the approximate KB values as decimal. `V = 250,000` bytes and `B = 250,000` bytes give `S = 0.5 MB`. At 200 new distinct keys/day with seven-day retention, value and blob storage totals about 0.7 GB. This excludes staging and spare capacity for cleanup. Both objects remain in R2. -For planning, interpret the approximate KB values as decimal: `V = 250,000` bytes and `B = 250,000` bytes give `S = 0.5 MB`. At 200 new distinct keys/day and seven-day retention, value plus blob storage is about 0.7 GB before staging and cleanup headroom. Both objects remain in R2. D1's provisional 4 KiB per-key estimate covers index and generation records only; validate it with the reported key sizes, including repeated key bytes in indexes and associations. +D1's provisional 4 KiB estimate for each key covers index and generation records only. Validate it with the reported key sizes. Include repeated key bytes in indexes and associations. -Every exact or fallback response transfers the value even if client validation prevents a blob download. Before protocol overhead and retries, daily response payload is `F * V + H * B` bytes. With 1,000 value-bearing fetches and 800 blob downloads at the example's sizes, that is about 450 MB/day, including 250 MB of values. This affects transfer time, CBOR work, and memory; request and R2 operation counts follow the existing equations. +Every exact or fallback response transfers the value, even if client validation prevents a blob download. Before protocol overhead and retries, daily response payload equals `F * V + H * B` bytes. At the example's sizes, 1,000 fetches with values and 800 blob downloads transfer about 450 MB/day. This includes 250 MB of values. These transfers affect time, CBOR work, and memory. Request and R2 operation counts follow the same equations. ### Artifact-size evidence -Use 5 MB for a small-output scenario. Measurements of four established products' published Vite frontend outputs on 2026-09-07 provide broader size evidence. The [artifact study](remote-cache-size-study/README.md) records pinned sources, exact bytes, and a reproduction script. +Use 5 MB for a scenario with small outputs. On 2026-09-07, we measured published Vite frontend outputs from four established products. The [artifact study](remote-cache-size-study/README.md) records pinned sources, exact bytes, and a reproduction script. | Product/release | Output MB | `tar.zst` MB | `tar.zst` MB without source maps | | ------------------------------- | --------: | -----------: | -------------------------------: | @@ -445,26 +683,43 @@ Use 5 MB for a small-output scenario. Measurements of four established products' | Hoppscotch `2026.8.0` | 127.53 | 32.18 | 12.18 | | n8n `n8n-editor-ui@2.16.2` | 162.76 | 34.71 | 13.15 | -These are official npm/Docker frontend outputs recompressed with zstd level 3, not local rebuilds or private cloud deployment measurements. They exclude backend tasks, dependencies, terminal events, and client validation metadata. Keep source maps when the task requires them. All four sampled archives fit the 64 MiB blob limit. +We recompressed official npm/Docker frontend outputs with zstd level 3. We did not rebuild them locally or measure private cloud deployments. The measurements exclude backend tasks, dependencies, terminal events, and client validation metadata. Keep source maps when the task requires them. All four sampled archives fit the 64 MiB blob limit. -Three samples exceed 5 MB. Use 50 MB as an additional complete-result planning case for mature frontends, with room above these sampled archives. It is not a measured population average or an upper bound. +Three samples exceed 5 MB. Use 50 MB as an additional planning case for complete results from mature frontends. This leaves room above the sampled archive sizes. It is neither a measured population average nor an upper bound. -Measure one task at a time. A whole local-cache directory can contain several tasks and keys. During the canary, record compressed blob/value bytes, store and overwrite counts, live distinct keys, retention, pending peaks, task types, and lookup/validated-hit rates. Report mean, median, p95, maximum, and sample count by task type over at least two retention windows. Measure the fraction of sampled deployments that stay free before claiming support for most average users. +Measure one task at a time. A whole local-cache directory can contain several tasks and keys. During the canary, record these values: + +- Compressed blob bytes and value bytes. +- Store counts and overwrite counts. +- Live distinct keys and retention. +- Peak pending bytes. +- Task types. +- Lookup rates and hit rates after validation. + +Report mean, median, p95, maximum, and sample count by task type over at least two retention windows. Here, p95 means the 95th percentile. Measure the fraction of sampled deployments that stay free before a claim of support for most average users. ### Public-read workloads with explicit CI publication -In v1, many developer reads can share a small main-branch publication stream. The examples below use `H = 0.8 * L`, `F = L`, 5 MB per published entry, seven-day retention, and distinct keys for all stores. Publication counts are independent inputs: +In version 1, many developers can read a small set of results published from the main branch. The examples below use `H = 0.8 * L` and `F = L`. Each published entry contains 5 MB. Retention is seven days, and every store creates a distinct key. Publication counts are independent inputs: | Public-cache workload | Fetches/day | Published entries/day | Current R2 | Worker/day | D1 reads/day | D1 writes/day | Free monthly estimate | Paid monthly estimate | | --------------------- | ----------: | --------------------: | ---------: | ---------: | -----------: | ------------: | --------------------: | --------------------: | | Public project | 1,000 | 20 | 0.7 GB | 2,302 | 76,104 | 1,880 | $0 | $5.00 | | High read traffic | 40,000 | 100 | 3.5 GB | 79,610 | 2,824,520 | 5,400 | $0 | $5.00 | -Both fit the modeled request, row, storage, and R2-operation allowances, subject to per-request CPU qualification and operational headroom. The second case uses 2.3883 million Worker requests, 6,600 R2 Class A operations, and 2.376 million Class B operations per 30-day month. Under the same provisional 5 ms CPU assumption, Workers Paid stays at its $5 base charge. No identity-seat fee scales with the number of readers. These are workload examples, not measurements of typical projects or a guarantee against arbitrary public traffic. +Both examples fit the estimated request, row, storage, and R2-operation allowances. Each request must also meet the CPU limit, with spare capacity for operation. Over a 30-day month, the second case uses: + +- 2.3883 million Worker requests. +- 6,600 R2 Class A operations. +- 2.376 million R2 Class B operations. + +With the provisional average of 5 ms CPU per invocation, Workers Paid remains at its $5 base charge. The number of readers adds no identity subscription fees. These examples do not measure typical projects or guarantee costs under arbitrary public traffic. ### Illustrative workloads and prices -These scenarios compare storage and operation costs with `H = 0.8 * L`, `P = 0.2 * L`, and `F = L`. Only main-branch CI publications count toward `P`; the chosen ratio is a modeling assumption. Assume each store creates a distinct key. Set `V = 250 KB` (250,000 bytes), based on the upstream example, and include it in `S`. Calculate multipart counts from the remaining blob bytes. Average CPU of 5 ms per invocation is a Paid-cost assumption; Free eligibility requires per-request measurements. +These scenarios compare storage and operation costs with `H = 0.8 * L`, `P = 0.2 * L`, and `F = L`. Only CI publications from the main branch count toward `P`. The ratio is an assumption for this model. Assume each store creates a distinct key. + +Set `V = 250 KB` (250,000 bytes), based on the upstream example. Include this value in `S`. Calculate multipart counts from the remaining blob bytes. An average of 5 ms CPU per invocation is an assumption for Paid costs. Free support requires measurements for each request. | Workload | Fetches/day | Stores/day | Mean size | Retention | Current R2 | Worker/day | D1 writes/day | | -------------------- | ----------: | ---------: | --------: | --------: | ---------: | ---------: | ------------: | @@ -486,13 +741,22 @@ These scenarios compare storage and operation costs with `H = 0.8 * L`, `P = 0.2 | Busy team | Does not fit D1 Free writes | $6.95 | | Large organization | Exceeds Free requests, writes, and single-database storage | $19.91 | -The public cache has no per-reader subscription charge. These steady-state prices exclude pending/retired storage, other account usage, domain costs, GitHub Actions compute, and unusual or abusive traffic. Free eligibility still requires CPU measurements, including JWT verification on stores. The default 8 GB profile refuses excess stores; it does not incur the higher-storage scenario by itself. R2 Class A remains within one million monthly operations in the 20/50 MB rows: 46,200/85,800 operations under the chosen multipart model. +The public cache has no subscription charge for each reader. These prices assume steady usage. They exclude pending/retired storage, other account usage, domain costs, GitHub Actions compute, and unusual or abusive traffic. Free support still requires CPU measurements, including JWT verification on stores. + +The default 8 GB profile rejects excess stores. It does not automatically increase the storage budget. Under this multipart model, the 20 MB case uses 46,200 R2 Class A operations each month. The 50 MB case uses 85,800. Both remain within the one-million monthly allowance. -For the large-organization row: 6.609 million Worker requests/month and 33.045 million CPU ms cost about $5.06; 700 GB R2 storage costs $10.35; 1.32 million Class A operations cost $4.50 after free allowance and unit rounding; 5.94 million Class B operations remain included. Modeled D1 use is 232.47 million reads, 26.43 million writes, and about 573 MB of current-key metadata, within Paid allowances. Cache infrastructure subtotal: about $19.91/month. Bursts, CPU outliers, and D1 throughput still need load tests; monthly allowances do not promise a request rate. +The large-organization example has these monthly costs: + +- 6.609 million Worker requests and 33.045 million CPU ms cost about $5.06. +- 700 GB of R2 storage costs $10.35. +- 1.32 million Class A operations cost $4.50 after the free allowance and unit rounding. +- 5.94 million Class B operations remain within the included allowance. + +The D1 estimate includes 232.47 million reads, 26.43 million writes, and about 573 MB of metadata for current keys. These values fit Paid allowances. The cache infrastructure subtotal is about $19.91/month. Bursts, unusually high CPU use, and D1 throughput still require load tests. Monthly allowances do not guarantee a request rate. ### How much can remain free? -For distinct keys, the 8 GB application budget gives these storage-only ceilings before pending bytes and cleanup headroom: +For distinct keys, the 8 GB application budget gives these storage ceilings. They exclude pending bytes and spare capacity for cleanup: | Mean stored size | New distinct keys/day, 7 days | New distinct keys/day, 30 days | | ---------------- | ----------------------------: | -----------------------------: | @@ -501,29 +765,41 @@ For distinct keys, the 8 GB application budget gives these storage-only ceilings | 20 MB | 57 | 13 | | 50 MB | 22 | 5 | -Compute `floor(8000 / (S * R))`; other limits may bind first. If 200 daily stores repeatedly replace the same ten 50 MB keys, current storage is about 0.5 GB, plus temporary old generations and staging. If they create 200 different keys daily, seven-day current storage is 70 GB. The client's actual key reuse therefore matters as much as archive size. +Calculate the ceiling with `floor(8000 / (S * R))`. Other limits can reduce it. If 200 daily stores repeatedly replace the same ten 50 MB keys, current storage totals about 0.5 GB. Add temporary old generations and staging to that total. If each store creates a different key, seven-day current storage reaches 70 GB. The client's actual key reuse matters as much as archive size. + +The stress comparison uses `P = 0.2 * L`, 80% blob downloads, and no storage constraint. Under these assumptions, Free Worker requests allow about 45,318 fetches/day. The provisional D1 write budget reduces this to 11,250 fetches/day, or about 8,977 at the 80% write alert threshold. Cleanup and CPU can reduce these numbers further. + +For read-only workloads, D1 reads and Worker requests determine the limits. Apply the equations with `P = 0`. + +The plan targets free operation with these choices: -In the stress comparison where `P = 0.2 * L`, with 80% blob downloads and no storage constraint, Free Worker requests allow about 45,318 fetches/day. The provisional D1 write budget binds earlier at 11,250 fetches/day, or about 8,977 at an 80% write alert threshold. Cleanup and CPU can reduce these numbers. For read-only workloads, D1 reads and Worker requests bind instead; apply the equations with `P = 0`. +- Check the local cache before public remote reads. +- Use one metadata fetch. Download blobs only after validation. +- Publish explicitly from the main branch. +- Replace existing entries for repeated keys. +- Make no D1 writes during reads. +- Limit retention. Use private Standard R2. -The plan targets free operation through local-first public reads, one metadata fetch, blob downloads only after validation, explicit main-branch publication, replacement semantics, no D1 writes on reads, bounded retention, and private Standard R2. Read traffic can grow without adding writers or user-seat fees. The modeled examples fit free allowances only while traffic, storage, and per-request CPU remain within their budgets. Production measurements remain necessary to substantiate a claim about typical users. +Read traffic can grow without more writers or user subscription fees. The examples fit free allowances only while traffic, storage, and CPU for each request remain within budget. Production measurements must support any claim about typical users. -## 14. Storage tradeoffs +## 14. Version 2 and implementation questions -D1 plus R2 preserves atomic replacement of two indexes while streaming object data. An R2-only implementation would need another coordination mechanism for those mappings. Workers KV would require a separate consistency design for replacement and scope-state changes. Durable Objects could serialize writes and accounting, but would add another state service; consider them only if D1 concurrency measurements show a need. +Version 2 can add private projects through Cloudflare One: -Storing small values inline in D1 could remove one R2 read/write for many tasks. Defer this optimization until value-size measurements justify a split storage path; values above D1's row limit would still need R2. Cross-result content deduplication could reduce repeated asset storage but adds reference accounting and changes garbage collection. +- Access policies protect reads and writes. +- Service tokens support automation that cannot use GitHub OIDC. +- Managed OAuth provides developer login. -## 15. Delivery stages and acceptance +Keep namespace isolation and explicit publication. Separate private namespaces or deployments from public version 1 endpoints. A private authorization failure must never permit public access. -1. **Public reads and OIDC write policy.** Exercise every cell of section 5's permission matrix, including anonymous fresh-checkout reads. Test wrong issuer/audience, forged signatures, missing claims, expired/future tokens, wrong repository/owner/visibility, forks on their own main branch, PR and `pull_request_target` events, `workflow_run`, tags, non-main refs, and reusable workflows. Cover JWKS rotation, unknown-key refresh bounds, outages, policy changes, namespace disable, same-key/blob-ID isolation, and route/alias bypass attempts. Stores rejected at admission must reserve no quota and perform no R2 operation. -2. **Protocol and storage foundation.** Pin the final PR #713 contract; add CBOR/multipart fixtures and schema migrations. Test exact/fallback/not-found, required nullable fields, arbitrary binary/empty keys, replacement, and associations shared across keys. Add size fixtures for 1 KB keys, 50-byte secondary keys, and 250 KB values/blobs, plus configured maxima and just-over-limit `413` cases. Fetch must leave both mappings unchanged. Verify absent versus empty blobs and plain-text errors. -3. **Streaming and atomic publication.** Test both part orders, unknown content length, boundaries split across stream chunks, malformed/truncated bodies, maximum sizes, and cancellation. Exercise concurrent same-key and same-secondary-key stores, lease/token expiry, changed write policy before commit, lost responses, R2 failures, and rollback guards. No visible entry may reference an incomplete generation or change one mapping after a failed publication guard. -4. **Explicit client publication and canary.** Agree on the push command, selection semantics, and portable client encoding. Prove that `vp run` never stores remotely and only explicit push acquires OIDC credentials. Test snapshot pinning, local replacement/eviction, changed working-tree files, failed-run selection reset, opt-outs, exclusion of imported/restored entries, no-op push, partial failure, and retry behavior. On Unix and Windows, verify token acquisition/redaction, credential handling across redirects, exact validation, diagnostics-only fallback, inferred/missing inputs, tracked environment queries, safe restoration, local promotion, and failed-read fallback. Demonstrate anonymous reuse in a compatible fresh checkout after a main-branch push. -5. **Cleanup and self-deployment.** Prove concurrent quota reservation, old-blob grace, current-pointer protection during GC, bounded association cleanup, abandoned multipart cleanup, and lifecycle margins. Ship setup/policy-change/disable/upgrade/teardown guides and a real GitHub Actions-to-Worker smoke test. Test anonymous rate limits and namespace withdrawal; local emulation does not establish production OIDC or provider-limit behavior. -6. **Capacity qualification.** Measure operation counts, actual D1 storage, JWT/JWKS cold and warm costs, Free CPU across value/blob sizes, concurrent memory, and sustainable cleanup. Update cost tables with independent public-read and main-branch publication rates. Sample task mixes and key replacement over two retention windows before claiming broad free coverage. +For version 2, review [Workers Access integration](https://developers.cloudflare.com/workers/configuration/cloudflare-access/), [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/), revocation behavior, and [Access pricing](https://www.cloudflare.com/sase/products/access/). None adds a dependency or cost to version 1. -## 16. Version 2 and implementation questions +Resolve these questions during implementation: -Version 2 can add private projects using Cloudflare One: Access policies protect reads and writes, service tokens serve automation that cannot use GitHub OIDC, and Managed OAuth provides developer login. Retain namespace isolation and explicit publication. Separate private namespaces or deployments from public v1 endpoints; a private authorization failure must never fall back to public access. Revisit [Workers Access integration](https://developers.cloudflare.com/workers/configuration/cloudflare-access/), [Managed OAuth](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/managed-oauth/), revocation semantics, and [Access pricing](https://www.cloudflare.com/sase/products/access/) when designing that version. None is a v1 dependency or cost. +- Additional flags for push selection and the lifetime of snapshots. +- Portable client encoding and compatibility. +- JWT time tolerances and limits for cached keys. +- Measured Free CPU and D1 costs. +- Representative rates for public traffic and publication. -Implementation questions include additional push selection flags, snapshot lifetime, portable client encoding and compatibility, JWT time tolerances and key-cache limits, measured Free CPU and D1 costs, and representative public traffic/publication rates. Verify compatibility with the merged version of PR #713 before release. +Check compatibility with the merged version of PR #713 before release. diff --git a/docs/rfcs/images/repository-binding-form.svg b/docs/rfcs/images/repository-binding-form.svg new file mode 100644 index 000000000..7b21a7fd2 --- /dev/null +++ b/docs/rfcs/images/repository-binding-form.svg @@ -0,0 +1,47 @@ + + Bind a public GitHub repository + The maintainer enters a GitHub repository, namespace, and main branch. Setup resolves immutable repository and owner IDs, checks public visibility, and produces the public endpoint and OIDC audience. Reads are public. Writes require an authorized main-branch push job from this repository. + + + + Bind a public GitHub repository + Setup form schematic · example values + + MAINTAINER INPUTS + AUTOMATIC VALUES · READ-ONLY + GitHub repository + + your-org/your-project + Public repositories on GitHub.com + Cache namespace + + docs-trusted-v1 + Main branch + + main + Default: main · saved as refs/heads/main + Repository and owner IDs + + Resolved from GitHub + repository_id + repository_owner_id + Required visibility + + Public + Read access + Anyone · no credentials + Write access + This repository · push jobs on main + + Public cache endpoint + https://cache.example.com/projects/docs-trusted-v1 + OIDC audience: this exact URL, without a trailing slash + + Save binding + No developer registration or cache secret + diff --git a/docs/rfcs/remote-cache-size-study/README.md b/docs/rfcs/remote-cache-size-study/README.md index 4206d7879..a68770491 100644 --- a/docs/rfcs/remote-cache-size-study/README.md +++ b/docs/rfcs/remote-cache-size-study/README.md @@ -2,11 +2,11 @@ Measured on 2026-09-07 for [the remote-cache RFC](../0001-remote-cache.md#13-free-and-paid-capacity-comparison). -The four sampled frontend outputs compress to **4.77–34.71 MB** with zstd level 3. A 5 MB result is a useful small-output case, but three of these four releases exceed it. Use a 50 MB planning case for mature SaaS frontends alongside the smaller scenarios. This sample does not establish the average cache size across users or tasks. +The four sampled frontend outputs compress to **4.77–34.71 MB** with zstd level 3. A 5 MB result is useful for a scenario with small outputs. Three of these four releases exceed it. Use an additional 50 MB planning case for mature SaaS frontends. This sample does not establish the average cache size across users or tasks. ## Projects and scope -We selected established products with public source and downloadable release artifacts. Each repository had more than 20,000 GitHub stars when checked; this selects recognizable projects, not a random sample of Vite users. The sample includes commercial products with different source licenses. +We selected established products with public source and downloadable release artifacts. Each repository had more than 20,000 GitHub stars when we checked it. This method selects recognizable projects. It does not produce a random sample of Vite users. The sample includes commercial products with different source licenses. | Project | Product | GitHub stars at observation | Measured release | Published frontend output | | ------------------------------------------------------ | ---------------------------- | --------------------------: | -------------------------------------- | -------------------------------------------------- | @@ -15,7 +15,9 @@ We selected established products with public source and downloadable release art | [Hoppscotch](https://github.com/hoppscotch/hoppscotch) | API development platform | 80,228 | `2026.8.0` | Docker build's `/site/selfhost-web/` COPY layer | | [n8n](https://github.com/n8n-io/n8n) | Workflow automation | 203,576 | `n8n-editor-ui@2.16.2` | npm package `dist/` | -These are measurements of **published build products**. We did not run local builds or measure the vendors' private cloud deployments. Docker samples use `linux/amd64` manifests and the layer that copies the frontend from the build stage, before runtime dependency installation or startup transformations. We exclude the Docker base image, server code, dependencies, and unrelated npm package files. +These measurements cover **published build products**. We did not run local builds or measure the vendors' private cloud deployments. Docker samples use `linux/amd64` manifests. We selected the layer that copies the frontend from the build stage. This layer precedes runtime dependency installation and startup transformations. + +We exclude the Docker base image, server code, dependencies, and unrelated npm package files. The pinned source confirms Vite usage: @@ -26,11 +28,13 @@ The pinned source confirms Vite usage: | Hoppscotch | [`generate` calls `build`, which invokes Vite](https://github.com/hoppscotch/hoppscotch/blob/ac145e7f758151b41fd46d3e5f513886ce9068ba/packages/hoppscotch-selfhost-web/package.json) | [Vite configuration](https://github.com/hoppscotch/hoppscotch/blob/ac145e7f758151b41fd46d3e5f513886ce9068ba/packages/hoppscotch-selfhost-web/vite.config.ts), [production Dockerfile](https://github.com/hoppscotch/hoppscotch/blob/ac145e7f758151b41fd46d3e5f513886ce9068ba/prod.Dockerfile) | | n8n | [`build` invokes `vite build`](https://github.com/n8n-io/n8n/blob/9bdde69954a4d7d1569d37b6fd3a3f55f55b297a/packages/frontend/editor-ui/package.json) | [Vite configuration](https://github.com/n8n-io/n8n/blob/9bdde69954a4d7d1569d37b6fd3a3f55f55b297a/packages/frontend/editor-ui/vite.config.mts) | -[sources.json](sources.json) records artifact URLs, SHA-256 digests, source commits, npm provenance URLs, and Docker manifest/layer digests. We checked npm downloads against their registry SHA-512 integrity values and Docker layers against their SHA-256 digests. We obtained npm source commits from the registry's provenance records; the Docker source references identify the corresponding release tags. We did not verify attestation signatures or independently rebuild the releases. +[sources.json](sources.json) records artifact URLs, SHA-256 digests, source commits, npm provenance URLs, and Docker manifest/layer digests. We checked npm downloads against the registry's SHA-512 integrity values. We checked Docker layers against their SHA-256 digests. + +We obtained npm source commits from the registry's provenance records. The Docker source references identify the corresponding release tags. We did not verify attestation signatures or independently rebuild the releases. ## Measured sizes -All sizes use decimal MB (`1 MB = 1,000,000 bytes`). “Output” sums regular-file bytes, including static assets and source maps present in the selected directory. “Archive” measures a single sorted GNU tar stream compressed with `zstd -3 -T1`, using zstd `1.5.7`. We normalize tar paths and metadata. Archive sizes estimate the compressed output payload; a complete cache entry also includes task metadata. +All sizes use decimal MB (`1 MB = 1,000,000 bytes`). “Output” sums regular-file bytes, including static assets and source maps in the selected directory. “Archive” measures one sorted GNU tar stream compressed with `zstd -3 -T1`, using zstd `1.5.7`. We normalize tar paths and metadata. Archive sizes estimate the compressed output payload. A complete cache entry also includes task metadata. | Project | Files | Output MB | Archive MB | Source-map MB before compression | Archive MB with `.map` files omitted | | ---------- | ----: | --------: | ---------: | -------------------------------: | -----------------------------------: | @@ -39,15 +43,21 @@ All sizes use decimal MB (`1 MB = 1,000,000 bytes`). “Output” sums regular-f | Hoppscotch | 471 | 127.53 | **32.18** | 89.82 | 12.18 | | n8n | 1,477 | 162.76 | **34.71** | 118.66 | 13.15 | -Source maps comprise about 70% of Hoppscotch's uncompressed output and 73% of n8n's. Their configurations enable source maps for these builds; n8n also enables its legacy-browser plugin for releases. Hoppscotch includes a TypeScript worker, while n8n includes worker and WebAssembly assets. A page's initial JavaScript download omits much of this build output and cannot substitute for the cache size. +Source maps account for about 70% of Hoppscotch's uncompressed output and 73% of n8n's. Their configurations enable source maps for these builds. n8n also enables its legacy-browser plugin for releases. Hoppscotch includes a TypeScript worker. n8n includes worker and WebAssembly assets. + +A page's initial JavaScript download omits much of this build output. Its size cannot substitute for the cache size. -The last column is a controlled sensitivity calculation on the same files, not another build. Removing maps reduces the compressed archives to 12.18 MB and 13.15 MB respectively, still above 5 MB. A cache must preserve the outputs required by its task; these measurements do not justify silently dropping source maps. For Directus and Docmost, the published frontend directories contain no `.map` files. +The last column measures the same files without source maps. It does not represent another build. Without maps, Hoppscotch's compressed archive measures 12.18 MB and n8n's measures 13.15 MB. Both remain above 5 MB. -These archives contain frontend output files only. A complete cached task also needs terminal events and client validation metadata. Under [PR #713](https://github.com/voidzero-dev/vite-task/pull/713), the client encodes these in an opaque value and optional blob; the Worker does not prescribe their internal format. A real cached task can produce additional files outside `dist/`; release packaging can omit such files. Measure those bytes during implementation before assigning a complete per-result size. Backend and shared-package builds are separate task results unless the operator caches them as one task. +A cache must preserve the outputs that its task requires. These measurements do not justify silent removal of source maps. The published frontend directories for Directus and Docmost contain no `.map` files. + +These archives contain frontend output files only. A complete cached task also needs terminal events and client validation metadata. Under [PR #713](https://github.com/voidzero-dev/vite-task/pull/713), the client encodes these in an opaque value and optional blob. The Worker does not define or interpret their internal format. + +A cached task can produce additional files outside `dist/`. Release packaging can omit these files. Measure their bytes during implementation before you assign a complete size to each result. Backend and shared-package builds are separate task results unless the operator caches them as one task. ## Effect on the free storage budget -Using the RFC's 8 GB application budget and seven-day retention, the measured output archives alone give these steady-state ceilings when each store creates a different exact key: +The table uses the RFC's 8 GB application budget and seven-day retention. Each store creates a different exact key at a steady rate. The ceilings include only the measured output archives: | Project-sized result | New results/day within 8 GB | Storage at 200 new results/day, seven days | | -------------------- | --------------------------: | -----------------------------------------: | @@ -56,13 +66,29 @@ Using the RFC's 8 GB application budget and seven-day retention, the measured ou | Hoppscotch | 35 | 45.06 GB | | n8n | 32 | 48.59 GB | -Calculate the ceiling as `floor(8,000,000,000 / (archive_bytes * 7))`. These are artifact-only upper bounds; client metadata, logs, pending uploads, retired generations, and delayed deletion lower them. A new exact key stores another complete archive even when many assets match the previous build. Repeated stores to the same key replace its value and blob, keeping the old generation only for a short download grace period. Input changes do not necessarily create a different exact key. Read hits do not create another copy. Count separate namespaces and client compatibility identities where the service stores separate results. +Calculate the ceiling as `floor(8,000,000,000 / (archive_bytes * 7))`. These upper bounds include only artifacts. Client metadata, logs, pending uploads, retired generations, and delayed deletion reduce them. + +A new exact key stores another complete archive, even when many assets match the previous build. Repeated stores to the same key replace its value and blob. The old generation remains only for a short download grace period. Input changes do not necessarily create a different exact key. Read hits do not create another copy. Count separate namespaces and client compatibility identities when the service stores separate results. -For a **50 MB complete-result planning case**, the same budget supports at most **22 new distinct keys/day** with seven-day retention, before operational headroom. At 200 new distinct keys/day, storage reaches 70 GB. Under the RFC's operation assumptions, raising the storage budget would cost about **$0.90/month in R2 storage** beyond the 10 GB included allowance. Workers could remain Free if the streaming implementation meets its CPU limit. This uses the RFC's steady-state 30-day billing model and [R2 Standard pricing](https://developers.cloudflare.com/r2/pricing/), rechecked on 2026-09-09. The default profile would reject additional stores instead of raising its budget. +For a **50 MB planning case for complete results**, the same budget supports at most **22 new distinct keys/day** with seven-day retention. This excludes spare capacity for operation. At 200 new distinct keys/day, storage reaches 70 GB. -Keep 5 MB for a small-output scenario and 50 MB for mature frontend builds. All four measured archives fit the RFC's 64 MiB blob limit. These values are planning inputs, not estimates of population averages. A few new full-frontend keys per day can fit the free budget; hundreds of different keys per day need smaller results, shorter retention, or additional storage. Frequent overwrites can retain much less data but still consume operations. The number of readers does not determine this storage case. In v1, only explicit main-branch CI publication adds remote results; public readers need no credentials or per-user subscription. +Under the RFC's operation assumptions, this higher budget costs about **$0.90/month in R2 storage** beyond the included 10 GB. Workers can remain Free if the streaming implementation meets its CPU limit. The estimate uses steady usage, a 30-day month, and [R2 Standard pricing](https://developers.cloudflare.com/r2/pricing/), which we rechecked on 2026-09-09. The default profile rejects additional stores instead of increasing its budget. -This sample covers one release per product and favors mature applications. It does not measure a store-weighted average, daily change rate, exact-key replacement rate, cache hit rate, or the fraction of ordinary users that stay free. The RFC's canary still needs those measurements across task types and successive changes. +Use 5 MB for a scenario with small outputs and 50 MB for mature frontend builds. All four measured archives fit the RFC's 64 MiB blob limit. These values are planning inputs. They do not estimate population averages. + +A few new keys for complete frontend outputs each day can fit the free budget. Hundreds of different keys each day require smaller results, shorter retention, or more storage. Frequent overwrites can retain much less data but still consume operations. + +The number of readers does not determine this storage case. In version 1, only explicit CI publication from the main branch adds remote results. Public readers need no credentials or subscription for each user. + +This sample covers one release per product and favors mature applications. It does not measure these values: + +- Average size weighted by store counts. +- Daily change rate. +- Exact-key replacement rate. +- Cache hit rate. +- The fraction of ordinary users that stay free. + +The RFC's limited production trial still needs these measurements across task types and successive changes. ## Reproduce @@ -74,6 +100,8 @@ python3 docs/rfcs/remote-cache-size-study/measure.py \ --output /tmp/vite-cache-size-study-results.json ``` -The script downloads about 84 MB of pinned npm archives and Docker layers, checks SHA-256 digests, and decompresses them into temporary tar files for reading. It does not install packages, execute project code, or start containers. It writes compressed comparison archives in the cache directory. Allow about 600 MB of disk space. Registry availability and anonymous Docker pull limits can affect reruns. +The script downloads about 84 MB of pinned npm archives and Docker layers. It checks SHA-256 digests and decompresses the downloads into temporary tar files for inspection. It does not install packages, execute project code, or start containers. It writes compressed comparison archives in the cache directory. + +Allow about 600 MB of disk space. Registry availability and anonymous Docker pull limits can affect repeated runs. -[results.json](results.json) contains exact byte counts, compressed archive digests, file-type totals, and the five largest files for each sample. Use those counts for calculations; the tables round values for readability. +[results.json](results.json) contains exact byte counts, compressed archive digests, file-type totals, and the five largest files for each sample. Use those counts for calculations. The tables round values for readability. From a9d82244dd63a396e22467960aecbccda9fa4b62 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 10 Sep 2026 21:46:34 +0800 Subject: [PATCH 5/6] docs: define per-run remote cache modes Co-authored-by: GPT-6 --- docs/rfcs/0001-remote-cache.md | 59 ++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/docs/rfcs/0001-remote-cache.md b/docs/rfcs/0001-remote-cache.md index fba252d96..5557f694a 100644 --- a/docs/rfcs/0001-remote-cache.md +++ b/docs/rfcs/0001-remote-cache.md @@ -2,7 +2,7 @@ Status: Draft design. -Updated: 2026-09-09. Repository baseline: `9a1d32cf`. API baseline: [PR #713](https://github.com/voidzero-dev/vite-task/pull/713), commit [`362f5bd9`](https://github.com/voidzero-dev/vite-task/blob/362f5bd91bb32806b512d3d9a5339ff435bf5f0a/docs/remote-cache-server-api.md). The API proposal remains a draft; check its final contract before implementation. +Updated: 2026-09-10. Repository baseline: `9a1d32cf`. API baseline: [PR #713](https://github.com/voidzero-dev/vite-task/pull/713), commit [`362f5bd9`](https://github.com/voidzero-dev/vite-task/blob/362f5bd91bb32806b512d3d9a5339ff435bf5f0a/docs/remote-cache-server-api.md). The API proposal remains a draft; check its final contract before implementation. ## 1. Motivation @@ -27,8 +27,9 @@ PR #713 defines the HTTP contract. This RFC defines the Cloudflare storage, auth | Store | Replace the entry and secondary association together after object storage succeeds | | Access control | Anonymous reads; GitHub OIDC writes restricted to the registered repository and main-branch push events | | Transfer | One multipart HTTP store request; internal R2 multipart upload for larger blobs | +| Client mode | `--remote-cache` or `VP_REMOTE_CACHE` selects `off`, `read`, or `read-write`; defaults to `read` with an endpoint and `off` without one | | Defaults | Seven-day retention, 8 GB total R2 budget, 64 MiB maximum blob | -| Failure | Bounded waits; read failures become misses; explicit push reports publication failures | +| Failure | Bounded waits; read failures become misses; upload failures warn without changing the task exit status | The first delivery includes a template for self-deployment and a native Rust client adapter. The client must work on macOS, Linux, and Windows. Reuse across different operating systems or architectures requires a separate agreement about client compatibility. @@ -64,7 +65,7 @@ The client follows this sequence: 3. Validate an exact response. Download its blob only if the result passes validation. 4. Restore the outputs. Save the result in local storage. -A fallback or `not_found` response leads to task execution. A successful eligible execution updates local storage only. `vp cache push` publishes selected results through `/store`. +A fallback or `not_found` response leads to task execution. A successful eligible execution updates local storage, then queues its result for `/store` if uploads are enabled for that run. Cache hits do not trigger uploads. ## 4. HTTP API mapping @@ -150,11 +151,11 @@ If metadata is absent, return `200` with `not_found`. Version 1 reads require no Keep these errors generic. Use plain text. PR #713 does not define authentication. Rate limiting can return `429` with `Retry-After`. Cloudflare can reject requests before the Worker runs. Clients must handle error bodies outside the protocol without authentication redirects. -## 5. Public reads, GitHub OIDC writes, and explicit publication +## 5. Public reads, GitHub OIDC writes, and cache uploads Version 1 serves public cache data for open-source repositories on GitHub.com. Anyone can call `/fetch` and `/blob/{blob_id}` without credentials. Only an authorized GitHub Actions job can call `/store`. Developers use the checked-in endpoint without login, secrets, or individual permission setup. Version 2 covers private projects with Cloudflare One authorization. -### Client configuration and `vp cache push` +### Client configuration and remote cache modes ```ts export default { @@ -166,23 +167,27 @@ export default { }; ``` -An endpoint enables public remote reads during `vp run`. Successful eligible tasks save their results locally. Publication is explicit: `vp cache push` sends selected local results through one `/store` request per entry. +Use `--remote-cache` to select a mode for one invocation, or set `VP_REMOTE_CACHE` to configure all `vp run` commands in an environment. Both accept the same values: -Use `VP_REMOTE_CACHE_URL` to override the endpoint on a host. Use `--no-remote-cache` to disable remote use for one invocation. Task-level `remoteCache: false` excludes remote reads and publication but retains local caching. `cache: false`, `--no-cache`, and tool-requested cache disabling also exclude results from publication. +| Command | Environment variable | Remote reads | Uploads | +| --- | --- | --- | --- | +| `vp run build --remote-cache=off` | `VP_REMOTE_CACHE=off` | Disabled | Disabled | +| `vp run build --remote-cache=read` | `VP_REMOTE_CACHE=read` | Enabled | Disabled | +| `vp run build --remote-cache=read-write` | `VP_REMOTE_CACHE=read-write` | Enabled | Enabled | -Without an endpoint, the client makes no remote reads. An explicit push without an endpoint reports a configuration error. +The command-line option takes precedence over `VP_REMOTE_CACHE`. If neither is set, use `read` when an endpoint is configured; otherwise, use `off`. The mode leaves local caching unchanged. -By default, push selects eligible results from the latest completed `vp run` invocation. The results must belong to the current workspace, CI job, and commit. A new run replaces the selection. A failed or cancelled run must not leave an older successful run selected. +Use `VP_REMOTE_CACHE_URL` to override the endpoint on a host. Without an endpoint, the client makes no remote requests. Selecting `read` or `read-write` through the command-line option or `VP_REMOTE_CACHE` without an endpoint reports a configuration error before execution. -Push does not select the whole local cache by default. It excludes entries imported from remote and caches restored from other jobs. +Task-level `remoteCache: false` excludes remote reads and uploads but retains local caching. `cache: false`, `--no-cache`, and tool-requested cache disabling also prevent uploads. The remote cache mode does not override these exclusions. -Record the selection locally. Preserve its exact metadata and archive snapshot until publication or bounded cleanup. The current [cache update](../../crates/vt/src/session/execute/cache_update.rs) archives outputs. [Entry replacement](../../crates/vt/src/session/cache/mod.rs) can remove the previous archive. A delayed push therefore needs a snapshot or lease to preserve the selected data. +Version 1 uploads require GitHub Actions OIDC authorization for a main-branch push job. Enable uploads in that job with `VP_REMOTE_CACHE=read-write` or `--remote-cache=read-write`. -Do not rebuild the archive from the later working tree. Do not silently upload a replacement entry. If a snapshot is missing or inconsistent, fail publication without changes to remote mappings. +### Upload lifecycle -`vp cache push` reports published, skipped, and failed entries. A remote publication failure returns a nonzero exit status and preserves local results. CI can set `continue-on-error` for this cache-only step. Each entry commits separately, so some entries can succeed while others fail. +In `read-write` mode, queue one `/store` request after each successful eligible task saves its result locally. Upload only results generated by the current invocation. Cache hits do not trigger uploads. -Repeated pushes follow the replacement rules in PR #713. They do not guarantee exactly-once delivery. If no entries qualify, push succeeds without an upload or OIDC token. +Upload in the background with bounded concurrency so dependent tasks can start without waiting for network transfers. A successful task's result remains eligible even if another task fails. Before exiting after task execution, wait for pending uploads within a bounded deadline. ### One-time repository binding @@ -198,9 +203,9 @@ After a repository transfer, review the binding. Update the owner ID. After an e The publishing job grants `permissions: id-token: write`. This permission lets the job request an OIDC token. The Worker decides whether the token grants write access. The native client uses GitHub's `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` to request a token with the namespace audience. See GitHub's [OIDC workflow configuration](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-cloud-providers). -Only `vp cache push` requests the token. Send the returned JWT to `/store` as `Authorization: Bearer `. The Worker verifies it directly, without a custom endpoint for token exchange. +`vp run` requests the token only when uploads are enabled and a newly generated result is ready to upload. Send the returned JWT to `/store` as `Authorization: Bearer `. The Worker verifies it directly, without a custom endpoint for token exchange. -Keep the JWT in process memory. Reuse it only while it remains valid for the same audience. Obtain a fresh token before expiry. Send the runner's request token only to GitHub's token endpoint. Never send that request token to the cache Worker. Outside Actions, push reports that GitHub OIDC is unavailable. +Keep the JWT in process memory. Reuse it only while it remains valid for the same audience. Obtain a fresh token before expiry. Send the runner's request token only to GitHub's token endpoint. Never send that request token to the cache Worker. If GitHub OIDC is unavailable, report one warning and stop upload attempts for the invocation without changing the task exit status. Do not forward either token across redirects. Do not write either token to config, command arguments, output, or the cache. Remove the OIDC request variables and remote-cache controls from these locations, including through wildcard environment selection: @@ -475,7 +480,7 @@ Application budgets keep ordinary storage growth within the configured allowance ## 11. Failure handling and operations -The client preserves local results if remote reads, validation, downloads, or explicit publication fail. `vp run` executes the task after a failed read. `vp cache push` reports failures through its own exit status. +The client preserves local results if remote reads, validation, downloads, or uploads fail. `vp run` executes the task after a failed read. Upload failures produce warnings and a run-summary count without changing the task exit status. Reject invalid mode values or a missing required endpoint before execution. Use short metadata deadlines and bounded transfer deadlines. Support cancellation. Limit concurrency. After repeated failures, use a circuit breaker to stop remote attempts for the rest of the invocation. @@ -519,7 +524,7 @@ Provide setup that can run repeatedly without duplicate resources. Support polic Use the Free profile in section 10 by default. Change operational budgets for Paid only after the operator selects them. The operator must explicitly select more retention or R2 storage, independently of the Workers subscription. -The following workflow excerpt shows the client flow. Keep the existing checkout, Vite+ setup, and dependency-installation steps. The repository can store the endpoint in `vite.config.*`. This example uses a non-secret repository variable as a protected CI override. The build saves local results. A separate step publishes them: +The following workflow excerpt shows the client flow. Keep the existing checkout, Vite+ setup, and dependency-installation steps. The repository can store the endpoint in `vite.config.*`. This example uses a non-secret repository variable as a protected CI override. The build saves local results and uploads newly generated eligible entries: ```yaml on: @@ -534,25 +539,29 @@ jobs: id-token: write env: VP_REMOTE_CACHE_URL: ${{ vars.VP_REMOTE_CACHE_URL }} + VP_REMOTE_CACHE: read-write steps: # Existing checkout, Vite+ setup, and dependency installation steps. - run: vp run build working-directory: docs env: DOCS_SITE_ORIGIN: ${{ vars.DOCS_SITE_ORIGIN }} - - run: vp cache push - working-directory: docs - if: ${{ success() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} - continue-on-error: true ``` -The workflow trigger and step condition avoid unnecessary upload attempts. The Worker independently checks signed repository, branch, and event claims. A PR workflow reads from the same public endpoint without `id-token: write` or a push step. +The Worker checks signed repository, branch, and event claims. PR workflows use the default `read` mode with the same command and endpoint, and omit `id-token: write`. + +A workflow that handles both main-branch pushes and PRs can select the mode once for all run commands: + +```yaml +env: + VP_REMOTE_CACHE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'read-write' || 'read' }} +``` Preserve the existing [`DOCS_SITE_ORIGIN` input tracking](https://github.com/voidzero-dev/vite-plus/blob/ed1710f7aff8941436907a4d755d6b38c798ed41/docs/vite.config.ts). Keep the workflow's configured site-origin value. Keep dependency installation and package-manager caching. During a limited production trial, or canary, keep the existing task-directory restore/save steps within their trust boundary. Do not republish restored entries by default. Remove those steps after compatible clients pass anonymous reuse tests from fresh checkouts and explicit publication tests. -To roll back publication, remove the push step or disable server writes. To disable remote reads, remove the endpoint or use `--no-remote-cache`. +To stop uploads while retaining remote reads, select `read` through `--remote-cache` or `VP_REMOTE_CACHE`, or disable server writes. To disable remote reads and uploads, select `off`. ## 13. Free and Paid capacity comparison @@ -796,7 +805,7 @@ For version 2, review [Workers Access integration](https://developers.cloudflare Resolve these questions during implementation: -- Additional flags for push selection and the lifetime of snapshots. +- Upload concurrency, shutdown deadlines, and protection of archives while uploads are pending. - Portable client encoding and compatibility. - JWT time tolerances and limits for cached keys. - Measured Free CPU and D1 costs. From c201f8eff9d9d2827426c88667be39e8b1d21999 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 10 Sep 2026 21:49:07 +0800 Subject: [PATCH 6/6] docs: use HTTP 404 for remote cache misses Co-authored-by: GPT-6 --- docs/rfcs/0001-remote-cache.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/rfcs/0001-remote-cache.md b/docs/rfcs/0001-remote-cache.md index 5557f694a..a45b0380f 100644 --- a/docs/rfcs/0001-remote-cache.md +++ b/docs/rfcs/0001-remote-cache.md @@ -65,7 +65,7 @@ The client follows this sequence: 3. Validate an exact response. Download its blob only if the result passes validation. 4. Restore the outputs. Save the result in local storage. -A fallback or `not_found` response leads to task execution. A successful eligible execution updates local storage, then queues its result for `/store` if uploads are enabled for that run. Cache hits do not trigger uploads. +A fallback response or HTTP `404` from `/fetch` leads to task execution. A successful eligible execution updates local storage, then queues its result for `/store` if uploads are enabled for that run. Cache hits do not trigger uploads. ## 4. HTTP API mapping @@ -80,15 +80,14 @@ Content-Type: application/cbor { key: bytes, secondary_key: bytes } ``` -Return HTTP `200`, `Content-Type: application/cbor`, with one of: +For a match, return HTTP `200`, `Content-Type: application/cbor`, with one of: ```text { kind: "exact", value: bytes, blob_id: string | null } { kind: "fallback", key: bytes, value: bytes, blob_id: string | null } -{ kind: "not_found" } ``` -Check `key` first. If no live entry exists, resolve `secondary_key` to a stored key. Check that entry. Include the stored key only in the fallback variant. If neither resolves, return `not_found`. Fetch does not change entries or associations. +Check `key` first. If no live entry exists, resolve `secondary_key` to a stored key. Check that entry. Include the stored key only in the fallback variant. If neither resolves, return HTTP `404`. Fetch does not change entries or associations. ### Download a blob @@ -138,12 +137,12 @@ API errors use `Content-Type: text/plain; charset=utf-8`. Clients use the status | Status | Meaning | | ------ | ------------------------------------------------------------------------ | | `400` | Malformed request or invalid field types | -| `404` | Blob unavailable | +| `404` | No matching entry on fetch, or blob unavailable | | `413` | Request exceeds configured size limits | | `500` | Operation could not complete | | `503` | Service temporarily unavailable, including exhausted application budgets | -If metadata is absent, return `200` with `not_found`. Version 1 reads require no credentials. For `/store`, this deployment adds these errors: +If metadata is absent, return `404`. Version 1 reads require no credentials. For `/store`, this deployment adds these errors: - `401`: The JSON Web Token (JWT) is missing, invalid, or expired. - `403`: The verified JWT fails the namespace's write policy.