Skip to content
Open
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
48 changes: 46 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,49 @@ jobs:
flags: ${{ github.event_name == 'push' && 'full' || 'critical' }}-python-${{ matrix.python-version }}
fail_ci_if_error: false

# LAB-511: free-threaded CPython safety net. Runs the core suites on a
# 3.14 free-threaded build and fails if the GIL gets re-enabled, so code
# whose correctness silently depended on the GIL's memory ordering cannot
# regress unnoticed. Optional native deps without free-threaded wheels
# (orjson, numpy, pandas, pyarrow) are dev-group-only and stay out
# (--group test); hiredis is skipped because it does not declare
# free-threaded support — importing it re-enables the GIL — and redis-py
# transparently falls back to its pure-Python parser.
test-freethreaded:
name: Tests (Python 3.14t, GIL disabled)
runs-on: cachekit
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Start Redis (no persistence)
run: |
docker run -d --name redis -p 6379:6379 \
redis:7-alpine redis-server --save "" --appendonly no
until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done

- name: Install dependencies (test group only)
run: uv sync --python 3.14t --no-default-groups --group test --no-install-package hiredis

- name: Verify the GIL is actually disabled
run: |
uv run --no-sync python -c "
import sys, sysconfig
assert sysconfig.get_config_var('Py_GIL_DISABLED') == 1, 'not a free-threaded build'
import cachekit
import cachekit._rust_serializer
import redis
assert not sys._is_gil_enabled(), 'an import re-enabled the GIL'
print('free-threaded build, GIL still disabled after imports')
"

- name: Run unit + critical suites (GIL-free)
env:
REDIS_URL: redis://localhost:6379
run: |
uv run --no-sync pytest tests/unit/ -m "not slow" -n auto
uv run --no-sync pytest tests/critical/ -m "not slow"

# Version sync + doc tests (push to main only)
post-merge:
name: Post-Merge Checks
Expand Down Expand Up @@ -209,13 +252,14 @@ jobs:
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [quick-check, test]
needs: [quick-check, test, test-freethreaded]
if: always()
steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.quick-check.result }}" != "success" ]] || \
[[ "${{ needs.test.result }}" != "success" ]]; then
[[ "${{ needs.test.result }}" != "success" ]] || \
[[ "${{ needs.test-freethreaded.result }}" != "success" ]]; then
echo "One or more jobs failed"
exit 1
fi
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,14 @@ exposition setup.
<details>
<summary><strong>Thread Safety Details</strong></summary>

**Free-threaded CPython (3.14t):** the core suites run green on
free-threaded 3.14 with the GIL verified disabled (CI job
`test-freethreaded`), and the Rust extension declares free-threaded safety
(`gil_used = false`). Free-threaded wheels are **not yet published** and
free-threaded builds are not officially supported — blocked on upstream
wheels (orjson, hiredis; numpy/pandas/pyarrow for `[data]`). Details and the
full concurrency audit: [docs/free-threading.md](docs/free-threading.md).

**Per-Function Statistics:**
- Statistics tracked per function identity (`module.qualname`), shared across all calls and across re-decorations of the same function
- Thread-safe via RLock (all methods safe for concurrent access)
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Choose how data is stored:
| [Performance](performance.md) | Benchmarks and optimization |
| [Comparison](comparison.md) | vs. lru\_cache, aiocache, cachetools |
| [Error Codes](error-codes.md) | Error reference |
| [Free-Threaded CPython](free-threading.md) | Support status, concurrency audit, CI safety net |

---

Expand Down
103 changes: 103 additions & 0 deletions docs/free-threading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Free-Threaded CPython

Status as of LAB-511 (2026-08): **tested on 3.14t, not yet declared**.
(3.13t is not in the CI matrix — no claim is made for it.)

- The core test suites (`tests/unit/`, `tests/critical/`) run green on
free-threaded CPython 3.14 with the GIL verified disabled, gated by the
`test-freethreaded` CI job on every PR and push.
- The Rust extension declares free-threaded safety
(`#[pymodule(gil_used = false)]`), so importing `cachekit._rust_serializer`
does not force the GIL back on.
- **No free-threaded (`cp31Nt`) wheels are published yet**, and installing
cachekit on a free-threaded interpreter is not officially supported. See
[Deferred: declared support](#deferred-declared-support--free-threaded-wheels).

## What "works only under the GIL" used to mean here

Lock-free fast paths written under the GIL inherit its implicit guarantees:
one bytecode interleaving at a time and, effectively, sequentially consistent
publication of writes. Free-threaded CPython removes both. Its memory model
does not specify cross-variable store visibility order for plain reads, so a
reader may observe a *later* store while an *earlier* one is not yet visible.

Concrete instance (the defect that motivated LAB-511):
`decorators/session.py::_ensure_session_initialized` published three module
globals and relied on assignment order (`_session_start_ms`, then
`_session_id`, then `_session_pid`) to make the lock-free fast path safe. A
GIL-free reader observing `pid`+`id` but not yet `start_ms` sailed past the
fast path into `get_session_start_ms()`'s "should never happen"
`RuntimeError` — which `backends/cachekitio/backend.py` catches and converts
into *silently dropped session headers*, the exact telemetry loss LAB-506
eliminated. The fix gates the fast path (and the in-lock double-check) on
**every** published field; observing a partial publish now falls through to
the lock and waits for the in-flight initializer.

Rule of thumb applied throughout the audit:

- **Single-assignment publication** of a fully-constructed object through one
reference (e.g. a double-checked module-global singleton) is acceptable.
- **Multi-field publication** that readers expect to be mutually consistent
must be lock-protected or gated on every field — assignment order proves
nothing without the GIL.

## Concurrency audit (LAB-511)

Every lock-free fast path and shared mutable module/instance state named by
the ticket, plus what the free-threaded CI lane surfaced:

| Site | Mechanism | Verdict |
|:-----|:----------|:--------|
| `decorators/session.py` `_ensure_session_initialized` | Lock-free fast path over three module globals | **Fixed** — fast path and double-check gate on all three fields; regression tests in `tests/unit/test_saas_observability.py::TestMidPublishMemoryOrdering` and `tests/unit/test_free_threading.py` |
| `reliability/metrics_collection.py` `AsyncMetricsCollector.flush` | Polled `Queue.empty()` | **Fixed** — `empty()` flips when the worker *dequeues*, not when it finishes processing; flush now waits on `unfinished_tasks` (zeroed by `task_done()` after processing). Was a routine flake on the free-threaded lane, invisible under the GIL's coarse scheduling |
| `decorators/wrapper.py` `_FunctionStats` | `RLock` around every counter mutation and `get_info` | Safe. `l1_enabled` is a plain attribute re-set on re-decoration (under the registry lock) and read without the stats lock; a stale read yields a conservative rate-limit classification header, never corruption |
| `decorators/wrapper.py` `_function_stats_registry` | Module-level `Lock` around all access | Safe |
| `os.register_at_fork` handlers (session + stats registry) | Run in the child while single-threaded; replace locks wholesale | Safe — single-threaded by construction at execution time |
| `backends/cachekitio/session.py` header cache | `threading.local` | Safe — per-thread state; its only cross-thread hazard was the session-identity publication above |
| `decorators/stats_context.py` | `contextvars.ContextVar` | Safe by construction |
| `decorators/wrapper.py` L1/SWR + L2/SWR single-flight (`_l1_swr_*`, `_l2_swr_*`) | `BoundedSemaphore` slots + in-flight `set` + PID-owner swap | Safe. The check-then-add on the in-flight set was already documented as benign (worst case one duplicate refresh, absorbed by last-write-wins / the backend lease); builtin `set`/`dict` single ops are atomic under free-threading's per-object locking. The fork-detection wholesale swap races are the same documented-benign shape |
| `decorators/wrapper.py` `_cached_keys` | Builtin `set`, snapshot-copied before iteration in invalidation | Safe — single ops atomic; a copy racing an add can only miss a concurrently-written key, which invalidate-all semantics tolerate |
| `object_cache.py` `ObjectCache` | `RLock` on every public method | Safe |
| `reliability/metrics_collection.py` `get_async_metrics_collector` | Double-checked module-global singleton | Safe — single-assignment publication of a fully-constructed object; worst case a benign duplicate worker-restart check |
| Rust extension (`rust/src/`, cachekit-core 0.5.0) | `#[pymodule(gil_used = false)]`; every `#[pyclass]` exposes only `&self` methods; nonce counter is `AtomicU64`, metrics behind `Mutex`; PyO3 enforces `Send + Sync` on pyclasses at compile time | Safe — declared free-threading-ready. (The wasm32 `Cell` nonce variant is single-threaded by target.) |

## The CI safety net

`.github/workflows/ci.yml` job `test-freethreaded`:

1. Installs with `uv sync --python 3.14t --no-default-groups --group test
--no-install-package hiredis`. The `test` dependency group is the core
test toolchain; the extras that lack free-threaded wheels (orjson, numpy,
pandas, pyarrow) live only in the `dev` group and the `[data]`/`[json]`
extras, and their tests skip via `pytest.importorskip`.
2. Asserts the interpreter is a free-threaded build **and** that
`sys._is_gil_enabled()` is still `False` after importing `cachekit`,
`cachekit._rust_serializer`, and `redis` — a dependency that fails to
declare free-threaded support re-enables the GIL for the whole process at
import time, which would silently turn the lane back into a GIL run.
`tests/unit/test_free_threading.py` re-asserts this from inside the suite.
3. Runs `tests/unit/` and `tests/critical/`.

hiredis is excluded because it does not declare free-threaded support (no
`Py_mod_gil` slot); redis-py transparently falls back to its pure-Python
parser. On a GIL build nothing changes — hiredis remains the default parser.

## Deferred: declared support + free-threaded wheels

Publishing `cp314t` wheels and declaring official free-threaded support is
**explicitly deferred** (per the LAB-511 acceptance criteria) until the
dependency chain allows it. Blocking as of 2026-08:

- **orjson** — no free-threaded wheels through 3.12.0, and its build script
rejects free-threaded interpreters ("does not support free-threaded
Python"). Optional `[json]` extra, but a support declaration that breaks
the moment a user adds `cachekit[json]` is not a declaration worth making.
- **hiredis** — no `Py_mod_gil` declaration; importing it re-enables the GIL.
Pulled in unconditionally via the required `redis[hiredis]` dependency.
- **numpy / pandas / pyarrow** — the `[data]` extra; free-threaded wheel
coverage across all three is not yet complete enough to declare.

When those clear: add `-i python3.14t` targets to the `build-wheels` matrix in
`.github/workflows/release-please.yml`, revisit `redis[hiredis]` (marker or
documented degradation), and update this page plus the README support
statement. Track upstream — do not fork or vendor (LAB-511 non-goal).
30 changes: 19 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,12 @@ exclude_lines = [
]

[dependency-groups]
dev = [
# Testing
# Core test toolchain — everything tests/unit + tests/critical need on ANY
# interpreter, including free-threaded CPython: the free-threaded CI lane
# installs ONLY this group (LAB-511). A dep may live here only if it ships
# free-threaded wheels or builds cleanly from source on 3.14t; deps that
# don't (orjson, numpy, pandas, pyarrow) stay in dev below.
test = [
"fakeredis>=2.21.0",
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
Expand All @@ -210,6 +214,19 @@ dev = [
"pytest-markdown-docs>=0.6.0",
"pytest-redis>=3.0.0",
"pymemcache>=4.0.0",
# Utilities
"faker>=20.0.0",
"httpx>=0.28.1",
"hypothesis>=6.0.0",
"requests>=2.33.0; python_version >= '3.10'",
"psutil>=5.9.0",
"python-dotenv>=1.0.0",
"pyyaml>=6.0.3",
"pytest-xdist>=3.8.0",
"time-machine>=2.19.0",
]
dev = [
{ include-group = "test" },
# Competitive comparison suite (tests/competitive/ benchmarks cachekit vs these)
"cachetools>=5.3.0",
"aiocache>=0.12.0",
Expand All @@ -218,23 +235,14 @@ dev = [
"ruff>=0.6.0",
# Utilities
"bashlex>=0.18",
"faker>=20.0.0",
"httpx>=0.28.1",
"hypothesis>=6.0.0",
"pip-audit>=2.7.0",
"requests>=2.33.0; python_version >= '3.10'",
"psutil>=5.9.0",
"python-dotenv>=1.0.0",
"pyyaml>=6.0.3",
# Data science support (for testing AutoSerializer with numpy/pandas)
"numpy>=2.0.2",
"pandas>=1.3.0",
"pyarrow>=21.0.0",
# OrjsonSerializer support — now the [json] optional extra; kept here so the
# orjson tests, doctests, and markdown-docs still resolve it in dev/CI.
"orjson>=3.9.0",
"pytest-xdist>=3.8.0",
"time-machine>=2.19.0",
]
# Linux CI only - Atheris requires libFuzzer (not available on macOS without building LLVM)
fuzz = [
Expand Down
10 changes: 9 additions & 1 deletion rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ pub mod python_bindings;
use pyo3::prelude::*;

/// Python module definition - exports raw byte storage and encryption
///
/// `gil_used = false` (the PyO3 0.28+ default, made explicit): declares the
/// module thread-safe under free-threaded CPython so importing it does not
/// force the GIL back on. Verified by the LAB-511 audit: every `#[pyclass]`
/// exposes only `&self` methods, and shared state in cachekit-core is
/// `AtomicU64` (nonce counter) or `Mutex` (metrics) — no interior mutability
/// the GIL was papering over. PyO3 enforces `Send + Sync` on every pyclass at
/// compile time.
#[cfg(feature = "python")]
#[pymodule]
#[pymodule(gil_used = false)]
fn _rust_serializer(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
// Add byte storage class
m.add_class::<python_bindings::PyByteStorage>()?;
Expand Down
24 changes: 16 additions & 8 deletions src/cachekit/decorators/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,27 @@ def _ensure_session_initialized() -> None:

current_pid = os.getpid()

# Fast path: session already initialized for this process
if _session_pid == current_pid and _session_id is not None:
# Fast path: session already initialized for this process. Gates on EVERY
# published field, not just pid+id: assignment order below only guarantees
# visibility order under the GIL. On free-threaded CPython with a weak
# memory model (e.g. ARM64) a lock-free reader may observe the stores out
# of order — pid+id set while start_ms still reads None — and a pid+id
# gate would admit it straight into get_session_start_ms()'s RuntimeError,
# silently dropping the session headers upstream. Observing a partial
# publish here just means falling through to the lock, where the
# in-flight initializer completes before the double-check re-runs.
if _session_pid == current_pid and _session_id is not None and _session_start_ms is not None:
return

with _session_lock:
# Double-check inside lock (another thread might have initialized)
if _session_pid == current_pid and _session_id is not None:
# Double-check inside lock (another thread might have initialized).
# Same full-field gate as the fast path: entering the lock does not
# retroactively order stores a GIL-less writer made before we blocked.
if _session_pid == current_pid and _session_id is not None and _session_start_ms is not None:
return

# _session_pid is assigned LAST: the fast path above reads without the
# lock and admits readers once pid+id are set, so all other fields must
# already be populated by then (a reader admitted between id and
# start_ms assignments would find start_ms still None).
# _session_pid is assigned LAST as defense in depth for GIL builds,
# but the readers above no longer rely on assignment order.
_session_start_ms = int(time.time() * 1000)
_session_id = str(uuid.uuid4())
_session_pid = current_pid
Expand Down
14 changes: 11 additions & 3 deletions src/cachekit/reliability/metrics_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,19 @@ def clear(self):
self._stats["dropped_count"] = 0

def flush(self, timeout: float = 2.0):
"""Flush all pending metrics with timeout."""
"""Flush all pending metrics with timeout.

Waits for the worker to finish PROCESSING, not merely dequeue:
Queue.empty() flips the moment the worker get()s the last item —
before _process_metric runs — so polling empty() can return with the
final metric still mid-flight. Unobservable in practice under the
GIL's coarse scheduling, routine under free-threaded CPython
(LAB-511). unfinished_tasks only reaches zero at task_done(), which
the worker calls after processing and the stats update.
"""
if self._worker_thread and self._worker_thread.is_alive():
# Wait for queue to be processed
start_time = time.time()
while not self._metric_queue.empty() and (time.time() - start_time) < timeout:
while self._metric_queue.unfinished_tasks and (time.time() - start_time) < timeout:
time.sleep(0.01)

def shutdown(self, timeout: float = 2.0):
Expand Down
29 changes: 29 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""

import os
import sys

import pytest

Expand All @@ -33,6 +34,34 @@
pass


# =============================================================================
# Free-threaded build guard (LAB-511)
# =============================================================================


@pytest.fixture(scope="session", autouse=True)
def _gil_stays_disabled_on_free_threaded_builds():
"""On a free-threaded build, fail the session if anything re-enabled the GIL.

Any C extension without a Py_mod_gil declaration (a conftest import, a
plugin, a future dependency bump) re-enables the GIL for the whole
process at import time — silently turning the free-threaded CI lane back
into a GIL run while it keeps reporting green. Checked at teardown so
every lazily-imported module is covered, and autouse at session scope so
it runs in EVERY pytest-xdist worker process and in every suite. No-op
on GIL builds.
"""
yield
import sysconfig

if sysconfig.get_config_var("Py_GIL_DISABLED") and sys._is_gil_enabled(): # type: ignore[attr-defined]
pytest.fail(
"The GIL was re-enabled during a free-threaded test session — "
"some imported extension module does not declare free-threaded "
"support (Py_mod_gil). This worker silently ran under the GIL."
)


# =============================================================================
# Configuration Management
# =============================================================================
Expand Down
6 changes: 4 additions & 2 deletions tests/critical/test_cache_serializer_compression.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Test CacheSerializer compression functionality"""

import numpy as np
import pytest

# Requires the [data] extra — absent e.g. in the free-threaded CI lane (LAB-511).
np = pytest.importorskip("numpy")

# Import from compatibility wrapper
from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer
from tests.critical.cache_serializer_compat import CACHE_SERIALIZER_AVAILABLE, CacheSerializer # noqa: E402


@pytest.mark.skipif(not CACHE_SERIALIZER_AVAILABLE, reason="Cache serializer not available")
Expand Down
Loading
Loading