Skip to content

djhtmx 2.0: async views, synchronous dispatch on a bounded pool#61

Open
edelvalle wants to merge 4 commits into
masterfrom
feat/async-pipeline
Open

djhtmx 2.0: async views, synchronous dispatch on a bounded pool#61
edelvalle wants to merge 4 commits into
masterfrom
feat/async-pipeline

Conversation

@edelvalle

@edelvalle edelvalle commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Makes djhtmx's HTTP / SSE / WebSocket entry points async views, but runs each dispatch as a single synchronous job on a bounded worker pool. This bounds the Postgres connection count by DJHTMX_SYNC_WORKERS regardless of request or SSE-stream concurrency, while letting components define async def event 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: because django.db.connections is 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 exhausted max_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

  • Async views, sync dispatch. endpoint, sse_endpoint, and the WS Consumer are async shells that submit the whole dispatch to the sync-work pool via submit_sync_work. The SSE stream stays on the loop (its redis.asyncio pub/sub) and submits each drain to the pool. CommandProcessor.process is a synchronous generator.
  • Async handlers still supported. _invoke_handler runs plain/sync handlers directly on the pool thread and wraps async def handlers (and async generators) via async_to_sync on 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.
  • The user is resolved on the pool, not via request.auser() on the loop — this is what stops idle SSE streams from each holding a connection.
  • DJHTMX_SSE_RENDER_WORKERSDJHTMX_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_REQUESTS is applied per sync handler. Django refuses ATOMIC_REQUESTS on async views, so the views opt out for every DB and each sync handler is instead wrapped in transaction.atomic per atomic database.
  • Model fields keep their pre-2.0 loading (non-breaking): the field validator resolves a bare pk → instance (sync ORM, on the pool thread), and 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.
  • Added aemit_sse_event for publishing SSE events from async def handlers without blocking the loop.

Connection model

Peak Postgres connections ≈ DJHTMX_SYNC_WORKERS per process, flat under concurrency; an idle SSE stream holds zero connections (it borrows one only while a drain renders). Size DJHTMX_SYNC_WORKERS × processes under Postgres max_connections (and any pgbouncer pool).

Breaking changes / migration

  • Major version bump (2.0.0). Run the endpoints under ASGI (granian/uvicorn) — they're async views; ensure AuthenticationMiddleware is present (the views resolve the user).
  • ATOMIC_REQUESTS is now per-handler, not per-request — audit anything relying on whole-request atomicity across a cascade.
  • DJHTMX_SSE_RENDER_WORKERS still works but is deprecated in favor of DJHTMX_SYNC_WORKERS.
  • Not breaking: Model-field loading is unchanged from 1.x (validator resolves pk; lazy supported).

Testing

  • 150 tests pass; ruff + type-check clean.
  • New coverage: sync-on-pool dispatch, sync↔async handler interop, per-handler ATOMIC_REQUESTS, Model resolution + lazy proxy (deferred-until-access query counts), and loadtest_async.py asserting no deadlock and pool threads bounded by DJHTMX_SYNC_WORKERS under many concurrent SSE drains.

Reviewing

Best read commit-by-commit: feat(async)! (the pipeline), then tests, then docs (docs/async-architecture.md is the canonical explanation of the connection model).

🤖 Generated with Claude Code

@mvaled mvaled force-pushed the feat/async-pipeline branch 5 times, most recently from 8abf5da to 04beca6 Compare June 23, 2026 20:02
@mvaled mvaled force-pushed the feat/async-pipeline branch 3 times, most recently from c053684 to 64a0544 Compare June 24, 2026 04:43
edelvalle and others added 4 commits June 24, 2026 06:46
…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>
@mvaled mvaled force-pushed the feat/async-pipeline branch from 64a0544 to 38776f2 Compare June 24, 2026 04:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants