diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 12a3f71..7cf9b88 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -64,3 +64,40 @@ jobs: PYTHONDONTWRITEBYTECODE: 1 PYTHONUNBUFFERED: 1 POSTGRES_DSN: postgresql+asyncpg://outbox:outbox@127.0.0.1:5432/outbox + + freethreaded: + runs-on: ubuntu-latest + env: + # SQLAlchemy's cyext ships cp314t wheels but doesn't declare free-thread + # safety, so importing it re-enables the GIL; use its pure-Python fallback. + DISABLE_SQLALCHEMY_CEXT_RUNTIME: 1 + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: outbox + POSTGRES_PASSWORD: outbox + POSTGRES_DB: outbox + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U outbox" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + - run: uv python install 3.14t + - run: uv python pin 3.14t + - run: uv sync --all-extras --no-install-project + - name: Assert the GIL is actually disabled + run: uv run --no-sync python -c "import sys, asyncpg, sqlalchemy, pydantic_core, faststream, faststream_outbox; assert not sys._is_gil_enabled(), 'GIL re-enabled - a dependency lacks free-threaded support'" + - run: uv run --no-sync pytest . --no-cov + env: + PYTHONDONTWRITEBYTECODE: 1 + PYTHONUNBUFFERED: 1 + POSTGRES_DSN: postgresql+asyncpg://outbox:outbox@127.0.0.1:5432/outbox diff --git a/docs/introduction/installation.md b/docs/introduction/installation.md index 5409ad7..f053e6f 100644 --- a/docs/introduction/installation.md +++ b/docs/introduction/installation.md @@ -29,6 +29,40 @@ you're starting fresh. - A running Postgres instance accessible via SQLAlchemy `AsyncEngine` +## Free-threaded Python + +`faststream-outbox` supports free-threaded (no-GIL) CPython. CI runs the full +test suite on the **3.14t** interpreter, and a separate CI step asserts that +importing the outbox and its runtime dependencies keeps the GIL disabled. The +package is pure-Python +asyncio, so nothing in it depends on the GIL; installing on a `python3.14t` +interpreter resolves the free-threaded wheels of the compiled dependencies +(`asyncpg`, `sqlalchemy`, `pydantic-core`) automatically. + +!!! note "Keep the GIL disabled: `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1`" + + SQLAlchemy's Cython extensions ship free-threaded wheels but do not yet + declare themselves free-thread-safe, so importing SQLAlchemy re-enables the + GIL process-wide. Your outbox code still runs correctly either way, but if + you want the GIL to stay disabled — for example because other parts of your + process use threads for parallelism — set `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1` + (SQLAlchemy's own switch; it falls back to pure-Python implementations). This + is what CI runs, and it is what lets the GIL stay off. + + The same caveat applies to any foreign-broker client you install for the + [relay feature](../usage/relay.md): if it hasn't declared free-thread safety + (for example `aiokafka`), importing it re-enables the GIL. That is the + client library's limitation, not the outbox's — your outbox code still runs + correctly. + +What this does **not** change: the subscriber runs a single event loop by +design, so free-threading does not add cross-core parallelism within one +process. To use more cores, run more subscriber processes — the same scaling +lever as on a GIL build. + +Free-threaded wheels for the compiled dependencies currently exist for 3.14t +only (there are no `cp313t` wheels), so 3.13t is not a supported target. + ## Postgres If you don't have a Postgres instance, you can start one with Docker: diff --git a/planning/changes/2026-07-18.01-free-threaded-support.md b/planning/changes/2026-07-18.01-free-threaded-support.md new file mode 100644 index 0000000..443f305 --- /dev/null +++ b/planning/changes/2026-07-18.01-free-threaded-support.md @@ -0,0 +1,117 @@ +--- +summary: Guarantee free-threaded (3.14t) Python compatibility via a dedicated CI job (SQLAlchemy C-accel disabled so the GIL stays off), a "Free Threading :: 2 - Beta" classifier, and a docs note — no runtime change. +--- + +# Design: Free-threaded Python (3.14t) compatibility + +## Summary + +`faststream-outbox` is pure-Python asyncio with no `threading`, locks, or +C-extension code of its own, so it is *expected* to run on a free-threaded +(no-GIL) CPython — but nothing proves or advertises it. This change adds a +**compatibility guarantee**, not a behavior change: a dedicated CI job that runs +the full suite on **3.14t** and, in a separate step, asserts the outbox's runtime dependency graph keeps the GIL disabled, a `Free Threading :: +2 - Beta` trove classifier, and a short docs note. Keeping the GIL genuinely +disabled requires disabling SQLAlchemy's Cython extensions +(`DISABLE_SQLALCHEMY_CEXT_RUNTIME=1`, its own sanctioned switch) — see the +[SQLAlchemy-cyext decision](../decisions/2026-07-18-sqlalchemy-cyext-disables-gil.md). +No source, no dependency floors, no runtime semantics change. + +## Motivation + +CPython 3.14 free-threaded builds are on the GA track and users increasingly run +`python3.14t`. The package's own code has zero GIL-dependent surface (single +event loop, N worker *tasks*, not OS threads), so it should Just Work — but +"should" is not a guarantee, and a regression would be invisible. Two concrete +facts shape the design: + +- The compiled deps — `asyncpg`, `pydantic-core`, `sqlalchemy` — install + `cp314t` free-threaded wheels, so the **full** integration suite (asyncpg + against Postgres) runs under 3.14t today. `asyncpg` and `pydantic-core` + declare free-thread safety; **SQLAlchemy's Cython extensions do not**, so + importing `sqlalchemy` silently re-enables the GIL process-wide (a `cp314t` + wheel is necessary but not sufficient). We scope around this with SQLAlchemy's + documented `DISABLE_SQLALCHEMY_CEXT_RUNTIME` switch (pure-Python fallback). +- Without a CI gate, a future dep bump that drops or lacks a `cp314t` wheel — or + another extension that fails to declare free-thread safety — would silently + re-enable the GIL, and we would ship a false "free-threaded" claim. + +## Design + +**CI** — a new `freethreaded` job in `.github/workflows/_checks.yml`, mirroring +the `pytest` job (same Postgres 17 service block) but standalone (not in the +coverage matrix, no coverage upload), with `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1` +set job-wide: + +```yaml + freethreaded: + runs-on: ubuntu-latest + env: + DISABLE_SQLALCHEMY_CEXT_RUNTIME: 1 # SQLAlchemy cyext re-enables the GIL; use its pure-Python fallback + services: + postgres: { ... same as pytest ... } + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.2.0 + - run: uv python install 3.14t + - run: uv python pin 3.14t + - run: uv sync --all-extras --no-install-project + # Fail loudly if any imported extension silently re-enabled the GIL: + - run: uv run --no-sync python -c "import sys, asyncpg, sqlalchemy, pydantic_core, faststream, faststream_outbox; assert not sys._is_gil_enabled()" + - run: uv run --no-sync pytest . --no-cov + env: { POSTGRES_DSN: postgresql+asyncpg://outbox:outbox@127.0.0.1:5432/outbox, ... } +``` + +The GIL-off assertion is load-bearing: it turns a silent GIL re-enable (the most +likely failure mode) into a red build. `--no-cov` overrides the repo's +`--cov-fail-under=100` addopts — the free-threaded run is a compat/GIL-off smoke, +and the four GIL builds already enforce 100% on identical source; running +coverage under free-threaded `sys.monitoring` only adds flakiness for zero new +signal. + +**Packaging** — add `"Programming Language :: Python :: Free Threading :: +2 - Beta"` to `pyproject.toml` classifiers. Conservative first claim; bump to +`3 - Stable` after real-world soak. No dependency-floor change: free-threaded +users' resolver picks the `cp314t` wheels automatically; GIL users are +unaffected. + +**Docs** — a short "Free-threaded Python" note in +`docs/introduction/installation.md`: supported and CI-proven on 3.14t; the +library itself is GIL-independent; **to keep the GIL genuinely disabled, set +`DISABLE_SQLALCHEMY_CEXT_RUNTIME=1`** (otherwise SQLAlchemy's C extensions +re-enable it process-wide — correctness is unaffected either way). It does +**not** add cross-core parallelism (the subscriber is a single event loop by +design). + +Why 3.14t only, and why compat-only rather than a parallelism redesign → +[decision: free-threading-is-compat-only](../decisions/2026-07-18-free-threading-is-compat-only.md). +Why SQLAlchemy's C extensions are disabled → +[decision: sqlalchemy-cyext-disables-gil](../decisions/2026-07-18-sqlalchemy-cyext-disables-gil.md). + +## Non-goals + +- **No parallelism redesign.** The subscriber stays single-event-loop; this is + not a multi-thread/multi-loop rearchitecture. See the linked decision. +- **No 3.13t target** — the compiled deps ship no `cp313t` wheels. +- **No dependency-floor bumps** — wheel resolution handles it per-interpreter. +- **No attempt to force SQLAlchemy's C extensions to run GIL-free** — that is + upstream's to declare; we disable them and revisit when they do. + +## Testing + +- CI `freethreaded` job green: full suite passes on 3.14t and the + `sys._is_gil_enabled()` assertion holds (with `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1`). +- Fast local smoke (no Postgres): + `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1 uv run --python 3.14t --no-sync pytest tests/test_unit.py tests/test_fake.py --no-cov`. +- `just check-planning` passes. + +## Risk + +- **SQLAlchemy (or another dep) re-enables the GIL** (confirmed for SQLAlchemy's + cyext; medium likelihood for future deps): mitigated by + `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1` today and the GIL-off assertion, which + fails the job loudly on any new offender. +- **`sys.monitoring` coverage flakiness** on free-threaded (cf. the py3.11 + trace-loss class): avoided entirely by `--no-cov` on this job. +- **3.14t availability in setup-uv** (low): uv already resolves and installs + free-threaded interpreters. diff --git a/planning/decisions/2026-07-18-free-threading-is-compat-only.md b/planning/decisions/2026-07-18-free-threading-is-compat-only.md new file mode 100644 index 0000000..98c8840 --- /dev/null +++ b/planning/decisions/2026-07-18-free-threading-is-compat-only.md @@ -0,0 +1,54 @@ +--- +status: accepted +summary: Free-threaded support is a compatibility guarantee (runs on 3.14t, GIL off), not a parallelism redesign; target 3.14t only. +supersedes: null +superseded_by: null +--- + +# Free-threading is a compatibility guarantee, not a parallelism redesign + +**Decision:** "Free-threaded support" means proving and advertising that +`faststream-outbox` runs correctly on a free-threaded CPython (3.14t) with the +GIL disabled — it does **not** mean rearchitecting the subscriber to use multiple +CPU cores. Target 3.14t only, not 3.13t. + +## Context + +Free-threaded CPython removes the GIL, so threaded CPU-bound Python can use +multiple cores. The question raised: should `faststream-outbox` "support nogil"? +Two readings were on the table: + +1. **Compatibility** — guarantee the package imports and runs green under a + free-threaded interpreter, and say so. +2. **Exploit parallelism** — redesign the subscriber so its worker loops run + across OS threads / multiple event loops to use multiple cores. + +## Decision & rationale + +We take reading (1) and explicitly reject (2) for now. + +- The package is pure-Python asyncio: one event loop, N worker *tasks* (not OS + threads), zero `threading`/lock/C-extension code. Free-threading changes none + of its runtime semantics; the win from (1) is a proven guarantee, achieved with + a CI job + classifier + docs and no source change. +- Reading (2) is a substantial rearchitecture of a deliberately single-loop + design (the two-loop subscriber, lease-token invariant, drain-on-stop all + assume one loop). Its payoff is also questionable: outbox throughput is + dominated by Postgres I/O and row-lease contention, not by in-process CPU, so + multi-core in-process fan-out is unlikely to be the bottleneck. Scaling today is + "run more subscriber processes," which already uses more cores. (2) carries + real invariant-breaking risk for an unproven gain — declined. +- **3.14t only, not 3.13t:** the compiled deps (`asyncpg`, `sqlalchemy`, + `pydantic-core`) ship `cp314t` wheels but no `cp313t` wheels, so 3.13t cannot + install the full graph. No point targeting an interpreter the dependencies + cannot support. + +Details of the shipped compat work → change +[2026-07-18.01-free-threaded-support](../changes/2026-07-18.01-free-threaded-support.md). + +## Revisit trigger + +Reopen (2) if a profiled workload shows the subscriber is **in-process CPU-bound** +(not Postgres/lease-bound) and running more processes is not an acceptable scale +lever. Reopen the 3.13t question if the compiled deps start publishing `cp313t` +wheels *and* 3.13t is still within the support window. diff --git a/planning/decisions/2026-07-18-sqlalchemy-cyext-disables-gil.md b/planning/decisions/2026-07-18-sqlalchemy-cyext-disables-gil.md new file mode 100644 index 0000000..6e26519 --- /dev/null +++ b/planning/decisions/2026-07-18-sqlalchemy-cyext-disables-gil.md @@ -0,0 +1,57 @@ +--- +status: accepted +summary: The free-threaded CI job sets DISABLE_SQLALCHEMY_CEXT_RUNTIME=1 because SQLAlchemy's Cython extensions re-enable the GIL on 3.14t; docs tell users to do the same for a genuinely GIL-free process. +supersedes: null +superseded_by: null +--- + +# SQLAlchemy C extensions are disabled to keep the GIL off on 3.14t + +**Decision:** Scope the free-threaded (3.14t) guarantee to "runs with the GIL +genuinely disabled *when SQLAlchemy's Cython extensions are off*." The +`freethreaded` CI job sets `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1` (SQLAlchemy's own +sanctioned switch) and asserts `sys._is_gil_enabled() is False`; the docs tell +users to set the same env var for a truly GIL-free process. + +## Context + +Adding the free-threaded compat guarantee (change +[2026-07-18.01](../changes/2026-07-18.01-free-threaded-support.md)), the local +proof on 3.14t found that `import sqlalchemy` **silently re-enables the GIL**. +SQLAlchemy 2.0.50 ships `cp314t` wheels, but its Cython extensions +(`sqlalchemy.cyextension.*`) do not declare free-thread safety (no +`Py_MOD_GIL_NOT_USED` slot), so CPython force-re-enables the GIL process-wide on +import. `asyncpg` and `pydantic-core` are unaffected — SQLAlchemy is the sole +offender. A `cp314t` wheel is therefore necessary but not sufficient for a +GIL-free run. + +Options weighed: (1) set `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1` and keep the +GIL-off assertion; (2) drop the assertion and make no GIL claim (compat only); +(3) defer the whole change until SQLAlchemy ships a free-threading-safe +cyextension. + +## Decision & rationale + +Take (1). The library runs *correctly* on 3.14t regardless of the GIL, but a +genuinely-disabled GIL matters for a user who runs other threaded code in the +same process — SQLAlchemy's cyext would otherwise re-enable the GIL +process-wide and kill their parallelism. `DISABLE_SQLALCHEMY_CEXT_RUNTIME` is +SQLAlchemy's documented, supported switch (pure-Python fallback of the same +behavior, only slower), not a hack, and it is fully under our control — so +there is no "wait for upstream" gap that would justify (3). Dropping the +assertion (2) discards the regression guard that caught this in the first +place. The cost of (1) is one CI env var plus a one-line docs caveat, and it is +trivially reversible when upstream fixes the extensions. + +Rejected (3) — defer: the guarantee is already true today (the suite passes on +3.14t), the workaround is sanctioned and stable, the upstream timeline is +open-ended, and withholding classifier/docs leaves 3.14t adopters with no +signal. Deferring banks nothing and is strictly worse for users than shipping +with the documented caveat. + +## Revisit trigger + +Drop `DISABLE_SQLALCHEMY_CEXT_RUNTIME=1` (from CI and the docs caveat) once +SQLAlchemy ships Cython extensions that declare `Py_MOD_GIL_NOT_USED` and the +GIL stays disabled on 3.14t with C-accel on. Re-run the change's Step-3 GIL +assertion without the env var to confirm before removing it. diff --git a/pyproject.toml b/pyproject.toml index a453cea..0ef69cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Free Threading :: 2 - Beta", "Typing :: Typed", "Topic :: Software Development :: Libraries", ]