Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 34 additions & 0 deletions docs/introduction/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
117 changes: 117 additions & 0 deletions planning/changes/2026-07-18.01-free-threaded-support.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions planning/decisions/2026-07-18-free-threading-is-compat-only.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions planning/decisions/2026-07-18-sqlalchemy-cyext-disables-gil.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
Loading