Repository navigation
feat: queuing of calls to python process #718
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "apollo": patch | ||
| --- | ||
|
|
||
| Queue requests to the python process |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| // FIFO queue capping how many service calls bridge.ts runs at once. | ||
| // Calls past the cap wait here instead of running immediately. | ||
|
|
||
| import { captureMessage } from "./sentry"; | ||
|
|
||
| const DEFAULT_CONCURRENCY = 10; | ||
|
|
||
| export const maxConcurrency = (): number => | ||
| Number(process.env.APOLLO_CONCURRENCY) || DEFAULT_CONCURRENCY; | ||
|
|
||
| const DEFAULT_QUEUE_TIMEOUT_MS = 5 * 60 * 1000; | ||
|
|
||
| export const queueTimeoutMs = (): number => | ||
| Number(process.env.APOLLO_QUEUE_TIMEOUT_MS) || DEFAULT_QUEUE_TIMEOUT_MS; | ||
|
|
||
| let active = 0; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this is module scoped. Only one queue per process. Is that bad for testing? For re-use? Should there be a class or at least some instance based here? Open question, maybe this is fine. Just sticks out at me a little as a weak architecture |
||
| const pending: Array<() => void> = []; | ||
|
|
||
| export const activeCount = (): number => active; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are either of these actually neccessary? You could just export Looks like they're both just used by unit tests anyway
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. used by the test only. |
||
| export const queueDepth = (): number => pending.length; | ||
|
|
||
| // Distinct from cancel()'s rejection, so a caller can tell "the server gave | ||
| // up" apart from "the client went away". | ||
| export class QueueTimeoutError extends Error { | ||
| constructor() { | ||
| super("timed out waiting in the queue"); | ||
| this.name = "QueueTimeoutError"; | ||
| } | ||
| } | ||
|
|
||
| export type Enqueued<T> = { | ||
| promise: Promise<T>; | ||
| cancel: () => void; | ||
| }; | ||
|
|
||
| // cancel() drops a still-queued task without ever running it; calling it | ||
| // once the task has started is a no-op. | ||
| export const enqueue = <T>( | ||
| scriptName: string, | ||
| task: () => Promise<T> | ||
| ): Enqueued<T> => { | ||
| let removeFromQueue: ((reason: Error) => void) | null = null; | ||
| let timer: ReturnType<typeof setTimeout> | undefined; | ||
|
|
||
| const promise = new Promise<T>((resolve, reject) => { | ||
| const run = () => { | ||
| if (timer) clearTimeout(timer); | ||
| active++; | ||
| task().then( | ||
| (value) => { | ||
| active--; | ||
| pending.shift()?.(); | ||
| resolve(value); | ||
| }, | ||
| (error) => { | ||
| active--; | ||
| pending.shift()?.(); | ||
| reject(error); | ||
| } | ||
| ); | ||
| }; | ||
|
|
||
| if (active < maxConcurrency()) { | ||
| run(); | ||
| return; | ||
| } | ||
|
|
||
| console.log( | ||
| `queueing ${scriptName}: ${active}/${maxConcurrency()} running, ${ | ||
| pending.length + 1 | ||
| } waiting` | ||
| ); | ||
| captureMessage(`service call queued: ${scriptName}`, { | ||
| scriptName, | ||
| active, | ||
| queueDepth: pending.length + 1, | ||
| }); | ||
|
|
||
| pending.push(run); | ||
| timer = setTimeout( | ||
| () => removeFromQueue?.(new QueueTimeoutError()), | ||
| queueTimeoutMs() | ||
| ); | ||
| timer.unref?.(); | ||
|
|
||
| removeFromQueue = (reason: Error) => { | ||
| const i = pending.indexOf(run); | ||
| if (i === -1) return; // already started - too late to cancel | ||
| pending.splice(i, 1); | ||
| if (timer) clearTimeout(timer); | ||
| reject(reason); | ||
| }; | ||
| }); | ||
|
|
||
| return { | ||
| promise, | ||
| cancel: () => removeFromQueue?.(new Error("queue wait cancelled")), | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| // `bridge.fork.ts` reads APOLLO_FORK_SERVER into a top-level `const` at | ||
| // import time, so it can only be toggled by an env var set in the shell | ||
| // *before* bun starts - not from inside a test. That also means this file | ||
| // can't share a `bun test` process with bridge-queue.test.ts or | ||
| // bridge-cancel.test.ts: whichever file bun imports "../src/bridge" from | ||
| // first freezes USE_FORK_SERVER for every file in that run. Run this one on | ||
| // its own: | ||
| // | ||
| // OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES APOLLO_FORK_SERVER=true \ | ||
| // bun test platform/test/bridge-queue-fork.test.ts | ||
| // | ||
| // (the OBJC_DISABLE_... var is macOS-only; see .env.example) | ||
| // | ||
| // No pgrep-based process counting here, unlike bridge-queue.test.ts: a | ||
| // forked child never execs, so `ps`/`pgrep -f` only ever sees the master's | ||
| // original "entry.fork.py" command line, not the service name - there is no | ||
| // OS-level signal that distinguishes "a request is running" from "the master | ||
| // is idle". activeCount()/queueDepth() are the only reliable signal here. | ||
|
|
||
| import { afterEach, describe, expect, it } from "bun:test"; | ||
|
|
||
| import { run } from "../src/bridge"; | ||
| import { USE_FORK_SERVER } from "../src/bridge.fork"; | ||
| import { ApolloThrowable } from "../src/util/errors"; | ||
| import { activeCount, queueDepth } from "../src/util/python-queue"; | ||
|
|
||
| const PORT = 9873; | ||
|
|
||
| const started = () => { | ||
| let resolve!: () => void; | ||
| const promise = new Promise<void>((r) => { | ||
| resolve = r; | ||
| }); | ||
| return { | ||
| promise, | ||
| onEvent: (type: string) => { | ||
| if (type === "probe_started") resolve(); | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| afterEach(() => { | ||
| delete process.env.APOLLO_CONCURRENCY; | ||
| }); | ||
|
|
||
| // Skips cleanly (not a failure) when run as part of the default `bun test | ||
| // platform/test` sweep, where APOLLO_FORK_SERVER is unset - see the file | ||
| // header for why this suite needs its own process to run for real. | ||
| describe.skipIf(!USE_FORK_SERVER)("queueing python runs (fork path)", () => { | ||
| it("holds a second forked run queued until the first (over the cap) finishes", async () => { | ||
| process.env.APOLLO_CONCURRENCY = "1"; | ||
|
|
||
| const firstProbe = started(); | ||
| const first = run( | ||
| "_cancel_probe", | ||
| PORT, | ||
| { sleep_for: 1 }, | ||
| undefined, | ||
| firstProbe.onEvent | ||
| ); | ||
| await firstProbe.promise; // the fork really did start and is doing work | ||
| expect(activeCount()).toBe(1); | ||
|
|
||
| const second = run("_cancel_probe", PORT, { sleep_for: 0 }); | ||
| await Bun.sleep(200); | ||
| // Still held by `first` - `second` is in line, not a second fork. | ||
| expect(activeCount()).toBe(1); | ||
| expect(queueDepth()).toBe(1); | ||
|
|
||
| await first; | ||
| await second; // released straight to `second`, per releaseSlot()'s contract | ||
|
|
||
| expect(activeCount()).toBe(0); | ||
| expect(queueDepth()).toBe(0); | ||
| }, 30_000); | ||
|
|
||
| it("rejects a queued forked run on abort without ever forking it", async () => { | ||
| process.env.APOLLO_CONCURRENCY = "1"; | ||
|
|
||
| const holderProbe = started(); | ||
| const holder = run( | ||
| "_cancel_probe", | ||
| PORT, | ||
| { sleep_for: 2 }, | ||
| undefined, | ||
| holderProbe.onEvent | ||
| ); | ||
| await holderProbe.promise; | ||
| expect(activeCount()).toBe(1); | ||
|
|
||
| const abort = new AbortController(); | ||
| const queued = run( | ||
| "_cancel_probe", | ||
| PORT, | ||
| { sleep_for: 0 }, | ||
| undefined, | ||
| undefined, | ||
| abort.signal | ||
| ); | ||
| // run() awaits writing its tmp files before it ever reaches | ||
| // acquireSlot() - give that a tick before checking it actually queued. | ||
| await Bun.sleep(50); | ||
| expect(queueDepth()).toBe(1); | ||
|
|
||
| abort.abort(); | ||
| const error = (await queued.catch((e) => e)) as ApolloThrowable; | ||
| expect(error).toBeInstanceOf(ApolloThrowable); | ||
| expect(error.type).toBe("SUBPROCESS_CANCELLED"); | ||
|
|
||
| // The cancelled wait never became a fork - the slot is still only | ||
| // `holder`'s. | ||
| expect(queueDepth()).toBe(0); | ||
| expect(activeCount()).toBe(1); | ||
|
|
||
| await holder; | ||
| expect(activeCount()).toBe(0); | ||
| }, 30_000); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is nice - much cleaner control logic I'd say?