Skip to content

[miniflare] Share local storage across processes via a single owner (experimental)#14449

Draft
penalosa wants to merge 4 commits into
mainfrom
penalosa/global-resources
Draft

[miniflare] Share local storage across processes via a single owner (experimental)#14449
penalosa wants to merge 4 commits into
mainfrom
penalosa/global-resources

Conversation

@penalosa

@penalosa penalosa commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds an experimental option to Miniflare that makes a single process own local storage for a given persist root, so multiple Miniflare instances (e.g. several wrangler dev / vite dev sessions) no longer each open the same SQLite/blob files — the root cause of cross-process SQLITE_BUSY errors under concurrent access.

When enabled:

  • The first instance to find no published owner elects itself (via a per-persist-root spawn lock) and launches a detached "owner" process — a headless Miniflare that opens the files and hosts the storage services.
  • Every instance then routes its storage operations to the owner over the remote-bindings boundary and skips standing up its own local storage. Only the owner performs storage I/O.
  • The owner publishes its address to the persist root, heartbeats it, and self-terminates once no instances remain (startup grace + idle debounce). Crashed owners are reclaimed on the next election.

Transport

Routing reuses the existing remote-bindings boundary rather than a bespoke channel:

  • fetch for KV, R2, D1 and Images.
  • capnweb JSRPC for Streams and Secrets Store (these have no fetch entrypoint).

Bindings are repointed by id (type:id) against the owner server, so the owner avoids binding-name collisions across instances and resolves dynamic ids via an MF-Storage-Owner-Namespace request header. This replaces an earlier workerd-debug-port prototype and drops the previous unsafeDevRegistryPath requirement.

The owner-side server logic is shared with Wrangler's remote-bindings proxy server — both are thin adapters over one parameterised remote-bindings-proxy-server module (JSRPC-vs-fetch dispatch and the MF-Binding / MF-URL / MF-Header-* wire protocol), each supplying its own binding-resolution strategy.

What is and isn't shared

  • Routed to the owner: KV, R2, D1, Images, Streams, Secrets Store.
  • Kept per-instance (via isolateLocalStorage): Cache, Durable Objects, Workflows — when the feature is on and no explicit *Persist path is set, these use a per-instance temp path.
  • An explicit *Persist path for a given type always takes precedence and excludes that type from routing.

This also lands a supporting refactor: storage and remote (mixed-mode) bindings now carry their per-resource config (namespace/bucket/database id, remote connection string) via ctx.props against a single shared object-entry / proxy service, instead of one workerd service per resource.

Validation

A high-concurrency oracle runs multiple instances against one persist root and asserts cross-instance sharing with exactly-correct results under contention:

  • KV / R2 / D1 round-trips through the owner, including a D1 SQL round-trip and concurrent-write stress (60 concurrent D1 writes across 3 instances, exact final count).
  • Streams (upload on A, listed on B), Secrets Store (created via A's admin API, read by a worker in B), and Images (binding usable in every instance).
  • The full oracle (70 tests) is green under both direct Vitest and Turbo, with MINIFLARE_TEST_SHARED_OWNER added to turbo.json globalPassThroughEnv so it gates CI. The same tests also pass in baseline (feature-off) mode, where sharing is provided by the persist root.

Status — draft

Known follow-ups before this is review-ready:

  • Liveness should be pid-authoritative; a busy-but-alive owner whose heartbeat goes stale could currently be treated as absent and double-spawned. Intend to convert the spawn lock into a lifetime-held exclusive lease.
  • Register client presence before resolving routing (close a narrow teardown race).
  • Exclude the Workers Sites KV binding from rewriting.
  • Wrangler opt-in wiring; Windows validation.

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: the option is an internal/experimental unsafe* flag not intended for general use yet; user-facing docs will follow once the design stabilises and it is opted into by Wrangler.

… props

Move the per-binding configuration for storage and remote (mixed-mode)
bindings out of per-resource workerd services and into runtime `ctx.props`,
so a single shared service can serve any number of bindings.

- Local KV namespaces now share one entry service; the namespace id is
  passed via props and resolved in object-entry.worker.ts (idFromName).
- remoteProxyClientWorker() is now script-only; the connection string,
  binding name and trace id travel via props (buildRemoteProxyProps),
  read in remote-proxy-client.worker.ts and the dispatch-namespace proxy.
- All remote-binding plugins emit one shared remote-proxy service instead
  of one per resource.
- explorer.ts reads the KV namespace id from binding props rather than
  parsing it out of the service name.
@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ae287f4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
miniflare Minor
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch
wrangler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

@penalosa Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

✅ All changesets look good

@pkg-pr-new

pkg-pr-new Bot commented Jun 26, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@14449

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@14449

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@14449

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@14449

miniflare

npm i https://pkg.pr.new/miniflare@14449

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@14449

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@14449

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@14449

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@14449

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@14449

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@14449

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@14449

wrangler

npm i https://pkg.pr.new/wrangler@14449

commit: ae287f4

…boundary

Adds an experimental option that makes a single detached process own local
storage for a given persist root, so multiple Miniflare instances (e.g. several
wrangler dev / vite dev sessions) no longer each open the same SQLite/blob
files — the root cause of cross-process SQLITE_BUSY errors.

The first instance to find no published owner elects itself (via a per-persist
-root spawn lock) and launches a detached owner process. Every instance then
routes its storage operations to the owner over the remote-bindings boundary —
fetch for KV/R2/D1/Images and capnweb JSRPC for Streams/Secrets Store — and
skips standing up its own local storage. Bindings are repointed by id (type:id)
so the owner avoids binding-name collisions and resolves dynamic ids via the
MF-Storage-Owner-Namespace header. The owner publishes its address, heartbeats
it, and self-terminates once no instances remain.

Cache, Durable Objects and Workflows stay per-instance via isolateLocalStorage.
@penalosa penalosa force-pushed the penalosa/global-resources branch from c8f6d2d to cb05917 Compare June 30, 2026 13:45
Unit tests for owner election, presence, routing and teardown, plus a high
-concurrency oracle that runs multiple instances against one persist root and
asserts KV/R2/D1/Streams/Secrets/Images sharing with exactly-correct results
under contention. The oracle runs under MINIFLARE_TEST_SHARED_OWNER (added to
turbo.json globalPassThroughEnv) so it gates both feature-on and baseline modes.
@penalosa penalosa force-pushed the penalosa/global-resources branch from cb05917 to 0e7a7ed Compare June 30, 2026 13:52
Extract the shared server logic for the remote-bindings boundary (JSRPC vs
fetch dispatch, MF-Binding / MF-URL / MF-Header-* handling) into a single
parameterised module, `remote-bindings-proxy-server.ts`, and have both consumers
adapt to it:

- Miniflare's storage-owner server passes a "<type>:<id>" resolver that forwards
  the id via the MF-Storage-Owner-Namespace header.
- Wrangler's ProxyServerWorker passes the existing env[binding] resolver with its
  SendEmail / Dispatch Namespace special-cases.

The two previously near-identical implementations now share one source of truth;
behaviour is unchanged (wrangler remote-bindings and miniflare storage-owner
suites both pass).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

2 participants