fix(oidc-provider): never block a caller on another's credential renewal - #133
Conversation
`CredentialCache::get_or_fetch` took a per-key `futures::lock::Mutex` before the freshness check, so concurrent callers for a key awaited whoever was minting — including callers whose cached credential was perfectly fresh, since the fast path took the same lock. On Cloudflare Workers, which `multistore-cf-workers` exists to serve, awaiting that lock is fatal rather than slow. A request parked on an in-memory waker has no pending I/O of its own, so the runtime cancels it — "your Worker's code had hung and would never generate a response" — instead of waiting for the lock holder. A single isolate renewing one key therefore killed every concurrent request touching that key. Sharing the in-flight future instead of the lock is not an escape either; that trips "Cannot perform I/O on behalf of a different request". Serving stale-but-valid credentials removes the need for the lock. Inside the refresh lead the credential has not expired, so one caller claims the renewal and the rest keep using what they already have — single-flight without anyone waiting, because latecomers never need the claimant's result. Native consumers gain from the same change: a renewal no longer stalls every concurrent caller for the length of a token round-trip. Also fixes a cache-key defect. Entries were keyed on the caller-supplied scope alone (the role ARN), while `AwsBackendAuth` mints a distinct per-connection `sub`. Since an AWS role's trust policy conditions on that `sub`, a subject the policy would reject could be served a credential minted for one it accepts — succeeding where STS would have refused. `get_credentials` now scopes the entry by subject as well. Behaviour change: a cold key (absent or expired) is deliberately not single-flighted — there is nothing valid to serve, so collapsing the burst would mean waiting. The duplication is bounded to one burst per process or isolate cold start; the recurring event, renewal, is collapsed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @alukach's task in 3m 53s —— View job ✅ No blocking issues — safe to merge. Reviewed the diff ( CorrectnessThe new One benign race worth being aware of (not a bug): if caller A holds a claim and is overtaken by caller B inside the
Note on verificationThis sandbox's No Simplify (ponytail) findings — the new 💰 Estimated review cost: $1.13 · 3m53s · 30 turns |
|
📖 Docs preview deployed to https://multistore-docs-pr-133.development-seed.workers.dev
|
|
🚀 Latest commit deployed to https://multistore-proxy-pr-133.development-seed.workers.dev
|
…aims Two defects in the non-blocking renewal added in 203e693, found in review. A non-claimant could be served a credential with milliseconds of life left: the serve branch only required `expiration > now`. The backend request it signs still has to be sent and answered, so it could land after expiry — S3 answers `ExpiredToken`, which reaches the caller as a misleading `AccessDenied` rather than a retryable server-side error. Reachable whenever renewals keep failing (STS throttling): each attempt releases the claim, the next request claims and fails too, and everyone else keeps serving an ever-older credential for the whole 60s lead. `MIN_SERVE_SECS` now floors it at 5s; below that a request mints its own. A claim was also never reaped. `renewing` was cleared only by `store` or `release`, so a claimant whose request died between claiming and either — a cancelled or evicted request — pinned the key as renewing until the credential expired, at which point every concurrent request exchanged at once: exactly the burst the claim exists to prevent. `renewing` becomes `renewing_since`, and a claim older than `CLAIM_TIMEOUT_SECS` (30s, comfortably above the 10s `STS_REQUEST_TIMEOUT`) can be re-taken. Also corrects `release`'s doc comment, which claimed retries stay serialized unconditionally. That holds only while the credential is still servable; below the floor there is nothing safe to serve, so concurrent requests each exchange — correct behaviour, but not what the comment said. Matches developmentseed/multistore#133, which upstreams this design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…overtake Review raised two properties of the non-blocking cache that were understated. The cold-key burst was described as "bounded (one burst per process start, or per isolate cold start)", which undersells the case that matters. A single cold start mints once per concurrent caller on one isolate and is small; a deploy or mass eviction cools every isolate at once, so those bursts coincide across the fleet and reach the token endpoint together. Say so, name the failure mode (STS throttling → `StsError` → a 502 for every caller in the burst), and point at the L2 tier as the mitigation — a larger in-memory cache cannot help, since that tier is exactly what a deploy discards. Inside `MIN_SERVE_SECS` a live claim is also deliberately overtaken, so several fetches can run at once right at the edge of expiry. That was implied by the code but not stated. It is the intended trade — nothing safe is left to serve, and the alternatives are making the caller wait or failing a request that could have succeeded — so document it and pin it with a test rather than leave it looking accidental. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… overtake Mirrors developmentseed/multistore#133 (bcf3024), from review of that PR. "Bounded to one burst per isolate cold start" undersold the case that matters. One cold isolate's burst is small; a deploy or mass eviction cools every isolate at once, so the bursts coincide fleet-wide and reach STS together — where throttling becomes a 502 per request. Name that, and point at an L2 tier (#175) as the mitigation rather than a bigger in-memory cache, which is exactly what a deploy discards. Also state, in the module docs and at the call site, that inside MIN_SERVE_SECS a live claim is deliberately overtaken so several exchanges can run at once at the edge of expiry. Implied by the code but not written down; the existing test now asserts it rather than leaving it looking accidental. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ache 0.7.2 (developmentseed/multistore#133) is published, so the temporary `[patch.crates-io]` pin comes out and the dependencies move to the released version. Every multistore crate now resolves from crates.io with a checksum again; no git sources remain in the lock. The pinned rev and 0.7.2 are the same code, and the PR preview was A/B'd against `data.staging.source.coop` on it: 22 hung cancellations there over 320 concurrent federated requests, zero on the preview. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…197) ## What this does Bumps multistore `0.7.1` → `0.7.2`, which fixes production requests being cancelled with `The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response`. That is the whole diff. The fix itself is [developmentseed/multistore#133][up]; this PR started as a local reimplementation of it and was reduced to a dependency bump once that merged and released. [up]: developmentseed/multistore#133 ## The bug `multistore-oidc-provider`'s `CredentialCache` single-flighted concurrent misses on a role ARN behind a `futures::lock::Mutex`. On Cloudflare Workers, awaiting that lock is fatal rather than slow: per [Cloudflare's docs][err], the runtime cancels a request once "all the code associated with the request has executed and no events are left in the event loop", and a future parked on an in-memory waker has no pending I/O of its own. It is killed immediately, not when the lock holder finishes. The fast path took the same lock, so requests whose credentials were already cached died too. Nearly all of `data.source.coop`'s open data sits behind one connection — one role ARN, therefore one lock — so a single isolate renewing credentials took out every concurrent read on it. A five-minute `wrangler tail --status error` on production caught 167 cancellations, **all 167** on that one connection (`tge-labs/aef` 163, `protomaps/openstreetmap` 3, `ftw/global-data` 1). The timing split as the mechanism predicts: 149 at 5–8ms wall / 1–2ms CPU (warm requests, killed instantly with nothing pending) and 19 at 30–66ms / 21–26ms CPU (cold isolates — the lock holders' siblings). [err]: https://developers.cloudflare.com/workers/observability/errors/ ## Why it looked random The failures need a credential fetch in flight, so they cluster at isolate cold start and at renewal, and vanish once an isolate is warm. That is the "product won't load, works on reload" shape. ## Verification Two live Cloudflare deployments, same staging API, same product, same federated connection (`aws-opendata-eu-central-1`) — so the dependency version is the only variable. Control is `data.staging.source.coop`, running `main` on multistore 0.7.1. Four rounds of 80 concurrent GETs of `/alukach/euro-test/README.md` each, cache-busted: | round | `main` (0.7.1) | this PR (0.7.2) | |---|---|---| | 1 (cold) | 59× 200, **21× 500** | 80× 200 | | 2 | 80× 200 | 80× 200 | | 3 | 79× 200, **1× 500** | 80× 200 | | 4 | 80× 200 | 80× 200 | `wrangler tail` over that window: **staging logged 22 `hung and would never generate a response` cancellations, all on this product, at the same 5–8ms / 1–2ms signature. The preview logged zero events with exceptions across all 320 requests.** Also verified locally against the stub-API harness (50 concurrent federated reads: 49/50 cancelled on `main`, 0/50 on this branch), and 73 native tests, `cargo clippy --target wasm32-unknown-unknown -D warnings`, and the integration suite all pass. ## What changed upstream For anyone reading this later, 0.7.2 replaces the async lock with a cache that never blocks a caller: - Fresh credentials are served from a plain map under a sync lock that is never held across an `.await`. - **Renewals are single-flighted without anyone waiting**: inside the refresh lead the credential is still valid, so one caller claims the exchange and the rest keep serving what they have. That works precisely because latecomers never need the claimant's result — the thing a lock cannot give you here. - Two guards: a 5s floor so a credential that could expire in transit is never served (an expired one comes back from S3 as `ExpiredToken` and reaches the client as a misleading `AccessDenied`), and a 30s claim timeout so a claimant killed mid-exchange cannot pin the key as renewing until expiry. - Cache entries are now keyed by role ARN **and** subject. Keying on the ARN alone was unsound when two connections share a role: the trust policy conditions on the assertion's `sub`, so a connection that policy would reject could be served another's cached credentials. A cold key is deliberately *not* single-flighted — there is nothing valid to serve, so collapsing that burst would mean waiting. Worth knowing that a deploy or mass eviction cools every isolate at once, so those bursts coincide fleet-wide; the mitigation is an L2 tier (#175), not a larger in-memory cache, since that tier is exactly what a deploy discards. ## Relationship to #175 #175 adds an L2 Cloudflare-Cache-API tier for the STS response. It does **not** fix this on its own: it wraps `FetchHttpExchange::post_form`, which ran *inside* the old lock's critical section, so concurrent arrivals were still cancelled. It shortens the held window a lot, but a cold colo — L2 miss, highest concurrency — is exactly when it doesn't help. The two compose: 0.7.2 is L1, #175 is L2, and #175 keeps independent value by making the cold-key duplication mostly network-free. Its central constraint ("the only seam we control is `post_form`") is worth revisiting now that upstream owns the cache. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What I'm changing
CredentialCache::get_or_fetchtakes a per-keyfutures::lock::Mutexbefore the freshness check, so concurrent callers for a key await whoever is minting — including callers whose cached credential is perfectly fresh, since the fast path takes the same lock.On Cloudflare Workers — which
multistore-cf-workersexists to serve — awaiting that lock is fatal rather than slow. Per Cloudflare's docs, the runtime cancels a request once "all the code associated with the request has executed and no events are left in the event loop", and a future parked on an in-memory waker has no pending I/O of its own. It is killed immediately, not when the lock holder finishes. Sharing the in-flight future instead of the lock is no escape either: that tripsCannot perform I/O on behalf of a different request.This is not theoretical. It took down federated reads on
data.source.coop, where nearly all open data sits behind one connection — one role ARN, therefore one lock. A five-minutewrangler tailcaught 167 cancelled requests, all of them on that single key. 149 died at 5–8ms wall / 1–2ms CPU: warm requests whose credentials were already cached, killed purely for touching the lock while a sibling renewed.The downstream mitigation is source-cooperative/data.source.coop#197, which replaces
AwsBackendAuthwith a local middleware. This PR is the upstream fix that lets that fork be deleted.The design is not a Workers accommodation. Serving stale-but-valid credentials is what removes the need for a lock at all — latecomers never need the claimant's result, so there is nothing to wait for. Native consumers get the same win: a renewal no longer stalls every concurrent caller for the length of a token round-trip.
Also fixes a cache-key defect found while reviewing the above: entries were keyed on the caller-supplied scope alone (the role ARN), while
AwsBackendAuthmints a distinct per-connectionsub. Since an AWS role's trust policy conditions on thatsub, a subject the policy would reject could be served a credential minted for one it accepts — succeeding where STS would have refused.How I did it
crates/oidc-provider/src/cache.rs— replaced the per-keyArc<AsyncMutex<Option<..>>>slot map withHashMap<String, Entry>behind a plainstd::sync::Mutexthat is only ever held for a get or an insert, never across an.await.Entrycarries the credential plusrenewing_since.claim()returnsAction::Serve | Action::Fetchunder that lock. Fresh →Serve. Inside the refresh lead with a live claim →Serve(the credential has not expired). Otherwise take the claim →Fetch.MIN_SERVE_SECS(5s) floors the serve branch. Without it a non-claimant could receive a credential with milliseconds left, and the backend request it signs would land after expiry —ExpiredTokenreaching the client as a misleadingAccessDeniedrather than a retryable server-side error.CLAIM_TIMEOUT_SECS(30s) reaps an abandoned claim. A claimant whose request is cancelled between claiming and storing would otherwise pin the key as renewing until the credential expired, at which point every caller stampedes at once — the burst the single-flight exists to prevent.fetchclears the claim so the next caller retries rather than coasting on an unrenewed credential.scoped_key(scope, subject), joining on U+001F (unrepresentable in both a role ARN and an OIDC subject, so no escaping is needed).crates/oidc-provider/src/lib.rs—get_credentialsnow passescache::scoped_key(cache_key, subject). Fixing it here rather than at theAwsBackendAuthcall site also covers third-party callers that pass a bare role ARN. Public signatures are unchanged.crates/oidc-provider/Cargo.toml— dropped thefuturesdependency; theAsyncMutexwas its only use.docs/architecture/caching.md— rewrote the single-flight bullet, added a Why the cache never blocks section documenting the Workers constraint for other integrators, and noted the cold-key trade-off and the subject scoping.Considered and rejected
Single-flighting a cold key too. There is nothing valid to serve concurrent callers, so collapsing the burst means making them wait — and the only way to wait on Workers without being cancelled is polling a real timer, which costs about as long as the exchange it avoids while adding a spin loop and a dependency on the claimant surviving. The duplication is bounded to one burst per process (or isolate) cold start, against a token endpoint provisioned for it. The recurring event, renewal, is collapsed.
Downstream: how to adapt
get_or_fetchandget_credentialskeep their signatures; upgrading is a version bump.hung and would never generate a responseon federated requests, this is your fix. Upgrade; no code change needed.AwsBackendAuthto work around it (as data.source.coop#197 did), you can delete the fork after upgrading and go back toMaybeOidcAuth::Enabled(Box::new(AwsBackendAuth::new(provider))).docs/architecture/caching.mddescribes.Test plan
cargo test -p multistore-oidc-provider— 39 pass, including four new cache tests and one new provider test.serves_a_usable_credential_without_waiting_for_a_renewalwritten first and confirmed failing onmain(left: Some("renewal"), right: Some("served")— the serving caller waited on the renewal), passing after. It asserts ordering in plain tokio, so the bug is pinned without needing workerd.single_flights_a_renewal— 10 concurrent callers during a renewal produce exactly one mint.does_not_serve_a_credential_about_to_expire,reclaims_an_abandoned_renewal,a_failed_renewal_releases_the_claim,cold_misses_are_not_single_flightedpin the rest.different_subjects_on_one_backend_make_separate_callscovers the key fix.cargo test -p multistore -p multistore-oidc-provider -p multistore-sts -p multistore-path-mapping— 272 pass.cargo checkclean for every crate exceptmultistore-cf-workers, which does not build on the native target onmaineither (Rc<RefCell<_>>is notSend);cargo check -p multistore-cf-workers --target wasm32-unknown-unknown --features azure,gcpis clean.cargo clippy -p multistore-oidc-provider --all-targets -- -D warningsand the wasm32 cf-workers clippy are clean. (-p multistore --all-targetstrips a pre-existingtoo_many_argumentsincrates/core/src/auth/tests.rs.)cargo fmt --checkclean.mainproduced 23hung and would never generate a responsecancellations over 320 concurrent requests, the branch 0. Worth re-running that harness against a release candidate of this PR before cutting it.🤖 Generated with Claude Code