fix(storage): decide the join pushdown on enlistment, not the instance flag - #934
Merged
Merged
Conversation
…e flag `planSqlJoin` sent a join to the hash fallback when the right-hand storage sat inside a connection transaction the left side was not enlisted in, by testing `right.inTransaction`. That flag is written only by `setConnectionTransactionActive`, which only `runSingleSessionConnectionTransaction` calls — the SQLite / DuckDB / PGlite arm. `PostgresTabularStorage`'s pooled branch checks out its own client and deliberately flags no participant, so on a real `pg.Pool` the guard was dead: the pushdown ran, read the right-hand table off a different pooled client, and missed the transaction's uncommitted rows. An inner join returned zero rows where the same call on SQLite, or through the hash fallback, returned the row — a wrong answer with no error, differing between backends and between the two strategies for identical inputs. The question is now enlistment, unioned with the flag: `isEnlistedInConnectionTransaction()` defaults to `false` for the arm that sets the flag on every participant, and `PostgresTabularStorage` overrides it with the context check its pooled write path and `connectionTxQuery` already use. The `inTransaction` half stays for the `tx`-proxy case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The change is narrowly scoped (default behavior preserved for non-overriding backends) and is backed by a focused regression test covering the previously unguarded pooled Postgres case.
Pull request overview
This PR fixes incorrect SQL join pushdown decisions for pooled Postgres connection transactions by basing the decision on actual enlistment in the current async context (not just an instance-level inTransaction flag), preventing joins from being executed on a different pooled client than the one holding uncommitted writes.
Changes:
- Add an overridable
isEnlistedInConnectionTransaction()seam toBaseSqlTabularStorage, and use a unifiedinsideOpenTransaction()check when deciding join pushdown eligibility. - Implement the enlistment check for pooled Postgres via
isEnlistedInConnectionTx(this)inPostgresTabularStorage. - Add a regression test that forces the pooled-branch behavior and asserts the hash-join fallback is chosen when only the RHS is enlisted.
File summaries
| File | Description |
|---|---|
| providers/postgres/src/storage/PostgresTabularStorage.ts | Overrides transaction-enlistment detection for pooled Postgres so join planning can correctly avoid unsafe pushdown. |
| packages/storage/src/tabular/BaseSqlTabularStorage.ts | Introduces an enlistment hook and updates join pushdown planning to use a unified “inside open tx” predicate. |
| packages/test/src/test/storage-tabular/PostgresPooledJoinTransaction.test.ts | Adds coverage to lock in the pooled-transaction join routing decision (pushdown vs hash fallback). |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
What was wrong
BaseSqlTabularStorage.planSqlJoinguards against pushing a join down while the right-hand storage sits inside a connection transaction the left side is not enlisted in. Its own comment names the pooled-Postgres case explicitly — "on a real pool it would run on a different client from the one the right side is enlisted on and miss its uncommitted rows entirely… hand this case to it." — but the test it used was:inTransactionis an instance field written only bysetConnectionTransactionActive, which onlyrunSingleSessionConnectionTransactioncalls — the SQLite / DuckDB / PGlite arm.PostgresTabularStorage.runConnectionTransaction's pooled branch callsrunNativeConnectionTransactiondirectly withownsSession: falseand flags no participant, deliberately: the transaction runs on one checked-out client while the same instances keep serving unrelated callers off the pool, so enlistment there is a property of the async context, not of the instance.withTransactionon that arm says the same thing in a comment ("We deliberately do NOT setthis.inTransactionhere"), and_putBulkInternalalready asks the right question —this.inTransaction || isEnlistedInConnectionTx(this).So on a real
pg.Poolthe guard was dead.The concrete failure
AandBon the samePool.withConnectionTransaction([B], async () => { await B.put(row); … }).A.join(spec, B)—Ais not a participant. A join is a read, soassertNotForeignConnectionTx(write-only) raises nothing.planSqlJoin: same dialect, same shared handle (both the pool),B.inTransaction === false→ pushdown chosen.runSqlJoinexecutes throughA.db;connectionTxQuery(A)isundefined, so a fresh pooled client runs the JOIN.rowis invisible. The inner join returns zero rows where the same call on SQLite — or through the hash fallback, which goes viaB.query()and resolves the enlisted client — returns it.A wrong answer with no error, differing between backends and between the two join strategies for identical inputs.
What changed
BaseSqlTabularStoragegainsprotected isEnlistedInConnectionTransaction(), defaulting tofalse, plus a privateinsideOpenTransaction()that unions it with theinTransactionflag. The base class cannot import the connection-transaction module itself — that module is instantiated once per runtime entry (NativeConnectionTransaction.server/.browser), and pulling either in would dragnode:async_hooksinto the browser build — so the seam is a virtual method, in the same shape as the existingsetConnectionTransactionActive/sharedConnectionHandlehooks.PostgresTabularStorageoverrides it withisEnlistedInConnectionTx(this)— the predicate its pooled write path andconnectionTxQueryalready use.if (right.insideOpenTransaction() && !this.insideOpenTransaction()) return null;. TheinTransactionhalf is kept, so thetx-proxy case (wherecreateTxView'sextrassetsinTransaction: trueon a pooled backend whose instance flag staysfalse) is unchanged; the proxy binds methods to the receiver, so the union reads the override.No behavior change on the single-session arm, where the flag is set on every participant and the union is already
true.Test
packages/test/src/test/storage-tabular/PostgresPooledJoinTransaction.test.tsbuilds twoPostgresTabularStorageinstances over apg.Pool-shaped facade around one PGlite session. The facade exposesconnect(), which is exactly howPostgresTabularStoragedecides it is on the pooled arm — sorunConnectionTransactiontakes the pooled branch and flags no participant, reproducing the state that made the guard dead. It then enlists only the right side and joins from the un-enlisted left, asserting the hash fallback ran (the right side'squerywas called). A second case asserts the pushdown is still chosen when neither side is in a transaction.Run against the unfixed source first: the first case failed (
expected 0 to be greater than 0). With the fix, both pass.What could not be exercised here
There is no live Postgres in this environment, so the test cannot demonstrate the visibility half — under one PGlite session the pushdown sees the uncommitted row anyway, and the row assertion passes on both paths. What it pins is the routing decision, made from the same pooled state a real
pg.Poolproduces; the "different client, invisible rows" consequence of that decision ispg.Poolbehavior, not something this repo implements. The existingPostgresTabularStorage.integration.test.ts(real server, skipped without one) was not run.Verified
Run in a clean worktree after
bun install, withbun run use-dist(realdist) for the type checks:bunx vitest run packages/test/src/test/storage-tabular/PostgresPooledJoinTransaction.test.ts— 2 passed; 1 failed before the fix.bun scripts/test.ts storage unit vitest— 61 passed, 2 skipped (63 files, 1178 tests).bun scripts/test.ts storage unit bun— 1182 pass, 100 skip, 0 fail (same 63 files).bun scripts/test.ts --check-sections— every test file discoverable;bunx vitest run scripts/testDiscovery.test.ts— 11 passed.bunx tsc --noEmitinpackages/storage,providers/postgres,packages/test— clean.bunx oxlint --type-awareover the three touched trees — clean.bunx oxfmt --checkon the touched trees — clean. (packages/storage/src/vector/README.mdreports a pre-existing issue onmain; untouched here.)Not verified here
storageunit section above; they take the defaultfalseoverride and their behavior is unchanged by construction.🤖 Generated with Claude Code
https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
Generated by Claude Code