Skip to content

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

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

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

Conversation

@penalosa

Copy link
Copy Markdown
Contributor

What this does

Adds an experimental unsafeSharedStorageOwner 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 (and unsafeDevRegistryPath is set):

  • 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 hosts the storage Durable Object services and opens the files.
  • Every instance then routes its KV / R2 / D1 operations to the owner over the workerd debug port (getEntrypoint + props), and skips standing up its own local storage. Only the owner performs storage I/O.
  • The owner publishes its debug-port 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.

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.

Design note

Routing happens at the shared object-entry worker layer via getEntrypoint + props (not at the Durable Object actor layer). This is deliberate: the storage DO asserts on cf.miniflare.name and uses it to namespace the blob store, which would not survive actor RPC. Forwarding to the owner's own object-entry worker lets the owner attach cf and resolve the DO locally.

Validation

  • KV, R2 and D1 round-trips through the owner (client writes are visible to the owner and back through the proxy), including a D1 SQL round-trip.
  • Owner lifecycle: auto-spawn, route, self-teardown when idle.
  • Concurrency oracle: 3 instances sharing one persist root issue 60 concurrent D1 writes — all succeed (no 500s) with an exactly-correct final count, where the same workload contends today.
  • Flag-off path is unchanged (core index.spec + an explicit "feature off" test).

Status — draft

Known follow-ups before this is review-ready (tracked in the PR discussion):

  • 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).
  • Cache routing (cache keys its DO by a per-request header, not the props model).
  • Wrangler opt-in wiring; Windows validation. Commit history will be tidied (intermediate commits) before review.

  • 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.

penalosa added 9 commits June 26, 2026 17:48
… 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.
…torage owner, stage 0)

Scaffolding for the central storage owner feature: a per-persist-root
filesystem registry so exactly one process can own the storage backing
files and others can discover it.

- New `StorageOwnerRegistry` primitives in src/shared/storage-owner.ts:
  owner-definition read/write/clear (atomic, with mtime heartbeat + pid
  liveness staleness), an election spawn-lock with dead-pid/stale reclaim,
  and a client-presence registry for lifecycle teardown decisions.
- New experimental `unsafeSharedStorageOwner` core option (no-op for now).

No behavioural change yet; routing and owner spawning land in later stages.
…ner, stage 1)

When `unsafeSharedStorageOwner` is enabled and a persist root is set,
each Miniflare instance now records its role once the runtime (and thus
the workerd debug port) is available:

- owner role (`unsafeStorageOwnerRole: "owner"`, set on the detached
  owner process): writes the owner definition — pid + debug-port address —
  to the persist root and heartbeats it, so clients can discover and route
  to it. Cleared on dispose.
- client role (default): registers a heartbeated presence file so the
  owner can later tell when no clients remain.

Also carries the pre-existing per-persist-root startup lock that
serialises `runtime.updateConfig` across instances sharing a persist root.

No routing yet (clients still use local storage); that lands next.
Validated by unit + integration tests in test/storage-owner.spec.ts.
…e owner, stage 2)

A Miniflare instance running as a shared-storage client now routes its
local KV namespaces to the owner process instead of opening the SQLite/
blob files itself:

- New client-side `StorageOwnerProxy` worker (src/workers/core/
  storage-owner-proxy.worker.ts) forwards each request to the owner's
  shared `kv:ns:entry` service over the workerd debug port, passing the
  namespace id through as `ctx.props`. The owner's object-entry worker
  resolves the Durable Object locally, so the owner performs all I/O and
  attaches `cf.miniflare.name` itself (avoiding the actor-RPC cf problem).
- When an owner is published, `Miniflare` bakes its debug-port address into
  the proxy service, rewrites local KV bindings to the proxy, and skips
  standing up local KV storage (disk/DO/migrations) via a new
  `storageOwnerRoutePlugins` plugin-context flag. Remote (mixed-mode) KV
  bindings are left untouched.

Proven by a two-instance integration test: a client KV `put` is visible to
the owner and readable back through the proxy. R2/D1/Cache and the detached
owner spawn + lifecycle guards land next.
…entral storage owner, stage 1b)

The feature is now self-driving: a shared-storage client with no owner
published elects a single spawner (via the owner spawn-lock) and launches
a detached owner process before assembling, then routes to it.

- `runStorageOwnerProcess()` is the entry for the detached owner: it runs
  the same built miniflare module (path passed via env, so no second build
  entry), starts a headless owner-role Miniflare hosting the union of local
  KV ids, and self-terminates once no clients remain for a debounce window
  past a startup grace (minimal lifecycle guard).
- `#ensureStorageOwner()` performs election + spawn + wait-for-publish, and
  degrades to local storage if the dev registry (needed for the debug port)
  isn't configured or the owner doesn't come up.

Proven by an integration test: a lone client auto-spawns a separate owner
process, KV round-trips through it, and the owner exits after the client
disposes (with a pid safety-net kill to avoid leaking in CI).
… storage owner, stage 3)

Extends shared-storage client routing from KV to R2 and D1, reusing the
same object-entry + props mechanism:

- `rewriteStorageOwnerBinding` now also repoints R2 (`r2Bucket`), D1
  pre-3.3 (`service`) and D1 post-3.3 (`wrapped` inner fetcher service)
  bindings at the storage-owner proxy.
- R2 and D1 plugins skip standing up local storage when routed; the R2
  public-bucket service is skipped too (it binds the local entry service).
- The spawned owner now hosts the union of local KV / R2 / D1 ids.

Cache is intentionally not routed yet: it keys its DO by a per-request
header rather than the object-entry props model, and cache evictions are
recoverable, so it's lower priority.

Proven by an integration test: a client's R2 object and a D1 SQL row are
written through the owner and read back by the owner instance.
Demonstrates the feature resolves the original cross-process SQLITE_BUSY:
three client instances sharing one persist root issue 60 concurrent D1
inserts; with a single shared owner opening the files, every request
succeeds (no 500s) and the final row count is exactly correct.
…traction

Adds `buildObjectEntryProps` (the shared object-entry props builder used by
the KV/R2/D1 plugins) and teaches the local explorer to read R2/D1 resource
ids from binding props (falling back to service-name parsing for remote
bindings), mirroring the existing KV handling. These complete the
shared-object-entry props model the storage plugins already rely on.
@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c8f6d2d

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

Copy link
Copy Markdown
Contributor

⚠️ Issues found

.changeset/good-hoops-joke.md

  • Title should start with a capital letter or use a conventional commit prefix. Change:
    • remove skipLastDeployedFromApiCheck
    • Remove skipLastDeployedFromApiCheck

.changeset/fix-pipelines-setup-stream-binding.md

  • Title should start with a capital letter or use a conventional commit prefix. Change:
    • use \stream` instead of deprecated `pipeline` key in pipelines setup config snippet`
    • Use \stream` instead of deprecated `pipeline` key in pipelines setup config snippet`

@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: c8f6d2d

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