-
Notifications
You must be signed in to change notification settings - Fork 2
Deduplicate concurrent manifest fetches #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| /** | ||
| * Single-flight wrapper for cache-aside fetches. | ||
| * | ||
| * Wraps a result cache with an in-flight Map so concurrent misses for the | ||
| * same key share one fetch. The shared fetch is reference-counted against | ||
| * its waiters: each caller's `signal` rejects only that caller's wait, | ||
| * but if every waiter has aborted the underlying fetch is aborted too and | ||
| * the pending entry is dropped, so the next caller starts fresh instead of | ||
| * being stuck behind a hung request. New waiters arriving while the fetch | ||
| * is in flight bump the refcount, so a single straggler keeps the work | ||
| * alive for everyone who follows. | ||
| */ | ||
|
|
||
| export interface CacheLike<K, V> { | ||
| get(key: K): V | undefined; | ||
| set(key: K, value: V): void; | ||
| } | ||
|
|
||
| export interface SingleFlight<K, V> { | ||
| /** | ||
| * Resolve `key` from the cache, falling through to `fetcher` on miss. | ||
| * Concurrent callers for the same key share one in-flight `fetcher` | ||
| * invocation; the result is written to `cache` on success. | ||
| * | ||
| * `signal` rejects only the caller's await. The underlying fetch keeps | ||
| * running unless every waiter has aborted, in which case the | ||
| * `AbortSignal` passed to `fetcher` fires and the pending entry is | ||
| * dropped so the next caller starts fresh. | ||
| * | ||
| * `fetcher` may throw synchronously; the throw is converted to a | ||
| * rejected promise so `load`'s `Promise<V>` contract holds. | ||
| */ | ||
| load( | ||
| key: K, | ||
| fetcher: (signal: AbortSignal) => Promise<V>, | ||
| signal?: AbortSignal, | ||
| ): Promise<V>; | ||
| } | ||
|
|
||
| interface Pending<V> { | ||
| promise: Promise<V>; | ||
| controller: AbortController; | ||
| refCount: number; | ||
| } | ||
|
|
||
| /** Build a SingleFlight backed by `cache`. */ | ||
| export function singleFlight<K, V>(cache: CacheLike<K, V>): SingleFlight<K, V> { | ||
| const pending = new Map<K, Pending<V>>(); | ||
|
|
||
| return { | ||
| load( | ||
| key: K, | ||
| fetcher: (signal: AbortSignal) => Promise<V>, | ||
| signal?: AbortSignal, | ||
| ): Promise<V> { | ||
| // Reject (don't throw) when the caller is already aborted — callers | ||
| // expect a Promise back, including in the pre-aborted case. | ||
| if (signal?.aborted) return Promise.reject(makeAbortError()); | ||
|
|
||
| const hit = cache.get(key); | ||
| if (hit !== undefined) return Promise.resolve(hit); | ||
|
|
||
| let entry = pending.get(key); | ||
| if (!entry) { | ||
| const controller = new AbortController(); | ||
| const promise = invokeFetcher(fetcher, controller.signal) | ||
| .then((value) => { | ||
| cache.set(key, value); | ||
| return value; | ||
| }) | ||
| .finally(() => { | ||
| if (pending.get(key)?.promise === promise) pending.delete(key); | ||
| }); | ||
| const fresh: Pending<V> = { promise, controller, refCount: 0 }; | ||
| pending.set(key, fresh); | ||
| entry = fresh; | ||
| } | ||
| const owned = entry; | ||
| owned.refCount++; | ||
|
|
||
| const release = () => { | ||
| owned.refCount--; | ||
| if (owned.refCount > 0) return; | ||
| // Last waiter just released. If the entry is still in `pending` | ||
| // the fetch hasn't settled yet — drop the slot synchronously so | ||
| // any new caller starts fresh, then abort the in-flight work. | ||
| // If `.finally` already cleared it, the fetch resolved and the | ||
| // controller.abort() is a no-op. | ||
| if (pending.get(key) === owned) { | ||
| pending.delete(key); | ||
| owned.controller.abort(); | ||
| } | ||
| }; | ||
|
|
||
| if (!signal) { | ||
| return owned.promise.then( | ||
| (value) => { | ||
| release(); | ||
| return value; | ||
| }, | ||
| (error) => { | ||
| release(); | ||
| throw error; | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| return new Promise<V>((resolve, reject) => { | ||
| let done = false; | ||
| // `finish` ensures release runs exactly once per caller and wins | ||
| // the resolve/reject race between abort and underlying settle. | ||
| const finish = (): boolean => { | ||
| if (done) return false; | ||
| done = true; | ||
| signal.removeEventListener("abort", handleAbort); | ||
| release(); | ||
| return true; | ||
| }; | ||
| const handleAbort = () => { | ||
| if (finish()) reject(makeAbortError()); | ||
| }; | ||
| signal.addEventListener("abort", handleAbort, { once: true }); | ||
| owned.promise.then( | ||
| (value) => { | ||
| if (finish()) resolve(value); | ||
| }, | ||
| (error) => { | ||
| if (finish()) reject(error); | ||
| }, | ||
| ); | ||
| }); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Invoke `fetcher`, converting a synchronous throw into a rejected | ||
| * Promise so callers always observe a Promise-shaped failure. | ||
| */ | ||
| function invokeFetcher<V>( | ||
| fetcher: (signal: AbortSignal) => Promise<V>, | ||
| signal: AbortSignal, | ||
| ): Promise<V> { | ||
| try { | ||
| return fetcher(signal); | ||
| } catch (error) { | ||
| return Promise.reject(error); | ||
| } | ||
| } | ||
|
|
||
| function makeAbortError(): Error { | ||
| return new DOMException("The operation was aborted.", "AbortError"); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.