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>
48 lines
1.6 KiB
Python
48 lines
1.6 KiB
Python
"""Per-invocation identity for pipeline runs.
|
|
|
|
A pipeline invocation stamps a unique run id into the task context. The scan it
|
|
runs records that id alongside its completion markers, and the shadow book
|
|
requires an *exact* match before acting on the scan's batch.
|
|
|
|
This is what timestamp comparison cannot provide. A manually triggered scan and
|
|
the scheduled near-close pipeline are separate APScheduler jobs, and
|
|
``max_instances=1`` only serialises a job against itself — not two different
|
|
jobs. So a manual scan can start just before the pipeline and finish just after
|
|
it began, leaving a completion timestamp later than the pipeline's start even
|
|
though its batch is unrelated. Matching on a run id generated by the pipeline,
|
|
and stamped only by the scan running inside that pipeline, removes the ambiguity.
|
|
|
|
Lives in its own module so the scheduler (which sets the id), the scanner (which
|
|
stamps it), and the shadow book (which checks it) can all import it without an
|
|
import cycle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import uuid
|
|
|
|
_run_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
|
|
"pipeline_run_id", default=None
|
|
)
|
|
|
|
|
|
def new_run_id() -> str:
|
|
"""A fresh, collision-free run id."""
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
def current() -> str | None:
|
|
"""Run id of the pipeline invocation on the current task, if any."""
|
|
return _run_id.get()
|
|
|
|
|
|
def bind(run_id: str) -> contextvars.Token:
|
|
"""Set the current run id; pass the returned token to ``release``."""
|
|
return _run_id.set(run_id)
|
|
|
|
|
|
def release(token: contextvars.Token) -> None:
|
|
"""Restore the previous run id (call in a finally)."""
|
|
_run_id.reset(token)
|