Skip to content

[miniflare] Persist local rate limit counters so they survive Durable Object eviction - #14968

Merged
petebacondarwin merged 3 commits into
mainfrom
fix/miniflare-ratelimit-eviction
Aug 3, 2026
Merged

[miniflare] Persist local rate limit counters so they survive Durable Object eviction#14968
petebacondarwin merged 3 commits into
mainfrom
fix/miniflare-ratelimit-eviction

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #14962.

Miniflare's emulated Ratelimit binding kept its bucket/epoch state on the JS heap of the internal RateLimiterObject. That namespace is evictable, so once workerd evicted the idle object (after ~10s) the counters were gone and the next limit() call started from zero, even though the rate limit window had not rolled over. In practice the emulation only held for tight bursts: in wrangler dev you could hit your Worker, pause for ~15 seconds to look at something, hit it again, and your limit had silently reset.

Counters now live in the Durable Object's state.storage, backed by a disk directory service like the KV, R2 and D1 simulators.

The second commit fixes the related bug the issue also flagged — see "Mixed periods in one namespace" below. The third fixes a stray directory the first one would otherwise have created.

Why not preventEviction?

Server::deleteAllActors guards with if (ns->isEvictable()), so pinning the namespace makes it immune to deleteAllDurableObjects() — which is exactly what vitest-pool-workers' reset() calls. Confirmed empirically: preventEviction: true makes fixtures/vitest-pool-workers-examples/reset/test/reset.test.ts fail on "sees reset ratelimit state after reset".

Why not inMemory storage?

The issue left this as an open question. It turns out inMemory would not have helped either, despite what the compatibility-date.capnp docs claim ("data will persist for the lifetime of the process... individual objects will still shut down when idle as normal"). When a namespace has no actorStorage, ActorContainer::start() builds kj::heap<ActorCache>(newEmptyReadOnlyActorStorage(), ...) per actor, owned by the Worker::Actor that eviction destroys. It is exactly as lossy as the heap. Verified empirically: flipping this branch to inMemory fails every rate limit test, because that mode isn't SQLite-backed so state.storage.sql isn't even available.

Does reset() still work?

Yes, and no new mechanism was needed. ActorNamespace::deleteAll() calls resetStorage()SqliteDatabase::reset() before aborting the actor, which is precisely how the KV, R2 and D1 simulators are already reset. The existing reset fixture passes unchanged.

Because the counters go through getPersistPath(), they also now survive a wrangler dev restart within the same window — consistent with every other stateful plugin, and arguably more faithful to production. Under vitest-pool-workers, which does not set resourcePersistencePath, they land in Miniflare's temp directory and are discarded on dispose as before.

Mixed periods in one namespace

The issue also flagged that the epoch was namespace-wide while period arrives per request, so bindings sharing a namespace_id with different periods clear each other's counters. This is fixed in the second commit, by adding period to the bucket table's primary key and scoping the expiry sweep to it.

This went back and forth, so for the record — the deciding evidence is production's own counter identity. In doppler-lib/src/counts.rs:

pub struct Key {
    pub account_id: u64,
    pub namespace: u64,        // the binding's namespaceId
    pub id: u64,               // hash(key)
    pub bucket: u64,           // (time / period) % NUM_BUCKETS
    pub bucket_start_ts: u32,  // (time / period) * period
}

bucket and bucket_start_ts are both derived from the period (get_buckets() in doppler-lib/src/memcache.rs), so production partitions counters by period implicitly. Two bindings on one namespace_id with different periods each keep a working counter there. Note also that the limit itself is not part of the key — it is a threshold applied to a shared count — which is why differing simple.limit on a shared namespace remains coherent and is left alone here.

Miniflare did the opposite: a single row per key meant each call saw an epoch belonging to the other's window, reset the count to zero, and then deleted the other's row as expired, so neither binding limited anything at all. That is a strictly worse divergence than the alternative, and it pre-exists this PR — main has the same flaw via its single #epoch scalar plus #buckets.clear().

When periods match — the normal case, and the only one the public docs describe — (key, period) and key partition identically, so the documented "bindings sharing a namespace_id share counters for a given key" behaviour is unchanged.

No stray directory when rate limiting isn't used

Wrangler passes ratelimits: {} rather than omitting the key when a Worker declares no rate limit bindings (packages/wrangler/src/dev/miniflare/index.ts:1073), and {} is truthy. The plugin's !options.ratelimits guard therefore let every dev session reach the new fs.mkdir() and leave an unused ratelimit directory behind. The third commit bails on emptiness instead, which also avoids registering an unreferenced Durable Object namespace.

Known limitation (pre-existing, not introduced here)

ActorContainer::resetStorage() is guarded by KJ_IF_SOME(a, actor), so it only resets running actors. If a disk-backed simulator DO has already been evicted when reset() runs, its .sqlite file survives. This affects KV/R2/D1 identically today and only triggers if >10s elapse between touching a resource and calling reset(). Probably worth an upstream workerd issue.

Also out of scope

Miniflare uses a fixed window; production uses a sliding window (prev * (1 - elapsed/period) + cur) and skips the increment when already over the threshold. Worth a follow-up, but it is a behaviour change rather than a bug fix, so it is not in this PR.


  • 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: this restores the documented behaviour of the local Ratelimit emulation (counters holding for the configured period). There is no API, configuration or user-facing surface change.

Four tests were added to packages/miniflare/test/plugins/ratelimit/index.spec.ts:

"ratelimit counters survive a workerd restart" — exhausts the limit, calls setOptions() (which unconditionally kills and respawns workerd via Runtime#updateConfig, tearing down every Durable Object), and asserts the next call is still rejected. This is a strictly stronger teardown than the ~10s idle eviction, and runs in milliseconds rather than needing a real sleep. Verified it fails against the old heap implementation and passes with the fix.

"ratelimit counters are scoped per period" — two bindings on one namespace_id, one with period: 10 and one with period: 60, both limit: 1. Verified it fails against the key-only schema, and with exactly the predicted symptom: the third call returns 200 instead of 429, i.e. no limiting whatsoever.

"ratelimit persists on file-system" — exhausts the limit, disposes, and builds a fresh Miniflare sharing the same resourcePersistencePath; the window hasn't rolled over, so the limit is still exhausted. Covers the wrangler dev restart claim above.

"ratelimit creates no storage directory when unconfigured"ratelimits: {} must not leave a ratelimit directory in the persistence directory. Verified it fails against the presence-only guard.

The issue's verbatim repro (one call, 20s sleep inside a single 60s window, second call) was also run as a throwaway test against a real eviction and passes with the fix. It is not included in the suite because it costs ~55s of wall clock for a property the restart test already covers.

waitForFreshRateLimitWindow() (added in #14961 to de-flake bucket boundaries) now takes a list of periods so the mixed-period test can require headroom in both windows at once. Its threshold for the existing 60s tests is unchanged, and because 10 divides 60 a single sleep realigns both, so the new test waits ~3s at worst.

The existing "ratelimit counters are keyed by namespace_id" test and the reset fixture's "shares ratelimit state across bindings with the same namespace_id" cover the documented shared-counter semantics, and both pass unchanged.

Also verified: full miniflare suite (1006 passed), the @scoped/reset vitest-pool-workers fixture (11 passed), and pnpm check (201/201).

A picture of a cute animal (not mandatory, but encouraged)

A sleepy dormouse curled up, about to be evicted from memory

Note

This is a contribution from an AI agent: opencode, claude-opus-5.

@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6e7a029

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

This PR includes changesets to release 8 packages
Name Type
miniflare Patch
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/remote-bindings Patch
@cloudflare/runtime-types 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

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Aug 1, 2026
@workers-devprod
workers-devprod requested review from a team and edmundhung and removed request for a team August 1, 2026 16:47
@workers-devprod

workers-devprod commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

✅ All changesets look good

@ask-bonk

ask-bonk Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

enableSql: true is used by stream, workflows, and observability plugins that use state.storage.sql. KV uses the key-value storage API (not raw SQL) so it doesn't need it, but this plugin uses this.db (SQL), so enableSql: true is correct and necessary.

The implementation looks solid and closely mirrors established patterns. Let me examine one more potential concern: the getBucket uses key and period, but deleteExpired deletes by period and epoch. There's a subtle interaction — when a bucket rolls over, deleteExpired deletes ALL keys for that period with an older epoch, then putBucket writes the new one. This is correct.

Let me double-check the test logic for waitForFreshRateLimitWindow with the two overlapping waits in the period test, and confirm there's no timing flaw.

Looking at the period test:

  • await waitForFreshRateLimitWindow(60) — ensures ≥10s left in the 60s window
  • await waitForFreshRateLimitWindow(10, 2_000) — ensures ≥2s left in the 10s window

These run 4 quick calls. Both must land in the same 60s and 10s window. After the first wait we have ≥10s in the 60s window. The second wait could sleep up to ~2s, still leaving ≥8s in the 60s window. Fine. The logic is sound.

The implementation is correct, follows existing patterns (KV/R2/D1), the SQL usage is safe (parameterized statements), and concurrency within a single DO is serialized by workerd's input gates. I found no logic bugs, security issues, or backward-compatibility violations.

LGTM

github run

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

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

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@14968

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@14968

create-cloudflare

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

@cloudflare/deploy-helpers

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

@cloudflare/kv-asset-handler

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

miniflare

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

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@14968

@cloudflare/pages-shared

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

@cloudflare/unenv-preset

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

@cloudflare/vite-plugin

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

@cloudflare/vitest-pool-workers

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

@cloudflare/workers-auth

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

@cloudflare/workers-editor-shared

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

@cloudflare/workers-utils

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

wrangler

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

commit: 6e7a029

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

…tion

The emulated Ratelimit binding kept its bucket/epoch state on the JS heap of
the internal `RateLimiterObject`. That namespace is evictable, so once workerd
evicted the idle object (~10s) the counters were gone and the next `limit()`
call started from zero, even though the window had not rolled over.

`preventEviction` is not an option: `Server::deleteAllActors` skips
non-evictable namespaces, so pinning the object would break the `reset()`
provided by vitest-pool-workers. `inMemory` storage is no better -- workerd
backs it with a per-actor `ActorCache` over empty read-only storage, so it is
discarded on eviction too (and cannot support `state.storage.sql` at all).

Move the counters into `state.storage`, backed by a disk directory service
like the KV, R2 and D1 simulators. That survives eviction while remaining
resettable, because `ActorNamespace::deleteAll()` calls `SqliteDatabase::reset()`
before aborting the actor.

This is a pure relocation of the existing state: counters stay keyed by key
alone, so bindings sharing a `namespace_id` keep sharing a counter as
documented, and windows still roll over together across the namespace.

Closes #14962
@petebacondarwin
petebacondarwin force-pushed the fix/miniflare-ratelimit-eviction branch from 3efd52c to 7366487 Compare August 2, 2026 10:37
devin-ai-integration[bot]

This comment was marked as resolved.

@petebacondarwin petebacondarwin added package:miniflare Relating to Miniflare ci-flake Applied to PRs addressing CI flakiness labels Aug 2, 2026
Bindings that share a `namespace_id` but are configured with different
`simple.period` values overwrote each other's counter on every call: each
saw an epoch belonging to the other's window, reset the count to zero, and
then deleted the other's row as expired. Neither binding ever limited
anything.

Production keys a counter by `(account_id, namespace, hash(key), bucket,
bucket_start_ts)`, and both `bucket` and `bucket_start_ts` are derived from
the period, so differing periods never share a counter there. Add `period`
to the bucket table's primary key and scope the expiry sweep to it, which
reproduces that partitioning. When periods match — the normal case, and the
one the docs describe — the partition is unchanged, so bindings sharing a
namespace still share a counter for a given key.

Also point the two "keep in sync with" comments at the current location of
the structs they track: they are `JSG_STRUCT`s in edgeworker's
`internal-api/ratelimit.h`, not in the neighbouring capnp schema, and the
repository has since moved from Bitbucket to GitLab.
devin-ai-integration[bot]

This comment was marked as resolved.

…ured

Wrangler passes `ratelimits: {}` rather than omitting the key when a Worker
declares no rate limit bindings, and `{}` is truthy, so the presence check
let every `wrangler dev` session fall through to `fs.mkdir()` and leave an
unused `ratelimit` directory in the user's persistence directory. Bail on
emptiness instead, which also stops an unreferenced Durable Object namespace
being registered.

Add a test for that, plus one covering the persistence the disk service buys
us: a counter exhausted by one Miniflare instance is still exhausted for the
next one sharing a `resourcePersistencePath`, as when restarting
`wrangler dev` mid-window.
devin-ai-integration[bot]

This comment was marked as resolved.

@petebacondarwin petebacondarwin moved this from Untriaged to In Review in workers-sdk Aug 3, 2026

@workers-devprod workers-devprod left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codeowners reviews satisfied

@github-project-automation github-project-automation Bot moved this from In Review to Approved in workers-sdk Aug 3, 2026
@petebacondarwin
petebacondarwin merged commit a88d169 into main Aug 3, 2026
88 of 91 checks passed
@petebacondarwin
petebacondarwin deleted the fix/miniflare-ratelimit-eviction branch August 3, 2026 12:45
@github-project-automation github-project-automation Bot moved this from Approved to Done in workers-sdk Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-flake Applied to PRs addressing CI flakiness package:miniflare Relating to Miniflare

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[miniflare] Local rate limit counters silently reset after ~10s of inactivity

3 participants