fix(deps): bump multistore to 0.7.2 to stop federated reads hanging - #197
Conversation
…ials `multistore_oidc_provider`'s `AwsBackendAuth` caches federated credentials behind a `futures::lock::Mutex` that single-flights concurrent misses on a role ARN. On Cloudflare Workers that lock is fatal: a request parked on an in-memory waker has no pending I/O of its own, and the runtime cancels any request whose code has run with "no events left in the event loop" — "your Worker's code had hung and would never generate a response". Every request that arrived while a sibling held the lock died at ~5ms with a 500, including ones whose credentials were already cached, since the fast path takes the same lock. All of data.source.coop's open data sits behind one connection (`aws-opendata-us-west-2`, one role ARN, hence one lock), so a single cold isolate took out every concurrent read on it. In a 5-minute prod tail, 167 of 167 such errors were products on that connection. Replace the middleware with `federation::AwsFederation`: fresh credentials come from a plain map under a sync lock that is never held across an `.await`, and a miss runs its own `AssumeRoleWithWebIdentity`. Concurrent misses duplicate that exchange — a few extra STS calls per isolate per credential lifetime — rather than queueing behind one. Verified against the local integration harness (stub API + wrangler dev), 50 concurrent federated reads: before: 49x 500 "hung and would never generate a response", 1x 502 after: 50x 502 (the real STS answer; CI's throwaway key can't federate) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @alukach's task in 1m 21s —— View job Review
✅ No blocking issues — safe to merge. The diff vs
This means the local CI is green ( |
|
🚀 Latest commit deployed to https://source-data-proxy-pr-197.source-coop.workers.dev
|
Keying on the role ARN alone — inherited from multistore's `AwsBackendAuth`,
whose cache key is the ARN — is unsound when two connections point at the same
role. The role's trust policy conditions on the assertion's `sub`
(`scv1:conn:{id}`), so a connection whose subject that policy would reject could
be served another connection's cached credentials, succeeding where STS would
have refused. It also silently misattributes CloudTrail, which is the whole
point of `sts_session_name`.
Spotted in the review of #175, which keys its L2 cache on
(RoleArn, RoleSessionName) for the same reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from review of this PR. Renewals were a stampede, not a single duplicate. `lookup`'s predecessor reported a miss for the whole 60s refresh lead, so on a busy isolate *every* concurrent request in that window minted an RSA assertion and ran its own `AssumeRoleWithWebIdentity` — recurring once per credential lifetime per isolate, and liable to earn a `Throttling` → 502 burst from AWS. The discarded credential was still valid for up to 60 more seconds. Serve it. Inside the lead, one caller claims the renewal and the rest keep using the credential they already have. That is single-flight without a lock, and it works precisely because latecomers never need the claimant's result — the thing a mutex cannot give us here. An expired credential is still never served, and a failed exchange releases the slot so the next request retries rather than waiting out the window. Cold isolates still exchange concurrently: with nothing valid to serve there is no single-flight to do that would not mean blocking. Separately, `chrono` was declared `default-features = false, features = ["clock"]`, which drops `wasmbind`. Without it `Utc::now()` resolves to the `SystemTime::now()` branch — std's unimplemented stub on wasm32-unknown-unknown, which panics, i.e. every federated request would 500 at `lookup`. It only worked because multistore and worker pull chrono with defaults on; name it explicitly rather than depend on that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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 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>
multistore#133 merged the fix upstream, so the local `federation` middleware is now a duplicate of `AwsBackendAuth`. Delete it and go back to the upstream wiring: `MaybeOidcAuth::Enabled(AwsBackendAuth::new(provider))` over the isolate-shared `OIDC_PROVIDER`, whose cache no longer blocks. `chrono` goes with it — it was a direct dependency only for the local cache's expiry checks; upstream owns those now, so the `wasmbind` hazard is upstream's to hold (multistore depends on chrono with default features, which include it). `[patch.crates-io]` temporarily points every multistore crate at the exact commit release-please will cut 0.7.2 from (multistore#134), so the PR preview exercises the real upstream code on a live Workers deployment — the only place this failure mode is observable. Pinned to a rev rather than `branch = "main"`, so an unrelated merge cannot silently change what is being verified. The patch must not reach production: once 0.7.2 publishes, drop it and move the dependencies to `version = "0.7.2"`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he-hang # Conflicts: # Cargo.lock # Cargo.toml
…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>
🤖 I have created a release *beep* *boop* --- ## [2.3.2](v2.3.1...v2.3.2) (2026-07-28) ### Bug Fixes * **deps:** bump multistore to 0.7.2 to stop federated reads hanging ([#197](#197)) ([02ddcff](02ddcff)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: source-release-bot[bot] <265100246+source-release-bot[bot]@users.noreply.github.com>
What this does
Bumps multistore
0.7.1→0.7.2, which fixes production requests being cancelled withThe 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; this PR started as a local reimplementation of it and was reduced to a dependency bump once that merged and released.
The bug
multistore-oidc-provider'sCredentialCachesingle-flighted concurrent misses on a role ARN behind afutures::lock::Mutex. On Cloudflare Workers, 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.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 erroron production caught 167 cancellations, all 167 on that one connection (tge-labs/aef163,protomaps/openstreetmap3,ftw/global-data1). 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).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 isdata.staging.source.coop, runningmainon multistore 0.7.1. Four rounds of 80 concurrent GETs of/alukach/euro-test/README.mdeach, cache-busted:main(0.7.1)wrangler tailover that window: staging logged 22hung and would never generate a responsecancellations, 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:
.await.ExpiredTokenand reaches the client as a misleadingAccessDenied), and a 30s claim timeout so a claimant killed mid-exchange cannot pin the key as renewing until expiry.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.