Orchestrata Ultimate › Plans › parallel-flakiness-fix-plan

Parallel Test Flakiness — Root-Cause Fix Plan

Source: plans/parallel-flakiness-fix-plan.md at 8a2cb42ad · last changed 2026-07-27 · first committed 2026-06-18 · 159 lines.

Prose pending.

Plan text

Parallel Test Flakiness — Root-Cause Fix Plan

Date: 2026-06-18 · Status: proposed (review before applying) · Author: Mohamed

Grounded in a 4-agent AST+code analysis of the runners, fixtures, src/, and tests. Every claim below is file:line-verified or empirically confirmed.


0. The reframe (why this is tractable and fast to verify)

Two confirmed facts change everything about how we fix and verify this:

  1. The runners run one subprocess per test module (core/threading/test_executor.py:68,133 — [sys.executable,"-m","pytest",path,...] dispatched by a ThreadPoolExecutor; confirmed: distinct PIDs per module). Modules do not share a Python process. Therefore cross-module flakiness is shared-backend collision (one Redis, one Neo4j), not in-process data races. In-process/no-GIL races (Tier B below) are real production bugs but are not the dominant cause of parallel test flakiness.

  2. Test order is deterministic — pytest-randomly is not installed (confirmed). The -p no:randomly flags in our scripts are no-ops. So flakiness is not order-driven; it is 100% concurrency/shared-backend. That means every flake is structurally reproducible — we never need probabilistic "run it 5×30min" hunting.

Decisive infra constraint: Neo4j is Community 5.26 (confirmed) → a single user database; per-worker Neo4j DB isolation is impossible. Redis does get per-worker DBs (PYTEST_XDIST_WORKER=gw{slot} IS injected by both runners → _worker_redis_db() → db=slot; confirmed). So the fix must make Neo4j isolation rest entirely on token-scoped writes + token-scoped teardown, and must eliminate every global-wipe.

Verification philosophy (the user's core ask): convert nondeterministic collisions into deterministic, single-run, seconds-fast signals via (a) an isolation-assertion conftest hook, (b) static AST gates, (c) targeted concurrent micro-stress of the named colliding module sets. No 30-minute runs anywhere.


1. Root causes, ranked

Tier A — cross-module backend corruption (THE flakiness)

ID Site Problem Effect
A1 tests/session/test_kg_sync_extended.py:123-127 _cleanup_syncer does global/fixed-id deletes: MATCH (a:Action) DETACH DELETE a, Session {seq:1}, Spec {id:'001'}, Requirement STARTS WITH 'US-001', RequirementItem STARTS WITH 'REQ-001' (lines 121-122 are correctly {project_id:$pid}-scoped; 123-127 are not) Deletes other modules' live Neo4j data mid-run. Dominant corruption source.
A2 tests/knowledge_graph/test_sync_coverage.py:248 MATCH ()-[r:MAPS_TO]->() DELETE r Deletes every MAPS_TO edge in the shared DB.
A3 src/.../agents/volks/architect/context_mixin.py _build_monorepo_context (queries Project {name:'orchestrata'}) and _query_project_tree (matches by label only, no test_id) SUT query is not test-scoped though the seed is The two known-flaky tests (test_context_mixin.py:344 and :245) return empty when a concurrent peer's teardown (A1) deletes the seeded rows. Requires a SUT change, not just a test change.
A4 both runners + test_executor.py not_parallelizable marker registered (pyproject.toml:169) and applied to 10 modules, but neither runner honors it (confirmed) Modules authors explicitly marked "do not parallelize" (the flushdb / fixed-id ones) run in parallel anyway.
A5 fixed db=15+flushdb() cluster led by tests/providers/test_cost_tracker.py:40-45; also db 3/5/6/7/13/14 across ~18 modules hardcoded db=N bypasses _worker_redis_db(), and flushdb() wipes that DB for every concurrent slot-mate Cross-slot Redis wipes.
A6 os.chdir into tmp in 8 modules; assertion-bearing at tests/session/test_session_cli_coverage.py:474, tests/core/test_config_manager_extended.py:409 mutates process-global cwd Concurrent module sees wrong cwd.
A7 tests/conftest.py:253 session-autouse _cleanup_test_prefix deletes _TEST_*; Scheme-B fixtures (conftest.py:102/135/168) share Redis db 0 a finishing subprocess deletes _TEST_* a still-running peer needs; flushdb(0) by a gw0 slot-mate clobbers db0 Scheme-B state Lower-frequency but real.

Tier B — no-GIL / event-loop correctness (production-real; flakes only within a module)

ID Site Problem Fix
B1 kernel/volk_registry.py:744,762,777,721,735,1172 _volks dict: RLock guards writers, readers/iteration unlocked → "dict changed size during iteration" snapshot under self._lock
B2 providers/health_monitor.py:42,54,103 _metrics dict mutated+iterated, both sync, no lock add threading.Lock
B3 orchestrator/fact_neo4j.py:16,26 _NEO4J_FAILURE_COUNT += 1 unguarded RMW → lost increments module threading.Lock
B4 providers/provider_manager.py:101-107; llm/client.py:304; llm/config.py:239,313; orchestrator/redis_state.py:162 unlocked dict mutations / lazy singletons (double-init) locks / double-checked locks
B5 llm/client.py:64,293-312 _provider_manager singleton caches a loop-bound async Redis client within a module, async test #2 (fresh loop) touches a pool bound to test #1's loop → Future attached to a different loop/hang per-loop rebind or root autouse reset fixture
B6 orchestrator/redis_state.py:159-171 async_client cache; knowledge_graph/anomaly_detector.py:471-483 loop caching/set_event_loop same loop-affinity anti-pattern rebind to running loop; never cache self._loop

Tier C — hygiene

Dismissed (not bugs): the except A, B: "syntax errors" several agents flagged (kernel/event_bus.py:326, kernel/audit/logger.py) are valid on 3.14 (PEP 758) — the full package already imports with 0 failures. No action.


2. Fix phases (each independently shippable + independently verifiable)

Phase 1 — Stop the corruption (Tier A1, A2, A5) — fixes the known flakes

Phase 2 — Make it un-recurrable (enforcement; this is also the verifier — see §3)

Phase 3 — Honor not_parallelizable (A4)

Phase 4 — A3 SUT scoping + A6/A7

Phase 5 — No-GIL + loop affinity (Tier B, C)


3. The fast-verification harness (built once, in Phase 2; verifies all phases)

Goal: every fix is confirmed in seconds, deterministically. Total budget for the whole plan's verification: < 3 minutes, no full-suite run.

3a. Isolation-assertion conftest hook — turns collisions into deterministic single-test failures

Add an autouse, function-scoped hook in tests/conftest.py that, around each test touching a backend:

→ One ISOLATION_ASSERT=1 run over the suspect dirs names every offender. No repetition needed.

3b. Static AST gates — instant, no test run; also prevents recurrence

New checker check_test_isolation(filepath, tree) (mirror the existing check_env_access pattern in verification/review/checks_safety.py, wired into engine.py, documented as a standards.py rule) flagging, in tests/:

3c. Targeted concurrent micro-stress — deterministic repro of the named collisions

scripts/stress_parallel.sh <modules...> [rounds] runs a chosen module set concurrently under the streaming runner N times, stops on first failure:

for i in $(seq 1 "${ROUNDS:-20}"); do
  python scripts/run_tests_streaming.py "$@" -j 4 -t 120 || { echo "FLAKE round $i"; exit 1; }
done

The colliding sets are already identified, so this reproduces in seconds:

# A1/A3 repro (architect-empty + Action-wipe collide):
ROUNDS=20 scripts/stress_parallel.sh \
  tests/session/test_kg_sync_extended.py \
  tests/agents/volks/architect/test_context_mixin.py \
  tests/knowledge_graph/test_kg_sync.py
# A5 repro (db=15 flushdb cluster):
ROUNDS=20 scripts/stress_parallel.sh \
  tests/providers/test_cost_tracker.py \
  tests/kernel/test_audit_logger.py \
  tests/core/test_redis_factory.py

Before fix: fails within a few rounds. After fix: 20/20 green in well under a minute.

3d. Keep -j ≤ 16 always (Redis has 16 DBs; slot 16 → "DB index out of range"). Document in the runners (or clamp).


4. Per-fix → fast deterministic check

Fix Reproduce the flake (seconds) Confirm fixed (seconds)
A1, A3 ISOLATION_ASSERT=1 run of the architect+kg_sync set → sentinel-deleted failure; or 3c set #1 3c set #1 20/20 green
A2 ISOLATION_ASSERT=1 over tests/knowledge_graph/ residue/sentinel assertion clean
A5 3c set #2 → flushdb wipe failure 3c set #2 20/20 green
A4 pytest --collect-only -m not_parallelizable lists 10 modules now run in parallel runner logs show them in the serial lane
A6 3c with the two os.chdir asserters 20/20 green
B1/B2/B3 targeted in-process thread stress (small script firing the reader+writer from N threads) assertion-stable across 10k iterations
B5/B6 a 2-async-test module that reuses the singleton across loops passes (per-loop rebind)
3b gates run scripts/check_test_isolation.py on current tree → lists offenders exits 0 after Phases 1/4/A6

5. Sequencing, effort, risk

  1. Phase 2b (static gates) first — instant, read-only, and it enumerates the full offender list to size Phases 1/4 precisely. ~½ day. Risk: none.
  2. Phase 2a (isolation hook) — the deterministic detector. ~½ day. Risk: low (env-gated).
  3. Phase 1 (A1/A2/A5) — the actual flake fixes. ~½ day. Verify with 3c. Risk: low (test-only).
  4. Phase 3 (honor marker) — ~¼ day. Risk: low (runner-only).
  5. Phase 4 (A3 SUT scope, A6, A7) — ~½ day. A3 is the one SUT change → review before applying. Risk: low-med.
  6. Phase 5 (Tier B/C) — ~1 day. Independent of the above; high production value. Risk: low (additive locks / per-loop rebind), verify each with a focused thread/loop stress.

Whole-plan acceptance: static gate exits 0; ISOLATION_ASSERT=1 over tests/knowledge_graph tests/session tests/providers is clean; all 3c stress sets 20/20 green; one normal -j 8 streaming run of those dirs green. All of that is < 3 minutes total, deterministic — never a 30-minute probabilistic run.


6. Open decisions for review