Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions .release-notes/next-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ end

## Add SSL/TLS negotiation support

You can now encrypt connections to PostgreSQL using SSL/TLS. Pass `SSLRequired(sslctx)` to `Session.create()` to enable SSL negotiation before authentication. The default `SSLDisabled` preserves the existing plaintext behavior.
You can now encrypt connections to PostgreSQL using SSL/TLS. Pass `SSLRequired(sslctx)` via `ServerConnectInfo` to `Session.create()` to enable SSL negotiation before authentication. The default `SSLDisabled` preserves the existing plaintext behavior.

```pony
use "ssl/net"
Expand All @@ -129,14 +129,11 @@ end

// Connect with SSL
let session = Session(
auth,
ServerConnectInfo(auth, host, port, SSLRequired(sslctx)),
notify,
host,
port,
username,
password,
database,
SSLRequired(sslctx))
database)
```

If the server accepts SSL, the connection is encrypted before authentication begins. If the server refuses, `pg_session_connection_failed` fires.
Expand Down Expand Up @@ -246,3 +243,44 @@ rs1 == rs2 // true

No code changes are needed — `Session.close()` handles this automatically.

## Add query cancellation support

You can now cancel a running query by calling `session.cancel()`. This sends a PostgreSQL CancelRequest on a separate connection, requesting the server to abort the in-flight query. Cancellation is best-effort — the server may or may not honor it. If cancelled, the query's `ResultReceiver` receives `pg_query_failed` with an `ErrorResponseMessage` containing SQLSTATE `57014` (query_canceled).

```pony
be pg_session_authenticated(session: Session) =>
session.execute(SimpleQuery("SELECT pg_sleep(60)"), receiver)
session.cancel()

be pg_query_failed(session: Session, query: Query,
failure: (ErrorResponseMessage | ClientQueryError))
=>
match failure
| let err: ErrorResponseMessage =>
if err.code == "57014" then
// query was successfully cancelled
end
end
```

`cancel()` is safe to call at any time — it is a no-op if no query is in flight. When the session uses `SSLRequired`, the cancel connection uses SSL as well.

## Change Session constructor to accept ServerConnectInfo

`Session.create()` now takes a `ServerConnectInfo` as its first parameter instead of individual connection arguments. `ServerConnectInfo` groups auth, host, service, and SSL mode into a single immutable value.

Before:

```pony
let session = Session(
auth, notify, host, port, username, password, database)
```

After:

```pony
let session = Session(
ServerConnectInfo(auth, host, port),
notify, username, password, database)
```

2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ All notable changes to this project will be documented in this file. This projec
- Add equality comparison for Field ([PR #85](https://github.com/ponylang/postgres/pull/85))
- Add equality comparison for Row ([PR #85](https://github.com/ponylang/postgres/pull/85))
- Add equality comparison for Rows ([PR #85](https://github.com/ponylang/postgres/pull/85))
- Add query cancellation support ([PR #89](https://github.com/ponylang/postgres/pull/89))

### Changed

- Change ResultReceiver and Result to use Query union type instead of SimpleQuery ([PR #70](https://github.com/ponylang/postgres/pull/70))
- Change ResultReceiver and PrepareReceiver callbacks to take Session as first parameter ([PR #84](https://github.com/ponylang/postgres/pull/84))
- Change Session constructor to accept ServerConnectInfo ([PR #89](https://github.com/ponylang/postgres/pull/89))
- Fix typo in SesssionNeverOpened ([PR #59](https://github.com/ponylang/postgres/pull/59))

## [0.2.2] - 2025-07-16
Expand Down
21 changes: 17 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Managed via `corral`.

### Session State Machine

`Session` actor is the main entry point. Implements `lori.TCPConnectionActor` and `lori.ClientLifecycleEventReceiver`. State transitions via `_SessionState` interface with four concrete states:
`Session` actor is the main entry point. Constructor takes `ServerConnectInfo` (auth, host, service, ssl_mode) as its first parameter. Implements `lori.TCPConnectionActor` and `lori.ClientLifecycleEventReceiver`. The stored `ServerConnectInfo` is accessible via `server_connect_info()` for use by `_CancelSender`. State transitions via `_SessionState` interface with four concrete states:

```
_SessionUnopened --connect (no SSL)--> _SessionConnected
Expand Down Expand Up @@ -71,10 +71,12 @@ This design makes illegal state transitions call `_IllegalState()` (panic) by de

Only one operation is in-flight at a time. The queue serializes execution. `query_queue`, `query_state`, `backend_pid`, and `backend_secret_key` are non-underscore-prefixed fields on `_SessionLoggedIn` because other types in this package need cross-type access (Pony private fields are type-private).

**Query cancellation:** `session.cancel()` requests cancellation of the currently executing query by opening a separate TCP connection via `_CancelSender` and sending a `CancelRequest`. The `cancel` method on `_SessionState` follows the same "never illegal" contract as `close` — it is a no-op in all states except `_SessionLoggedIn`, where it fires only when a query is in flight (not in `_QueryReady` or `_QueryNotReady`). Cancellation is best-effort; the server may or may not honor it. If cancelled, the query's `ResultReceiver` receives `pg_query_failed` with an `ErrorResponseMessage` (SQLSTATE 57014).

### Protocol Layer

**Frontend (client → server):**
- `_FrontendMessage` primitive: `startup()`, `password()`, `query()`, `parse()`, `bind()`, `describe_portal()`, `describe_statement()`, `execute_msg()`, `close_statement()`, `sync()`, `ssl_request()`, `terminate()` — builds raw byte arrays with big-endian wire format
- `_FrontendMessage` primitive: `startup()`, `password()`, `query()`, `parse()`, `bind()`, `describe_portal()`, `describe_statement()`, `execute_msg()`, `close_statement()`, `sync()`, `ssl_request()`, `cancel_request()`, `terminate()` — builds raw byte arrays with big-endian wire format

**Backend (server → client):**
- `_ResponseParser` primitive: incremental parser consuming from a `Reader` buffer. Returns one parsed message per call, `None` if incomplete, errors on junk.
Expand All @@ -95,6 +97,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
- `ResultReceiver` interface (tag) — `pg_query_result(Session, Result)`, `pg_query_failed(Session, Query, (ErrorResponseMessage | ClientQueryError))`
- `PrepareReceiver` interface (tag) — `pg_statement_prepared(Session, name)`, `pg_prepare_failed(Session, name, (ErrorResponseMessage | ClientQueryError))`
- `ClientQueryError` trait — `SessionNeverOpened`, `SessionClosed`, `SessionNotAuthenticated`, `DataError`
- `ServerConnectInfo` — val class grouping connection parameters (auth, host, service, ssl_mode). Passed to `Session.create()` as the first parameter. Also used by `_CancelSender`.
- `SSLMode` — union type `(SSLDisabled | SSLRequired)`. `SSLDisabled` is the default (plaintext). `SSLRequired` wraps an `SSLContext val` for TLS negotiation.
- `ErrorResponseMessage` — full PostgreSQL error with all standard fields
- `AuthenticationFailureReason` = `(InvalidAuthenticationSpecification | InvalidPassword)`
Expand All @@ -111,6 +114,10 @@ In `_RowsBuilder._field_to_type()`:
- Everything else → `String`
- NULL → `None`

### Query Cancellation

`_CancelSender` actor — fire-and-forget actor that sends a `CancelRequest` on a separate TCP connection. PostgreSQL requires cancel requests on a different connection from the one executing the query. No response is expected on the cancel connection — the result (if any) arrives as an `ErrorResponse` on the original session connection. When the session uses `SSLRequired`, the cancel connection performs SSL negotiation before sending the CancelRequest — mirroring the main session's connection setup. If the server refuses SSL or the TLS handshake fails, the cancel is silently abandoned. Created by `_SessionLoggedIn.cancel()` using the session's `ServerConnectInfo`, `backend_pid`, and `backend_secret_key`. Design: [discussion #88](https://github.com/ponylang/postgres/discussions/88).

### Mort Primitives

`_IllegalState` and `_Unreachable` in `_mort.pony`. Print file/line to stderr via FFI and exit. Issue URL: `https://github.com/ponylang/postgres/issues`.
Expand All @@ -129,6 +136,8 @@ Tests live in the main `postgres/` package (private test classes).
- `_TestSSLNegotiationRefused` — mock server responds 'N' to SSLRequest; verifies `pg_session_connection_failed` fires
- `_TestSSLNegotiationJunkResponse` — mock server responds with junk byte to SSLRequest; verifies session shuts down
- `_TestSSLNegotiationSuccess` — mock server responds 'S', both sides upgrade to TLS, sends AuthOk+ReadyForQuery; verifies full SSL→auth flow
- `_TestCancelQueryInFlight` — mock server accepts two connections; first authenticates with BackendKeyData(pid, key) and receives query; second receives CancelRequest and verifies 16-byte format with correct magic number, pid, and key
- `_TestSSLCancelQueryInFlight` — same as `_TestCancelQueryInFlight` but with SSL on both connections; verifies that `_CancelSender` performs SSL negotiation before sending CancelRequest
- `_TestField*Equality*` / `_TestFieldInequality` — example-based reflexive, structural, symmetric equality and inequality tests for Field
- `_TestRowEquality` / `_TestRowInequality` — example-based equality and inequality tests for Row
- `_TestRowsEquality` / `_TestRowsInequality` — example-based equality and inequality tests for Rows
Expand All @@ -145,7 +154,8 @@ Tests live in the main `postgres/` package (private test classes).
- PreparedStatement/PrepareAndClose, PreparedStatement/PrepareFails, PreparedStatement/PrepareAfterClose
- PreparedStatement/CloseNonexistent, PreparedStatement/PrepareDuplicateName
- PreparedStatement/MixedWithSimpleAndPrepared
- SSL/Connect, SSL/Authenticate, SSL/Query, SSL/Refused
- Cancel/Query
- SSL/Connect, SSL/Authenticate, SSL/Query, SSL/Refused, SSL/Cancel

Test helpers: `_ConnectionTestConfiguration` reads env vars with defaults. Several test message builder classes (`_Incoming*TestMessage`) construct raw protocol bytes for unit tests.

Expand Down Expand Up @@ -298,8 +308,9 @@ Can arrive between any other messages (must always handle):
## File Layout

```
postgres/ # Main package (30 files)
postgres/ # Main package (32 files)
session.pony # Session actor + state machine traits + query sub-state machine
server_connect_info.pony # ServerConnectInfo val class (auth, host, service, ssl_mode)
ssl_mode.pony # SSLDisabled, SSLRequired, SSLMode types
simple_query.pony # SimpleQuery class
prepared_query.pony # PreparedQuery class
Expand All @@ -315,6 +326,7 @@ postgres/ # Main package (30 files)
field_data_types.pony # FieldDataTypes union
row.pony # Row class
rows.pony # Rows, RowIterator, _RowsBuilder
_cancel_sender.pony # Fire-and-forget cancel request actor
_frontend_message.pony # Client-to-server messages
_backend_messages.pony # Server-to-client message types
_message_type.pony # Protocol message type codes
Expand All @@ -337,5 +349,6 @@ examples/ssl-query/ssl-query-example.pony # SSL-encrypted query with SSLRequired
examples/prepared-query/prepared-query-example.pony # PreparedQuery with params and NULL
examples/named-prepared-query/named-prepared-query-example.pony # Named prepared statements with reuse
examples/crud/crud-example.pony # Multi-query CRUD workflow
examples/cancel/cancel-example.pony # Query cancellation with pg_sleep
.ci-dockerfiles/pg-ssl/ # Dockerfile for SSL-enabled PostgreSQL CI container
```
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Named prepared statements using `Session.prepare()` and `NamedPreparedQuery`. Pr

SSL-encrypted query using `SSLRequired`. Same workflow as `query` but with TLS negotiation enabled. Demonstrates how to create an `SSLContext`, wrap it in `SSLRequired`, and pass it to `Session`. Requires a PostgreSQL server configured to accept SSL connections.

## cancel

Query cancellation using `Session.cancel()`. Executes a long-running query (`SELECT pg_sleep(10)`), cancels it, and handles the resulting `ErrorResponseMessage` with SQLSTATE `57014` (query_canceled) in the `ResultReceiver`. Shows that cancellation is best-effort and arrives as a query failure, not a separate callback.

## crud

Multi-query workflow mixing `SimpleQuery` and `PreparedQuery`. Creates a table, inserts rows with parameterized INSERTs, selects them back, deletes, and drops the table. Demonstrates all three `Result` types (`ResultSet`, `RowModifying`, `SimpleResult`) and `ErrorResponseMessage` error handling.
78 changes: 78 additions & 0 deletions examples/cancel/cancel-example.pony
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use "cli"
use "collections"
use lori = "lori"
// in your code this `use` statement would be:
// use "postgres"
use "../../postgres"

actor Main
new create(env: Env) =>
let server_info = ServerInfo(env.vars)
let auth = lori.TCPConnectAuth(env.root)

let client = Client(auth, server_info, env.out)

actor Client is (SessionStatusNotify & ResultReceiver)
let _session: Session
let _out: OutStream

new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
_out = out
_session = Session(
ServerConnectInfo(auth, info.host, info.port),
this,
info.username,
info.password,
info.database)

be close() =>
_session.close()

be pg_session_authenticated(session: Session) =>
_out.print("Authenticated.")
_out.print("Sending long-running query....")
let q = SimpleQuery("SELECT pg_sleep(10)")
session.execute(q, this)

_out.print("Cancelling query....")
session.cancel()

be pg_session_authentication_failed(
s: Session,
reason: AuthenticationFailureReason)
=>
_out.print("Failed to authenticate.")

be pg_query_result(session: Session, result: Result) =>
_out.print("Query completed (was not cancelled).")
close()

be pg_query_failed(session: Session, query: Query,
failure: (ErrorResponseMessage | ClientQueryError))
=>
match failure
| let err: ErrorResponseMessage =>
if err.code == "57014" then
_out.print("Query was cancelled (SQLSTATE 57014).")
else
_out.print("Query failed with SQLSTATE " + err.code + ".")
end
| let ce: ClientQueryError =>
_out.print("Query failed with client error.")
end
close()

class val ServerInfo
let host: String
let port: String
let username: String
let password: String
let database: String

new val create(vars: (Array[String] val | None)) =>
let e = EnvVars(vars)
host = try e("POSTGRES_HOST")? else "127.0.0.1" end
port = try e("POSTGRES_PORT")? else "5432" end
username = try e("POSTGRES_USERNAME")? else "postgres" end
password = try e("POSTGRES_PASSWORD")? else "postgres" end
database = try e("POSTGRES_DATABASE")? else "postgres" end
4 changes: 1 addition & 3 deletions examples/crud/crud-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
_out = out
_session = Session(
auth,
ServerConnectInfo(auth, info.host, info.port),
this,
info.host,
info.port,
info.username,
info.password,
info.database)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@ actor Client is (SessionStatusNotify & ResultReceiver & PrepareReceiver)
new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
_out = out
_session = Session(
auth,
ServerConnectInfo(auth, info.host, info.port),
this,
info.host,
info.port,
info.username,
info.password,
info.database)
Expand Down
4 changes: 1 addition & 3 deletions examples/prepared-query/prepared-query-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
_out = out
_session = Session(
auth,
ServerConnectInfo(auth, info.host, info.port),
this,
info.host,
info.port,
info.username,
info.password,
info.database)
Expand Down
4 changes: 1 addition & 3 deletions examples/query/query-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
_out = out
_session = Session(
auth,
ServerConnectInfo(auth, info.host, info.port),
this,
info.host,
info.port,
info.username,
info.password,
info.database)
Expand Down
7 changes: 2 additions & 5 deletions examples/ssl-query/ssl-query-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,11 @@ actor Client is (SessionStatusNotify & ResultReceiver)
=>
_out = out
_session = Session(
auth,
ServerConnectInfo(auth, info.host, info.port, SSLRequired(sslctx)),
this,
info.host,
info.port,
info.username,
info.password,
info.database,
SSLRequired(sslctx))
info.database)

be close() =>
_session.close()
Expand Down
Loading