Research: Cross-Backend Concurrency Test Lane (BK-289)¶
Date: 2026-06-14
Context: BK-289 was filed as a Graph-only concurrency test lane out of the
expectation-driven Graph review (see
research-expectation-driven-review.md).
The premise of this document is broader: thread-safety and concurrency are
asserted about Store, AsyncStore, and several backends across src/ and
docs/, yet no test exercises concurrent operations on a single instance for
any backend except in-process Memory. The audit-001 finding M-14 ("zero
concurrency tests despite thread-safety spec claims") is still Open in
research-testing-best-practices.md. This
document analyses the claim surface, rejects the naive "every backend must pass a
concurrency test" framing, and designs a posture-gated concurrency conformance
lane that fits the existing capability/registry machinery.
1. The claim surface¶
Thread-safety / concurrency is asserted in specs and docs, but it is not a single uniform property. It splits across two layers (Store vs. Backend) and two axes (sync threads vs. async coroutines).
| Layer / backend | Declared posture | Source | Tested today? |
|---|---|---|---|
Store |
Immutable, safe to share across threads | STORE-007 (spec 001), ADR-0001 | Memory only — test_store.py::TestStoreThreadSafety |
Store.child |
Inherits STORE-007 | CHILD-010 (spec 015) | Memory only — test_store_child.py::TestChildThreadSafety |
AsyncStore |
Safe for concurrent coroutines on one loop; never across loops | ASYNC-055 (spec 029) | Memory + sync-adapter bridge only |
| Memory | Fully thread-safe (single coarse lock) | MEM-025 (spec 013) | Yes |
| Local | Effectively thread-safe (stateless; delegates to os/shutil) |
— (undocumented) | — |
S3 (s3fs, default s3 extra) |
Safe — s3fs runs its own dedicated event-loop thread; its cross-thread story is distinct from raw boto3 | s3fs / aiobotocore docs | — |
| S3 (boto3) | Safe — boto3 client is thread-safe (_s3_boto3.py uses boto3.client); boto3 resources / Session are not |
boto3 clients guide | — |
| S3-PyArrow | Distinct client — pyarrow S3FileSystem (Arrow C++), not boto3/s3fs; posture to be confirmed by the Tier-3 live probe (cf. Local, §6 risk #2) |
pyarrow fs.S3FileSystem |
— |
| Azure | Safe — service clients are immutable & thread-safe once constructed (BlobServiceClient/ContainerClient/BlobClient); the storage-blob service docs only state "no concurrent writes to the same blob" |
Azure SDK for Python design guidelines | — |
| Graph | Safe for concurrent coroutines on one loop; overwrite=False is a server-side atomic create-if-absent |
live-confirmed (review); spec gap — proposed GR-059 | none |
| SQLBlob | Safe — SQLAlchemy Engine/pool is thread-safe and each op opens its own engine.connect()/begin(); a shared Connection would not be thread-safe |
SQLAlchemy engine/connection docs | — |
| SFTP | NOT thread-safe — single paramiko socket; one instance per thread | SFTP-guide, _sftp.py docstring, async.md |
Carve-out asserted in test_sync_adapter_conformance.py |
| HTTP | NOT thread-safe — shared redirect-counter on the opener | _http.py docstring |
— |
Two facts dominate the design:
- The posture is per-backend and non-uniform. Memory/Local/cloud/SQLBlob are thread-safe; SFTP and HTTP are explicitly not.
- Most claims are untested. Everything green above is Memory or the bridge; every cloud and filesystem backend is unverified for concurrent single-instance use.
2. Why "every backend must pass a concurrency test" is the wrong frame¶
The reframing that triggered this work — "any store backend must be tested for
concurrency" — is right in spirit but wrong if read as "every backend must pass
the same concurrent-stress assertion." A blanket thread-stress over
fixture_params() would assert a property that SFTP and HTTP deliberately do
not have, and would either fail (correctly, but uselessly) or hang (paramiko
races on its shared socket — already observed; see
live_adapted_backend_concurrent excluding SFTP for exactly this reason).
The correct model is the one the codebase already uses everywhere else:
declare a posture, then test each backend against its own declared posture.
This is structurally identical to the Capability system —
fixture_params(Capability.X) runs a test only on backends that declare X, and
negative-direction guards assert the carve-out on backends that don't (e.g.
test_file_info_metadata_none_when_capability_absent). Concurrency is just
another such gated property.
3. Existing coverage and the gap¶
What exists:
test_store.py::TestStoreThreadSafety(STORE-007) — concurrent reads on a shared Store, Memory only.test_store_child.py::TestChildThreadSafety(CHILD-010) — concurrentchild(), Memory only.tests/backends/memory/aio/test_basics.py::TestAsyncMemoryConcurrency(ASYNC-055) —asyncio.gatherwrites/reads, Memory only.test_sync_adapter_conformance.py(ASYNC-031/055) — bridge concurrency with an explicit SFTP-not-thread-safe carve-out (live_adapted_backend_concurrentexcludes SFTP). This is the one place a posture carve-out is already encoded — and the precedent this design generalises.
The gap (per backend, single instance):
- No concurrent-read / concurrent-distinct-key-write / read-after-write stress on Local, S3, Azure, SQLBlob, or Graph.
- No create-once-race contract test (does concurrent
overwrite=Falseproduce exactly one winner where the backend claims server-side atomicity, e.g. Graph?). - No stream-vs-mutate, N-parallel-large-upload, or token-call-count probes (Graph-specific, from the review).
- No black-box
Store-surface DX validation in the downstream companion DX suite.
4. Design: posture-gated concurrency conformance lane¶
4.1 Posture as registry data¶
Add a concurrency posture to the fixture registry exactly as transport,
flat_namespace, and large_write_distinct are carried today — sourced
per-family from backends.toml, surfaced on BackendFixture:
# BackendFixture, sourced from [backend.<x>] in backends.toml
concurrency: Literal["thread_safe", "single_connection"] = "thread_safe"
thread_safe— concurrent threads on one instance are safe (Memory, Local, S3, S3-PyArrow, Azure, SQLBlob).single_connection— one instance per thread; concurrent ops on one instance race (SFTP, HTTP).
The async axis splits by how a backend reaches async:
- Native
AsyncBackends (async Memory, async Azure, Graph) arecoroutine_safeon a single loop and unsafe across loops (ASYNC-055). These need no per-fixture flag — they are asserted uniformly over the native-async fixture set, with the single "never across loops" negative guard. - Bridged
single_connectionbackends (SFTP, HTTP — sync-onlyBackends,SFTPBackend(Backend)/ReadOnlyHttpBackend(Backend), with no nativeAsyncBackend) reach async only throughAsyncBackendSyncAdapter, and carry their sync posture into the async lane: concurrent coroutines on one loop dispatch concurrentto_threadwork onto the single paramiko socket / shared redirect-counter opener and race — exactly whylive_adapted_backend_concurrentexcludes SFTP today (§3). So theconcurrencyflag does gate part of the async lane (the §4.4 carve-out is registry-driven there too); the async fixture set is not uniformlycoroutine_safe, and an implementer who drops the carve-out reintroduces the paramiko shared-socket hang.
Add a selector mirroring fixture_params:
fixture_params_concurrent() returns only thread_safe fixtures for the positive
thread-stress; the single_connection set is enumerated separately for the
carve-out guards.
Dependency: the posture must be declared in a spec before it can be conformance-tested. That declaration is precisely BK-287's "cross-backend concurrent-use-posture clause to specs 003/029." See §5.
4.2 Lane structure¶
New files: tests/backends/conformance/test_concurrency.py (sync) and an
aio/ async sibling, organised in three tiers. Every assertion is an
invariant — no error, correct final state, no lost writes on distinct keys,
create-once → exactly one winner — never a timing or interleaving assertion.
Tier 1 — deterministic contract guards (Stage 1, CI):
- In-process substrates (Memory, Local tmpdir) over the thread_safe set:
concurrent reads return consistent content; N concurrent writes to distinct
keys all land; read-after-write is consistent under a ThreadPoolExecutor.
- Mock-level create-once-race over respx (Graph) / moto (S3): concurrent
overwrite=False on one key yields exactly one success and the rest
AlreadyExists, where the backend declares server-side atomicity. Cassette
replay is not concurrency-safe (vcrpy matches sequentially), so these guards
use respx/moto, not cassettes — as BK-289 already specifies for Graph.
- Bridge deadlock-freedom and the BUG-219 aclose no-raise/no-warning property
(Graph), folded in from the review.
Tier 2 — carve-out guards (Stage 1, CI):
- Over the single_connection set (SFTP, HTTP): assert the documented posture —
the one-instance-per-thread pattern works concurrently, and the lane does
not subject a shared single instance to the thread-stress. This is the
negative-direction guard that keeps the posture honest (a backend that silently
became thread-safe, or unsafe, would diverge from its declaration).
Tier 3 — live race probes (Stage 3, gated):
- Behind RS_TEST_LIVE_* (e.g. RS_TEST_LIVE_GRAPH): real concurrent
create / overwrite / move / copy, N-parallel large uploads, and Graph
token-call counting. The review already built a re-runnable harness for these.
4.3 Determinism and flakiness guardrails¶
Concurrency tests are the historical #1 source of flaky CI, and this repo has
already paid for that lesson (CLAUDE.md Parallel tests: the team rejected
--dist loadgroup, MaxStartups tuning, and banner retries for the simplest
xdist carve-out). The lane must be deterministic by construction:
- Assert invariants only; never assert a particular interleaving, ordering, or
timing. No
sleep-based synchronisation. - Fixed, modest thread/coroutine count (8–16) so that under
pytest -n auto,workers × threadsdoes not saturate the host. (scripts/run_tests.pyalready caps xdist workers.) - A dedicated marker (e.g.
@pytest.mark.concurrency) so the lane is selectable and excludable in isolation. - SFTP probes stay in the existing serial lane —
sftp_dockeris already dropped from parametrize under xdist, and SFTP issingle_connectionanyway, so its only probe is the one-instance-per-thread carve-out. - Live probes are Stage-3 /
RS_TEST_LIVE_*-gated — real money, real creds, inherently slower — never in the default CI lane.
4.4 Consolidation map (reuse, don't duplicate)¶
The lane is the new single home for concurrency conformance; the scattered tests are folded in or cross-referenced, not duplicated:
| Existing test | Disposition |
|---|---|
test_store.py::TestStoreThreadSafety (STORE-007) |
Generalise share-across-threads over the thread_safe fixture set; keep Store-layer immutability note |
test_store_child.py::TestChildThreadSafety (CHILD-010) |
Cross-reference; CHILD-010 inherits STORE-007 |
memory/aio/.../TestAsyncMemoryConcurrency (ASYNC-055) |
Generalise into the async lane over the async fixture set |
test_sync_adapter_conformance.py SFTP carve-out |
The precedent; its single_connection exclusion becomes registry-driven |
4.5 Expectations mirror (separate repo)¶
The public-Store-surface subset (create-once race, read-after-write,
ThreadPoolExecutor + ext.batch) is mirrored into the companion black-box DX
suite as consumer-facing validation. It is a separate-repo follow-up, tracked as
BK-293, not part of the in-repo lane.
5. Dependencies and sequencing¶
- BK-287 must precede or co-ship with BK-289. You cannot conformance-test a
posture that no spec declares; the registry
concurrencyfield is the machine-readable shadow of BK-287's prose clause, and the carve-out guards assert that declaration. If BK-287 slips, BK-289's Tier 2/Tier 1 atomicity guards have nothing authoritative to bind to. - BK-288 (concurrency docs) is independent but naturally co-reviewed — the
concurrency.mdtable gains a Graph row and a posture column the lane mirrors. - BUG-219 (Graph
aclose) and BK-290/292 (I/O robustness, token single-flight) are exercised by Tier 3 live probes but fixed elsewhere.
6. Risks and open questions¶
- Posture spec ownership. The
thread_safevssingle_connectiontaxonomy is proposed here; BK-287 owns the authoritative wording. If BK-287 lands a finer taxonomy (e.g. distinguishing "process-safe" cloud backends from "in-process-lock" Memory), the registry enum must follow. Recommend the two-value enum unless BK-287 finds a third posture that an actual backend exhibits. - Inferred / unsourced postures must be confirmed, not assumed. Local is
marked
thread_safeby inference (stateless, OS-level), and S3-PyArrow's pyarrowS3FileSystembasis is not yet pinned to an upstream guarantee — both are deferred to the Tier-3 live probe. The cloud/SQL rows are now grounded in the client guarantee actually relied on (boto3 client, Azure SDK design-guideline immutability, SQLAlchemy per-opConnection), each with a carve-out (boto3 resource / sharedConnectionwould not bethread_safe). BK-287 should state Local explicitly, and the carve-out guard should treat an "undeclared" posture as a failure rather than a default. - moto/respx fidelity for the create-once race. In-process mocks may not
reproduce true server-side atomic create semantics; the authoritative
create-once-race evidence is the Tier-3 live probe. Tier-1 mock guards protect
the mapping (409 →
AlreadyExists), not the server's atomicity — keep that distinction explicit in the test docstrings (cf. the "exercised, not mechanism-asserted" discipline intest_atomic.py). - Cross-loop async negative guard. ASYNC-055's "never across loops" clause
needs a deliberate negative test (one
AsyncBackenddriven from two loops must be a documented misuse, not silent corruption). This is the async analogue of thesingle_connectioncarve-out.
7. Recommendation¶
- Broaden BK-289 in place from "Graph concurrency test lane" to a
cross-backend, posture-gated concurrency conformance lane, with Graph as
the Tier-3 live exemplar. (Done in this PR — see
sdd/BACKLOG.md.) - Co-sequence with BK-287: land the spec posture clause + the registry
concurrencyfield first (small), then the conformance lane (the bulk). - Build Tier 1 + Tier 2 for CI first (deterministic, Stage-1, in-process +
respx/moto), wire Tier 3 live probes behind
RS_TEST_LIVE_*second, and file the expectations-repo mirror as a separate-repo sub-task. - Close M-14 (audit-001) when Tier 1 lands — it is the long-open "zero concurrency tests" finding this lane finally discharges.