Skip to content

fix(connection): drain PostgreSQL writes through a cursor instead of buffering - #1328

Merged
alfredo1996 merged 3 commits into
release/1.4from
fix/issue-1326-pg-write-drain
Jul 27, 2026
Merged

fix(connection): drain PostgreSQL writes through a cursor instead of buffering#1328
alfredo1996 merged 3 commits into
release/1.4from
fix/issue-1326-pg-write-drain

Conversation

@alfredo1996

@alfredo1996 alfredo1996 commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #1326 · Refs #1298 — the PostgreSQL half. Neo4j is #1325.

Problem

The write branch buffered everything before the row limit applied:

const result = await client.query(query, paramValues);
fetchedRows = result.rows;          // whole set, 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 on the process.

Why the obvious fix would have been worse

readBoundedCursor does one bounded read and closes the cursor in its finally. That is right for a SELECT, and wrong here: PostgreSQL applies an UPDATE … RETURNING incrementally, so rows never pulled are never modified, and closing the portal abandons them. Swapping it in would have turned "update 1,000 rows" into "update 11" — silently, while still reporting success.

drainBoundedCursor reads to exhaustion — every row produced, every side effect run — retaining only maxRows. Peak memory becomes the batch size plus maxRows rather than the whole result.

A bug the integration test caught in my own fix

The completing batch of a RETURNING statement reports rowCount: 0. My first version let that overwrite the real count, so an UPDATE that changed a thousand rows reported NO_DATA. affectedRowCount is now surfaced only when the statement returned no rows — precisely the INSERT-without-RETURNING case it exists for.

Worth stating plainly: a stubbed client would have happily passed the broken version.

Tests — against a real PostgreSQL

connection/__tests__/postgresql/postgres-write-drain.ts, Testcontainers:

  1. UPDATE … RETURNING over 1,000 rows with rowLimit: 10 returns ≤10 rows and leaves all 1,000 updated. This is the regression guard for the trap above — it fails on any implementation that stops reading early.
  2. INSERT without RETURNING still reports its affected-row count rather than NO_DATA — the behaviour the original buffered path existed to protect.
  3. A write exceeding the limit still flags COMPLETE_TRUNCATED.

Note these pass on the old code too: they assert correctness properties the buffered version also satisfied. Their job is to make the fix's failure modes impossible to ship, not to demonstrate the memory bug — which is why the memory claim rests on the implementation change rather than on a flaky heap measurement.

One adapted test

postgres-error-handling.test.ts simulated a failing INSERT through client.query, which the write path no longer uses — so the failure never occurred and onFail was never called. The rejection now originates in drainBoundedCursor. The test's subject is unchanged: a failed query whose ROLLBACK also fails must still surface the original error.

Verification

8 Postgres suites, 72 tests.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added bounded result handling for write queries, limiting rows returned to the caller while ensuring all database changes complete.
    • Write queries that exceed the row limit now report a truncated completion status.
    • For writes that don’t return rows, affected-row counts are preserved.
  • Bug Fixes

    • Write operations without returned rows no longer incorrectly report “no data”.
    • Improved regression coverage so the original write failure is reported even if rollback also fails (rollback failure is logged separately).
  • Tests

    • Added an integration test suite validating write-path behavior against a real PostgreSQL instance.

…buffering

The write branch called client.query(), which materialises the entire result
set before the row limit is applied:

    const result = await client.query(query, paramValues);
    fetchedRows = result.rows;          // whole set, then sliced later

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
on the process.

The obvious fix is the dangerous one. readBoundedCursor performs ONE bounded
read and closes the cursor in its finally — right for a SELECT, but
PostgreSQL applies an UPDATE ... RETURNING incrementally, so rows never
pulled are never modified and closing the portal abandons them. Reusing it
here would silently turn "update 1,000 rows" into "update 11" and still
report success.

New drainBoundedCursor reads to exhaustion — every row produced, every side
effect run — while retaining only maxRows. Peak memory becomes the batch
size plus maxRows instead of the whole result.

One subtlety the integration test caught: the completing batch of a
RETURNING statement reports rowCount 0, which overwrote the real count and
made the module report NO_DATA for an UPDATE that changed a thousand rows.
affectedRowCount is now reported only when the statement returned no rows —
exactly the INSERT-without-RETURNING case it exists for.

Three integration tests against a real PostgreSQL, because a stubbed client
cannot prove any of this:
  - UPDATE ... RETURNING over 1000 rows with rowLimit 10 returns <= 10 rows
    AND leaves all 1000 updated. This is the regression guard: it fails on
    any implementation that stops reading early.
  - INSERT without RETURNING still reports its affected-row count, not
    NO_DATA.
  - a write exceeding the limit still flags COMPLETE_TRUNCATED.

postgres-error-handling.test.ts adapted: its failing-INSERT simulation drove
client.query, which the write path no longer uses. The failure now
originates in drainBoundedCursor; the test's subject — a failed query whose
ROLLBACK also fails must still surface the ORIGINAL error — is unchanged.

Verified: 8 pg suites, 72 tests.

Closes #1326
Refs #1298

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alfredo1996 alfredo1996 added bug Something isn't working security Security-related issue pkg:connection Database connector library release-blocker Must fix before release priority:P1 Ship-but-fix before release labels Jul 27, 2026
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 98baaaf2-d941-4975-ad5a-2f667c57bba8

📥 Commits

Reviewing files that changed from the base of the PR and between 6cbaaf9 and d0891b8.

📒 Files selected for processing (4)
  • connection/__tests__/postgresql/postgres-error-handling.test.ts
  • connection/__tests__/postgresql/postgres-write-drain.ts
  • connection/src/postgresql/PostgresConnectionModule.ts
  • connection/src/postgresql/cursor-read.ts
💤 Files with no reviewable changes (2)
  • connection/tests/postgresql/postgres-error-handling.test.ts
  • connection/src/postgresql/cursor-read.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • connection/src/postgresql/PostgresConnectionModule.ts
  • connection/tests/postgresql/postgres-write-drain.ts

Walkthrough

PostgreSQL write queries now drain cursor results to completion while retaining bounded returned rows. Affected-row reporting is preserved, and integration and error-handling tests cover write side effects, truncation, non-returning writes, and rollback failures.

Changes

PostgreSQL write draining

Layer / File(s) Summary
Bounded cursor drain implementation
connection/src/postgresql/cursor-read.ts
Adds DrainedCursor and drainBoundedCursor, which drain batches to exhaustion, retain bounded rows, preserve affected-row data, and close cursors safely.
Write execution integration
connection/src/postgresql/PostgresConnectionModule.ts
Routes non-read-only queries through bounded cursor draining with rowLimit + 1 and uses the drained rows, fields, and affected-row count.
Write-path regression coverage
connection/__tests__/postgresql/postgres-write-drain.ts, connection/__tests__/postgresql/postgres-error-handling.test.ts
Adds integration and error-handling coverage for complete write effects, non-returning writes, truncation status, and original error propagation when rollback fails.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PostgresConnectionModule
  participant drainBoundedCursor
  participant PostgreSQL
  Client->>PostgresConnectionModule: submit write query with rowLimit
  PostgresConnectionModule->>drainBoundedCursor: execute query with rowLimit + 1
  drainBoundedCursor->>PostgreSQL: read cursor batches until exhaustion
  PostgreSQL-->>drainBoundedCursor: returned rows and affected-row data
  drainBoundedCursor-->>PostgresConnectionModule: bounded rows and truncation state
  PostgresConnectionModule-->>Client: query status and returned rows
Loading

Possibly related issues

  • alfredo1996/neoboard#1298 — Covers the broader PostgreSQL write-path draining and bounded-row-retention fix implemented here.

Possibly related PRs

  • alfredo1996/neoboard#507 — Propagates row-limit truncation status into UI metadata, connecting to this PR’s COMPLETE_TRUNCATED behavior.

Suggested labels: testing, area:connectors, area:query-exec

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The cursor-draining fix and real PostgreSQL regression tests are present, but the required WRITE row-limit conformance coverage is not shown. Add a conformance test that applies the row-limit rule to WRITE operations, alongside the existing READ case.
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: draining PostgreSQL writes through a cursor instead of buffering.
Out of Scope Changes check ✅ Passed All listed changes support the PostgreSQL write-path fix and its tests; no unrelated code changes are evident.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1326-pg-write-drain

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
connection/__tests__/postgresql/postgres-write-drain.ts (1)

112-120: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert COMPLETE positively rather than the absence of NO_DATA.

not.toContain(NO_DATA) also passes on ERROR or on no status at all. The stated contract for a non-returning INSERT is COMPLETE.

💚 Proposed fix
-    expect(statuses).not.toContain(QueryStatus.NO_DATA);
+    expect(statuses).toContain(QueryStatus.COMPLETE);
+    expect(statuses).not.toContain(QueryStatus.NO_DATA);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@connection/__tests__/postgresql/postgres-write-drain.ts` around lines 112 -
120, Update the test "still reports the affected-row count for an INSERT without
RETURNING" to assert that statuses positively contain QueryStatus.COMPLETE,
replacing the weaker NO_DATA absence check while preserving the existing INSERT
scenario and runWrite flow.
connection/__tests__/postgresql/postgres-error-handling.test.ts (1)

211-218: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer a typed import over inline require.

require in a .ts test trips @typescript-eslint/no-var-requires in most configs and loses typing. Import the mocked module at the top and use jest.mocked.

♻️ Proposed refactor
-    const { drainBoundedCursor } = require("../../src/postgresql/cursor-read");
-    (drainBoundedCursor as jest.Mock).mockRejectedValueOnce(
-      new Error("insert exploded"),
-    );
+    jest
+      .mocked(drainBoundedCursor)
+      .mockRejectedValueOnce(new Error("insert exploded"));

Add at the top of the file:

import { drainBoundedCursor } from "../../src/postgresql/cursor-read";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@connection/__tests__/postgresql/postgres-error-handling.test.ts` around lines
211 - 218, Replace the inline require of drainBoundedCursor in the test with a
top-level typed import from the cursor-read module, then configure the imported
mock via jest.mocked before setting mockRejectedValueOnce. Preserve the existing
rejection behavior and original-error assertion.
connection/src/postgresql/cursor-read.ts (1)

163-177: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Drain cost is unbounded by design — consider a larger batch or a drain ceiling.

Correctness requires exhausting the portal, but a 10M-row UPDATE … RETURNING now costs 20k round trips at DRAIN_BATCH_SIZE = 500, all inside the write transaction holding locks. The statement_timeout set by the caller bounds server-side execution per command, not the total client-driven drain wall time. Consider raising the batch size (rows are discarded past maxRows, so memory cost is one batch) and/or documenting the interaction with the transaction-level timeout.

♻️ Suggested tweak
-/** Rows pulled per round-trip while draining. Bounds memory, not correctness. */
-const DRAIN_BATCH_SIZE = 500;
+/**
+ * Rows pulled per round-trip while draining. Bounds memory, not correctness.
+ * Larger batches cut round-trips on very large writes; rows beyond `maxRows`
+ * are discarded immediately, so peak memory is one batch + `maxRows`.
+ */
+const DRAIN_BATCH_SIZE = 2000;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@connection/src/postgresql/cursor-read.ts` around lines 163 - 177, Reduce the
client-driven drain overhead in the cursor-reading flow by increasing
DRAIN_BATCH_SIZE while keeping discarded rows bounded to one batch beyond
maxRows. Preserve full portal exhaustion and affectedRowCount correctness, and
document that draining can extend transaction wall time beyond per-command
statement_timeout.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@connection/__tests__/postgresql/postgres-write-drain.ts`:
- Around line 60-63: Guard the connectionModule cleanup in the afterAll hook so
close() is only called when construction succeeded, matching the existing
optional guard on container.stop(). Preserve the cleanup timeout and ensure
setup failures are not masked by a TypeError.
- Around line 91-97: Update the assertion in the “applies the UPDATE to EVERY
row while returning at most rowLimit” test to require exactly ROW_LIMIT returned
rows, replacing the weaker upper-bound check while preserving the existing
UPDATE and runWrite setup.
- Around line 70-86: Update the runQuery invocation in the postgres write-drain
test to use params instead of parameters and connectionType instead of type,
matching _runSqlQuery and DEFAULT_CONNECTION_CONFIG. Preserve the existing
query, rowLimit, accessMode, callbacks, and status tracking.
- Around line 28-35: Change the container variable declaration in the PostgreSQL
write-drain test to use the StartedPostgreSqlContainer type returned by
PostgreSqlContainer.start(), preserving the existing getUsername(), getPort(),
and stop() usage.

In `@connection/src/postgresql/PostgresConnectionModule.ts`:
- Around line 173-186: Update the preceding write-query block comment to
describe the current drainBoundedCursor behavior rather than claiming writes use
the direct affected-row-count path. In the caller around drainBoundedCursor,
consume batch.truncated for truncation handling instead of recomputing it from
fetchedRows.length, keeping the result consistent with the helper’s interface.

---

Nitpick comments:
In `@connection/__tests__/postgresql/postgres-error-handling.test.ts`:
- Around line 211-218: Replace the inline require of drainBoundedCursor in the
test with a top-level typed import from the cursor-read module, then configure
the imported mock via jest.mocked before setting mockRejectedValueOnce. Preserve
the existing rejection behavior and original-error assertion.

In `@connection/__tests__/postgresql/postgres-write-drain.ts`:
- Around line 112-120: Update the test "still reports the affected-row count for
an INSERT without RETURNING" to assert that statuses positively contain
QueryStatus.COMPLETE, replacing the weaker NO_DATA absence check while
preserving the existing INSERT scenario and runWrite flow.

In `@connection/src/postgresql/cursor-read.ts`:
- Around line 163-177: Reduce the client-driven drain overhead in the
cursor-reading flow by increasing DRAIN_BATCH_SIZE while keeping discarded rows
bounded to one batch beyond maxRows. Preserve full portal exhaustion and
affectedRowCount correctness, and document that draining can extend transaction
wall time beyond per-command statement_timeout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0457533c-088b-4a7b-b712-ef156c827b57

📥 Commits

Reviewing files that changed from the base of the PR and between ae293bb and 6cbaaf9.

📒 Files selected for processing (4)
  • connection/__tests__/postgresql/postgres-error-handling.test.ts
  • connection/__tests__/postgresql/postgres-write-drain.ts
  • connection/src/postgresql/PostgresConnectionModule.ts
  • connection/src/postgresql/cursor-read.ts

Comment thread connection/__tests__/postgresql/postgres-write-drain.ts Outdated
Comment thread connection/__tests__/postgresql/postgres-write-drain.ts
Comment thread connection/__tests__/postgresql/postgres-write-drain.ts
Comment thread connection/__tests__/postgresql/postgres-write-drain.ts Outdated
Comment thread connection/src/postgresql/PostgresConnectionModule.ts
@alfredo1996

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

CodeRabbit on #1328, four findings — all correct:

- the test passed `parameters: {}` where the module destructures `params`,
  and `type:` where the config declares `connectionType`. The second is the
  one that mattered: DEFAULT_CONNECTION_CONFIG.connectionType defaults to
  NEO4J, so the suite was configuring a Neo4j type against the Postgres
  module. Behaviour was still right, but the test would have started lying
  the moment anything branched on it.

- `expect(rows.length).toBeLessThanOrEqual(ROW_LIMIT)` also passes when the
  drain returns ZERO rows — exactly the regression this suite exists to
  catch. Now asserts the exact bound.

- afterAll called connectionModule.close() unguarded, so a failure in
  beforeAll before construction raised a TypeError that masked the real
  setup error. `container?.stop()` was already guarded.

- the block comment above the branch still claimed writes "keep the direct
  path", the opposite of what it now does. Rewritten to say what actually
  differs: both stream, they differ in how they STOP.

DrainedCursor.truncated is dropped rather than wired up. The caller derives
truncation uniformly from the retained row count, so the field was dead on
arrival; shipping an unused one invites a future reader to trust it.

Verified: 8 pg suites, 72 tests.

Refs #1326

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alfredo1996

Copy link
Copy Markdown
Owner Author

All four addressed — thanks, one of these was a real hole in the test rather than a style point.

params / connectionType (Major) — correct on both. The second is the one that mattered: DEFAULT_CONNECTION_CONFIG.connectionType defaults to NEO4J, so the suite was configuring a Neo4j connection type against the Postgres module. Behaviour was still right — the UPDATE ran, which a READ ONLY transaction would have rejected, so accessMode: "WRITE" was being honoured — but the test would have started lying the moment anything branched on connectionType.

toBeLessThanOrEqual (Minor, but the best catch here) — you're right that it passes when the drain returns zero rows, which is precisely the failure this suite exists to detect. An early-stopping implementation that returned nothing would have sailed through. Now toHaveLength(ROW_LIMIT).

Unguarded connectionModule.close() — fixed; container?.stop() was already guarded, so this was inconsistent as well as fragile.

Stale block comment + discarded batch.truncated — the comment still claimed writes "keep the direct path", the opposite of the new behaviour. Rewritten to state what actually differs: both paths stream, they differ in how they stop.

On truncated: rather than wiring it up, I removed it from DrainedCursor. The caller derives truncation uniformly from the retained row count, so the field was dead on arrival — and shipping an unused one invites the next reader to trust it.

72 tests across 8 Postgres suites still green with the stricter assertion.

CodeRabbit on #1328: start() resolves to StartedPostgreSqlContainer, so
getUsername/getPort/stop were being called on the wrong type.

postgres-query.ts has the identical mistake and is left alone here — an
unrelated file in a P1 PR. Flagged on the PR instead.

Refs #1326

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alfredo1996

Copy link
Copy Markdown
Owner Author

Fifth finding addressed — container is now StartedPostgreSqlContainer, which is what start() actually resolves to.

Worth noting: postgres-query.ts:11 has the identical mistake, and my file copied it from there. I've left that one alone rather than widening a P1 PR into an unrelated file — but it means the two files now disagree, so it's worth a follow-up sweep rather than being forgotten.

The other four from this review are already fixed in the previous commit; those comments are anchored to pre-fix line numbers rather than still being open.

@alfredo1996
alfredo1996 merged commit fd4bb87 into release/1.4 Jul 27, 2026
11 checks passed
@alfredo1996
alfredo1996 deleted the fix/issue-1326-pg-write-drain branch July 27, 2026 23:07
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working pkg:connection Database connector library priority:P1 Ship-but-fix before release release-blocker Must fix before release security Security-related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants