djhtmx 2.0: async views, synchronous dispatch on a bounded pool#61
Open
edelvalle wants to merge 4 commits into
Open
djhtmx 2.0: async views, synchronous dispatch on a bounded pool#61edelvalle wants to merge 4 commits into
edelvalle wants to merge 4 commits into
Conversation
8abf5da to
04beca6
Compare
c053684 to
64a0544
Compare
…0.0) BREAKING. djhtmx's HTTP, SSE and WebSocket entry points are now `async` views, but each dispatch they trigger runs as a single *synchronous* job on a bounded worker pool. This bounds the Postgres connection count by `DJHTMX_SYNC_WORKERS` regardless of request/stream concurrency. Bump to 2.0.0. Pipeline & handlers - `CommandProcessor.process` is a synchronous generator. `_invoke_handler` is the auto-wrap boundary: plain/sync handlers run directly on the pool thread, `async def` handlers (and async generators) run via `async_to_sync` on that same thread. Components mix sync and async handlers freely (they interoperate through the command/event bus, not direct calls). - HTTP `endpoint`, `sse_endpoint` and the WS `Consumer` are async shells that submit the whole dispatch to the pool via `submit_sync_work`; the SSE stream stays on the loop (redis.asyncio pub/sub) and submits each drain to the pool. The user is resolved on the pool, not via `request.auser()` on the loop. Connections - One bounded pool (`DJHTMX_SYNC_WORKERS`, alias `DJHTMX_SSE_RENDER_WORKERS`) carries ALL ORM-touching work: full dispatches, construction, model resolution and rendering. Peak connections approximate `DJHTMX_SYNC_WORKERS` per process, flat under concurrency; an idle SSE stream holds none. Because async handlers run via `async_to_sync` on the pool thread, even ORM awaited inside an async handler stays on the pooled connection. Model fields - The Model-field validator resolves a bare pk to an instance (sync ORM, on the pool thread) — pre-2.0 behaviour — and `ModelConfig(lazy=True)` still defers the query via a `_LazyModelProxy`. No pre-resolution step: constructing a component directly from a pk works. Model-typed `Query` fields carry the pk in the URL and resolve at build. Resolution running on the pool (not the loop) is what keeps it connection-safe. ATOMIC_REQUESTS - The async views opt out of `ATOMIC_REQUESTS` for every database (Django refuses async views otherwise); atomicity is instead applied per synchronous handler. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oad harness Cover the synchronous dispatch pipeline: sync handlers run directly, async / async-generator handlers are wrapped via async_to_sync, and sync<->async handlers interoperate across an event cascade. Add Model-resolution-at-build tests (pure validator raises on a bare pk), per-handler ATOMIC_REQUESTS tests, and `loadtest_async.py` asserting no deadlock and pool threads bounded by DJHTMX_SYNC_WORKERS under many concurrent SSE drains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the 2.0 model: async views with the whole dispatch run synchronously on the bounded sync-work pool, so Postgres connections stay bounded by DJHTMX_SYNC_WORKERS and idle SSE streams hold none. Update the architecture note, the model-resolution plan, AGENTS conventions, and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
orjson 3.11 ships cp314 wheels and newer serialization fixes; the prior 3.10.7 floor only provided prebuilt wheels through cp313. Raising the floor lets the lock resolve to 3.11.9 and keeps wheels available on current interpreters. Similar for pydantic-core and lxml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
64a0544 to
38776f2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes djhtmx's HTTP / SSE / WebSocket entry points
asyncviews, but runs each dispatch as a single synchronous job on a bounded worker pool. This bounds the Postgres connection count byDJHTMX_SYNC_WORKERSregardless of request or SSE-stream concurrency, while letting components defineasync defevent handlers. Bumps to 2.0.0.Why
An earlier iteration of this branch made the dispatch pipeline fully async (ORM on the event loop via Django async ORM +
request.auser()). A load test against a real app disproved the connection model: becausedjango.db.connectionsis per-async-task, every in-flight request and every idle SSE stream pinned its own Postgres connection, so connections scaled ~1:1 with concurrency and exhaustedmax_connections. The fix is to keep all ORM off the loop entirely — run the whole dispatch on the pool, where connections are thread-affine and reused.What changed
endpoint,sse_endpoint, and the WSConsumerare async shells that submit the whole dispatch to the sync-work pool viasubmit_sync_work. The SSE stream stays on the loop (itsredis.asynciopub/sub) and submits each drain to the pool.CommandProcessor.processis a synchronous generator._invoke_handlerruns plain/sync handlers directly on the pool thread and wrapsasync defhandlers (and async generators) viaasync_to_syncon that same thread — so even ORM awaited inside an async handler shares the one pooled connection. Sync/async handlers interoperate freely across an event cascade.request.auser()on the loop — this is what stops idle SSE streams from each holding a connection.DJHTMX_SSE_RENDER_WORKERS→DJHTMX_SYNC_WORKERS(old name kept as a deprecated alias). One pool now bounds all ORM-touching work — it's the single Django DB connection budget.ATOMIC_REQUESTSis applied per sync handler. Django refusesATOMIC_REQUESTSon async views, so the views opt out for every DB and each sync handler is instead wrapped intransaction.atomicper atomic database.ModelConfig(lazy=True)defers the query to first access via_LazyModelProxy. Resolution running on the pool (not the loop) is what makes it connection-safe.aemit_sse_eventfor publishing SSE events fromasync defhandlers without blocking the loop.Connection model
Peak Postgres connections ≈
DJHTMX_SYNC_WORKERSper process, flat under concurrency; an idle SSE stream holds zero connections (it borrows one only while a drain renders). SizeDJHTMX_SYNC_WORKERS × processesunder Postgresmax_connections(and any pgbouncer pool).Breaking changes / migration
AuthenticationMiddlewareis present (the views resolve the user).ATOMIC_REQUESTSis now per-handler, not per-request — audit anything relying on whole-request atomicity across a cascade.DJHTMX_SSE_RENDER_WORKERSstill works but is deprecated in favor ofDJHTMX_SYNC_WORKERS.lazysupported).Testing
ATOMIC_REQUESTS, Model resolution + lazy proxy (deferred-until-access query counts), andloadtest_async.pyasserting no deadlock and pool threads bounded byDJHTMX_SYNC_WORKERSunder many concurrent SSE drains.Reviewing
Best read commit-by-commit:
feat(async)!(the pipeline), then tests, then docs (docs/async-architecture.mdis the canonical explanation of the connection model).🤖 Generated with Claude Code