Skip to content

fix(deps): bump multistore to 0.7.2 to stop federated reads hanging - #197

Merged
alukach merged 8 commits into
mainfrom
worktree-fix-cred-cache-hang
Jul 28, 2026
Merged

fix(deps): bump multistore to 0.7.2 to stop federated reads hanging#197
alukach merged 8 commits into
mainfrom
worktree-fix-cred-cache-hang

Conversation

@alukach

@alukach alukach commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What this does

Bumps multistore 0.7.10.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; 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'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, 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).

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.

…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

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 1m 21s —— View job


Review

  • Gather context and read diff
  • Review Cargo.toml / Cargo.lock changes
  • Post findings

✅ No blocking issues — safe to merge.

The diff vs origin/main is exactly the dependency bump described in the PR body's "Re-verified against the upstream code" section — nothing else changed:

  • Cargo.toml: multistore, multistore-oidc-provider, multistore-path-mapping, multistore-sts, multistore-cf-workers bumped 0.7.10.7.2 (plus a trailing blank line removed at EOF, cosmetic).
  • Cargo.lock: matching version/checksum bumps for all five multistore* crates, consistent with each other (verified no stale 0.7.1 multistore entries remain — grep for 0\.7\.1 in Cargo.lock only matches unrelated packages). multistore-oidc-provider's futures dependency is dropped, consistent with the PR's premise that the blocking futures::lock::Mutex cache was removed upstream in multistore#133.

This means the local federation.rs fork (the actual fix logic) is not part of this diff — it was superseded once the fix landed upstream and released as multistore 0.7.2, per the PR's "Follow-up" section. The PR description's live A/B tests (staging vs. preview, 320 requests each) validate that the released 0.7.2 behaves identically to the local fork it replaces (0 hangs vs. 22-23 on main/0.7.1).

CI is green (CI and both Ensure conventional commits checks passed; Preview still in progress at time of review).

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

🚀 Latest commit deployed to https://source-data-proxy-pr-197.source-coop.workers.dev

  • Date: 2026-07-28T06:31:54Z
  • Commit: 7333615

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>
alukach and others added 2 commits July 27, 2026 23:16
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>
@alukach alukach changed the title fix(federation): don't await a cross-request lock for backend credentials fix(deps): bump multistore to 0.7.2 to stop federated reads hanging Jul 28, 2026
@alukach
alukach merged commit 02ddcff into main Jul 28, 2026
14 checks passed
@alukach
alukach deleted the worktree-fix-cred-cache-hang branch July 28, 2026 06:39
alukach pushed a commit that referenced this pull request Jul 28, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant