Repository navigation
Conversation
e7cb05e to
7443f35
Compare
|
Joe to do: write a script to send a bunch of concurrent requests and see how the queue behaves |
josephjclark
left a comment
There was a problem hiding this comment.
Just some initial thoughts, shooting from the hip
| # Max python child processes bridge.ts runs at once, per Apollo process. | ||
| # Requests past this cap queue in FIFO order rather than spawning immediately. | ||
| # Defaults to 10. See platform/src/util/python-queue.ts. | ||
| # APOLLO_MAX_CONCURRENT_PYTHON=10 |
There was a problem hiding this comment.
This isn't just python though, it's service calls in general. So APOLLO_MAX_CONCURRENT_SERVICE_CALLS is technically what this is, although that's super long.
Maybe APOLLO_CONCURRENCY? maybe claude has better suggestions. Just take python out of it please.
| // FIFO semaphore capping how many python processes bridge.ts runs at once. | ||
| // Requests past the cap wait here instead of spawning. | ||
|
|
||
| const DEFAULT_MAX_CONCURRENT = 10; |
There was a problem hiding this comment.
IS 10 a random number?
Do we have any idea of the memory overhead per fork?
|
|
||
| const DEFAULT_MAX_CONCURRENT = 10; | ||
|
|
||
| export const maxConcurrentPython = (): number => { |
There was a problem hiding this comment.
I can't help but look at this and think "why is this so complicated?"
Can I suggest:
const DEFAULT_MAX_CONCURRENT = '10';
const maxConcurrency = Number(process.env.APOLLO_MAX_CONCURRENT_PYTHON ?? DEFAULT_MAX_CONCURRENT)
| : DEFAULT_MAX_CONCURRENT; | ||
| }; | ||
|
|
||
| let active = 0; |
There was a problem hiding this comment.
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
| let active = 0; | ||
| const queue: (() => void)[] = []; | ||
|
|
||
| export const activeCount = (): number => active; |
There was a problem hiding this comment.
Are either of these actually neccessary? You could just export active and queue?
Looks like they're both just used by unit tests anyway
|
|
||
| /** Resolves once a slot is free, FIFO. Rejects without consuming a slot if | ||
| * `signal` is already aborted or aborts while still queued. */ | ||
| export const acquireSlot = ( |
There was a problem hiding this comment.
I personally don't like the language of this.
This is a queue implementation. Why is there no add or enqueue or pop API?
A slot is just a promise right? I'm sort of just expecting clients to push tasks on to the queue and get a callback or event when done.
This slot business feels... wierd to me.
If its the correct language for this sort of thing I stand corrected, but it's not what I expect
| }; | ||
| queue.push(grant); | ||
|
|
||
| signal?.addEventListener( |
There was a problem hiding this comment.
Should signal be mandatory?
| * `signal` is already aborted or aborts while still queued. */ | ||
| export const acquireSlot = ( | ||
| scriptName: string, | ||
| signal?: AbortSignal |
There was a problem hiding this comment.
The signal stuff is new to be and I see it being passed a looong way down the stack.
If acquireSlot returned a { cancel } object, could we do the signal stuff higher up the stack? Would that help anything? Maybe not
|
|
||
| /** Releases a slot acquireSlot() granted, straight to the next waiter. Call | ||
| * exactly once per resolved acquireSlot(). */ | ||
| export const releaseSlot = (): void => { |
There was a problem hiding this comment.
Should this be guarded to be a bit more idempotent? Or are we absolutely sure it's super tight?
Or what if every slot has its own release functuon, like with the cancel function?
| // Nothing was spawned, so no "close" is coming - without settling here the | ||
| // request stays open until something upstream gives up | ||
| proc.on("error", (err) => { | ||
| releaseSlotOnce(); |
There was a problem hiding this comment.
the foked bridge doesn't do this - is that because it throws on error and we release in the finally block?
josephjclark
left a comment
There was a problem hiding this comment.
Oh and we mentioned raising a sentry alert whenever an item is added to the queue? Or perhaps if more than N items are added
|
|
||
| /** Resolves once a slot is free, FIFO. Rejects without consuming a slot if | ||
| * `signal` is already aborted or aborts while still queued. */ | ||
| export const acquireSlot = ( |
There was a problem hiding this comment.
Should there be some sort of timeout on this? Like if waiting for more than 5 minutes should it return a 503 or something?
Short Description
Caps how many python child processes
bridge.tsruns at once(
APOLLO_MAX_CONCURRENT_PYTHON, default 10). Requests past the cap queue inFIFO order in Node instead of spawning immediately, so a burst of traffic
can't pile an unbounded number of python interpreters on top of each other.
Fixes #
Implementation Details
Today every request to a python service spawns (or forks, if
APOLLO_FORK_SERVERis on) a process immediately, with no limit on how manyrun at once. A burst of concurrent requests has no backpressure before it
hits the host's CPU/memory limits.
platform/src/util/python-queue.ts(new) — a small hand-rolled FIFOsemaphore:
acquireSlot(scriptName, signal)grants immediately while underAPOLLO_MAX_CONCURRENT_PYTHON, otherwise queues the caller. Ifsignalaborts while still queued (the client went away before its turn), the wait
rejects and the slot is never consumed - the request never spawns a process
at all.
releaseSlot()hands a freed slot straight to the next waiter.platform/src/bridge.ts-run()now callsacquireSlot()rightbefore doing any real work, and releases exactly once (guarded against
double-release) from whichever path ends the run first. This applies
uniformly to both execution paths: forking from the warm master
(
USE_FORK_SERVER) and spawning a fresh interpreter - either way it's onepython process doing a service's worth of real work, so both are capped the
same way.
.env.example- documentsAPOLLO_MAX_CONCURRENT_PYTHON(default 10).Testing:
platform/test/util/python-queue.test.ts- pure unit tests of the queuelogic: immediate grant under the cap, FIFO ordering past it, a queued wait
rejecting on abort without consuming a slot, and an already-aborted signal
rejecting immediately when the cap is full.
platform/test/bridge-queue.test.ts- real-subprocess integration tests viathe existing
_cancel_probefixture: tworun()calls serialize under acap of 1, and a call cancelled while queued never spawns a process.
bun dev: underAPOLLO_MAX_CONCURRENT_PYTHON=1/2,concurrent requests to
/services/echocorrectly queue(
queueing echo: 1/1 python processes running, 1 waiting) and completeserially; repeated with
APOLLO_FORK_SERVER=trueto confirm the samequeueing holds for the fork path.
bun test platform/test: 122 pass, 7 fail (pre-existing, unrelated -live-Postgres tests with no DB available locally).
AI Usage
Please disclose whether you've used AI in this work (it's cool, we just want to
know!):
You can read more details in our
Responsible AI Policy