Skip to content

feat(postgres): keep a tab's transaction alive across runs - #801

Merged
debba merged 25 commits into
TabularisDB:mainfrom
egertaia:feat/pg-pinned-transaction-session
Oct 1, 2026
Merged

debba merged 25 commits into
TabularisDB:mainfrom
egertaia:feat/pg-pinned-transaction-session

Conversation

@egertaia

@egertaia egertaia commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A batch already runs its statements on one pooled connection, so BEGIN … COMMIT inside a single script works. The workflow the transaction exists for does not:

Run 1:  BEGIN; UPDATE accounts SET balance = 0 WHERE id = 42;
Run 2:  SELECT balance FROM accounts WHERE id = 42;   -- check before committing
Run 3:  COMMIT;

Between runs the connection goes back to the pool (execute_batch acquires at the top and drops at the end), so run 2 can land on a different connection and show pre-transaction data, and run 3 reports there is no transaction in progress while the real transaction stays open somewhere else.

It is also a correctness hazard for unrelated queries. No RecyclingMethod is configured for the Postgres pool, so deadpool's default Fast applies and resets nothing: the stranded connection returns to the pool still inside the transaction, holding its locks, and the next borrower runs inside it.

Fix

An editor tab is a session. When a batch leaves an explicit transaction open, that tab's connection is pinned to it instead of returning to the pool, so the next run from the same tab continues the same transaction. Committing, rolling back, closing the tab, or leaving it idle for 30 minutes releases it. A pinned connection is never handed back to the pool without a ROLLBACK first, so an open transaction can no longer leak into an unrelated query.

Pinning is lazy: a tab that never opens a transaction behaves exactly as before and holds nothing, so N idle tabs still cost no connections.

Core

  • common/query: transaction_effect() classifies a statement's effect on the surrounding transaction from its leading keywords only (BEGIN, START TRANSACTION, COMMIT, END, ROLLBACK, ABORT, PREPARE TRANSACTION, and ... AND CHAIN, which ends the transaction and opens a new one), so a BEGIN in a string literal or a PL/pgSQL block body cannot be mistaken for transaction control. ROLLBACK TO SAVEPOINT leaves the transaction open and is classified as no-op.
  • driver_trait: execute_query_in_session(), execute_batch_in_session() and release_session(), all defaulting to today's behaviour, so every driver that does not implement them is untouched.
  • commands: execute_query and execute_query_batch take session_id and emit session-transaction-state; new release_query_session command.
  • plugins/driver: session_id goes out in the execute_query and execute_query_batch RPCs and a release_session call is added, tolerating method-not-found. parse_query_response/parse_batch_response accept the existing bare shapes and the new { result | results, in_transaction } objects, so this and the PostgreSQL plugin can be updated in either order. The wrapper is recognised by in_transaction being present, so a query selecting a column named result is not mistaken for one.

Postgres (built-in driver)

  • postgres/session: the registry of pinned connections, the ROLLBACK-before-release rule and the idle sweep, which runs every minute from a task started on the first pin.
  • postgres/mod: execute_batch_in_session reuses the tab's pinned connection and re-pins it if a transaction is still open. A failed ordinary statement leaves the transaction state alone (it is aborted but still open, so the user can ROLLBACK from the same tab). A failed COMMIT is different: PostgreSQL has already rolled the transaction back, so the session is released. execute_query is now a one-statement execute_batch_in_session, so there is a single session code path.

Editor

  • Sends sessionId (the tab id) on every run, batch and single statement. The single-statement path matters most: Editor.tsx routes a one-statement run to runQuery/execute_query, which is exactly how a transaction gets driven, so without it BEGIN, the changes and COMMIT each landed on a different pooled connection and COMMIT/ROLLBACK hit a connection with no transaction, where PostgreSQL only warns and reports success.
  • Paging within a result and copy-all-rows pass it too, or they read around the tab's own open transaction and show pre-transaction data.
  • A TX badge on tabs reported as in a transaction, since their uncommitted changes are invisible to every other tab.
  • Releases the connection once a close actually proceeds, after the unsaved-file prompt, so cancelling it cannot discard a live transaction. All five close paths funnel through requestTabClosure, so it is hooked there once.

Behaviour change worth flagging

A batch that leaves a transaction open with no session to pin it to (a non-editor caller) is now rolled back rather than handed back to the pool. That is the leak described above; nothing depended on it except by accident.

Testing

  • pnpm vitest run tests/utils tests/components: 3680 pass. Two intermediate runs failed on jsdom timeouts in NewConnectionModal and other component tests; I reproduced that class of failure on a clean main checkout under load too, so it is pre-existing flakiness, not a regression.
  • tsc -p tsconfig.app.json --noEmit and eslint src/pages/Editor.tsx clean.
  • New Rust tests for transaction_effect (opens / closes / savepoint / leading comments / PL/pgSQL body / empty), parse_batch_response (bare array, object form, missing flag) and parse_query_response (bare result, wrapper, a result column not mistaken for a wrapper).
  • The Rust side is not compiled or tested here. This machine has no GTK/WebKit dev libraries, so cargo check fails in gdk-sys before reaching any of this code. CI is the first real build, and I have not exercised it against a live PostgreSQL instance.

Follow-up

The same pinning is needed in the PostgreSQL plugin, whose execute_query_batch handler has the identical acquire-and-release shape. Separate PR there; this one only defines the contract and forwards the session id.

Egert Aia added 2 commits September 21, 2026 18:25
An editor batch already runs its statements on one pooled connection, so
BEGIN ... COMMIT inside a single script works. The workflow the
transaction exists for did not: run BEGIN, inspect, run the changes,
verify, then COMMIT, each as its own run. Between runs the connection
went back to the pool, so the next run could land on a different one -
the verify step saw pre-transaction data and COMMIT reported no
transaction in progress, while the real one stayed open elsewhere.

Worse, deadpool recycles without resetting (no RecyclingMethod is
configured), so the stranded connection went back to the pool still
inside the transaction, holding its locks, and the next borrower ran
inside it.

- common/query: transaction_effect() classifies a statement's effect on
  the surrounding transaction from its leading keywords.
- postgres/session: registry of connections pinned per editor tab, with
  a ROLLBACK before any pinned connection returns to the pool and an
  idle sweep so an abandoned tab cannot hold locks indefinitely.
- driver_trait: execute_batch_in_session() and release_session(), both
  defaulting to today's behaviour so only Postgres changes.
- commands: execute_query_batch takes session_id and emits
  session-transaction-state; new release_query_session command.
Plumb the editor tab id through as a session id, forward it to plugin
drivers over RPC, release the connection when the tab closes, and show
on the tab that it is holding one.

- plugins/driver: session_id in the execute_query_batch RPC plus a
  release_session call, both tolerating method-not-found so existing
  plugins keep working. parse_batch_response accepts the old bare array
  and the new { results, in_transaction } object, so core and plugin can
  be updated in either order.
- Editor: send sessionId with every batch, track the tabs a
  session-transaction-state event reports as in a transaction, show a TX
  badge on them, and release the connection once a close proceeds -
  after the unsaved-file prompt, so cancelling cannot discard a live
  transaction.
- Tests for transaction_effect and parse_batch_response.
Running statements one at a time is exactly how a transaction is driven -
BEGIN, the changes, a verifying SELECT, COMMIT - and that path bypassed
the session entirely. Editor.tsx routes a one-statement run to runQuery,
which calls execute_query, not execute_query_batch, so each run took a
fresh pooled connection: the UPDATE auto-committed on its own connection
and the later COMMIT/ROLLBACK hit a connection with no transaction, where
PostgreSQL only warns and reports success. The TX badge stayed on from an
earlier multi-statement run with nothing to clear it, which made it look
like pinning was working.

- driver_trait: execute_query_in_session(), defaulting to execute_query.
- postgres: execute_query is now a one-statement execute_batch_in_session,
  so there is a single session code path.
- plugins/driver: session_id on the execute_query RPC, and
  parse_query_response accepting both the bare QueryResult and the new
  { result, in_transaction } wrapper. The wrapper is recognised by
  in_transaction being present, so a query selecting a column named
  'result' is not mistaken for one.
- commands: execute_query takes session_id and emits
  session-transaction-state like the batch does.
- Editor: sessionId on all three execute_query call sites. Paging within
  a result and copy-all-rows need it too, or they read around the tab's
  own open transaction and show pre-transaction data.
@aesslinger

Copy link
Copy Markdown
Contributor

Cross-linking from the companion PR review over in tabularis-postgresql-plugin#114: a live-DB pass over there against the plugin's run_batch_in_session (which is a byte-for-byte port of this PR's execute_batch_in_session in drivers/postgres/mod.rs) turned up a bug that applies here unchanged, plus a follow-on effect specific to this PR's UI wiring.

The bug

execute_batch_in_session only advances in_transaction via transaction_effect() when outcome.is_ok():

if outcome.is_ok() {
    match crate::drivers::common::transaction_effect(q) {
        crate::drivers::common::TransactionEffect::Opens => in_transaction = true,
        crate::drivers::common::TransactionEffect::Closes => in_transaction = false,
        crate::drivers::common::TransactionEffect::None => {}
    }
}

That's correct for an ordinary statement failing mid-transaction (a bad INSERT aborts the transaction but leaves it open, so staying pinned is right). It's not correct for a COMMIT that fails on its own — which happens whenever a deferred constraint is violated. In that case Postgres has already ended the transaction attempt server-side (a later ROLLBACK on the same backend just warns there is no transaction in progress), but because COMMIT returned Err, this code leaves in_transaction at its prior value (true) and re-pins the connection believing a transaction is still open.

Confirmed against real Postgres 16, with a test built around a DEFERRABLE INITIALLY DEFERRED foreign key (the INSERT succeeds since the check is deferred; only COMMIT fails):

assertion `left == right` failed: a failed COMMIT already ended the transaction
server-side; the session must not stay pinned believing one is still open
  left: Bool(true)
 right: Bool(false)

Also worth noting since it surprised me: a COMMIT on a transaction merely aborted by an earlier statement (e.g. SELECT 1/0) does not error at all — Postgres silently converts it to a ROLLBACK and reports success. So this only bites on a COMMIT that fails in its own right (deferred constraints, SET CONSTRAINTS ... IMMEDIATE, etc.), which is probably why it's easy to miss without a live database in the loop.

The fix is likely the same in both places: a COMMIT/ROLLBACK failure should be treated as TransactionEffect::Closes regardless of outcome, rather than folded into the "only apply transaction_effect on success" rule that's correct for every other statement.

Why it's more visible here than in the plugin alone

This PR wires in_transaction into a session-transaction-state event that drives the amber "TX" tab badge in Editor.tsx, and release_query_session is only invoked for tabs the UI believes are pinned (transactionTabIds). So the bug isn't just an internal bookkeeping slip here — a user who hits a deferred-constraint COMMIT failure will see the tab keep showing "TX" indefinitely even though the server has already ended the transaction, with no user-visible way to clear it short of the 30-minute idle sweep or closing the tab. Worth fixing in drivers/postgres/mod.rs before/alongside the plugin-side fix so the two don't drift.

Egert Aia added 2 commits September 26, 2026 12:18
PostgreSQL rolls the transaction back when COMMIT fails on its own (e.g. a
deferred constraint), so keeping the tab pinned left the TX badge on and a
connection held with no transaction behind it. Also classify ABORT,
PREPARE TRANSACTION, COMMIT/ROLLBACK PREPARED and ... AND CHAIN, the last of
which previously returned a connection to the pool with a fresh transaction
still open.
The idle check only ran inside take(), so a tab abandoned mid-transaction
kept its locks until another tab happened to run a query. A sweeper task
started on the first pin now releases idle sessions every minute.
@egertaia

Copy link
Copy Markdown
Contributor Author

Same two fixes applied here to keep it in step with the plugin (31d8f71, 2fda2dc):

  • A failed COMMIT now ends the tab's transaction, so the TX badge clears. The classifier also handles ... AND CHAIN, ABORT, PREPARE TRANSACTION and COMMIT PREPARED / ROLLBACK PREPARED, with the same code and unit tests as the plugin.
  • Idle pinned sessions are now swept every minute by a task started on the first pin, instead of only when another tab runs a query.

The host Rust still can't be built on my machine, so CI is the first compile.

Comment thread src-tauri/src/drivers/postgres/mod.rs Outdated
Comment thread src-tauri/src/commands.rs
Comment thread src-tauri/src/drivers/common/query.rs Outdated
Comment thread src-tauri/src/drivers/common/query.rs
Comment thread src/pages/Editor.tsx Outdated
Comment thread src-tauri/src/plugins/driver.rs
Comment thread src-tauri/src/plugins/driver.rs Outdated
Comment thread src/pages/Editor.tsx
Comment thread src/pages/Editor.tsx Outdated
Comment thread src/pages/Editor.tsx
@kilo-code-bot

kilo-code-bot Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1

This pass reviewed the incremental diff a181e676..ff132136 (182 insertions, 28 deletions across 6 files). The previously carried plugin-driver finding is now resolved: RpcDriver overrides session_in_transaction, so the failure- and cancel-path badge fix in 060b3d4d is live for plugin drivers and the driver_trait.rs doc no longer needs its plugin carve-out.

Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/drivers/postgres/export.rs 26 Carried, unchanged. Aborting the export drops the future while SAVEPOINT tabularis_read is still open, and the pinned connection is handed back to the tab mid-query_raw. The savepoint is only released on the paths that run to completion. An active thread already covers this exact line, so no duplicate was posted; it remains disputed by the author.

SUGGESTION

File Line Issue
src-tauri/src/plugins/driver.rs 1042 New. parse_session_error is applied only in execute_query_in_session; the batch path falls through to parse_batch_response, which turns a session-aware error response into a serde parse error and short-circuits before remember_session_state, so a failed batch neither reports the plugin message nor updates the cached transaction state.
Files Reviewed (6 files changed in this pass)
  • src-tauri/src/drivers/common/query.rs - 0 issues. New leading_keywords lexer plus the rewritten transaction_effect. Traced by hand: the word flush happens before the comment/quote arms (so COMMIT/*x*/AND CHAIN still chains), the n = 6 early return fires only after a push, the tail flush is guarded by words.len() < n, and WORK/TRANSACTION stripping is applied only to the four control verbs so START TRANSACTION is unaffected. Nested and unterminated block comments, -- at EOF, E'...' and dollar quotes, and $1 params all resolve correctly. transaction_effect is called only from the PostgreSQL driver, so losing strip_leading_sql_comments' # support is not a regression here.
  • src-tauri/src/drivers/common/tests.rs - 0 issues. The new cases match the implementation exactly, including the nested-comment chain case.
  • src-tauri/src/drivers/driver_trait.rs - 0 issues. Doc-only change; accurate now that the plugin side implements the method.
  • src-tauri/src/drivers/postgres/session.rs - 0 issues. Drop for Slot calls Client::take, deadpool's discard-the-object call, so the doc claim that a dropped slot closes rather than recycles the connection is correct. Every removal path (sweep_idle, release_all, session::release) either takes the pin first or keeps pinned slots, so Drop is a safety net rather than a behaviour change. Lock order stays map-then-slot everywhere and the map guard is a temporary in each case, so release_all's try_lock inside retain cannot deadlock.
  • src-tauri/src/drivers/postgres/session_tests.rs - 0 issues. The new shutdown test is a pure map-membership assertion; because the module never pins, the global release_all() it calls cannot race the sibling tests.
  • src-tauri/src/plugins/driver.rs - 1 issue.
Verification limits

No Rust compilation, no cargo test, and no live PostgreSQL or plugin execution was performed. deadpool::managed::Client::take semantics were confirmed from the documented take/put_back split and the identical, already-shipping use at src-tauri/src/drivers/postgres/mod.rs:1255, not from the crate source, which is not vendored in this environment. The plugin error contract for execute_query_batch is not documented in this repository, which is why the finding on line 1042 is a SUGGESTION rather than a WARNING.

Fix these issues in Kilo Cloud

Previous Review Summaries (5 snapshots, latest commit a181e67)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit a181e67)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1

This pass reviewed the incremental diff 6c8db35c..a181e676 (57 insertions, 29 deletions in src-tauri/src/commands.rs, src-tauri/src/health_check.rs, src/contexts/EditorProvider.tsx, src/pages/Editor.tsx). No new issues were found. Both warnings raised in the previous pass on commands.rs are fixed: release_connection_sessions spawns the releases, and handle_connection_failure now drains the same bookkeeping before closing the pool. The remaining findings are on files outside this diff and still carry open threads, so no duplicate comments were posted.

Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/drivers/postgres/export.rs 26 Carried, unchanged. Aborting the export drops the future while SAVEPOINT tabularis_read is still open, and the pinned connection is returned to the tab mid-query_raw. The savepoint is only released on the paths that run to completion, so the next run from that tab inherits it.

SUGGESTION

File Line Issue
src-tauri/src/drivers/driver_trait.rs 746 Carried, unchanged. The doc now states that None is expected for plugin drivers, but RpcDriver still does not override session_in_transaction, so the failure- and cancel-path reporting added in 060b3d4 stays inert for plugin drivers and their TX badge remains lit after a failed COMMIT.
Files Reviewed (4 files changed in this pass)
  • src-tauri/src/commands.rs - new release_connection_sessions, disconnect_connection call site. Both previous findings resolved.
  • src-tauri/src/health_check.rs - handle_connection_failure resolves params once, releases sessions, then closes the pool. Previous finding resolved.
  • src/contexts/EditorProvider.tsx - comment only; the !activeConnectionId early return is now covered by the two backend release paths.
  • src/pages/Editor.tsx - TX badge switched from hard-coded amber to TONE_SOFT_BG_CLASS.warning / TONE_TEXT_CLASS.warning, matching the design-token contract.

Verified but unchanged, findings carried above: src-tauri/src/drivers/postgres/export.rs, src-tauri/src/drivers/driver_trait.rs.

Verification limits

The Rust side was not compiled or exercised against a live PostgreSQL instance. release_connection_sessions releasing after close_pool_with_id was checked against deadpool's owned-object semantics: a checked-out client keeps the pool internals alive, so the background ROLLBACK still reaches a live socket. The previous postgres/export.rs:44 finding was dropped after re-reading the current code, where the Slot guard is scoped to the pinned branch.

Fix these issues in Kilo Cloud

Previous review (commit 47a420c)

Status: 6 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 4
SUGGESTION 2

This pass reviewed the incremental diff a3e9473f..47a420c6 (116 insertions, 29 deletions, all in src-tauri/src/commands.rs). Two new findings are inline on the new disconnect path. The four carried findings are unchanged and re-verified against 47a420c6; each already has an open thread, so no duplicate was posted.

Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/commands.rs 5828 New. drv.release_session(id).await awaits the tab's Slot lock, which a run holds for its whole duration, and nothing cancels in-flight runs first. One long query or export in a transaction tab makes the awaited disconnect_connection invoke hang with the connection still shown as open, where release_all and sweep_idle deliberately use try_lock and skip busy slots.
src-tauri/src/commands.rs 5823 New. open_sessions is drained only by disconnect_connection. handle_connection_failure (health_check.rs:202) also closes the pool, and the pinned deadpool object keeps the socket alive, so the transaction holds its server locks for 30 minutes — the outcome this commit removes — and the map entry is never removed either.
src-tauri/src/drivers/postgres/export.rs 26 Carried, unchanged. Aborting the export drops only the Slot guard; the client stays pinned mid-query_raw and the tabularis_read savepoint is never released, which contradicts the InFlight guard in postgres/mod.rs.
src/contexts/EditorProvider.tsx 60 Carried, unchanged. !activeConnectionId returns after line 59 already overwrote the previous tab list, so tabs removed on detachConnection are never released. Severity reduced by 47a420c for the explicit-disconnect path (the backend now rolls those sessions back), but the connection-health-failed path is still unhandled on both sides — see the new finding at commands.rs:5823.

SUGGESTION

File Line Issue
src-tauri/src/drivers/postgres/export.rs 44 Carried, unchanged. slot stays live for the whole function, so when nothing is pinned — the common case — the tab's session lock is held across the entire pooled export and any run from that tab blocks on lock().
src-tauri/src/drivers/driver_trait.rs 746 Carried, unchanged. The doc now explains None (8819a0b), but RpcDriver still does not override session_in_transaction, so the failure- and cancel-path reporting added in 060b3d4 is inert for plugin drivers and their badge stays lit after a failed COMMIT.
Files Reviewed (1 file changed in this pass)
  • src-tauri/src/commands.rs - 2 new issues (open_sessions bookkeeping, emit_session_state / emit_session_state_after_failure threading connection_id, release_query_session and disconnect_connection call sites)

Verified but unchanged, findings carried above: src-tauri/src/drivers/postgres/export.rs, src-tauri/src/drivers/postgres/session.rs, src-tauri/src/drivers/driver_trait.rs, src-tauri/src/plugins/driver.rs, src/contexts/EditorProvider.tsx, src/pages/Editor.tsx, src-tauri/src/export.rs, src-tauri/src/lib.rs.

Verification limits

The Rust side was not compiled or exercised against a live PostgreSQL instance. The two new findings are derived from the code: session::release's lock(session_id).await, the module's documented "lock held for a whole run" contract, deadpool's owned-object semantics for close_pool_with_id, and the absence of any cancellation call in disconnect_connection and in DatabaseProvider.disconnect. The diff for the incremental commit was read from gh pr diff; git diff a3e9473f..47a420c6 could not be produced locally because the partial clone's missing blob fetch returned HTTP 429.

Fix these issues in Kilo Cloud

Previous review (commit a3e9473)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/drivers/postgres/export.rs 26 The new session path borrows the pinned client instead of taking it, so aborting the export (the only cancellation mechanism cancel_export has) drops only the Slot guard. The client stays in the slot mid-query_raw and the tabularis_read savepoint is never released — the asymmetry with InFlight (postgres/mod.rs:1250), which closes the connection on drop, is now the bug.
src/contexts/EditorProvider.tsx 60 if (!activeConnectionId) return; skips the release on paths that set the id to null without closing the backend pool (detachConnection, connection-health-failed), and line 59 has already overwritten the previous tab list, so those tab ids are never released later either. The transaction is stranded on the server holding its locks, with no badge and no owner, until MAX_IDLE sweeps it 30 minutes later.

SUGGESTION

File Line Issue
src-tauri/src/drivers/postgres/export.rs 44 slot stays live for the whole function, so when nothing is pinned — the common case — the tab's session lock is held across the entire pooled export and any run from that tab blocks on lock(). Before 1d036bb2 the lock was taken per page.
src-tauri/src/drivers/driver_trait.rs 746 Unresolved from the previous pass. The doc now explains None (8819a0b), but RpcDriver still does not override session_in_transaction, so the failure- and cancel-path session-transaction-state emit added in 060b3d4 is still inert for plugin drivers and a plugin tab's badge stays lit after a failed COMMIT.
Files Reviewed (9 changed in this pass)
  • src-tauri/src/drivers/driver_trait.rs - 1 issue (carried over)
  • src-tauri/src/drivers/postgres/export.rs - 2 issues
  • src-tauri/src/drivers/postgres/session.rs - 0 issues (the sweeper now starts on the first lock(), and release_all's try_lock plus the exit log close out the previous two findings)
  • src-tauri/src/drivers/postgres/session_tests.rs - 0 issues
  • src-tauri/src/export.rs - 0 issues (the built-in PostgreSQL driver no longer takes the paged plugin path)
  • src-tauri/src/lib.rs - 0 issues
  • src/contexts/EditorProvider.tsx - 1 issue
  • src/pages/Editor.tsx - 0 issues (the release hook now covers every close path, and the badge announcement comes from an always-mounted role="status" region)
  • tests/contexts/EditorProvider.test.tsx - 0 issues
Verification limits

The Rust side was not compiled or exercised against a live PostgreSQL instance in this environment. The export cancellation and savepoint findings are derived from the code, from cancel_export's use of AbortHandle::abort, and from tokio-postgres' contract for a dropped query future, not from a test run. The EditorProvider finding rests on the disconnect paths reported by DatabaseProvider, which were read but not executed.

Fix these issues in Kilo Cloud

Previous review (commit e4b4f37)

Status: 8 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 5

This pass covers the 7 new commits since the last review (2fda2dcc..e4b4f376). All previously reported issues are resolved: the cancel-abort leak is closed by the InFlight RAII wrapper, session-transaction-state is now emitted on every failure path, the (Chains, true) fallthrough and the null in_transaction / doc-splice issues in plugins/driver.rs are fixed, and release_all() is genuinely called on exit. The PREPARE TRANSACTION finding is withdrawn — the PostgreSQL docs confirm a failed prepare becomes a ROLLBACK, and the single-connectionId finding was a false positive (EditorProvider already scopes tabs to the active connection).

The remaining findings all concern the newly added per-session locking and the new read-in-transaction path used by export and count.

Issue Details (click to expand)

WARNING

File Line Issue
src-tauri/src/export.rs 195 A session export for a built-in driver is routed to the generic paged path: the query is re-run per 1000-row page with LIMIT/OFFSET, and each page takes a new READ COMMITTED snapshot, so concurrent commits shift the offsets and the exported file silently duplicates or drops rows (one scan becomes N). The mysql/sqlite trait default ignores the session id, so those would export committed data.
src-tauri/src/export.rs 233 Cancelling an in-session export aborts the task mid-page while the client is out of the slot, so InFlight::drop closes it and the server rolls the tab's transaction back with no ROLLBACK and no signal beyond "Export cancelled".
src-tauri/src/drivers/postgres/session.rs 97 lock() inserts a map entry for every session id it is handed, including on plain runs and closes; only sweep_idle prunes them and the sweeper is started from Slot::pin, so an app that never opens a transaction grows the process-global map forever.

SUGGESTION

File Line Issue
src-tauri/src/lib.rs 759 The 3s exit budget is shared across release_all's serial slot locks, so one in-flight run skips every later session, and the timeout result is discarded without a log (the sibling run_exit_backup in the same handler logs its own). The call also bypasses DatabaseDriver, so no other pinning driver is swept.
src-tauri/src/drivers/driver_trait.rs 744 session_in_transaction defaults to None and RpcDriver does not override it, so the new failure-path badge fix is inert for plugin drivers; the doc also does not say what a caller should do with None.
src/pages/Editor.tsx 331 The comment claims "every closing tab", but ExplorerSidebar.tsx:371 and EditorErrorBoundary.tsx:137 call closeTab directly and never reach requestTabClosure, leaving the connection and transaction pinned for up to 30 minutes.
src/pages/Editor.tsx 3999 The role="status" region is mounted by a conditional together with its content, so the transaction-state change is not announced.
src/pages/Editor.tsx 326 (carried over, previously reported) Unmounting the Editor or switching connections never releases pinned sessions — the effect cleanup only unlistens, and EditorProvider keeps the previous connection's tabs alive.
Files Reviewed (11 changed in this pass)
  • src-tauri/src/commands.rs - 0 issues (the failure-path emit now covers every arm; re-verified)
  • src-tauri/src/drivers/common/query.rs - 0 issues (Opens | Chains truth table correct, PREPARE TRANSACTION claim upheld)
  • src-tauri/src/drivers/common/tests.rs - 0 issues
  • src-tauri/src/drivers/driver_trait.rs - 1 issue
  • src-tauri/src/drivers/postgres/mod.rs - 0 issues (drop order of in_flight/slot is correct; re-verified)
  • src-tauri/src/drivers/postgres/session.rs - 1 issue
  • src-tauri/src/drivers/postgres/session_tests.rs - 0 issues
  • src-tauri/src/export.rs - 2 issues
  • src-tauri/src/lib.rs - 1 issue
  • src-tauri/src/plugins/driver.rs - 0 issues (doc splice and null-flag handling both verified fixed)
  • src/pages/Editor.tsx - 3 issues
Verification limits

The Rust side was not compiled or exercised against a live PostgreSQL instance in this environment. The export paging/snapshot and savepoint-semantics findings are derived from the code and documented PostgreSQL behaviour, not from a test run. The new session_tests.rs lock tests use sleep-based negative assertions and would pass vacuously if the spawned waiter were never scheduled, so they are weaker evidence than they look.

Fix these issues in Kilo Cloud

Previous review (commit 2fda2dc)

Status: 11 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 5
SUGGESTION 5

The core design is sound and well tested: transaction_effect() correctly classifies from leading keywords only, the session registry is lazy and mutex-guarded without holding a guard across .await, and the plugin response parsers keep both wire shapes valid. The remaining findings cluster around one theme — the new pinning invariant is not upheld on every exit path, so the exact pool leak this PR removes can still happen.

Issue Details (click to expand)

CRITICAL

File Line Issue
src-tauri/src/drivers/postgres/mod.rs 1211 cancel_query aborts the spawned task after session::take removed the entry, so the client is returned to the pool inside the open transaction and the session becomes permanently unreleasable. Breaks the invariant asserted in session.rs:12-14.

WARNING

File Line Issue
src-tauri/src/commands.rs 4510 session-transaction-state is emitted only on the success path, so a COMMIT that fails on a deferred constraint leaves the TX badge lit forever (same shape in the batch arm at 4669).
src-tauri/src/drivers/common/query.rs 526 PREPARE TRANSACTION is mapped to Closes, and in_transaction_after ignores succeeded for Closes; a failed 2PC prepare leaves the block open, so the client goes back to the pool mid-transaction without ROLLBACK.
src-tauri/src/drivers/common/query.rs 491 (Chains, true) falls through to _ => before, contradicting the variant's own doc. With before == false a successful COMMIT AND CHAIN drops a live transaction into the pool. The existing test cannot discriminate.
src/pages/Editor.tsx 338 One connectionId is used for every pinned tab id, but handleCloseOtherTabs/handleCloseAllTabs pass ids from all connections while the store's close is connection-scoped. For plugin drivers the release reaches the wrong process, and the id is dropped from transactionTabIds while its tab is still open.
src-tauri/src/drivers/postgres/session.rs 141 release_all (and is_pinned) are never called anywhere, yet both this file and driver_trait.rs:737 document shutdown release as implemented. The RunEvent::Exit hook at lib.rs:751-759 does not touch the session map.

SUGGESTION

File Line Issue
src-tauri/src/plugins/driver.rs 42 New doc block is spliced onto is_method_not_found's (lines 39-41), so Rust attaches both to parse_query_response and is_method_not_found (line 92) is left undocumented.
src-tauri/src/plugins/driver.rs 57 Wrapper detection requires in_transaction to be a JSON bool, so null (an Option<bool> field) hard-fails on from_value::<QueryResult>; parse_batch_response tolerates this via unwrap_or(false).
src/pages/Editor.tsx 326 Effect cleanup only unlistens — unmount and connection switch never release pinned sessions, while EditorProvider keeps the previous connection's tabs alive.
src/pages/Editor.tsx 336 Closing a tab mid-run finds no pinned id, so nothing is released; the in-flight run then pins the connection to a session id the UI has already forgotten (ghost badge entry).
src/pages/Editor.tsx 3994 aria-label on a role-less <span> is not exposed; the file's own convention at line 3931 wraps the identical title/aria-label pair in role="status".
Also worth noting (not commentable inline — outside the diff)
  • src/pages/Editor.tsx:3678 — export_query_to_file is still invoked without a sessionId, while the copy-all-rows path at 3742 was given one. Exporting from a tab with an open transaction writes pre-transaction data, which is the same class of read-around the PR fixes elsewhere. count_query (line 1808) has the same gap for total_rows.
Files Reviewed (22 files)
  • src-tauri/src/commands.rs - 1 issue
  • src-tauri/src/drivers/common.rs - 0 issues
  • src-tauri/src/drivers/common/query.rs - 2 issues
  • src-tauri/src/drivers/common/tests.rs - 0 issues
  • src-tauri/src/drivers/driver_trait.rs - 0 issues (false on shutdown doc noted above)
  • src-tauri/src/drivers/postgres/mod.rs - 1 issue
  • src-tauri/src/drivers/postgres/session.rs - 1 issue
  • src-tauri/src/lib.rs - 0 issues
  • src-tauri/src/plugins/driver.rs - 2 issues
  • src/i18n/locales/{de,en,es,fr,it,ja,ko,pt-BR,ru,tl,zh}.json - 0 issues (all 11 locales carry both keys, translated, consistent placement)
  • src/pages/Editor.tsx - 4 issues

Verification limits: the Rust side was not compiled or exercised against a live PostgreSQL instance in this environment, so the PostgreSQL-behaviour findings above are derived from the code and documented server semantics, not from a test run.

Fix these issues in Kilo Cloud


Reviewed by free · Input: 0 · Output: 0 · Cached: 0

Egert Aia added 7 commits September 27, 2026 13:18
Two overlapping runs from one tab each took a fresh connection, and the
later store() rolled back the other's transaction. Each session now has its
own lock held for the whole run, and release waits for a run still in
flight.

Pressing Stop aborted the run after its connection had left the session
map, returning it to the pool inside the open transaction. A run's
connection is now closed if the run is dropped, so the server rolls back.
Only the success path emitted session-transaction-state, so a COMMIT that
failed on a deferred constraint left the TX badge on although the
transaction had ended. The failure and cancel arms now ask the driver
through a new session_in_transaction() and emit what it reports.
release_all was documented as running on shutdown but nothing called it.
…omment

parse_query_response now recognises the wrapper by the in_transaction key
alone, like parse_batch_response. The is_method_not_found doc had been
spliced onto parse_query_response.
Only tabs shown as pinned were released, but that state is lost when the
Editor remounts and is not yet set while a tab's first run is in flight.
The backend now waits for an in-flight run before releasing, so every
closing tab is released. The TX badge gets role=status so it is announced.
Row count and export ran on a pooled connection, so a tab mid-transaction
counted and exported pre-transaction data, while its grid showed its own
uncommitted rows. For a badged tab both now read on the pinned connection,
wrapped in a savepoint so a failing read cannot abort the transaction.
@egertaia

Copy link
Copy Markdown
Contributor Author

Replied on each inline thread. Two things not covered there:

  • Export and row count (from the summary, outside the diff): fixed in e4b4f37. For a tab with an open transaction, count_query and export_query_to_file now take the tab's sessionId and read on its pinned connection, so they see the same uncommitted rows as the grid. The read runs between SAVEPOINT and ROLLBACK TO SAVEPOINT, so a failing count or export cannot abort the user's transaction. In that case export pages through the pinned connection instead of streaming from a fresh one.
  • Concurrent runs from one tab: the race the reviewer found on tabularis-postgresql-plugin#114 applied here too and is fixed in bc5301d with the same per-session lock.

The host Rust still can't be built on my machine (no GTK), so CI is the first compile of these commits. I type-checked the new session.rs and the InFlight guard against deadpool-postgres 0.14.1 in a scratch crate and ran its unit tests there. Frontend: tsc and eslint are clean, and vitest run tests/utils tests/components passes 3680 tests.

Comment thread src-tauri/src/export.rs Outdated
Comment thread src-tauri/src/export.rs
.await?;
let result = match session_id {
Some(id) => {
crate::commands::read_in_open_transaction(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

WARNING: Cancelling an in-session export closes the pinned connection and silently rolls back the tab's transaction

Every page enters postgres::execute_batch_in_session, which takes the client out of the slot for the whole batch (mod.rs:1210), and cancel_export aborts the spawned task. If the abort lands mid-page, InFlight::drop closes the connection instead of re-pinning it, so the server rolls the tab's transaction back without a ROLLBACK and the user only sees "Export cancelled". The run is therefore cancellable at exactly the point where cancelling it destroys uncommitted work. Pinning once for the whole export, rather than re-taking the slot per page, would remove the window.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1d036bb. The export now borrows the pinned connection through Slot::client() while holding the session lock, instead of taking it out for a batch. A cancel only drops the row stream: the connection stays pinned and the transaction stays open. At worst it leaves the export's savepoint behind, which is harmless.

Comment thread src-tauri/src/drivers/postgres/session.rs
Comment thread src-tauri/src/lib.rs Outdated
// the timeout keeps a run still holding a session from blocking exit.
tauri::async_runtime::block_on(async {
let release = crate::drivers::postgres::session::release_all();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), release).await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: The 3s budget is shared across release_all's serial slot locks, and the timeout result is discarded without a log

release_all awaits slot.lock().await one slot at a time (session.rs:155-157), so a single run still in flight consumes the whole 3 seconds and every later session is skipped — precisely the case the comment above says the timeout exists for. sweep_idle handles the same situation with try_lock and skipping busy slots. Separately, let _ = ... drops the timeout result, so a skipped release leaves no trace; the sibling run_exit_backup in this same handler logs its timeout, and matching that pattern would keep the exit path diagnosable. Also worth a comment: this calls the PostgreSQL module directly rather than going through DatabaseDriver, so no other pinning driver gets an exit sweep.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 7380b4f. release_all now uses try_lock and skips a session a run still holds, as sweep_idle does. That connection closes with the process anyway, so one busy tab can no longer use up the budget for the others. A timeout is now logged with log::warn!. The comment also says that only the built-in PostgreSQL driver pins, and that plugin processes release in their own shutdown handler. The plugin's release_all got the same change.

Comment thread src-tauri/src/drivers/driver_trait.rs
Comment thread src/pages/Editor.tsx Outdated
Comment thread src/pages/Editor.tsx Outdated
// This tab is holding a pooled connection open, and its
// uncommitted changes are invisible to every other tab.
<span
role="status"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: A live region inserted together with its content is not announced

The span is mounted by the transactionTabIds.has(tab.id) && conditional above, so the role="status" region does not exist before its text appears inside it. Per the live-region model (and observed NVDA/JAWS behaviour), content added along with the region is not announced, which defeats the purpose of the role. If the state change should be announced, keep the region mounted and toggle its contents; otherwise the existing title/aria-label pair already covers the static case.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a3e9473. The badge is back to a plain span with its title and aria-label. A single always-mounted, visually hidden role="status" region in the tab bar now holds the active tab's state, so its text changes inside an existing live region and is announced.

Egert Aia added 5 commits September 27, 2026 13:52
A PostgreSQL tab inside a transaction was exported through the generic
paged path, re-running the query per page with LIMIT/OFFSET. Each page took
a new snapshot, so concurrent commits could duplicate or drop rows, and a
cancel mid-page closed the connection and rolled the transaction back.
The export now runs as one cursor on the pinned connection, which stays
pinned, inside a savepoint.
The idle sweep, which also forgets the empty slot every run leaves, only
started on the first pin, so an app that never opened a transaction grew
the map for its lifetime. It now starts on the first lock. On exit,
release_all skips a session a run still holds instead of waiting on it,
so one busy tab cannot use up the timeout, and a timeout is logged.
Deleting a notebook and closing a crashed tab call closeTab directly and
skipped the release in requestTabClosure. EditorProvider now releases any
tab that leaves its list, on the tab's own connection, so every path is
covered, including while the Editor itself has crashed.
The badge's role=status region was mounted together with its text, which
screen readers do not announce. One always-mounted, visually hidden region
in the tab bar now carries the active tab's state; the badge keeps its
title and aria-label.
Comment thread src-tauri/src/drivers/postgres/export.rs
Comment thread src-tauri/src/drivers/postgres/export.rs
Comment thread src/contexts/EditorProvider.tsx
Disconnecting closed the pool but left pinned sessions holding their
transactions and locks until the idle sweep. The host now records which
sessions each connection reported as inside a transaction and, on
disconnect, releases them through the driver before closing the pool, so
plugin drivers are covered with no protocol change. Their TX badges clear.
@egertaia

Copy link
Copy Markdown
Contributor Author

One more gap, not raised in review: disconnecting a connection closed its pool but left the tabs' pinned sessions holding their transactions and locks until the idle sweep. Fixed in the latest commit on the branch.

The host now records which sessions each connection has reported as inside a transaction (note_session_state, fed by the same code that emits session-transaction-state). On disconnect_connection it releases those sessions through DatabaseDriver::release_session before closing the pool, so plugin drivers are covered without a protocol change, and it emits in_transaction: false so their badges clear. The bookkeeping has a unit test (open_sessions_tests). The command itself is untested here, since the host still can't be built on my machine.

Comment thread src-tauri/src/commands.rs Outdated
Comment thread src-tauri/src/commands.rs Outdated
Egert Aia added 4 commits September 28, 2026 09:26
…ting

Disconnect released each open session inline, so it waited on any tab
still running a query, and a failed health check closed the pool without
releasing them at all. Both paths now share release_connection_sessions,
which clears the bookkeeping, clears the badges and runs the releases in
the background.
…ORK/TRANSACTION

ROLLBACK WORK/TRANSACTION TO SAVEPOINT was classified as closing, and only
leading comments were skipped, so COMMIT -- and chain read as chaining.
Same change as the PostgreSQL plugin.
…ped pin

release_all drained every slot before skipping busy ones, so a run still in
flight pinned into an untracked slot and its connection went back to the
pool without a ROLLBACK. Busy slots now stay in the map, and a slot dropped
while holding a connection closes it.
A session-aware plugin now answers a failed statement with its error and
in_transaction. The host reads that, remembers each session's last reported
state, and answers session_in_transaction from it, so a plugin tab's TX
badge clears after a failed COMMIT.
match res {
Ok(value) => {
let (results, in_transaction) = parse_batch_response(value)?;
remember_session_state(session_id, in_transaction);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: The batch path never sees the session-error shape execute_query_in_session just gained

parse_session_error is applied only to the single-statement RPC. execute_batch_in_session hands the value straight to parse_batch_response, which looks for a results key and otherwise deserialises the whole thing as Vec<BatchStatementResult>. A session-aware plugin that reports a failed batch as {error: ..., in_transaction: false} - the shape parse_session_error documents, and close to BatchStatementResult's own {result, error, execution_time_ms} - therefore surfaces as a serde message such as invalid type: map, expected a sequence instead of the plugin's error text, and the ? on the previous line short-circuits before remember_session_state, so session_in_transaction keeps the value from the previous run. That is exactly the stale TX badge this commit set out to fix, and it means the state of a batch that ends in a failed COMMIT is never learned. Hoisting the same two-line check as execute_query_in_session above parse_batch_response would cover both RPCs.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@debba

debba commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Hey @egertaia, @aesslinger, thanks a lot to both of you for this one. The back and forth with the live-DB pass on the plugin side really paid off.

I did a local review on top of the latest commit (ff132136): the new Rust tests all pass, CI is green, and the design holds up well (per-session lock, InFlight closing cancelled connections, ROLLBACK before anything goes back to the pool, release on close/disconnect/health failure/exit). On the two open Kilo threads I agree with @egertaia: the leftover export savepoint is harmless, and the batch {error, in_transaction} shape is never produced by the plugin, so neither is blocking.

To me this looks mergeable. Only a few nits came up, nothing that needs to hold this PR:

  1. Pool size: every tab with an open transaction keeps one pooled connection. The Postgres pool size is user-configurable (down to 1), so with a tiny pool an open transaction can stall the sidebar, autocomplete and other tabs until COMMIT. Maybe worth a hint in the UI or docs.
  2. search_path on a pinned connection: the pinned client skips acquire_pg_client, so if the user switches schema mid-transaction, the following runs keep using the old search_path.
  3. Live-DB coverage: session_tests.rs says pinning "is covered by the postgres integration tests", but there is nothing for it under src-tauri/tests/ yet. The scenarios from the plugin review (deferred FK failing on COMMIT, AND CHAIN, close/disconnect releasing) would make a great integration suite.

@aesslinger, since the same logic lives in the plugin (#114), these could be picked up there as follow-ups, if you're both ok with that. If you agree, I'll go ahead with the merge.

@aesslinger

Copy link
Copy Markdown
Contributor

Hey @egertaia, @aesslinger, thanks a lot to both of you for this one. The back and forth with the live-DB pass on the plugin side really paid off.

I did a local review on top of the latest commit (ff132136): the new Rust tests all pass, CI is green, and the design holds up well (per-session lock, InFlight closing cancelled connections, ROLLBACK before anything goes back to the pool, release on close/disconnect/health failure/exit). On the two open Kilo threads I agree with @egertaia: the leftover export savepoint is harmless, and the batch {error, in_transaction} shape is never produced by the plugin, so neither is blocking.

To me this looks mergeable. Only a few nits came up, nothing that needs to hold this PR:

  1. Pool size: every tab with an open transaction keeps one pooled connection. The Postgres pool size is user-configurable (down to 1), so with a tiny pool an open transaction can stall the sidebar, autocomplete and other tabs until COMMIT. Maybe worth a hint in the UI or docs.
  2. search_path on a pinned connection: the pinned client skips acquire_pg_client, so if the user switches schema mid-transaction, the following runs keep using the old search_path.
  3. Live-DB coverage: session_tests.rs says pinning "is covered by the postgres integration tests", but there is nothing for it under src-tauri/tests/ yet. The scenarios from the plugin review (deferred FK failing on COMMIT, AND CHAIN, close/disconnect releasing) would make a great integration suite.

@aesslinger, since the same logic lives in the plugin (#114), these could be picked up there as follow-ups, if you're both ok with that. If you agree, I'll go ahead with the merge.

If you want to create a new issue in the plugin repo for these follow-ups we could track it that way. Defer to @debba for release/merge confirmation on this side.

@debba
debba merged commit 1de08bb into TabularisDB:main Oct 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants