Skip to content

fix(sql-pg): retire a session whose CancelRequest may be in flight - #8306

Closed
kristojorg wants to merge 1 commit into
Effect-TS:mainfrom
kristojorg:sql-pg-cancel-in-flight
Closed

kristojorg wants to merge 1 commit into
Effect-TS:mainfrom
kristojorg:sql-pg-cancel-in-flight

Conversation

@kristojorg

Copy link
Copy Markdown

Summary

PgConnection.query and PgConnection.stream cancel an interrupted statement with a CancelRequest, drain to ReadyForQuery, and keep the session. But a CancelRequest is asynchronous by protocol design: it travels on a side connection, the backend applies it to whatever it is running when it arrives, and nothing on the wire confirms it was delivered.

A pooler or hosted proxy may close the side connection before it forwards the request. If the interrupted statement finishes on its own in the meantime, the session goes back to the pool looking healthy — and the cancel lands on the next caller instead.

The race

sequenceDiagram
    participant A as Caller A
    participant P as Pool
    participant X as Pooler or proxy
    participant PG as PostgreSQL
    participant B as Caller B

    A->>PG: SELECT ... on session S
    Note over A: Effect.timeout fires, interrupt
    A->>X: CancelRequest on a side connection
    X-->>A: closes the side connection, looks delivered
    PG-->>A: statement completes on its own
    Note over A,PG: drain reaches ReadyForQuery, S looks healthy
    A->>P: return S
    B->>P: get
    P-->>B: S
    B->>PG: SELECT ... on S
    X->>PG: CancelRequest, forwarded at last
    PG-->>B: ERROR 57014 canceling statement due to user request
Loading

Caller B gets a SqlError with StatementTimeoutError on a statement nobody interrupted. We hit this in CI against Neon.

The existing test interrupt cancels an in-flight query covers only the happy ordering, where the cancel arrives while the statement is still running.

The fix

The backend tells us whether the cancel actually landed: it reports 57014 for the cancelled statement. QueryMachine and the stream consumer now watch for that, before or during the drain, and decide from it.

on interrupt
  send CancelRequest
  drain to ReadyForQuery
  if the backend reported 57014 for this statement
    keep the session      # the cancel landed, nothing is in flight
  else
    retire the session    # the cancel may still be coming, a pool replaces S

The multiplexed unpinned path never cancels and is unchanged.

57014 is also what statement_timeout raises, so a timeout that coincides with an interrupt is read as the cancel having landed. Separating the two would mean matching the message text, which is locale dependent.

What the retirement costs

For pooled callers the change is invisible: the pool discards the session and opens another.

Callers holding a connection from PgConnection.make directly see a behavior change. After an interrupt whose cancel was not confirmed, that connection is dead, and every later use fails with ConnectionError exactly as it would after a dropped socket. A listen queue on the session fails with the same error, and Stream.retry reconnects.

Alternatives considered

  • Send the cancel on the open connection. The protocol routes a CancelRequest to the postmaster over a new connection precisely because an executing backend does not read the frontend socket.
  • Retire unconditionally after any interrupt. Correct, but it discards a session even in the common case where the backend did report 57014 and nothing is in flight.
  • Skip the cancel and destroy the socket. The backend keeps executing until it notices the client is gone, and client_connection_check_interval is off by default, so a long statement can run to completion.
  • Keep the session and absorb one spurious 57014 on its next statement. A spurious cancel cannot be told apart from a real statement_timeout or a legitimate concurrent cancel, so this would silently retry statements that were cancelled on purpose.

Connection churn is bounded by how often statements are interrupted, which the protocol documentation already assumes is infrequent. Only an interrupt that loses the race retires a session; one whose cancel the backend confirmed does not.

Tests

PgPool.integration.test.ts gains a LateCancelProxy Duplex, passed to the pool as the driver's stream option. It is the Pooler or proxy participant above: it forwards every byte, but acknowledges a CancelRequest at once and delivers it to the backend 250 ms later.

Two new tests drive the diagram end to end, one interrupting a query and one aborting a stream, then run a statement on the pool and assert it is not cancelled.

Validation

  • Full packages/sql/pg suite against testcontainers with EFFECT_INTEGRATION_TESTS=1: 403 pass, 15 files, no failures.
  • Both new tests fail on the base commit with StatementTimeoutError (57014, "canceling statement due to user request") on the follow-up statement. Reverting only PgConnection.ts and keeping the tests gives 2 failed, 16 passed in PgPool.integration.test.ts.
  • pnpm lint, pnpm circular, pnpm check, pnpm build, pnpm check-dist-types and pnpm test-types --target '>=5.9' all pass. pnpm ai-docgen leaves the tree clean.
  • deno check . was not run locally; deno was unavailable on the machine used.

A CancelRequest travels on a side connection and the backend applies it
to whatever it is running when it arrives. Nothing on the wire confirms
delivery: a pooler or hosted proxy may close the side connection before
it forwards the request. An interrupted query or an aborted stream that
then completes on its own returns the session to the pool with the
cancel still on its way, and the next statement on that session fails
with StatementTimeoutError (SQLSTATE 57014).

Track whether the backend reported the cycle's statement cancelled. If
it did, the session stays usable as before. Otherwise close it with a
ConnectionError so the pool replaces it.

The new tests put a Duplex between client and backend that acknowledges
a CancelRequest at once and forwards it 250 ms later, which is what a
pooler does, and check that the next statement on the pool is not
cancelled. Both fail on the previous code with 57014.
@changeset-bot

changeset-bot Bot commented Sep 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4f7e4da

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 31 packages
Name Type
@effect/sql-pg Patch
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 37.71 KB 37.71 KB 0.00 KB (0.00%)
basic.ts 6.87 KB 6.87 KB 0.00 KB (0.00%)
batching.ts 9.95 KB 9.95 KB 0.00 KB (0.00%)
brand.ts 6.45 KB 6.45 KB 0.00 KB (0.00%)
cache.ts 10.77 KB 10.77 KB 0.00 KB (0.00%)
config.ts 21.83 KB 21.83 KB 0.00 KB (0.00%)
differ.ts 20.67 KB 20.67 KB 0.00 KB (0.00%)
http-client.ts 21.94 KB 21.94 KB 0.00 KB (0.00%)
http-router.ts 36.96 KB 36.96 KB 0.00 KB (0.00%)
logger.ts 10.88 KB 10.88 KB 0.00 KB (0.00%)
metric.ts 9.02 KB 9.02 KB 0.00 KB (0.00%)
optic.ts 6.70 KB 6.70 KB 0.00 KB (0.00%)
pubsub.ts 15.10 KB 15.10 KB 0.00 KB (0.00%)
queue.ts 11.85 KB 11.85 KB 0.00 KB (0.00%)
schedule.ts 10.96 KB 10.96 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 13.40 KB 13.40 KB 0.00 KB (0.00%)
schema-binary.ts 39.82 KB 39.82 KB 0.00 KB (0.00%)
schema-class.ts 20.38 KB 20.38 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 31.18 KB 31.18 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.60 KB 26.60 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.96 KB 13.96 KB 0.00 KB (0.00%)
schema-string.ts 11.55 KB 11.55 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.70 KB 15.70 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 37.24 KB 37.24 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.84 KB 24.84 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.61 KB 19.61 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.75 KB 19.75 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.85 KB 19.85 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 24.15 KB 24.15 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.89 KB 19.89 KB 0.00 KB (0.00%)
schema.ts 19.59 KB 19.59 KB 0.00 KB (0.00%)
stm.ts 12.80 KB 12.80 KB 0.00 KB (0.00%)
stream.ts 9.83 KB 9.83 KB 0.00 KB (0.00%)

This branch is waiting to be deployed

1 waiting deployment
fork — 4f7e4da9 Waiting Sep 19, 2026 by kristojorg via approval-gate #15340
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant