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:
+15
-18
@@ -16,7 +16,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import contextvars
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
@@ -37,6 +36,7 @@ from app.providers.protocol import SentimentData
|
||||
from app.services import (
|
||||
fundamental_service,
|
||||
ingestion_service,
|
||||
pipeline_run,
|
||||
sentiment_service,
|
||||
settings_store,
|
||||
shadow_book_service,
|
||||
@@ -626,16 +626,17 @@ async def run_shadow_book() -> None:
|
||||
prices the discretionary book sees, leaving *selection* as the only
|
||||
difference between the two books.
|
||||
|
||||
When run as a pipeline step it only acts on a scan that completed *inside
|
||||
this pipeline pass* (``require_scan_after``): if the pipeline's own scan was
|
||||
disabled or failed, an earlier still-fresh manual scan must not stand in for
|
||||
it. Triggered directly from Admin (no pipeline context) it falls back to the
|
||||
When run as a pipeline step it acts only on the scan that stamped *this
|
||||
pipeline's* run id (``expected_run_id``): if the pipeline's own scan was
|
||||
disabled or failed, the stored run id is some other scan's — including a
|
||||
manual scan that overlapped and finished last — and shadow refuses.
|
||||
Triggered directly from Admin (no pipeline context) it falls back to the
|
||||
scan-freshness window — an explicit operator action.
|
||||
|
||||
Opt-in (``shadow_book_enabled``) because it writes live trades.
|
||||
"""
|
||||
job_name = "shadow_book"
|
||||
require_scan_after = _pipeline_started_at.get()
|
||||
expected_run_id = pipeline_run.current()
|
||||
_log_event(logging.INFO, "job_start", job=job_name)
|
||||
_runtime_start(job_name, total=1)
|
||||
|
||||
@@ -656,7 +657,7 @@ async def run_shadow_book() -> None:
|
||||
summary = await shadow_book_service.open_shadow_positions(
|
||||
db,
|
||||
activation_config=activation_config,
|
||||
require_scan_after=require_scan_after,
|
||||
expected_run_id=expected_run_id,
|
||||
)
|
||||
symbols = await shadow_book_service.symbols_for(db, summary["symbols"])
|
||||
|
||||
@@ -1329,20 +1330,16 @@ _INTRADAY_PIPELINE_STEPS = [
|
||||
_NEAR_CLOSE_DURATION_WARN_SECONDS = 600
|
||||
|
||||
|
||||
# Wall-clock start of the pipeline invocation currently running, visible to its
|
||||
# steps via the shared task context. The shadow book uses it to require that the
|
||||
# scan it acts on completed *inside this pipeline pass* — a fresh but earlier
|
||||
# manual scan must not stand in for a scan that failed or was disabled here.
|
||||
_pipeline_started_at: contextvars.ContextVar[datetime | None] = contextvars.ContextVar(
|
||||
"pipeline_started_at", default=None
|
||||
)
|
||||
|
||||
|
||||
async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||
"""Run an ordered list of (step_name, coroutine_name) steps.
|
||||
|
||||
Each step respects its own enable flag and manages its own runtime status; a
|
||||
failing step is logged and the pipeline continues with the next one.
|
||||
|
||||
A unique run id is bound for the invocation and visible to every step via the
|
||||
shared task context: the scan step stamps it into its completion markers and
|
||||
the shadow step requires an exact match, so only a scan that ran inside this
|
||||
pipeline can drive the shadow book.
|
||||
"""
|
||||
_log_event(logging.INFO, "job_start", job=job_name)
|
||||
async with async_session_factory() as db:
|
||||
@@ -1356,7 +1353,7 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||
|
||||
funcs = globals()
|
||||
done = 0
|
||||
token = _pipeline_started_at.set(datetime.now(timezone.utc))
|
||||
token = pipeline_run.bind(pipeline_run.new_run_id())
|
||||
try:
|
||||
for step_name, func_name in steps:
|
||||
_runtime_progress(job_name, processed=done, total=total, current_ticker=step_name)
|
||||
@@ -1371,7 +1368,7 @@ async def _run_pipeline(job_name: str, steps: list[tuple[str, str]]) -> None:
|
||||
_runtime_finish(job_name, "error", processed=done, total=total, message=str(exc))
|
||||
_log_event(logging.ERROR, "job_error", job=job_name, error_type=type(exc).__name__, message=str(exc))
|
||||
finally:
|
||||
_pipeline_started_at.reset(token)
|
||||
pipeline_run.release(token)
|
||||
|
||||
|
||||
async def run_daily_pipeline() -> None:
|
||||
|
||||
Reference in New Issue
Block a user