Skip to content

fix(live): deallocate the total count statement for windowed queries - #1

Closed
askalf wants to merge 2 commits into
mainfrom
fix/live-windowed-deallocate-total-count
Closed

askalf wants to merge 2 commits into
mainfrom
fix/live-windowed-deallocate-total-count

Conversation

@askalf

@askalf askalf commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Staging PR for branch review before submission upstream. Closed once submitted; the branch is kept until upstream resolves.

A windowed live query (one given an offset and limit) prepares both
live_query_<id>_get and live_query_<id>_get_total_count, but unsubscribe
only deallocated the former. The count statement outlived every teardown
while its backing view was dropped, so it was left permanently dangling
and accumulated for the lifetime of the database.
@askalf askalf added the oss-candidate Sprayberry Code candidate for upstream label Sep 14, 2026
@askalf
askalf marked this pull request as ready for review September 14, 2026 02:31

@sprayberry-redline sprayberry-redline 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: changes requested — the OSS-candidate verification evidence does not meet the required static-verification bar.

  • Blocking — packages/pglite/tests/live.test.ts:1180-1186

    await unsubscribe()

    const unsubscribed = await db.query<{ name: string }>(
    SELECT name FROM pg_prepared_statements WHERE name LIKE 'live_query_%';,
    )
    expect(unsubscribed.rows).toEqual([])

    This is a well-targeted regression assertion and, traced against the base, it fails before the fix: a windowed query prepares both live_query_<id>_get and live_query_<id>_get_total_count, while base teardown deallocates only the former. The new conditional deallocation makes the assertion pass. However, the candidate facts sheet declares this test verification static, fork CI reports no checks, and it supplies neither a runtime/OS-specific reason for static-only test verification nor an explicit command for the operator to run on their Windows machine before upstream submission. The candidate gate requires one of a green relevant fork CI job or that explicit operator instruction alongside the static rationale; as submitted, there is no executable test evidence route before submission.

    Suggested fix: amend the facts sheet with the exact package/test command the operator must run on Windows (and its expected passing result), or supply a green relevant fork CI run. If static verification remains necessary, state the concrete unavailable runtime or OS-specific constraint. For example:

    ## Verification method
    Static for this candidate because <concrete OS/runtime constraint>. Before submitting upstream, run on a Windows checkout:
    pnpm --filter @electric-sql/pglite test -- live.test.ts
    Expected result: the "deallocates all prepared statements for a windowed query on unsubscribe" test passes.
    

What's good: the code change mirrors the existing isWindowed prepare path precisely, the base version confirms the count statement was prepared but not deallocated, and the regression test covers both the subscribed and post-unsubscribe statement sets. I also re-ran prior-art searches: no open upstream PR matched total_count or deallocate; the closed teardown precedent is electric-sql#643. No CI checks are reported for this fork branch.

@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — blocking finding addressed

The blocking finding was the evidence route, not the code: the facts sheet declared test verification static with no runtime rationale, no fork CI, and no pre-submission command for the operator. Rather than supply the rationale, this run executed the evidence.

How the runtime problem was solved: packages/pglite builds its WASM from the postgres-pglite submodule, which this container cannot build. But tests/live.test.ts imports from ../dist/, so installing the published @electric-sql/pglite@0.5.8 and dropping its dist/ into packages/pglite/dist/ lets the repo's own vitest run unmodified against real WASM Postgres. The 0.5.8 teardown is behaviourally identical to base ae182ff (verified by reading the shipped bundle). For the fixed run the PR's one-line change is applied into that bundle's teardown template.

Fails before (base teardown, both esm and cjs import paths):

$ npx vitest run tests/live.test.ts -t 'deallocates all prepared statements' --typecheck.enabled=false
 ❯ |pglite| tests/live.test.ts  (40 tests | 2 failed | 38 skipped) 10775ms
   × esm import > live > deallocates all prepared statements for a windowed query on unsubscribe
     → expected [ Array(1) ] to deeply equal []
   × cjs import > live > deallocates all prepared statements for a windowed query on unsubscribe
     → expected [ Array(1) ] to deeply equal []

AssertionError: expected [ Array(1) ] to deeply equal []
- Array []
+ Array [
+   Object {
+     "name": "live_query_89b9c51c4425421abcda1d052730f9a0_get_total_count",
+   },
+ ]

 Test Files  1 failed (1)
      Tests  2 failed | 38 skipped (40)

Passes after:

 ✓ |pglite| tests/live.test.ts  (40 tests | 38 skipped) 11030ms
 Test Files  1 passed (1)
      Tests  2 passed | 38 skipped (40)

Whole touched file green (nothing else regressed):

$ npx vitest run tests/live.test.ts --typecheck.enabled=false
 ✓ |pglite| tests/live.test.ts  (40 tests) 65120ms
 Test Files  1 passed (1)
      Tests  40 passed (40)
   Duration  70.19s

Formatter:

$ npx prettier --check src/live/index.ts tests/live.test.ts ../../.changeset/windowed-live-deallocate.md
Checking formatting...
All matched files use Prettier code style!

Also corrected in the facts sheet, since the previous body was assembled second-hand and had errors:

  • Base/version said 0.5.4; the actual base is ae182ff8bd5ba4acb887d6c925d607a1498aa0b5 and the package version is 0.5.8.
  • The repro console block was paraphrased, not copy-pasted. It is now verbatim, and the "dangling" claim is now backed by a separate executed script (EXECUTE ... -> relation "live_query_<id>_view" does not exist).
  • ## Verification method now reads executed, states exactly how the runtime was obtained, and is explicit about what was not run here — tsc --noEmit and eslint need the workspace pnpm install, which needs the submodule. Those remain for upstream CI, and the operator's pre-submission commands are spelled out.
  • Confirmed fork CI genuinely cannot cover this: askalf/pglite has 0 workflow runs; build_and_test.yml requests blacksmith-32vcpu-ubuntu-2204 runners that exist only for the upstream org.

No code change in this rework — the fix and test are unchanged at 89e8e69. Repro scripts and raw output are archived at /agent-output/oss/pglite/fix-live-windowed-deallocate-total-count/repro/.

@sprayberry-secondread sprayberry-secondread 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: No blocking issues found — the fix is correctly scoped and I could confirm the bug from the base code myself.

What I checked

  • Traced packages/pglite/src/live/index.ts end to end for the windowed live.query path: isWindowed is set at line 58 (offset !== undefined && limit !== undefined), and when true, init() prepares two statements (lines 96-98 and 102-105): live_query_${id}_get(int, int) and live_query_${id}_get_total_count.
  • Confirmed on base (pre-fix) that the matching unsubscribe (starting packages/pglite/src/live/index.ts:241) only ever ran one DEALLOCATE:
    await tx.exec(`
      DROP VIEW IF EXISTS live_query_${id}_view;
      DEALLOCATE live_query_${id}_get;
    `)
    
    This is a real, reachable bug: call db.live.query({ query, offset: 1, limit: 2, callback }), then unsubscribe(). live_query_<id>_get is deallocated but live_query_<id>_get_total_count is not, and its backing view (live_query_<id>_view) is dropped in the same statement — so the surviving prepared statement is not just leaked but permanently unusable. The PR's own repro output demonstrates exactly this trace (EXECUTE live_query_..._get_total_count -> relation "live_query_..._view" does not exist).
  • Confirmed there are two other unsubscribe closures in this file (index.ts:502 for incrementalQuery's diff/state tracking, index.ts:664 for changes) — neither has isWindowed in scope, so the fix is not accidentally missing a second call site.
  • The fix itself, packages/pglite/src/live/index.ts:254:
    ${isWindowed ? `DEALLOCATE live_query_${id}_get_total_count;` : ''}
    is guarded correctly: unconditional DEALLOCATE would error for non-windowed queries where the count statement was never prepared. This exactly mirrors the existing guard used at the call sites that reference the count statement (e.g. index.ts:211 if (isWindowed) { ... EXECUTE live_query_${id}_get_total_count ... }).
  • Regression test at packages/pglite/tests/live.test.ts:1154 is placed correctly, immediately after the existing 'live query with windowing' test and before the offset/limit validation tests, matching the file's existing describe-block ordering and per-test CREATE TABLE/INSERT scaffolding style used throughout the file.

Maintainer's-eye read (oss-candidate)

  • Idiom match: the ternary-inside-template-literal pattern (${cond ? \...` : ''}) is already used elsewhere in this same file for optional SQL fragments (e.g. index.ts:374, :389, :393forUSER-DEFINED` type casts), so this isn't introducing a new style to the module.
  • Prior art in this exact function: PR electric-sql#643 (fix(live): prevent double deallocate of live query prepared statement, merged) touched this same teardown block for a related dead-flag race, confirming maintainers accept narrowly-scoped one-line fixes here rather than asking for a larger refactor.
  • Test shape: matches the file's convention — same table/insert boilerplate, same pg_prepared_statements querying style already used by the adjacent windowing test (live.test.ts:1067), asserting count before and empty array after.
  • Changeset: filename/format matches other merged PRs' changesets (.changeset/heavy-params-bind.md from electric-sql#1056, .changeset/calm-parsers-recover.md from electric-sql#1072, etc. — all patch bump, one paragraph description). This PR's .changeset/windowed-live-deallocate.md follows the same shape.
  • Scope: the diff is exactly the reported bug — one guarded DEALLOCATE line, a regression test, and a changeset. No unrelated cleanup.
  • I did not find anything in this diff, in the repo's recent history, or in the surrounding code that a maintainer would plausibly push back on beyond taste (e.g. some might prefer separate DEALLOCATE statements or an IF EXISTS, but the existing code in this file doesn't use DEALLOCATE IF EXISTS anywhere, so following the established convention is the right call).

On the PR body's verification-method disclosure

The body is candid that the executed test ran against the published 0.5.8 dist/ bundle with the fix hand-patched into the minified template via patch-dist.mjs, not a full local build of the TS source, and that tsc/eslint weren't run. Given the teardown block is a single trivial string edit with no type-level surface (no new function signature, no new import), the risk that the compiled TS diverges from the tested bundle patch is low — but it is still an inference, not a build-verified fact, and the body says so plainly rather than overclaiming. That's an honest disclosure, not a finding.

No blocking issues. This is a small, well-targeted, well-tested fix with correct prior art and appropriate scope for this codebase.

SECOND READ: READY

@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — evidence route rebuilt from source

HEAD UNCHANGED at 89e8e6960155113185564b5890dbf395c0e0fdcb. No code change; the blocking finding was the verification route, not the diff.

Two things to reconcile from the gating review:

  1. The review says the facts sheet "declares this test verification static". The body at the reviewed head actually said executed, with vitest output — but that executed run used the published 0.5.8 dist/ with the fix hand-patched into the minified bundle, which the second-read lane correctly called out as "an inference, not a build-verified fact". That caveat is a fair thing for a gating lane to read as static-flavoured. Rather than argue the wording, this rework removed the caveat.

  2. The test now runs against a bundle compiled from this branch's own src/live/index.ts. No hand-patching. esbuild (the bundler tsup wraps) builds src/live/index.ts → dist/live/index.{js,cjs} with the same options tsup.config.ts passes (bundle, minify, sourcemap, esm + cjs); workspace-internal imports resolve to their sources. Everything else in dist/ — the Postgres WASM and the sibling entry points, none of which this PR touches — comes from the published 0.5.8 package, because packages/pglite builds its WASM from the postgres-pglite submodule and this container has no Emscripten. Build script archived at repro/build-live.mjs.

Both runs below are real rebuilds from source: fails-before with the one line reverted in src/live/index.ts and the bundle rebuilt; passes-after with it restored and rebuilt. Verified at each step by grepping the emitted bundle:

# base source, rebuilt                  # this branch's source, rebuilt
DEALLOCATE live_query_${f}_get;         DEALLOCATE live_query_${f}_get;
DEALLOCATE live_query_${o}_diff1;       DEALLOCATE live_query_${f}_get_total_count;
DEALLOCATE live_query_${o}_diff2;       DEALLOCATE live_query_${o}_diff1;
                                        DEALLOCATE live_query_${o}_diff2;

Fails before (base source rebuilt, both esm and cjs import paths):

$ npx vitest run tests/live.test.ts -t 'deallocates all prepared statements' --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork

 RUN  v2.1.2 /agent-workspace/oss/pglite-wt-1789361963/packages/pglite

 ❯ |pglite| tests/live.test.ts  (40 tests | 2 failed | 38 skipped) 20544ms
   × esm import > live > deallocates all prepared statements for a windowed query on unsubscribe
     → expected [ Array(1) ] to deeply equal []
   × cjs import > live > deallocates all prepared statements for a windowed query on unsubscribe
     → expected [ Array(1) ] to deeply equal []

AssertionError: expected [ Array(1) ] to deeply equal []

- Expected
+ Received

- Array []
+ Array [
+   Object {
+     "name": "live_query_89d46c1bb2784027bfebd3c563a32e41_get_total_count",
+   },
+ ]

 ❯ tests/live.test.ts:1186:33

 Test Files  1 failed (1)
      Tests  2 failed | 38 skipped (40)
   Duration  29.84s

Passes after (this branch's source rebuilt):

 ✓ |pglite| tests/live.test.ts  (40 tests | 38 skipped) 18482ms

 Test Files  1 passed (1)
      Tests  2 passed | 38 skipped (40)
   Duration  23.47s

Whole touched file green:

$ npx vitest run tests/live.test.ts --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork

 ✓ |pglite| tests/live.test.ts  (40 tests) 87910ms

 Test Files  1 passed (1)
      Tests  40 passed (40)
   Duration  94.64s

Typecheck, newly added this pass — tsc --noEmit over src/live/index.ts and its import graph using the repo's root tsconfig.json. Identical error list before and after the change, and none of the pre-existing errors are in live/index.ts:

$ grep -c "error TS" tsc-fixed.txt
60
$ grep "live/index.ts" tsc-fixed.txt
(none)
$ diff tsc-base.txt tsc-fixed.txt && echo "IDENTICAL: the change introduces no new type errors"
IDENTICAL: the change introduces no new type errors

(The 60 are ArrayBuffer/SharedArrayBuffer variance in packages/pg-protocol/src/* plus a missing tinytar — artefacts of typechecking against sources with a newer @types/node than the workspace pins. Unrelated to this diff, and unchanged by it.)

Formatter:

$ prettier --check src/live/index.ts tests/live.test.ts ../../.changeset/windowed-live-deallocate.md
Checking formatting...
All matched files use Prettier code style!

On the fork CI ask. A green fork CI job is not obtainable at this target and the body no longer implies one is pending: askalf/pglite has 0 workflow runs because build_and_test.yml's jobs request blacksmith-32vcpu-ubuntu-2204 runners that exist only for the upstream org. gh pr checks 1 reporting no checks is that, not a failure. The operator's pre-submission command is in ## Verification method — it is not OS-specific (the bug and fix are pure SQL-string teardown, nothing Windows-related):

git submodule update --init --recursive
pnpm install
pnpm --filter @electric-sql/pglite build
pnpm --filter @electric-sql/pglite test:basic
pnpm --filter @electric-sql/pglite stylecheck

Still not run here, and said plainly in the body: pnpm build (needs Emscripten) and eslint (needs the workspace pnpm install). Upstream CI covers both.

Raw output and scripts archived at /agent-output/oss/pglite/fix-live-windowed-deallocate-total-count/repro/ (before-tsbuild.txt, after-tsbuild.txt, full-after-tsbuild.txt, tsc-base.txt, tsc-fixed.txt, build-live.mjs, tsconfig.check.json).

@sprayberry-redline sprayberry-redline 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: APPROVE — ready for the operator to submit.

I re-reviewed the evidence-only rework at unchanged head 89e8e6960155113185564b5890dbf395c0e0fdcb. The facts sheet contains all required sections. I independently traced the base: its windowed setup prepares both statements at packages/pglite/src/live/index.ts:95-104, while teardown only deallocates _get at base lines 251-254. Thus a call with both offset and limit, followed by final unsubscribe(), leaves _get_total_count prepared after the view is dropped. The changed line mirrors the existing isWindowed setup condition, so it does not deallocate a nonexistent statement for non-windowed queries.

I also reviewed the focused regression test, the patch changeset, commit hygiene, upstream search results, and the updated ## Rework evidence. The evidence now records real source rebuilds for both ESM and CJS: failure with the changed source line reverted, success after restoring it, and a 40/40 whole-file run. The PR has no checks because this fork cannot access the upstream-only runner; the body accurately states this and provides the operator's pre-submission command. No open upstream PR matching the searched total_count/deallocate terms was found.

What's good: the fix is minimal, preserves the non-windowed path, and the test asserts both the two-statement setup and the zero-statement teardown outcome. The changeset communicates the user-visible leak fix clearly.

…down

Add regression coverage around the windowed total count deallocation: a
non-windowed query must still tear down cleanly without a total count
statement, a window of offset 0 / limit 0 is still a window, repeated
subscribe/unsubscribe cycles must not accumulate statements, and
unsubscribing one windowed query must leave a concurrent one working.
@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Verification

Adversarial verification by a fresh run, independent of the run that produced the fix. Nothing in
the PR body was taken on trust: the scratch toolchain was reinstalled from scratch (vitest@2.1.2,
esbuild@0.28.2), dist/live/index.{js,cjs} was rebuilt from this branch's own src/live/index.ts
with esbuild mirroring tsup.config.ts (bundle, minify, sourcemap, esm+cjs, platform node), and the
published @electric-sql/pglite@0.5.8 dist/ supplied only the untouched parts (Postgres WASM,
sibling entry points). Every fails-before arm below is a genuine source build: the fix line was
reverted in src/, the module rebuilt, and the tests re-run.

Verified at head 47cdc47 (fix commit 89e8e69 + this run's extra tests). Base origin/main
@ ae182ff8bd5ba4acb887d6c925d607a1498aa0b5.

Which build is which — proven by grepping the emitted bundle

$ # fixed build (branch source)
$ grep -o 'DEALLOCATE live_query_[^`"]*' packages/pglite/dist/live/index.js
DEALLOCATE live_query_${f}_get;
DEALLOCATE live_query_${f}_get_total_count;
DEALLOCATE live_query_${o}_diff1;
DEALLOCATE live_query_${o}_diff2;

$ # base build (src/live/index.ts checked out from origin/main, rebuilt)
$ grep -o 'DEALLOCATE live_query_[^`"]*' packages/pglite/dist/live/index.js
DEALLOCATE live_query_${f}_get;
DEALLOCATE live_query_${o}_diff1;
DEALLOCATE live_query_${o}_diff2;

1. The PR's own test, re-run independently

Passes with the fix:

$ node vitest.mjs run tests/live.test.ts -t 'deallocates all prepared statements for a windowed query on unsubscribe' --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork

 RUN  v2.1.2 /agent-workspace/oss/pglite-wt-1789365337/packages/pglite

 ✓ |pglite| tests/live.test.ts  (40 tests | 38 skipped) 12725ms

 Test Files  1 passed (1)
      Tests  2 passed | 38 skipped (40)
   Duration  15.96s

2. Whole touched test file, with the fix, before adding anything

$ node vitest.mjs run tests/live.test.ts --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork

 RUN  v2.1.2 /agent-workspace/oss/pglite-wt-1789365337/packages/pglite

 ✓ |pglite| tests/live.test.ts  (40 tests) 67063ms

 Test Files  1 passed (1)
      Tests  40 passed (40)
   Duration  70.18s

3. Four new tests written to break the fix

Committed to this branch in 47cdc47 — they belong in the PR:

  • deallocates prepared statements for a non-windowed query on unsubscribe — the control. The
    fix is guarded by isWindowed; if that guard were wrong the teardown would try to DEALLOCATE
    a _get_total_count that was never prepared and the transaction would throw. This test must pass
    on both arms, and does.
  • deallocates prepared statements for a windowed query with an offset and limit of 0 — 0 is
    falsy, so a isWindowed derived from truthiness rather than !== undefined would misclassify this
    as non-windowed. src/live/index.ts:58 uses offset !== undefined && limit !== undefined, so the
    window is real (totalCount is 5, rows are empty) and both statements must go.
  • does not accumulate prepared statements over repeated windowed subscribe/unsubscribe cycles —
    asserts zero residue after each of three cycles, which is the leak the PR actually claims to fix.
  • only deallocates the prepared statements of the unsubscribed windowed query — two concurrent
    windowed queries; unsubscribing one must leave exactly the other's two statements, and the survivor
    must still refresh() and then tear down cleanly. Guards against over-deallocation.

Passes-after (esm and cjs arms both run via testEsmCjsAndDTC):

$ node vitest.mjs run tests/live.test.ts -t 'deallocate' --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork --reporter=verbose

 RUN  v2.1.2 /agent-workspace/oss/pglite-wt-1789365337/packages/pglite

 ✓ |pglite| tests/live.test.ts > esm import > live > deallocates all prepared statements for a windowed query on unsubscribe 6370ms
 ✓ |pglite| tests/live.test.ts > esm import > live > deallocates prepared statements for a non-windowed query on unsubscribe 1338ms
 ✓ |pglite| tests/live.test.ts > esm import > live > deallocates prepared statements for a windowed query with an offset and limit of 0 1024ms
 ✓ |pglite| tests/live.test.ts > esm import > live > only deallocates the prepared statements of the unsubscribed windowed query 1270ms
 ✓ |pglite| tests/live.test.ts > cjs import > live > deallocates all prepared statements for a windowed query on unsubscribe 4561ms
 ✓ |pglite| tests/live.test.ts > cjs import > live > deallocates prepared statements for a non-windowed query on unsubscribe 1319ms
 ✓ |pglite| tests/live.test.ts > cjs import > live > deallocates prepared statements for a windowed query with an offset and limit of 0 1373ms
 ✓ |pglite| tests/live.test.ts > cjs import > live > only deallocates the prepared statements of the unsubscribed windowed query 1044ms

 Test Files  1 passed (1)
      Tests  8 passed | 40 skipped (48)
   Duration  20.86s

$ node vitest.mjs run tests/live.test.ts -t 'accumulate' --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork --reporter=verbose

 ✓ |pglite| tests/live.test.ts > esm import > live > does not accumulate prepared statements over repeated windowed subscribe/unsubscribe cycles 6553ms
 ✓ |pglite| tests/live.test.ts > cjs import > live > does not accumulate prepared statements over repeated windowed subscribe/unsubscribe cycles 3947ms

 Test Files  1 passed (1)
      Tests  2 passed | 46 skipped (48)
   Duration  12.99s

4. Fails-before for every new test — src/ reverted to origin/main, module rebuilt

$ git show origin/main:packages/pglite/src/live/index.ts > packages/pglite/src/live/index.ts
$ node build-live.mjs packages/pglite     # rebuild from the reverted source
$ node vitest.mjs run tests/live.test.ts -t 'deallocate' --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork --reporter=verbose

 RUN  v2.1.2 /agent-workspace/oss/pglite-wt-1789365337/packages/pglite

 × |pglite| tests/live.test.ts > esm import > live > deallocates all prepared statements for a windowed query on unsubscribe 6315ms
   → expected [ Array(1) ] to deeply equal []
 ✓ |pglite| tests/live.test.ts > esm import > live > deallocates prepared statements for a non-windowed query on unsubscribe 1102ms
 × |pglite| tests/live.test.ts > esm import > live > deallocates prepared statements for a windowed query with an offset and limit of 0 912ms
   → expected [ Array(1) ] to deeply equal []
 × |pglite| tests/live.test.ts > esm import > live > only deallocates the prepared statements of the unsubscribed windowed query 1129ms
   → expected 3 to be 2 // Object.is equality
 × |pglite| tests/live.test.ts > cjs import > live > deallocates all prepared statements for a windowed query on unsubscribe 4675ms
   → expected [ Array(1) ] to deeply equal []
 ✓ |pglite| tests/live.test.ts > cjs import > live > deallocates prepared statements for a non-windowed query on unsubscribe 1375ms
 × |pglite| tests/live.test.ts > cjs import > live > deallocates prepared statements for a windowed query with an offset and limit of 0 1289ms
   → expected [ Array(1) ] to deeply equal []
 × |pglite| tests/live.test.ts > cjs import > live > only deallocates the prepared statements of the unsubscribed windowed query 1360ms
   → expected 3 to be 2 // Object.is equality

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |pglite| tests/live.test.ts > esm import > live > deallocates all prepared statements for a windowed query on unsubscribe
 FAIL  |pglite| tests/live.test.ts > cjs import > live > deallocates all prepared statements for a windowed query on unsubscribe
AssertionError: expected [ Array(1) ] to deeply equal []

- Expected
+ Received

- Array []
+ Array [
+   Object {
+     "name": "live_query_88e40557485941b1ace3b7e743f84df0_get_total_count",
+   },
+ ]

 ❯ tests/live.test.ts:1186:33

 Test Files  1 failed (1)
      Tests  6 failed | 2 passed | 40 skipped (48)
   Duration  20.97s
$ node vitest.mjs run tests/live.test.ts -t 'accumulate' --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork --reporter=verbose

 × |pglite| tests/live.test.ts > esm import > live > does not accumulate prepared statements over repeated windowed subscribe/unsubscribe cycles 6499ms
   → expected [ Array(1) ] to deeply equal []
 × |pglite| tests/live.test.ts > cjs import > live > does not accumulate prepared statements over repeated windowed subscribe/unsubscribe cycles 4722ms
   → expected [ Array(1) ] to deeply equal []

AssertionError: expected [ Array(1) ] to deeply equal []

- Expected
+ Received

- Array []
+ Array [
+   Object {
+     "name": "live_query_ff986f700b5c4b5cbb4a4c0a530702a6_get_total_count",
+   },
+ ]

 ❯ tests/live.test.ts:1284:32

 Test Files  1 failed (1)
      Tests  2 failed | 46 skipped (48)
   Duration  14.12s

The leaked statement is named in the diff output every time — live_query_<id>_get_total_count,
exactly the statement the fix adds a DEALLOCATE for. The non-windowed control passes on both
arms, which is what proves the isWindowed guard is doing its job rather than the fix being
unconditional.

5. Whole test file with all five tests, fix restored

$ node vitest.mjs run tests/live.test.ts --typecheck.enabled=false --pool=forks --poolOptions.forks.singleFork

 RUN  v2.1.2 /agent-workspace/oss/pglite-wt-1789365337/packages/pglite

 ✓ |pglite| tests/live.test.ts  (48 tests) 70422ms

 Test Files  1 passed (1)
      Tests  48 passed (48)
   Duration  73.46s
$ node prettier.cjs --check packages/pglite/tests/live.test.ts packages/pglite/src/live/index.ts
Checking formatting...
All matched files use Prettier code style!

6. Diff read for behaviour outside the stated bug

The whole source change is one interpolated line inside the existing tx.exec teardown template:

               DROP VIEW IF EXISTS live_query_${id}_view;
               DEALLOCATE live_query_${id}_get;
+              ${isWindowed ? `DEALLOCATE live_query_${id}_get_total_count;` : ''}
  • isWindowed (src/live/index.ts:58) is const, computed once before init(), never reassigned —
    it cannot disagree between the PREPARE site (:101-104) and this teardown. refresh({offset, limit})
    moves the window but explicitly cannot turn a non-windowed query into a windowed one (:150-157).
  • On the non-windowed path the interpolation is the empty string, so the emitted SQL is byte-identical
    to base — confirmed by the control test passing on both arms.
  • No change to live.changes / live.incrementalQuery teardown (:503-520, :665), which deallocate
    _diff1/_diff2 and are untouched.
  • The changeset file is patch and required by this repo's release tooling; nothing else is modified.

Scope of this verification

  • Executed, not static. Node v24.19.0, real WASM Postgres from @electric-sql/pglite@0.5.8's
    published dist/, with dist/live/index.{js,cjs} rebuilt from branch source by esbuild.
  • A full pnpm build is still not possible here (the Emscripten/submodule toolchain is absent), so
    the Postgres WASM and the sibling entry points come from the published package rather than from
    source. The module under test does not.
  • Fork CI produces nothing at this target: askalf/pglite has 0 workflow runs because
    build_and_test.yml requests blacksmith-32vcpu-ubuntu-2204 runners that only exist for the
    upstream org. Absence of checks here is not a failing signal.
  • eslint remains unrunnable (needs the workspace pnpm install, which needs the submodule).

Verdict: the fix holds. Every claim in the PR body reproduced independently, and four additional
tests designed to break it — including the over-deallocation and falsy-window cases — pass with the
fix and fail without it (except the non-windowed control, which correctly passes on both).

@askalf askalf added the verified Adversarially verified by a fresh run label Sep 14, 2026

@sprayberry-redline sprayberry-redline 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: APPROVED — ready for the operator to submit; no blocking issues found.

I re-reviewed the live head 47cdc47431c8cfabe83c19785f0d775e1ba5bf61, including the newly added adversarial regression coverage. The base implementation defines isWindowed as both offset and limit being present (including zero), prepares _get_total_count only in that branch, and the changed teardown now uses that same flag. Thus the new DEALLOCATE is issued exactly when the count statement exists and leaves non-windowed teardown untouched.

The added tests cover the original leak, non-windowed teardown, zero-valued window bounds, repeated subscribe/unsubscribe cycles, and isolation between concurrent windowed subscriptions. The candidate facts sheet contains all required sections, provides executed before/after test evidence, and gives the operator an explicit pre-submission command for the unavailable full build environment. I independently traced the base preparation/teardown paths and re-ran upstream PR/issue prior-art searches; no open duplicate was found. Commit messages contain no prohibited AI attribution. Fork CI reports no checks, as documented in the candidate evidence; I did not run the local suite per reviewer policy.

What's good: the one-line production change mirrors the existing setup invariant precisely, while the expanded tests protect the meaningful boundary cases without expanding the implementation scope.

@sprayberry-secondread sprayberry-secondread 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: No blocking issues. This commit (47cdc47) is test-only — it adds four regression tests on top of the already-reviewed one-line fix commit (89e8e69); the fix itself did not change.

What I checked

  • Confirmed the underlying bug directly against base ae182ff (packages/pglite/src/live/index.ts:238-256 at that ref): the windowed teardown only ran DEALLOCATE live_query_${id}_get; with no _get_total_count counterpart, while the isWindowed prepare path (live_index.ts:95-100 at head) prepares both. Bug is real, not a static-analysis artifact.
  • Confirmed packages/pglite/src/live/index.ts is byte-identical between 89e8e69 and 47cdc47 via gh api .../compare/89e8e69...47cdc47 — only packages/pglite/tests/live.test.ts changed.
  • Read all four new tests (tests/live.test.ts:1189-1315 in the diff) against the fix logic (isWindowed = offset !== undefined && limit !== undefined, src/live/index.ts:58).

Findings

Info — tests/live.test.ts, only deallocates the prepared statements of the unsubscribed windowed query (diff lines ~1288-1315): after windowed.unsubscribe(), the test asserts remaining.rows.length equals 2 but not which two statements remain:

const remaining = await db.query<{ name: string }>(
  `SELECT name FROM pg_prepared_statements WHERE name LIKE 'live_query_%' ORDER BY name;`,
)
expect(remaining.rows.length).toBe(2)

A count-only assertion can't distinguish "the right query's statements survived" from "some other pair coincidentally survived." Given both windowed and other are windowed queries with 2 statements each, this is a low-probability gap in practice, not a live bug — suggested tightening:

expect(remaining.rows.map((r) => r.name)).toEqual([
  `live_query_${other.id}_get`,
  `live_query_${other.id}_get_total_count`,
])

(the query object doesn't currently expose id — this would need a small test-only accessor, or asserting on a LIKE-filtered pair captured before windowed.unsubscribe() for comparison). Not blocking; the existing count check plus the later full-teardown assertion (line ~1315, unsubscribed.rows equal []) already gives good confidence.

Info — the four new tests each re-paste the same CREATE TABLE IF NOT EXISTS testTable ... INSERT ... generate_series(1,5) boilerplate (diff lines 1191-1200, 1224-1233, 1261-1270, 1290-1299). This matches the file's pre-existing style (every other it block in live.test.ts does the same), so it's not a regression this PR introduces, just an opportunity if the maintainers ever want a shared beforeEach fixture. Not a finding against this PR specifically.

OSS-candidate read (maintainer's-eye)

  • Commit message (test(live): cover non-windowed and repeated windowed unsubscribe teardown) follows the repo's type(scope): ... convention seen throughout git log -- packages/pglite/src/live/index.ts (fix(live): ..., feat(pglite/live): ...).
  • Test shape matches the file exactly: same testEsmCjsAndDTC wrapper, same db.query<{ name: string }> generic pattern already used by the original windowed-deallocate test in the fix commit, same CREATE TABLE IF NOT EXISTS / generate_series fixture idiom used by ~20 other it blocks in this file.
  • No changeset added in this commit — correct; changesets in this repo track user-visible behavior (the fix commit already carries one), not test-only commits.
  • Direct precedent for maintainers accepting teardown fixes in this exact function: PR electric-sql#643 (fix(live): prevent double deallocate of live query prepared statement, merged). Prior-art search re-run: gh api repos/electric-sql/pglite/commits?path=packages/pglite/src/live/index.ts — no other open or merged PR touches the _get_total_count deallocation; electric-sql#1051, electric-sql#710, electric-sql#845, electric-sql#598 all touch this file for unrelated reasons.
  • No for (let ...) loop pattern exists elsewhere in live.test.ts prior to this PR (does not accumulate prepared statements over repeated ... test, diff line ~1259); it's a reasonable, readable choice for a 3-cycle repetition test and not something I'd expect a maintainer to push back on.
  • I could not execute pnpm build/vitest myself (no Emscripten toolchain, no submodule checkout in this environment, per the PR's own stated limitation) — I did not attempt to independently re-run the test evidence tables in the PR body/rework comments; my confidence here rests on static tracing of the diff against the base source, not a fresh run.

SECOND READ: READY

@askalf askalf changed the title [oss-candidate] fix(live): deallocate the total count statement for windowed queries fix(live): deallocate the total count statement for windowed queries Sep 14, 2026
@askalf askalf added the ready-for-operator Gated; submitted upstream label Sep 14, 2026
@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Submitted upstream. This staging PR is closed; the branch stays until upstream resolves. The upstream link lives in the team ledger.

@askalf askalf closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oss-candidate Sprayberry Code candidate for upstream ready-for-operator Gated; submitted upstream verified Adversarially verified by a fresh run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants