-
Notifications
You must be signed in to change notification settings - Fork 2
fix(db): give every query an abort budget #33
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
Open
Nicolas0315
wants to merge
11
commits into
arkorlab:main
Choose a base branch
from
Nicolas0315:fix/db-query-budget
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
ed8d985
fix(db): give every query an abort budget
Nicolas0315 0419815
fix(db): compose the caller's signal, reuse timeoutSignal, prove the …
Nicolas0315 18995ed
test(db): pin the driver contract the budget depends on
Nicolas0315 976f09d
fix(db): keep query() lazy, and budget the transaction batch sends
Nicolas0315 d59c989
fix(db): release the per-statement budgets batch never executes
Nicolas0315 4826681
fix(db): stop the statement-timer cleanup breaking transaction's call…
Nicolas0315 08c2ffe
test(db): keep the driver-contract block to real-driver assumptions
Nicolas0315 a147eb1
test(db): assert the budget relationship the constant's comment claims
Nicolas0315 b1f7dda
fix(db): bound the query budget by the repo's own timeout rule
Nicolas0315 65f3f28
test(db): stop the drizzle integration case swallowing every error
Nicolas0315 e97e713
docs(db): stop the budget comment claiming a containment it does not …
Nicolas0315 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
Some comments aren't visible on the classic Files Changed page.
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
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
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,263 @@ | ||
| import { neon, neonConfig } from "@neondatabase/serverless"; | ||
| import { drizzle } from "drizzle-orm/neon-http"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { DEFAULT_QUERY_BUDGET_MS, withQueryBudget } from "./client.js"; | ||
| import { fleets } from "./schema/index.js"; | ||
|
|
||
| interface RecordedCall { | ||
| readonly query: string; | ||
| readonly params: unknown[] | undefined; | ||
| readonly options: Record<string, unknown> | undefined; | ||
| } | ||
|
|
||
| /** | ||
| * A stand-in for the Neon HTTP client that records what it was handed. | ||
| * The budget is asserted against the call the driver would really make, | ||
| * so a wrapper that misses the path drizzle takes cannot pass. | ||
| */ | ||
| function fakeClient( | ||
| behavior: (signal: AbortSignal | undefined) => Promise<unknown> = () => | ||
| Promise.resolve([]), | ||
| ) { | ||
| const calls: RecordedCall[] = []; | ||
| const client = { | ||
| query: async ( | ||
| query: string, | ||
| parameters?: unknown[], | ||
| options?: Record<string, unknown>, | ||
| ): Promise<unknown> => { | ||
| calls.push({ query, params: parameters, options }); | ||
| const fetchOptions = options?.fetchOptions as | ||
| | { signal?: AbortSignal } | ||
| | undefined; | ||
| return behavior(fetchOptions?.signal); | ||
| }, | ||
| // Present so the "everything else passes through" assertion has | ||
| // something to reach for, and to mirror the real client's shape. | ||
| unsafe: (text: string) => text, | ||
| }; | ||
| return { client, calls }; | ||
| } | ||
|
|
||
| /** Reject when the signal aborts, mirroring how fetch behaves. */ | ||
| async function hangUntilAborted(signal: AbortSignal | undefined) { | ||
| return new Promise<never>((_resolve, reject) => { | ||
| signal?.addEventListener("abort", () => { | ||
| reject(signal.reason as Error); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function activeTimerCount(): number { | ||
| return process | ||
| .getActiveResourcesInfo() | ||
| .filter((resource) => resource === "Timeout").length; | ||
| } | ||
|
|
||
| /** | ||
| * Read the fetch options off a recorded call, failing the test rather | ||
| * than returning undefined: every assertion below is about what the | ||
| * wrapper HANDED the client, so a missing call is a failure, not a | ||
| * nullable value to thread around. | ||
| */ | ||
| function fetchOptionsOf(call: RecordedCall | undefined): { | ||
| readonly signal: AbortSignal; | ||
| readonly [key: string]: unknown; | ||
| } { | ||
| const options = call?.options?.fetchOptions; | ||
| expect(options).toBeDefined(); | ||
| return options as { readonly signal: AbortSignal }; | ||
| } | ||
|
|
||
| describe("withQueryBudget", () => { | ||
| it("attaches a per-call AbortSignal to the query drizzle actually issues", async () => { | ||
| const { client, calls } = fakeClient(); | ||
| const budgeted = withQueryBudget(client, 5000); | ||
|
|
||
| await budgeted.query("select 1", []); | ||
|
|
||
| expect(calls).toHaveLength(1); | ||
| const fetchOptions = fetchOptionsOf(calls[0]); | ||
| expect(fetchOptions.signal).toBeInstanceOf(AbortSignal); | ||
| expect(fetchOptions.signal.aborted).toBe(false); | ||
| }); | ||
|
|
||
| it("gives each call its own signal", async () => { | ||
| const { client, calls } = fakeClient(); | ||
| const budgeted = withQueryBudget(client, 5000); | ||
|
|
||
| await budgeted.query("select 1", []); | ||
| await budgeted.query("select 2", []); | ||
|
|
||
| const first = fetchOptionsOf(calls[0]).signal; | ||
| const second = fetchOptionsOf(calls[1]).signal; | ||
| // A shared signal would abort every later query the moment the first | ||
| // budget expired. | ||
| expect(first).not.toBe(second); | ||
| }); | ||
|
|
||
| // A tiny real budget rather than fake timers: the assertion is that a | ||
| // promise rejects, and a 20ms wait states that more directly than | ||
| // driving the clock would. | ||
| it("rejects once the budget elapses, so a hung store surfaces as a throw", async () => { | ||
| const { client } = fakeClient(hangUntilAborted); | ||
| const budgeted = withQueryBudget(client, 20); | ||
|
|
||
| await expect(budgeted.query("select 1", [])).rejects.toThrow(); | ||
| }); | ||
|
|
||
| it("leaves a query that answers inside the budget untouched", async () => { | ||
| const { client, calls } = fakeClient(() => Promise.resolve([{ ok: true }])); | ||
| const budgeted = withQueryBudget(client, 10_000); | ||
|
|
||
| await expect(budgeted.query("select 1", [])).resolves.toEqual([ | ||
| { ok: true }, | ||
| ]); | ||
| const fetchOptions = fetchOptionsOf(calls[0]); | ||
| // The signal is scoped to the attempt: a query that answered is never | ||
| // reported as aborted, whatever the budget was. | ||
| expect(fetchOptions.signal.aborted).toBe(false); | ||
| }); | ||
|
|
||
| it("preserves the options drizzle sets and merges caller fetchOptions", async () => { | ||
| const { client, calls } = fakeClient(); | ||
| const budgeted = withQueryBudget(client, 5000); | ||
|
|
||
| await budgeted.query("select 1", ["a"], { | ||
| arrayMode: true, | ||
| fullResults: true, | ||
| authToken: "token", | ||
| fetchOptions: { priority: "high" }, | ||
| }); | ||
|
|
||
| const options = calls[0]?.options; | ||
| expect(options?.arrayMode).toBe(true); | ||
| expect(options?.fullResults).toBe(true); | ||
| expect(options?.authToken).toBe("token"); | ||
| const fetchOptions = fetchOptionsOf(calls[0]); | ||
| // Replacing fetchOptions instead of merging would silently drop | ||
| // whatever the caller had set. | ||
| expect(fetchOptions.priority).toBe("high"); | ||
| expect(fetchOptions.signal).toBeInstanceOf(AbortSignal); | ||
| expect(calls[0]?.params).toEqual(["a"]); | ||
| }); | ||
|
|
||
| it("composes a caller-supplied signal instead of dropping it", async () => { | ||
| const { client, calls } = fakeClient(); | ||
| const budgeted = withQueryBudget(client, 5000); | ||
| const callerController = new AbortController(); | ||
|
|
||
| await budgeted.query("select 1", [], { | ||
| fetchOptions: { signal: callerController.signal }, | ||
| }); | ||
|
|
||
| const { signal } = fetchOptionsOf(calls[0]); | ||
| expect(signal.aborted).toBe(false); | ||
| // Overwriting the caller's signal would make their cancellation a | ||
| // no-op, silently. | ||
| callerController.abort(new Error("caller cancelled")); | ||
| expect(signal.aborted).toBe(true); | ||
| }); | ||
|
|
||
| it("clears the budget timer once the query settles", async () => { | ||
| const { client } = fakeClient(); | ||
| const budgeted = withQueryBudget(client, 60_000); | ||
| const timersBefore = activeTimerCount(); | ||
|
|
||
| for (let attempt = 0; attempt < 5; attempt++) { | ||
| await budgeted.query("select 1", []); | ||
| } | ||
|
|
||
| // Measured as a delta because the test runner keeps timers of its | ||
| // own. A leak would hold one 60s timer per query, so five queries | ||
| // would leave five behind and keep the event loop alive. | ||
| expect(activeTimerCount()).toBe(timersBefore); | ||
| }); | ||
|
|
||
| it("passes every other property through untouched", () => { | ||
| const { client } = fakeClient(); | ||
| const budgeted = withQueryBudget(client, 5000); | ||
|
|
||
| expect(budgeted.unsafe("raw")).toBe("raw"); | ||
| }); | ||
|
|
||
| // The load-bearing test: drizzle resolves `client.query ?? client` once | ||
| // at construction, so a wrapper that budgets the wrong path compiles, | ||
| // passes every unit test above, and never applies to a real query. | ||
| // Only driving real drizzle proves which path is taken. | ||
| it("applies the budget to the query real drizzle issues", async () => { | ||
| const { client, calls } = fakeClient(); | ||
| const database = drizzle({ | ||
| client: withQueryBudget(client, 5000) as never, | ||
| schema: { fleets }, | ||
| }); | ||
|
|
||
| // The fake returns [] rather than a Neon result envelope, so drizzle | ||
| // may reject while mapping it. Irrelevant here: the recording happens | ||
| // when the query is ISSUED, which is what is under test. | ||
| try { | ||
| await database.select().from(fleets).limit(1); | ||
| } catch { | ||
| // response-shape mismatch only | ||
| } | ||
|
|
||
| expect(calls).toHaveLength(1); | ||
| expect(calls[0]?.query).toContain("fleets"); | ||
| expect(fetchOptionsOf(calls[0]).signal).toBeInstanceOf(AbortSignal); | ||
| }); | ||
|
|
||
| it("defaults to a budget that is bounded and well under undici's", () => { | ||
| // The point of the constant is that it exists and is small; pinning | ||
| // the exact number would just restate the source. | ||
| expect(DEFAULT_QUERY_BUDGET_MS).toBeGreaterThan(0); | ||
| expect(DEFAULT_QUERY_BUDGET_MS).toBeLessThanOrEqual(30_000); | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * Everything above asserts what the wrapper HANDS the client, against a | ||
| * fake. That is only meaningful while the fake reproduces the real | ||
| * driver's contract, and nothing above checks THAT: a fake which invents | ||
| * a parameter the driver ignores would keep every assertion green while | ||
| * production stayed unbounded. | ||
| * | ||
| * So this pins the assumption itself against the installed driver: that | ||
| * `fetchOptions` passed as the third argument to `query` reaches the | ||
| * fetch layer. If a future release moves it to construction-time only, | ||
| * this fails instead of the budget quietly becoming a no-op. | ||
| */ | ||
| describe("the driver contract the budget depends on", () => { | ||
| it("delivers query-level fetchOptions to the fetch layer", async () => { | ||
| const originalFetchFunction = neonConfig.fetchFunction as unknown; | ||
| let received: RequestInit | undefined; | ||
| neonConfig.fetchFunction = (_url: string, options: RequestInit) => { | ||
| received = options; | ||
| return Promise.resolve( | ||
| Response.json( | ||
| { | ||
| command: "SELECT", | ||
| fields: [], | ||
| rows: [], | ||
| rowCount: 0, | ||
| }, | ||
| { status: 200, headers: { "content-type": "application/json" } }, | ||
| ), | ||
| ); | ||
| }; | ||
| try { | ||
| const sql = neon("postgresql://user:pass@example.neon.tech/db"); | ||
| const controller = new AbortController(); | ||
|
|
||
| await sql.query("select 1", [], { | ||
| fetchOptions: { signal: controller.signal }, | ||
| }); | ||
|
|
||
| expect(received?.signal).toBe(controller.signal); | ||
| } finally { | ||
| // `neonConfig` is process-global; leaving a stub installed would | ||
| // silently rewrite every later query in this worker. | ||
| neonConfig.fetchFunction = originalFetchFunction; | ||
| } | ||
| }); | ||
| }); | ||
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.