fix: match shadow book to its pipeline's scan by run id, not timestamp

A manually triggered rr_scanner and the scheduled near-close pipeline are
separate APScheduler jobs; max_instances=1 serialises a job only against
itself, so they can overlap. A manual scan starting just before the
pipeline can finish just after it began and overwrite the scan markers.
Its completion timestamp is then later than the pipeline start, so the
previous 'completed >= pipeline_start' check accepted its batch as though
it were the pipeline's own -- exactly when the pipeline's scan may have
failed.

Replace the timestamp comparison with an exact run-id match. A new
pipeline_run module holds a per-task run-id contextvar (separate module so
the scanner and scheduler import it without a cycle). _run_pipeline binds a
fresh id per invocation; scan_all_tickers stamps that id -- or a fresh one
when run standalone -- into the scan markers, written with started/completed
in a single commit. The shadow step requires the stored run id to equal its
pipeline's id exactly, so a concurrent manual scan (its own id) or a failed
pipeline scan (a prior run's id) can never be mistaken for it. Direct Admin
triggers have no pipeline context and keep the freshness fallback.

Known residual: the id match governs whether shadow proceeds; setup
selection remains detected_at >= scan start, so a fully per-run setup
isolation would need a run_id column on trade_setups (not required here).

Tests cover the reported race (manual scan finishing last is refused), a
failed pipeline scan, the id-match accept path, and contextvar propagation
and non-leakage across tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 10:47:32 +02:00
co-authored by Claude Fable 5
parent 807cc4bdfa
commit 05ba138d35
6 changed files with 220 additions and 71 deletions
+72
View File
@@ -0,0 +1,72 @@
"""Pipeline run-id context and the scanner stamping it into scan markers."""
from __future__ import annotations
import asyncio
import pytest
from app.services import pipeline_run
def test_no_run_id_by_default():
assert pipeline_run.current() is None
def test_bind_and_release_restore_previous():
assert pipeline_run.current() is None
token = pipeline_run.bind("run-1")
try:
assert pipeline_run.current() == "run-1"
finally:
pipeline_run.release(token)
assert pipeline_run.current() is None
def test_new_run_ids_are_unique():
ids = {pipeline_run.new_run_id() for _ in range(100)}
assert len(ids) == 100
@pytest.mark.asyncio
async def test_run_id_propagates_to_awaited_coroutines():
"""The scan and shadow steps are awaited inside the pipeline's task, so they
must observe the id the pipeline bound."""
async def step() -> str | None:
return pipeline_run.current()
token = pipeline_run.bind("run-42")
try:
assert await step() == "run-42"
finally:
pipeline_run.release(token)
@pytest.mark.asyncio
async def test_run_id_does_not_leak_into_an_independent_task():
"""A manual scan is a separate APScheduler job, started independently of the
pipeline. Modelled here as a task created before the bind: it captures its
own context and never observes the id the pipeline binds afterwards."""
seen: dict[str, str | None] = {}
manual_started = asyncio.Event()
let_manual_finish = asyncio.Event()
async def manual_job() -> None:
manual_started.set()
await let_manual_finish.wait()
seen["manual"] = pipeline_run.current()
# Created with no id in context — the manual job predates the pipeline bind.
task = asyncio.create_task(manual_job())
await manual_started.wait()
token = pipeline_run.bind("pipeline")
try:
assert pipeline_run.current() == "pipeline"
let_manual_finish.set()
await task
finally:
pipeline_run.release(token)
assert seen["manual"] is None