fix(paper): BAT-039 — paper order seq Redis 영속화 (재시작 멱등 키 충돌 방지, REQ-011/issue #4)
REQ-011 change-req(CHG-20260612-01)→구현→confirm-req-implemented(CHG-20260612-02)
전체 delivery. paper 한정 결정성 버그(프로덕션 무관).
- seq_store.py 신규: SeqStore 포트 + RedisSeqStore(원자 INCR, 재시작 단조·영속)
+ InMemorySeqStore(폴백/테스트). REQ-016 token_cache 패턴 재사용.
- paper.py: optional seq_store 주입. 미주입 시 기존 in-memory 동작 byte-for-byte 유지.
- config.py/daemon.py: TREX_PAPER_REDIS_URL → RedisSeqStore wiring
(key=trex:broker:paper:{instance_key}:order-seq).
- 런타임 Redis 오류는 전파(silent in-memory 폴백 금지 — 버그 재현 방지).
- tests: 재시작 단조성/correlation_id 비충돌/in-memory 폴백/fake redis INCR (5).
434 passed (+5), ruff clean.
REQ-011 Approved→Implemented, BAT-039 release-candidate. issue #4 close.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import threading
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from trex_core.adapters.broker.seq_store import SeqStore
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
FillStatus,
|
||||
@@ -47,12 +48,18 @@ class PaperBroker:
|
||||
fill_after_polls: int = 0,
|
||||
qty_per_poll: Optional[int] = None,
|
||||
reject_symbols: Optional[set[str]] = None,
|
||||
seq_store: Optional[SeqStore] = None,
|
||||
) -> None:
|
||||
self._fill_after = fill_after_polls
|
||||
self._qty_per_poll = qty_per_poll
|
||||
self._reject = reject_symbols or set()
|
||||
self._orders: dict[str, _Order] = {}
|
||||
self._seq = 0
|
||||
# REQ-011 CHG-20260612-01 (issue #4): when injected, order seq is minted
|
||||
# from a persistent monotonic store so it survives daemon restarts and a
|
||||
# post-restart sell does not collide its correlation_id with a prior buy.
|
||||
# Unset (no Redis configured) keeps the byte-for-byte in-memory counter.
|
||||
self._seq_store = seq_store
|
||||
self._lock = threading.Lock()
|
||||
# REQ-038: programmable current price for safety-sell scenario tests.
|
||||
self._prices: dict[str, Decimal] = {}
|
||||
@@ -62,8 +69,12 @@ class PaperBroker:
|
||||
|
||||
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||
with self._lock:
|
||||
self._seq += 1
|
||||
oid = f"PAPER-{self._seq:06d}"
|
||||
if self._seq_store is not None:
|
||||
seq = self._seq_store.next_seq()
|
||||
else:
|
||||
self._seq += 1
|
||||
seq = self._seq
|
||||
oid = f"PAPER-{seq:06d}"
|
||||
self._orders[oid] = _Order(oid, req)
|
||||
return OrderAck(accepted=True, broker_order_id=oid)
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Paper broker order-seq store (REQ-011 CHG-20260612-01, issue #4).
|
||||
|
||||
The paper broker mints order ids from a counter (`PAPER-{seq:06d}`). When the
|
||||
counter lives only in process memory it resets to 0 on daemon restart, so the
|
||||
first order after a restart re-mints `PAPER-000001` and the deterministic
|
||||
`execution_id` (`paper:{order_id}:{filled_qty}`) collides with the previous
|
||||
run. Since the ledger idempotency key `correlation_id` does not include side,
|
||||
a post-restart sell then dedups against the prior buy and its ledger row is
|
||||
silently dropped.
|
||||
|
||||
This module persists the seq across restarts. `RedisSeqStore` uses an atomic
|
||||
`INCR` (same Redis pattern as REQ-016 token_cache); `InMemorySeqStore` is the
|
||||
explicit process-local double for tests. When no store is injected the paper
|
||||
broker keeps its byte-for-byte in-memory behaviour (no Redis configured).
|
||||
|
||||
Unlike the token cache, a runtime Redis error here is NOT degraded to an
|
||||
in-memory counter — that fallback would re-create the very collision this
|
||||
fixes — so errors propagate (paper is a test harness; surfacing is safe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class SeqStore(Protocol):
|
||||
def next_seq(self) -> int:
|
||||
"""Return the next strictly-increasing order seq (monotonic, persistent)."""
|
||||
...
|
||||
|
||||
|
||||
class RedisSeqStore:
|
||||
"""Redis-backed monotonic seq via atomic INCR (persists across restarts)."""
|
||||
|
||||
def __init__(self, redis_url: str, *, key: str, redis_module: Any = None) -> None:
|
||||
if redis_module is None:
|
||||
import redis as redis_module # type: ignore[import-untyped]
|
||||
self._client = redis_module.from_url(redis_url, socket_connect_timeout=2)
|
||||
self._key = key
|
||||
|
||||
def next_seq(self) -> int:
|
||||
# INCR is atomic and creates the key at 1 on first use, so the first
|
||||
# order id is PAPER-000001 (same start value as the in-memory counter).
|
||||
return int(self._client.incr(self._key))
|
||||
|
||||
|
||||
class InMemorySeqStore:
|
||||
"""Process-local monotonic seq (tests / explicit double). Shared if reused."""
|
||||
|
||||
def __init__(self, *, start: int = 0) -> None:
|
||||
self._seq = start
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def next_seq(self) -> int:
|
||||
with self._lock:
|
||||
self._seq += 1
|
||||
return self._seq
|
||||
Reference in New Issue
Block a user