UNPKG

@accounter/server

Version:
139 lines (128 loc) 5.82 kB
import { Plugin } from 'graphql-yoga'; import { AccounterContext } from '../shared/types/index.js'; export function dbCleanupPlugin(): Plugin<AccounterContext> { return { onContextBuilding({ context }) { // Initialize the cleanup list // Cast to AccounterContext to avoid potential readonly issues or type mismatches // during initial context building phase. const accounterContext = context as AccounterContext; accounterContext.dbClientsToDispose = []; // Dispose when the client goes away. // // `onExecuteDone` below only runs when execution *completes*. A request // the client cancels mid-flight (urql aborts the previous query on every // keystroke of a search box) rejects with an AbortError instead, so the // hook never fires and the request-scoped connection is never released: // it stays checked out of the pool, holding an open transaction, until // the process restarts. Postgres reports these as `idle in transaction` // with `wait_event = ClientRead`. Enough of them and the pool is gone and // every subsequent request hangs forever in `pool.connect()`. // // The abort is handled with `disposeWhenIdle` rather than `dispose`, // because the caller hanging up does not stop the operation this server is // already running — nothing can cancel the promise chain a resolver is on. // A document upload whose caller timed out mid-OCR still has a Cloudinary // upload and an INSERT ahead of it; pulling its connection there fails the // write with "TenantAwareDBClient is already disposed" and loses the work // for good. Deferring costs the pool a connection until the operation // finishes (or the watchdog reclaims it); yanking it costs the user a // document. const signal = accounterContext.request?.signal; if (signal) { if (signal.aborted) { void disposeClients(accounterContext, { whenIdle: true }); return; } signal.addEventListener( 'abort', () => void disposeClients(accounterContext, { whenIdle: true }), { once: true }, ); } }, onExecute({ args }) { const context = args.contextValue as AccounterContext; // Marks the window in which a quiet DB client belongs to a live request // rather than a leak. See `TenantAwareDBClient.operationInFlight`. context.executionInFlight = true; return { async onExecuteDone({ result }) { // If the result is an async iterable (stream/defer), wrap it. // This allows us to defer cleanup until the entire stream is consumed or the connection is closed. // Standard onExecuteDone fires before the stream is completely consumed. if (Symbol.asyncIterator in result) { const originalIterator = result[Symbol.asyncIterator](); result[Symbol.asyncIterator] = () => ({ next: async () => { const next = await originalIterator.next(); if (next.done) { await endExecution(context); } return next; }, return: async () => { // If the stream is cancelled/closed early await endExecution(context); return originalIterator.return ? originalIterator.return() : { done: true, value: undefined }; }, throw: async e => { await endExecution(context); return originalIterator.throw ? originalIterator.throw(e) : { done: true, value: undefined }; }, [Symbol.asyncIterator]() { return this; }, }); } else { // Regular execution (single response), clean up immediately. // CRITICAL: Must await to ensure connections are released before response is sent. // Without await, connections accumulate under load faster than they're freed. await endExecution(context); } }, }; }, }; } /** Execution is over: nothing is in flight any more, so disposal is unconditional. */ async function endExecution(context: AccounterContext) { if (context) { context.executionInFlight = false; } await disposeClients(context); } async function disposeClients(context: AccounterContext, options?: { whenIdle?: boolean }) { // Ensure context still exists and hasn't been corrupted if (!context?.dbClientsToDispose) { return; } const clients = context.dbClientsToDispose; context.dbClientsToDispose = []; // prevent double disposal const settled = await Promise.allSettled( clients.map(client => options?.whenIdle && client.disposeWhenIdle ? client.disposeWhenIdle() : client.dispose(), ), ); // End-of-execution disposal is final: whatever happened, there is no later // pass to hand these to, and the watchdog is the backstop for a failure. if (!options?.whenIdle) { return; } // A client that only *deferred* its disposal is still live and still owns a // connection, so it goes back on the list for the end-of-execution pass to // finish the job. So does one whose deferral *failed*: a rejection released // nothing, and dropping it here would strand a checked-out connection with // nothing left to release it. const unreleased = clients.filter((_, index) => { const outcome = settled[index]; return outcome?.status !== 'fulfilled' || outcome.value === true; }); if (unreleased.length) { context.dbClientsToDispose = [...unreleased, ...(context.dbClientsToDispose ?? [])]; } }