-
Notifications
You must be signed in to change notification settings - Fork 0
fix(connection): drain PostgreSQL writes through a cursor instead of buffering #1328
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 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
6cbaaf9
fix(connection): drain PostgreSQL writes through a cursor instead of …
alfredorubin96 324edc0
fix(connection): address review on the PostgreSQL write drain
alfredorubin96 d0891b8
test(connection): type the container as StartedPostgreSqlContainer
alfredorubin96 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
140 changes: 140 additions & 0 deletions
140
connection/__tests__/postgresql/postgres-write-drain.ts
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,140 @@ | ||
| import { PostgresConnectionModule } from "../../src/postgresql/PostgresConnectionModule"; | ||
| import { | ||
| DEFAULT_CONNECTION_CONFIG, | ||
| QueryStatus, | ||
| AuthType, | ||
| ConnectionTypes, | ||
| } from "@neoboard/connector-sdk"; | ||
| import { PostgreSqlContainer } from "@testcontainers/postgresql"; | ||
|
|
||
| /** | ||
| * Write-path row limiting against a REAL PostgreSQL (#1298 / #1326). | ||
| * | ||
| * The write branch buffered the entire result set and sliced afterwards, so | ||
| * `rowLimit` bounded what was displayed, not what was resident — one Form | ||
| * submit against a large table could exhaust the heap shared by every tenant. | ||
| * | ||
| * The obvious fix is the dangerous one. `readBoundedCursor` performs ONE | ||
| * bounded `cursor.read()` and closes the cursor in its `finally`. PostgreSQL | ||
| * executes a portal incrementally, so an `UPDATE … RETURNING` suspended after | ||
| * `rowLimit + 1` rows has NOT applied the rest, and closing the portal | ||
| * abandons that work — turning "update 1,000 rows" into "update 11" while | ||
| * still reporting success. | ||
| * | ||
| * These tests exist to make that failure impossible to ship. A stubbed client | ||
| * cannot prove any of it; only a real database can. | ||
| */ | ||
| describe("PostgreSQL write path — row limit must not truncate side effects", () => { | ||
| let container: PostgreSqlContainer; | ||
| let connectionModule: PostgresConnectionModule; | ||
|
|
||
| const ROW_COUNT = 1000; | ||
| const ROW_LIMIT = 10; | ||
|
|
||
| beforeAll(async () => { | ||
| container = await new PostgreSqlContainer("postgres:16-alpine").start(); | ||
|
|
||
| connectionModule = new PostgresConnectionModule({ | ||
| username: container.getUsername(), | ||
| password: container.getPassword(), | ||
| authType: AuthType.NATIVE, | ||
| uri: `postgresql://${container.getHost()}:${container.getPort()}/${container.getDatabase()}`, | ||
| }); | ||
|
|
||
| expect(await connectionModule.authModule.verifyAuthentication()).toBe(true); | ||
|
|
||
| const client = await connectionModule.getPool()!.connect(); | ||
| try { | ||
| await client.query( | ||
| `CREATE TABLE counters (id SERIAL PRIMARY KEY, n INT NOT NULL)`, | ||
| ); | ||
| await client.query( | ||
| `INSERT INTO counters (n) SELECT 0 FROM generate_series(1, $1)`, | ||
| [ROW_COUNT], | ||
| ); | ||
| } finally { | ||
| client.release(); | ||
| } | ||
| }, 120_000); | ||
|
|
||
| afterAll(async () => { | ||
| await connectionModule.close(); | ||
| await container?.stop(); | ||
| }, 60_000); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** Run a write query through the module and collect what the caller sees. */ | ||
| function runWrite(query: string) { | ||
| return new Promise<{ rows: unknown[]; statuses: QueryStatus[] }>( | ||
| (resolve, reject) => { | ||
| const statuses: QueryStatus[] = []; | ||
| connectionModule.runQuery( | ||
| { query, parameters: {} }, | ||
| { | ||
| onSuccess: (rows: unknown) => | ||
| resolve({ rows: rows as unknown[], statuses }), | ||
| onFail: reject, | ||
| setStatus: (s: QueryStatus) => statuses.push(s), | ||
| setFields: () => {}, | ||
| setSchema: () => {}, | ||
| }, | ||
| { | ||
| ...DEFAULT_CONNECTION_CONFIG, | ||
| type: ConnectionTypes.POSTGRESQL, | ||
| rowLimit: ROW_LIMIT, | ||
| accessMode: "WRITE", | ||
| }, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, | ||
| ); | ||
| } | ||
|
|
||
| it("applies the UPDATE to EVERY row while returning at most rowLimit", async () => { | ||
| const { rows } = await runWrite( | ||
| `UPDATE counters SET n = n + 1 RETURNING *`, | ||
| ); | ||
|
|
||
| // The caller sees only the capped page... | ||
| expect(rows.length).toBeLessThanOrEqual(ROW_LIMIT); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // ...but every row must have been updated. This is the assertion that | ||
| // fails on any implementation which stops reading the portal early. | ||
| const client = await connectionModule.getPool()!.connect(); | ||
| try { | ||
| const { rows: check } = await client.query( | ||
| `SELECT count(*)::int AS updated FROM counters WHERE n = 1`, | ||
| ); | ||
| expect(check[0].updated).toBe(ROW_COUNT); | ||
| } finally { | ||
| client.release(); | ||
| } | ||
| }, 60_000); | ||
|
|
||
| it("still reports the affected-row count for an INSERT without RETURNING", async () => { | ||
| // The buffered path was kept originally because result.rowCount is what | ||
| // makes a non-returning write report COMPLETE rather than NO_DATA. Any | ||
| // cursor-based rewrite has to preserve that. | ||
| const { statuses } = await runWrite( | ||
| `INSERT INTO counters (n) SELECT 99 FROM generate_series(1, 5)`, | ||
| ); | ||
|
|
||
| expect(statuses).not.toContain(QueryStatus.NO_DATA); | ||
|
|
||
| const client = await connectionModule.getPool()!.connect(); | ||
| try { | ||
| const { rows: check } = await client.query( | ||
| `SELECT count(*)::int AS inserted FROM counters WHERE n = 99`, | ||
| ); | ||
| expect(check[0].inserted).toBe(5); | ||
| } finally { | ||
| client.release(); | ||
| } | ||
| }, 60_000); | ||
|
|
||
| it("flags truncation when a write returns more rows than the limit", async () => { | ||
| const { statuses } = await runWrite( | ||
| `UPDATE counters SET n = n WHERE n <> 99 RETURNING *`, | ||
| ); | ||
|
|
||
| expect(statuses).toContain(QueryStatus.COMPLETE_TRUNCATED); | ||
| }, 60_000); | ||
| }); | ||
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
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.