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.
Files changed (154) hide show
  1. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/PKG-INFO +49 -4
  2. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/README.md +48 -3
  3. pgdevkit-0.16.1/pgdevkit/fastapi/__init__.py +4 -0
  4. pgdevkit-0.16.1/pgdevkit/fastapi/cancel_on_disconnect.py +136 -0
  5. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/fastapi/json_response.py +26 -5
  6. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pyproject.toml +1 -1
  7. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/skills/pgdevkit/SKILL.md +27 -2
  8. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/_asgi.py +22 -1
  9. pgdevkit-0.16.1/tests/_granian_app.py +72 -0
  10. pgdevkit-0.16.1/tests/test_cancel_on_disconnect.py +361 -0
  11. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_cancel.py +30 -5
  12. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_granian.py +66 -3
  13. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/uv.lock +1 -1
  14. pgdevkit-0.15.2/pgdevkit/fastapi/__init__.py +0 -3
  15. pgdevkit-0.15.2/tests/_granian_app.py +0 -40
  16. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.github/workflows/auto-release.yml +0 -0
  17. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.github/workflows/python-publish.yml +0 -0
  18. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.github/workflows/python-test.yml +0 -0
  19. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.gitignore +0 -0
  20. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/.python-version +0 -0
  21. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/docs/database-layout.md +0 -0
  22. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/__init__.py +0 -0
  23. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/areas.py +0 -0
  24. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/__init__.py +0 -0
  25. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/base.py +0 -0
  26. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/mssql.py +0 -0
  27. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/backends/postgres.py +0 -0
  28. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/cli.py +0 -0
  29. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/connection.py +0 -0
  30. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/__init__.py +0 -0
  31. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/complex_types.py +0 -0
  32. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/connection.py +0 -0
  33. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/crud.py +0 -0
  34. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/fetch.py +0 -0
  35. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/loader.py +0 -0
  36. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/model.py +0 -0
  37. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/mssql_crud.py +0 -0
  38. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/mssql_sql.py +0 -0
  39. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/db/transaction.py +0 -0
  40. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/dialect.py +0 -0
  41. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/diff.py +0 -0
  42. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/envtag.py +0 -0
  43. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/fetch_missing.py +0 -0
  44. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/introspect.py +0 -0
  45. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/lakebase.py +0 -0
  46. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/migrate.py +0 -0
  47. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/migrate_mssql.py +0 -0
  48. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/models.py +0 -0
  49. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/mssql_introspect.py +0 -0
  50. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/parser.py +0 -0
  51. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/schemas.py +0 -0
  52. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/sql_text.py +0 -0
  53. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/stats.py +0 -0
  54. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/__init__.py +0 -0
  55. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/_docker.py +0 -0
  56. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/api.py +0 -0
  57. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/config.py +0 -0
  58. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/constants.py +0 -0
  59. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/container.py +0 -0
  60. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/__init__.py +0 -0
  61. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/api.py +0 -0
  62. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/constants.py +0 -0
  63. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/mssql/container.py +0 -0
  64. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/naming.py +0 -0
  65. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/query.py +0 -0
  66. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/pgdevkit/testdb/schema.py +0 -0
  67. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  68. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/skills/pgdevkit/references/temporal-tables.md +0 -0
  69. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/__init__.py +0 -0
  70. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/conftest.py +0 -0
  71. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/__init__.py +0 -0
  72. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_complex_types.py +0 -0
  73. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_connection.py +0 -0
  74. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_crud.py +0 -0
  75. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_fetch.py +0 -0
  76. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_fetch_statement_timeout.py +0 -0
  77. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_loader.py +0 -0
  78. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_live.py +0 -0
  79. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_mssql_crud_sql.py +0 -0
  80. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_query_expr_types.py +0 -0
  81. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/db/test_transaction.py +0 -0
  82. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/01_schema.sql +0 -0
  83. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/02_types.sql +0 -0
  84. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/03_tables.sql +0 -0
  85. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/04_views.sql +0 -0
  86. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/05_functions.sql +0 -0
  87. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/fixtures/06_indexes.sql +0 -0
  88. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_areas.py +0 -0
  89. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_cli_compare.py +0 -0
  90. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_compare.py +0 -0
  91. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_compare_mssql_live.py +0 -0
  92. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_connection.py +0 -0
  93. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_dialect.py +0 -0
  94. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_diff_mssql.py +0 -0
  95. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_envtag.py +0 -0
  96. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_fetch_missing.py +0 -0
  97. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_fetch_missing_cli.py +0 -0
  98. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_lakebase.py +0 -0
  99. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate.py +0 -0
  100. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_areas.py +0 -0
  101. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_env.py +0 -0
  102. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_grants.py +0 -0
  103. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_mssql.py +0 -0
  104. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_mssql_live.py +0 -0
  105. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_migrate_schemas.py +0 -0
  106. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_mssql_introspect.py +0 -0
  107. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_parser_areas.py +0 -0
  108. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_parser_mssql.py +0 -0
  109. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_parser_schemas.py +0 -0
  110. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_response.py +0 -0
  111. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_pg_json_statement_timeout.py +0 -0
  112. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_schemas.py +0 -0
  113. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_stats_cli.py +0 -0
  114. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/test_stats_mssql_live.py +0 -0
  115. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/__init__.py +0 -0
  116. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/conftest.py +0 -0
  117. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  118. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  119. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  120. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  121. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  122. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  123. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  124. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  125. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  126. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  127. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  128. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  129. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  130. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  131. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  132. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  133. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  134. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  135. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  136. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  137. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  138. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  139. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_api.py +0 -0
  140. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql.py +0 -0
  141. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_api_mssql_live.py +0 -0
  142. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_cli.py +0 -0
  143. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_config.py +0 -0
  144. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_config_mssql.py +0 -0
  145. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_constants.py +0 -0
  146. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_container.py +0 -0
  147. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_container_mssql.py +0 -0
  148. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_docker.py +0 -0
  149. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_mssql_constants.py +0 -0
  150. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_naming.py +0 -0
  151. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_query.py +0 -0
  152. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_schema.py +0 -0
  153. {pgdevkit-0.15.2 → pgdevkit-0.16.1}/tests/testdb/test_schema_filtering.py +0 -0
  154. {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.15.2
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, but you wire the event to the disconnect yourself.)
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, but you wire the event to the disconnect yourself.)
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,4 @@
1
+ from .cancel_on_disconnect import CancelOnDisconnectRoute
2
+ from .json_response import PostgresJsonResponse
3
+
4
+ __all__ = ["CancelOnDisconnectRoute", "PostgresJsonResponse"]
@@ -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
- while (await receive())["type"] != "http.disconnect":
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
- cancel_scope.reschedule(loop.time()) # i.e. cancel the stream now
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(send)
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
- # the client is gone and the query has been cancelled: nobody is left to answer
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
 
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.15.2"
14
+ version = "0.16.1"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -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), and the `database/`-folder schema-as-code convention. Supersedes the old postgres-test-setup, postgres-best-practices,
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
+ )