pgdevkit 0.15.2__tar.gz → 0.16.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/PKG-INFO +49 -4
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/README.md +48 -3
- pgdevkit-0.16.1/pgdevkit/fastapi/__init__.py +4 -0
- pgdevkit-0.16.1/pgdevkit/fastapi/cancel_on_disconnect.py +136 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/fastapi/json_response.py +26 -5
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pyproject.toml +1 -1
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/skills/pgdevkit/SKILL.md +27 -2
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/_asgi.py +22 -1
- pgdevkit-0.16.1/tests/_granian_app.py +72 -0
- pgdevkit-0.16.1/tests/test_cancel_on_disconnect.py +361 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_cancel.py +30 -5
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_granian.py +66 -3
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/uv.lock +1 -1
- pgdevkit-0.15.2/pgdevkit/fastapi/__init__.py +0 -3
- pgdevkit-0.15.2/tests/_granian_app.py +0 -40
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.gitignore +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.python-version +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/docs/database-layout.md +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/fetch.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/transaction.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/migrate_mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/models.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/stats.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/conftest.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_fetch.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_fetch_statement_timeout.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_query_expr_types.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_transaction.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_areas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_compare.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_connection.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_dialect.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_envtag.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_mssql_live.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_response.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_statement_timeout.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_schemas.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_stats_cli.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_stats_mssql_live.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_schema_mssql.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pgdevkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.1
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -442,7 +442,7 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
442
442
|
with `set_default_pool()` (a `ConnectionSource` such as `PgPool`, or a plain callable returning an
|
|
443
443
|
async connection context manager). Set the `cancel` `asyncio.Event` to abort the query on the server
|
|
444
444
|
(`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
|
|
445
|
-
disconnect itself); cancelling the awaiting task cancels it there too, and
|
|
445
|
+
disconnect itself, and for buffered endpoints see `CancelOnDisconnectRoute`); cancelling the awaiting task cancels it there too, and
|
|
446
446
|
`CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
|
|
447
447
|
long (`QueryCanceled`, "canceling statement due to statement timeout"); it is applied per call with `SET LOCAL`
|
|
448
448
|
semantics, so it never outlives the call (a borrowed `con` gets its previous setting back); see
|
|
@@ -536,14 +536,16 @@ async with pool.connection() as con:
|
|
|
536
536
|
await pg_upsert(con, Widget(id=1, name="thing"), Widget)
|
|
537
537
|
```
|
|
538
538
|
|
|
539
|
-
## `pgdevkit.fastapi` — streaming JSON responses
|
|
539
|
+
## `pgdevkit.fastapi` — streaming JSON responses and cancel-on-disconnect
|
|
540
540
|
|
|
541
541
|
Requires the `fastapi` extra (`pip install pgdevkit[fastapi]`, FastAPI >= 0.118).
|
|
542
542
|
|
|
543
543
|
`PostgresJsonResponse` runs a query and streams its rows to the client as a JSON array. Postgres builds the JSON
|
|
544
544
|
(`row_to_json`), so there is no row-by-row Python serialization and no pydantic round trip, and memory use stays flat
|
|
545
545
|
for big results. **If the client goes away, the query is cancelled in Postgres** instead of running to completion.
|
|
546
|
-
(`fetch_all(..., cancel=event)` cancels a buffered query too
|
|
546
|
+
(`fetch_all(..., cancel=event)` cancels a buffered query too; for `fetch_all`-based endpoints put the route on
|
|
547
|
+
`CancelOnDisconnectRoute` ([below](#cancelling-other-endpoints-when-the-client-leaves)) instead of wiring the event
|
|
548
|
+
yourself.)
|
|
547
549
|
|
|
548
550
|
```python
|
|
549
551
|
from pgdevkit.db import PgPool, set_default_pool
|
|
@@ -596,6 +598,49 @@ big results, can use up the pool and make every other request wait for a connect
|
|
|
596
598
|
reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
|
|
597
599
|
(`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
|
|
598
600
|
|
|
601
|
+
### Cancelling other endpoints when the client leaves
|
|
602
|
+
|
|
603
|
+
`PostgresJsonResponse` is the only thing that notices a disconnect by itself. ASGI servers such as Granian do **not**
|
|
604
|
+
cancel a request handler when the client goes away, so a handler built on `fetch_all` (or a raw cursor, Meilisearch,
|
|
605
|
+
...) runs to the end and its result is thrown away, which defeats a frontend `AbortController`. Put such read
|
|
606
|
+
endpoints on a router with `CancelOnDisconnectRoute`: it runs the handler as a task and cancels it on
|
|
607
|
+
`http.disconnect` (in the request's own task, with an `asyncio.timeout` as the cancel scope, as
|
|
608
|
+
`PostgresJsonResponse` does), and psycopg/pgdevkit then cancel the running statement on the server (see `fetch_all`'s
|
|
609
|
+
`cancel`).
|
|
610
|
+
|
|
611
|
+
```python
|
|
612
|
+
from fastapi import APIRouter
|
|
613
|
+
from pgdevkit.db import fetch_all
|
|
614
|
+
from pgdevkit.fastapi import CancelOnDisconnectRoute
|
|
615
|
+
|
|
616
|
+
cancellable = APIRouter(route_class=CancelOnDisconnectRoute) # or router.add_api_route(..., route_class_override=...)
|
|
617
|
+
|
|
618
|
+
@cancellable.post("/customers/overview") # a POST that only reads
|
|
619
|
+
async def overview(payload: Filter) -> list[Row]:
|
|
620
|
+
return await fetch_all("select ...", {"q": payload.q}) # aborted in Postgres when the client leaves
|
|
621
|
+
|
|
622
|
+
app.include_router(cancellable)
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
- **Opt-in, reads only.** A cancelled write is rolled back at best and half-applied at worst. Don't select routes by
|
|
626
|
+
HTTP method: search and grid endpoints are often POSTs.
|
|
627
|
+
- **Everything inside the route is cancelled**, dependencies included (authentication, audit logging, counters), and
|
|
628
|
+
the client gets no response, so `BackgroundTasks` registered by the handler never run. Don't rely on side effects
|
|
629
|
+
that must always happen in an opted-in route: shield them or do them in a middleware. A `Depends` with `yield` sees
|
|
630
|
+
an `HTTPException` (status 499) after the cancellation, so a transaction rolls back instead of committing.
|
|
631
|
+
- The client that left gets a 499 nobody reads; the middleware stack sees a normal response (no
|
|
632
|
+
`No response returned`).
|
|
633
|
+
- The request body is **not** buffered: the handler reads it from a `Request` whose `receive()` is fed by a reader
|
|
634
|
+
task that also spots the disconnect. That reader runs at most a few chunks ahead of the handler, so a route that
|
|
635
|
+
never reads a large body can't pin it in memory, but a disconnect is then only noticed once the body is consumed.
|
|
636
|
+
- **Shared work must survive one caller's cancellation.** Await shared tasks and futures (single-flight caches,
|
|
637
|
+
"refresh once, many wait" loaders) through `asyncio.shield`; otherwise one aborted request cancels the load every
|
|
638
|
+
other request is waiting on.
|
|
639
|
+
- A returned `StreamingResponse` is not covered (Starlette watches for the disconnect there only on servers with ASGI
|
|
640
|
+
spec < 2.4; `PostgresJsonResponse` always does). Threads (`asyncio.to_thread`) and
|
|
641
|
+
external services (Databricks statements, HTTP calls) keep running; only the awaiting stops.
|
|
642
|
+
- Like the above, disconnects only show up over a real socket: see `tests/test_pg_json_granian.py` for a Granian test.
|
|
643
|
+
|
|
599
644
|
### Statement timeout
|
|
600
645
|
|
|
601
646
|
`statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
|
|
@@ -417,7 +417,7 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
417
417
|
with `set_default_pool()` (a `ConnectionSource` such as `PgPool`, or a plain callable returning an
|
|
418
418
|
async connection context manager). Set the `cancel` `asyncio.Event` to abort the query on the server
|
|
419
419
|
(`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
|
|
420
|
-
disconnect itself); cancelling the awaiting task cancels it there too, and
|
|
420
|
+
disconnect itself, and for buffered endpoints see `CancelOnDisconnectRoute`); cancelling the awaiting task cancels it there too, and
|
|
421
421
|
`CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
|
|
422
422
|
long (`QueryCanceled`, "canceling statement due to statement timeout"); it is applied per call with `SET LOCAL`
|
|
423
423
|
semantics, so it never outlives the call (a borrowed `con` gets its previous setting back); see
|
|
@@ -511,14 +511,16 @@ async with pool.connection() as con:
|
|
|
511
511
|
await pg_upsert(con, Widget(id=1, name="thing"), Widget)
|
|
512
512
|
```
|
|
513
513
|
|
|
514
|
-
## `pgdevkit.fastapi` — streaming JSON responses
|
|
514
|
+
## `pgdevkit.fastapi` — streaming JSON responses and cancel-on-disconnect
|
|
515
515
|
|
|
516
516
|
Requires the `fastapi` extra (`pip install pgdevkit[fastapi]`, FastAPI >= 0.118).
|
|
517
517
|
|
|
518
518
|
`PostgresJsonResponse` runs a query and streams its rows to the client as a JSON array. Postgres builds the JSON
|
|
519
519
|
(`row_to_json`), so there is no row-by-row Python serialization and no pydantic round trip, and memory use stays flat
|
|
520
520
|
for big results. **If the client goes away, the query is cancelled in Postgres** instead of running to completion.
|
|
521
|
-
(`fetch_all(..., cancel=event)` cancels a buffered query too
|
|
521
|
+
(`fetch_all(..., cancel=event)` cancels a buffered query too; for `fetch_all`-based endpoints put the route on
|
|
522
|
+
`CancelOnDisconnectRoute` ([below](#cancelling-other-endpoints-when-the-client-leaves)) instead of wiring the event
|
|
523
|
+
yourself.)
|
|
522
524
|
|
|
523
525
|
```python
|
|
524
526
|
from pgdevkit.db import PgPool, set_default_pool
|
|
@@ -571,6 +573,49 @@ big results, can use up the pool and make every other request wait for a connect
|
|
|
571
573
|
reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
|
|
572
574
|
(`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
|
|
573
575
|
|
|
576
|
+
### Cancelling other endpoints when the client leaves
|
|
577
|
+
|
|
578
|
+
`PostgresJsonResponse` is the only thing that notices a disconnect by itself. ASGI servers such as Granian do **not**
|
|
579
|
+
cancel a request handler when the client goes away, so a handler built on `fetch_all` (or a raw cursor, Meilisearch,
|
|
580
|
+
...) runs to the end and its result is thrown away, which defeats a frontend `AbortController`. Put such read
|
|
581
|
+
endpoints on a router with `CancelOnDisconnectRoute`: it runs the handler as a task and cancels it on
|
|
582
|
+
`http.disconnect` (in the request's own task, with an `asyncio.timeout` as the cancel scope, as
|
|
583
|
+
`PostgresJsonResponse` does), and psycopg/pgdevkit then cancel the running statement on the server (see `fetch_all`'s
|
|
584
|
+
`cancel`).
|
|
585
|
+
|
|
586
|
+
```python
|
|
587
|
+
from fastapi import APIRouter
|
|
588
|
+
from pgdevkit.db import fetch_all
|
|
589
|
+
from pgdevkit.fastapi import CancelOnDisconnectRoute
|
|
590
|
+
|
|
591
|
+
cancellable = APIRouter(route_class=CancelOnDisconnectRoute) # or router.add_api_route(..., route_class_override=...)
|
|
592
|
+
|
|
593
|
+
@cancellable.post("/customers/overview") # a POST that only reads
|
|
594
|
+
async def overview(payload: Filter) -> list[Row]:
|
|
595
|
+
return await fetch_all("select ...", {"q": payload.q}) # aborted in Postgres when the client leaves
|
|
596
|
+
|
|
597
|
+
app.include_router(cancellable)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
- **Opt-in, reads only.** A cancelled write is rolled back at best and half-applied at worst. Don't select routes by
|
|
601
|
+
HTTP method: search and grid endpoints are often POSTs.
|
|
602
|
+
- **Everything inside the route is cancelled**, dependencies included (authentication, audit logging, counters), and
|
|
603
|
+
the client gets no response, so `BackgroundTasks` registered by the handler never run. Don't rely on side effects
|
|
604
|
+
that must always happen in an opted-in route: shield them or do them in a middleware. A `Depends` with `yield` sees
|
|
605
|
+
an `HTTPException` (status 499) after the cancellation, so a transaction rolls back instead of committing.
|
|
606
|
+
- The client that left gets a 499 nobody reads; the middleware stack sees a normal response (no
|
|
607
|
+
`No response returned`).
|
|
608
|
+
- The request body is **not** buffered: the handler reads it from a `Request` whose `receive()` is fed by a reader
|
|
609
|
+
task that also spots the disconnect. That reader runs at most a few chunks ahead of the handler, so a route that
|
|
610
|
+
never reads a large body can't pin it in memory, but a disconnect is then only noticed once the body is consumed.
|
|
611
|
+
- **Shared work must survive one caller's cancellation.** Await shared tasks and futures (single-flight caches,
|
|
612
|
+
"refresh once, many wait" loaders) through `asyncio.shield`; otherwise one aborted request cancels the load every
|
|
613
|
+
other request is waiting on.
|
|
614
|
+
- A returned `StreamingResponse` is not covered (Starlette watches for the disconnect there only on servers with ASGI
|
|
615
|
+
spec < 2.4; `PostgresJsonResponse` always does). Threads (`asyncio.to_thread`) and
|
|
616
|
+
external services (Databricks statements, HTTP calls) keep running; only the awaiting stops.
|
|
617
|
+
- Like the above, disconnects only show up over a real socket: see `tests/test_pg_json_granian.py` for a Granian test.
|
|
618
|
+
|
|
574
619
|
### Statement timeout
|
|
575
620
|
|
|
576
621
|
`statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Cancel a FastAPI handler when its client goes away, so the database work it started stops too.
|
|
2
|
+
|
|
3
|
+
Requires the ``fastapi`` extra: ``pip install pgdevkit[fastapi]``.
|
|
4
|
+
|
|
5
|
+
ASGI servers such as Granian do not cancel a request handler when the client disconnects: the handler runs to the
|
|
6
|
+
end and its result is thrown away. ``PostgresJsonResponse`` watches for the disconnect itself, but ``fetch_all`` and
|
|
7
|
+
friends (or any other awaitable work, raw psycopg cursors included) only stop when the awaiting task is cancelled.
|
|
8
|
+
``CancelOnDisconnectRoute`` closes that gap: it runs the route handler as a task and cancels it on
|
|
9
|
+
``http.disconnect``; psycopg and pgdevkit then cancel the running statement on the server.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Callable, Coroutine
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import HTTPException, Request, Response
|
|
18
|
+
from fastapi.routing import APIRoute
|
|
19
|
+
from starlette.requests import ClientDisconnect
|
|
20
|
+
from starlette.types import Message, Receive
|
|
21
|
+
|
|
22
|
+
from .json_response import CLIENT_CLOSED_REQUEST
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Body chunks read ahead of the handler: enough to never stall it, too few to buffer an upload in memory.
|
|
27
|
+
_MAX_PENDING_MESSAGES = 8
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CancelOnDisconnectRoute(APIRoute):
|
|
31
|
+
"""An ``APIRoute`` that cancels its handler as soon as the client disconnects.
|
|
32
|
+
|
|
33
|
+
Opt in per router, never for the whole app, and only for routes that are safe to abort halfway: reads, searches,
|
|
34
|
+
exports. A cancelled write is rolled back at best and half-applied at worst::
|
|
35
|
+
|
|
36
|
+
cancellable = APIRouter(route_class=CancelOnDisconnectRoute)
|
|
37
|
+
|
|
38
|
+
@cancellable.post("/customers/overview") # a POST that only reads
|
|
39
|
+
async def overview(payload: Filter) -> Page:
|
|
40
|
+
return await fetch_all(...) # aborted in Postgres when the client leaves
|
|
41
|
+
|
|
42
|
+
app.include_router(cancellable)
|
|
43
|
+
|
|
44
|
+
A single route can opt in with ``router.add_api_route(..., route_class_override=CancelOnDisconnectRoute)``
|
|
45
|
+
(``APIRouter`` only; the ``@router.get`` decorators don't take it).
|
|
46
|
+
|
|
47
|
+
Things to know:
|
|
48
|
+
|
|
49
|
+
* **Everything inside the route is cancelled**, dependencies included (authentication, audit logging, counters)
|
|
50
|
+
and so is ``BackgroundTasks`` registration, since the client gets no response. Don't rely on side effects that
|
|
51
|
+
must always happen; shield them or do them in a middleware. A ``Depends`` with ``yield`` sees the 499
|
|
52
|
+
``HTTPException`` after the cancellation, so its cleanup rolls back instead of committing.
|
|
53
|
+
* The request body is not buffered: the handler reads it as usual, from a ``Request`` whose ``receive()`` is fed
|
|
54
|
+
by a reader task that also spots the disconnect. While the handler isn't reading a large body, the reader
|
|
55
|
+
stops after a few chunks, so a disconnect is only noticed once the handler has consumed the body.
|
|
56
|
+
* Work shared with other requests must survive the cancellation of one of them: await shared tasks and futures
|
|
57
|
+
through ``asyncio.shield`` (cache single-flight loaders, background refreshes), otherwise one aborted request
|
|
58
|
+
cancels the load every other request is waiting on.
|
|
59
|
+
* A returned ``StreamingResponse`` is not covered (the handler has finished by the time it is sent): Starlette
|
|
60
|
+
watches for the disconnect there only on servers with ASGI spec < 2.4, ``PostgresJsonResponse`` always does.
|
|
61
|
+
* Threads (``asyncio.to_thread``) and external services (Databricks statements, HTTP calls) keep running after
|
|
62
|
+
the cancellation; only awaiting them stops.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
66
|
+
handler = super().get_route_handler()
|
|
67
|
+
|
|
68
|
+
async def cancel_on_disconnect(request: Request) -> Response:
|
|
69
|
+
# The handler runs in the request's own task, as without this route (context variables, dependencies with
|
|
70
|
+
# `yield`, an outer `asyncio.timeout` all behave as usual); an asyncio timeout is the cancel scope, as in
|
|
71
|
+
# PostgresJsonResponse. The disconnect pulls its deadline to "now".
|
|
72
|
+
loop = asyncio.get_running_loop()
|
|
73
|
+
receiver = _Receiver(request.receive)
|
|
74
|
+
request._receive = receiver.receive # the same Request object, so a cached body is seen by everyone
|
|
75
|
+
reader = asyncio.create_task(receiver.read())
|
|
76
|
+
|
|
77
|
+
handled = False # as in PostgresJsonResponse: once set, the cancel scope may be gone
|
|
78
|
+
|
|
79
|
+
async def cancel_when_gone(cancel_scope: asyncio.Timeout) -> None:
|
|
80
|
+
await receiver.disconnected.wait()
|
|
81
|
+
if not handled: # (the Event can't swallow the cancellation; this keeps reschedule() safe anyway)
|
|
82
|
+
cancel_scope.reschedule(loop.time())
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
async with asyncio.timeout(None) as cancel_scope:
|
|
86
|
+
watcher = asyncio.create_task(cancel_when_gone(cancel_scope))
|
|
87
|
+
try:
|
|
88
|
+
return await handler(request)
|
|
89
|
+
finally:
|
|
90
|
+
handled = True
|
|
91
|
+
watcher.cancel()
|
|
92
|
+
except TimeoutError:
|
|
93
|
+
if not cancel_scope.expired(): # the handler's own timeout
|
|
94
|
+
raise
|
|
95
|
+
except ClientDisconnect: # it was reading the body when the client left
|
|
96
|
+
pass
|
|
97
|
+
finally:
|
|
98
|
+
reader.cancel()
|
|
99
|
+
logger.debug("client disconnected, cancelled %s %s", request.method, request.url.path)
|
|
100
|
+
# Raised, not returned: the dependencies' exit code (e.g. a transaction) must see a failure, not a success.
|
|
101
|
+
raise HTTPException(status_code=CLIENT_CLOSED_REQUEST, detail="Client closed the request")
|
|
102
|
+
|
|
103
|
+
return cancel_on_disconnect
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Receiver:
|
|
107
|
+
"""Gives the handler's ``Request`` its own ``receive()`` while one reader task watches for the disconnect."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, receive: Receive) -> None:
|
|
110
|
+
self._receive = receive
|
|
111
|
+
self._messages: asyncio.Queue[Message | Exception] = asyncio.Queue(maxsize=_MAX_PENDING_MESSAGES)
|
|
112
|
+
self._gone: Message | None = None
|
|
113
|
+
self.disconnected = asyncio.Event()
|
|
114
|
+
|
|
115
|
+
async def read(self) -> None:
|
|
116
|
+
"""Pump the server's messages into the queue until the client is gone (the queue's limit is the back-pressure)."""
|
|
117
|
+
try:
|
|
118
|
+
while True:
|
|
119
|
+
message = await self._receive()
|
|
120
|
+
if message["type"] == "http.disconnect":
|
|
121
|
+
self.disconnected.set()
|
|
122
|
+
await self._messages.put(message)
|
|
123
|
+
if message["type"] == "http.disconnect":
|
|
124
|
+
return
|
|
125
|
+
except Exception as exc: # noqa: BLE001 - a broken server: the handler sees it, the client is not treated as gone
|
|
126
|
+
await self._messages.put(exc)
|
|
127
|
+
|
|
128
|
+
async def receive(self) -> Message:
|
|
129
|
+
if self._gone is not None: # the disconnect is final, whoever asks again
|
|
130
|
+
return self._gone
|
|
131
|
+
message = await self._messages.get()
|
|
132
|
+
if isinstance(message, Exception):
|
|
133
|
+
raise message
|
|
134
|
+
if message["type"] == "http.disconnect":
|
|
135
|
+
self._gone = message
|
|
136
|
+
return message
|
|
@@ -4,10 +4,11 @@ Requires the ``fastapi`` extra: ``pip install pgdevkit[fastapi]``.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import asyncio
|
|
7
|
+
import contextlib
|
|
7
8
|
import json
|
|
8
9
|
import logging
|
|
9
|
-
from contextlib import aclosing
|
|
10
10
|
from collections.abc import AsyncGenerator, Mapping
|
|
11
|
+
from contextlib import aclosing
|
|
11
12
|
from string.templatelib import Template
|
|
12
13
|
from typing import Any, cast
|
|
13
14
|
|
|
@@ -34,6 +35,8 @@ from ..db.fetch import (
|
|
|
34
35
|
|
|
35
36
|
logger = logging.getLogger(__name__)
|
|
36
37
|
|
|
38
|
+
CLIENT_CLOSED_REQUEST = 499 # nginx's code for a client that left; nobody reads it
|
|
39
|
+
|
|
37
40
|
_CANCEL_TIMEOUT_SECONDS = 5.0
|
|
38
41
|
# libpq < 17 (a system libpq; psycopg's binary wheels bundle a newer one) has no chunked rows mode.
|
|
39
42
|
_CHUNKED_ROWS = pq.version() >= 170000
|
|
@@ -109,23 +112,41 @@ class PostgresJsonResponse(StreamingResponse):
|
|
|
109
112
|
# Starlette only watches for http.disconnect on ASGI spec < 2.4, so do it ourselves. An asyncio timeout is
|
|
110
113
|
# the cancel scope: unlike an anyio task group, it doesn't leave Granian hanging when the body fails.
|
|
111
114
|
loop = asyncio.get_running_loop()
|
|
115
|
+
streamed = False # set before the watcher is cancelled: from then on the cancel scope may be gone
|
|
112
116
|
|
|
113
117
|
async def watch_for_disconnect(cancel_scope: asyncio.Timeout) -> None:
|
|
114
|
-
|
|
118
|
+
# Checked again after every receive(): a wrapped receive() can swallow the watcher's cancellation and
|
|
119
|
+
# return instead (BaseHTTPMiddleware's turns it into an http.disconnect once the response has been sent),
|
|
120
|
+
# and reschedule() raises once the scope has been exited.
|
|
121
|
+
while not streamed and (await receive())["type"] != "http.disconnect":
|
|
115
122
|
pass
|
|
116
|
-
|
|
123
|
+
if not streamed:
|
|
124
|
+
cancel_scope.reschedule(loop.time()) # i.e. cancel the stream now
|
|
125
|
+
|
|
126
|
+
started = False
|
|
127
|
+
|
|
128
|
+
async def tracking_send(message: Message) -> None:
|
|
129
|
+
nonlocal started
|
|
130
|
+
started = started or message["type"] == "http.response.start"
|
|
131
|
+
await send(message)
|
|
117
132
|
|
|
118
133
|
try:
|
|
119
134
|
async with asyncio.timeout(None) as cancel_scope:
|
|
120
135
|
watcher = asyncio.create_task(watch_for_disconnect(cancel_scope))
|
|
121
136
|
try:
|
|
122
|
-
await self.stream_response(
|
|
137
|
+
await self.stream_response(tracking_send)
|
|
123
138
|
finally:
|
|
139
|
+
streamed = True
|
|
124
140
|
watcher.cancel()
|
|
125
141
|
except TimeoutError:
|
|
126
142
|
if not cancel_scope.expired():
|
|
127
143
|
raise
|
|
128
|
-
#
|
|
144
|
+
# The client is gone and the query has been cancelled: nobody is left to answer. A middleware in front
|
|
145
|
+
# (BaseHTTPMiddleware) still expects a response, though, or it fails with "No response returned.".
|
|
146
|
+
if not started:
|
|
147
|
+
with contextlib.suppress(OSError, ClientDisconnect):
|
|
148
|
+
await send(self._start_message(CLIENT_CLOSED_REQUEST, []))
|
|
149
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
129
150
|
if self.background is not None:
|
|
130
151
|
await self.background()
|
|
131
152
|
|
|
@@ -6,7 +6,8 @@ description: >
|
|
|
6
6
|
Docker/Podman test database (`pgdb testdb`), importable ORM-free CRUD and
|
|
7
7
|
query helpers (`pgdevkit.db`: `pg_*`, `fetch_all`/`fetch_one`/`fetch_scalar`/`execute`, `readonly_transaction`, `PgPool`, `SqlLoader`), streaming a query as JSON from a
|
|
8
8
|
FastAPI endpoint with query cancellation on client disconnect (`pgdevkit.fastapi.PostgresJsonResponse`: large grid
|
|
9
|
-
endpoints
|
|
9
|
+
endpoints; `pgdevkit.fastapi.CancelOnDisconnectRoute`: cancel plain `fetch_all`/cursor read handlers when the client
|
|
10
|
+
aborts, e.g. a frontend AbortController), and the `database/`-folder schema-as-code convention. Supersedes the old postgres-test-setup, postgres-best-practices,
|
|
10
11
|
and database-in-source skills — pgdevkit is a real dependency now, not
|
|
11
12
|
copy-pasted reference files. Use whenever the user wants to set up a local
|
|
12
13
|
test Postgres, write or review psycopg code, add a table/view/function to
|
|
@@ -233,7 +234,7 @@ deleted = await execute("DELETE FROM sessions WHERE user_id = %(id)s", {"id": 1}
|
|
|
233
234
|
- `model=` validates each row into a Pydantic model; `row_mapper=` maps each dict row to anything else (exclusive with `model`).
|
|
234
235
|
- Passing `con=` runs on *your* connection: `fetch_all` never commits, closes or releases it. Without it, a connection is borrowed from `pool=` (or the `set_default_pool()` pool) and released afterwards.
|
|
235
236
|
- `statement_timeout=<seconds>` (optional) lets Postgres abort the query after that long, raising `QueryCanceled`; it is applied with `SET LOCAL` semantics for this call only (a borrowed `con` gets its previous value back). A plain value *replaces* the connection's own setting in both directions, so a per-call 900 s overrides a database-level safety net of 600 s; `statement_timeout=at_most(120)` (`from pgdevkit.db import at_most`) never loosens it: the effective timeout is `min(current, 120)`.
|
|
236
|
-
- `cancel=` takes an `asyncio.Event`: set it (e.g. when the HTTP client disconnects) and the running query is cancelled on the server, raising `psycopg.errors.QueryCanceled`. If the awaiting task is cancelled instead, the server-side query is cancelled too and `CancelledError` propagates as usual (not `QueryCanceled`), so no query keeps running unattended. Don't use `cancel=` on a `con` that concurrent tasks share (it aborts whatever statement is running on it); if the cancel request itself can't be delivered, the connection is closed. After `QueryCanceled` a borrowed `con` is usable again once you `await con.rollback()` (autocommit connections need nothing). To stream a whole result as JSON from FastAPI (it wires the disconnect itself) see `PostgresJsonResponse` below.
|
|
237
|
+
- `cancel=` takes an `asyncio.Event`: set it (e.g. when the HTTP client disconnects) and the running query is cancelled on the server, raising `psycopg.errors.QueryCanceled`. If the awaiting task is cancelled instead, the server-side query is cancelled too and `CancelledError` propagates as usual (not `QueryCanceled`), so no query keeps running unattended. Don't use `cancel=` on a `con` that concurrent tasks share (it aborts whatever statement is running on it); if the cancel request itself can't be delivered, the connection is closed. After `QueryCanceled` a borrowed `con` is usable again once you `await con.rollback()` (autocommit connections need nothing). To stream a whole result as JSON from FastAPI (it wires the disconnect itself) see `PostgresJsonResponse` below; to cancel a plain `fetch_all` endpoint when the client leaves, see `CancelOnDisconnectRoute` below.
|
|
237
238
|
- `query` is typed `SqlQuery`: a literal string (`SqlLoader.load_sql()`), a sqlglot expression (any `exp.Expr`, so Select, Union, Subquery and other `exp.Query` values are fine), a `psycopg.sql` composable or a t-string. A plain `str` fails type checking on purpose, so don't build SQL with f-strings/concatenation. A sqlglot expression is only as safe as the strings it was built from (its builders parse plain strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text. A literal `%` in a sqlglot expression (`LIKE 'a%'`, modulo) just works, with or without `params`; in a plain string or `psycopg.sql` composable it is psycopg's rule: write `%%` when you pass `params`. A t-string carries its own values, so don't also pass `params`.
|
|
238
239
|
|
|
239
240
|
### Dynamic SQL
|
|
@@ -326,6 +327,29 @@ async def articles(lng: str) -> PostgresJsonResponse:
|
|
|
326
327
|
- `statement_timeout=<seconds>` (optional, as in `fetch_all`): Postgres aborts a slow query; before the first byte the client gets a 504. It does not free a client that stopped reading (use a proxy write timeout), and needs a non-autocommit connection.
|
|
327
328
|
- It does no authorization; scope the query yourself. Full reference: the `pgdevkit.fastapi` section of the README.
|
|
328
329
|
|
|
330
|
+
### Cancelling `fetch_all` endpoints when the client leaves
|
|
331
|
+
|
|
332
|
+
Granian does not cancel a request handler on disconnect, so a frontend abort (debounced search, grid filter change) only stops `PostgresJsonResponse` endpoints. Put other read endpoints — including POSTs that only read, e.g. grid/search — on a router with `CancelOnDisconnectRoute`: the handler task is cancelled on `http.disconnect` and psycopg cancels the statement in Postgres.
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
from fastapi import APIRouter
|
|
336
|
+
from pgdevkit.fastapi import CancelOnDisconnectRoute
|
|
337
|
+
|
|
338
|
+
cancellable = APIRouter(route_class=CancelOnDisconnectRoute) # single route: router.add_api_route(..., route_class_override=CancelOnDisconnectRoute)
|
|
339
|
+
|
|
340
|
+
@cancellable.post("/customers/overview") # reads only
|
|
341
|
+
async def overview(payload: Filter) -> list[Row]:
|
|
342
|
+
return await fetch_all(sql.load_sql("customers", "overview"), payload.model_dump())
|
|
343
|
+
|
|
344
|
+
app.include_router(cancellable)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- **Reads only, opt-in.** Never for writes; don't select by HTTP method (searches are POSTs).
|
|
348
|
+
- **Everything in the route is cancelled**, dependencies included (auth, audit logging), and `BackgroundTasks` never run; a `yield` dependency sees an `HTTPException` (499) and rolls back. Don't put must-happen side effects in such a route.
|
|
349
|
+
- The client that left gets a 499 nobody reads. The body is not buffered (the handler gets a `Request` fed by a reader task).
|
|
350
|
+
- Shared work (single-flight caches, "refresh once, many wait" loaders) must be awaited through `asyncio.shield`, or one aborted request cancels it for all waiters.
|
|
351
|
+
- `to_thread` work and external services (Databricks statements, HTTP calls) keep running; only awaiting them stops. Returned `StreamingResponse`s aren't covered (use `PostgresJsonResponse`).
|
|
352
|
+
|
|
329
353
|
### SQL formatting
|
|
330
354
|
|
|
331
355
|
```bash
|
|
@@ -405,6 +429,7 @@ Reports drift between the `database/` `.sql` files and the actual schema — tab
|
|
|
405
429
|
- [ ] Untrusted SQL runs under a SELECT-only role (plus `readonly_transaction(con)` and an `at_most(...)` timeout as safety nets, not as a sandbox)
|
|
406
430
|
- [ ] `set_default_pool()` at startup; `helper.clear_cache()` after DDL on a long-lived connection when using `complex_helper=`
|
|
407
431
|
- [ ] Large/grid reads from FastAPI may use `pgdevkit.fastapi.PostgresJsonResponse` (streams, cancels on disconnect, no model validation)
|
|
432
|
+
- [ ] Other read endpoints built on `fetch_all` that should stop when the client leaves use `APIRouter(route_class=CancelOnDisconnectRoute)` (never writes)
|
|
408
433
|
- [ ] No `LATERAL JOIN` — use a CTE that groups/aggregates first, then joins it
|
|
409
434
|
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
410
435
|
- [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Drive an ASGI response by hand, with a client that can disconnect at will (httpx's ASGITransport can't)."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import gc
|
|
4
6
|
import time
|
|
5
|
-
from collections.abc import Awaitable, Callable
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
6
8
|
|
|
7
9
|
import psycopg
|
|
8
10
|
|
|
@@ -13,6 +15,7 @@ class FakeClient:
|
|
|
13
15
|
self.sent: list[dict] = []
|
|
14
16
|
self.fail_sends_after: int | None = None # simulate ASGI spec >= 2.4: send() raises OSError once gone
|
|
15
17
|
self.disconnect_when_send_fails = False # ... and http.disconnect arrives at that very moment
|
|
18
|
+
self.send_iterations = 0 # event loop iterations each send() takes, as a write to a real socket does
|
|
16
19
|
|
|
17
20
|
async def receive(self) -> dict:
|
|
18
21
|
await self.disconnect.wait()
|
|
@@ -24,6 +27,8 @@ class FakeClient:
|
|
|
24
27
|
self.disconnect.set()
|
|
25
28
|
raise OSError("client went away")
|
|
26
29
|
self.sent.append(message)
|
|
30
|
+
for _ in range(self.send_iterations):
|
|
31
|
+
await asyncio.sleep(0)
|
|
27
32
|
|
|
28
33
|
async def call(self, app: Callable[..., Awaitable[None]]) -> None:
|
|
29
34
|
await app({"type": "http", "asgi": {"spec_version": "2.3"}}, self.receive, self.send)
|
|
@@ -51,3 +56,19 @@ async def wait_until(condition: Callable[[], Awaitable[bool] | bool], *, timeout
|
|
|
51
56
|
return True
|
|
52
57
|
await asyncio.sleep(0.05)
|
|
53
58
|
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@contextlib.asynccontextmanager
|
|
62
|
+
async def unhandled_loop_errors() -> AsyncIterator[list[dict]]:
|
|
63
|
+
"""Collect what the event loop would log, e.g. "Task exception was never retrieved" for a failed watcher task."""
|
|
64
|
+
loop = asyncio.get_running_loop()
|
|
65
|
+
errors: list[dict] = []
|
|
66
|
+
previous_handler = loop.get_exception_handler()
|
|
67
|
+
loop.set_exception_handler(lambda _, context: errors.append(context))
|
|
68
|
+
try:
|
|
69
|
+
yield errors
|
|
70
|
+
for _ in range(5): # let leftover tasks finish, then have the failed ones reported
|
|
71
|
+
await asyncio.sleep(0)
|
|
72
|
+
gc.collect()
|
|
73
|
+
finally:
|
|
74
|
+
loop.set_exception_handler(previous_handler)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The app served by Granian in test_pg_json_granian.py (its DSN comes from the environment)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, FastAPI, Request
|
|
8
|
+
from psycopg_pool import AsyncConnectionPool
|
|
9
|
+
|
|
10
|
+
from pgdevkit.db import fetch_all
|
|
11
|
+
from pgdevkit.fastapi import CancelOnDisconnectRoute, PostgresJsonResponse
|
|
12
|
+
|
|
13
|
+
pool = AsyncConnectionPool(os.environ["PGJSON_TEST_DSN"], min_size=1, max_size=2, open=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@asynccontextmanager
|
|
17
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
18
|
+
async with pool:
|
|
19
|
+
yield
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = FastAPI(lifespan=lifespan)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.middleware("http") # a BaseHTTPMiddleware, as in our apps: it must cope with a handler that was cancelled
|
|
26
|
+
async def passthrough(request: Request, call_next):
|
|
27
|
+
return await call_next(request)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
cancellable = APIRouter(route_class=CancelOnDisconnectRoute)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@cancellable.post("/cancellable-sleep")
|
|
34
|
+
async def cancellable_sleep() -> list[dict]:
|
|
35
|
+
return await fetch_all("select pg_sleep(30) as slept", pool=pool)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@cancellable.post("/cancellable-ok")
|
|
39
|
+
async def cancellable_ok() -> list[dict]:
|
|
40
|
+
return await fetch_all("select 1 as one", pool=pool)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app.include_router(cancellable)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.post("/plain-sleep") # not opted in: runs to completion even if the client is gone
|
|
47
|
+
async def plain_sleep() -> list[dict]:
|
|
48
|
+
return await fetch_all("select pg_sleep(3) as slept", pool=pool)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.get("/ok")
|
|
52
|
+
async def ok():
|
|
53
|
+
return PostgresJsonResponse("select i from generate_series(1, 3) i", pool=pool)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.get("/sleep")
|
|
57
|
+
async def sleep():
|
|
58
|
+
return PostgresJsonResponse("select pg_sleep(30) as slept", pool=pool)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.get("/fails-at-once") # before the first byte: a 500 JSON error
|
|
62
|
+
async def fails_at_once():
|
|
63
|
+
return PostgresJsonResponse("select 1 / 0 as boom", pool=pool)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.get("/fails-midway")
|
|
67
|
+
async def fails_midway():
|
|
68
|
+
return PostgresJsonResponse(
|
|
69
|
+
"select i, repeat('x', 100) as pad, 1 / (case when i = 1500 then 0 else 1 end) as boom "
|
|
70
|
+
"from generate_series(1, 2000) i",
|
|
71
|
+
pool=pool,
|
|
72
|
+
)
|