feat(slot,intent): slot strategy-state 저장소 (dcy-028) + sell retryable 플래그 (issue #27)
BAT-042 (dcy-028 / issue #26 C-1/C-2/C-2b/C-3, REQ-079~082 + REQ-073/074 CHG): - slot_strategy_state 별도 테이블 + StrategyStateStore(in-memory+PG) + occupancy_seq·version CAS + opaque UTF-8 data + 2단 size cap (REQ-079) - trex.slots.strategy_state.set write RPC — reject reason 집합, 멱등 (REQ-080) - Slot.occupancy_seq 필드 + inactive→pending_buy bump + →INACTIVE close 삭제 훅 → 점유 전환 ABA 구조적 차단 (REQ-081) - slots.req 응답 행에 strategy_state 맵(batched 조립)·occupancy_seq additive (REQ-073 AC-7/REQ-074 AC-6) - 마이그레이션 014(occupancy_seq)/015(slot_strategy_state), baseline 문서 정합 (REQ-082) BAT-043 (issue #27 / REQ-007 CHG): - rejected SellIntent 응답에 additive retryable: bool (core 소유 reason→retryable 매핑, 미인식 기본 true, bad_request* false) — 손절매 재시도 안전 경로 decoupling 검증: 487 passed, 47 skipped (신규 41: store CAS 6+ABA, occupancy bump/close 4경로, set RPC 9, slots read 5, retryable 8, PG 게이트), ruff clean. REQ-079·080·081·082·073·074·007 → Implemented. issue #27 close. PG 경로 실 DB 검증은 release 위임. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ from trex_core.adapters.messaging.topology import (
|
||||
direct_routing_key,
|
||||
intent_queue,
|
||||
slots_queue,
|
||||
strategy_state_set_queue,
|
||||
)
|
||||
from trex_core.application.messaging import (
|
||||
EventHandler,
|
||||
@@ -140,7 +141,11 @@ class RabbitMQTransport:
|
||||
iq = intent_queue(instance_key)
|
||||
sq = slots_queue(instance_key)
|
||||
cq = control_queue(instance_key)
|
||||
for q, channel_type in ((iq, "intent"), (sq, "slots"), (cq, "control")):
|
||||
ssq = strategy_state_set_queue(instance_key) # dcy-028 / REQ-080
|
||||
for q, channel_type in (
|
||||
(iq, "intent"), (sq, "slots"), (cq, "control"),
|
||||
(ssq, "strategy_state_set"),
|
||||
):
|
||||
ch.queue_declare(q, durable=True, arguments=dlx)
|
||||
# Directed binding: trex.direct → {instance_key}.{channel_type}
|
||||
ch.queue_bind(q, DIRECT_EXCHANGE,
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
# --- RPC queues (durable for intent; transient-ish for slots but kept
|
||||
# durable for simplicity since the queue itself is shared) ---
|
||||
SLOTS_REQ_QUEUE = "trex.slots.req" # REQ-073 / dcy-025: replaces snapshot.req
|
||||
STRATEGY_STATE_SET_QUEUE = "trex.slots.strategy_state.set" # dcy-028 / REQ-080
|
||||
INTENT_REQ_QUEUE = "trex.intent.req"
|
||||
PAUSE_REQ_QUEUE = "trex.pause.req" # BAT-008 / REQ-032 (legacy alias of control)
|
||||
CONTROL_REQ_QUEUE = "trex.control.req" # BAT-012 / REQ-043 operator control channel
|
||||
@@ -44,6 +45,10 @@ def slots_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str:
|
||||
return _instance_queue(SLOTS_REQ_QUEUE, instance_key)
|
||||
|
||||
|
||||
def strategy_state_set_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str:
|
||||
return _instance_queue(STRATEGY_STATE_SET_QUEUE, instance_key)
|
||||
|
||||
|
||||
def control_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str:
|
||||
return _instance_queue(CONTROL_REQ_QUEUE, instance_key)
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ from trex_core.application.ledger_outbox import (
|
||||
)
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.application.ports import IntentRecord
|
||||
from trex_core.application.strategy_state import (
|
||||
SetResult,
|
||||
StrategyStateEntry,
|
||||
_cas,
|
||||
)
|
||||
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
||||
|
||||
_SLOT_COLUMNS = (
|
||||
@@ -31,7 +36,7 @@ _SLOT_COLUMNS = (
|
||||
"order_id, strategy_id, broker_order_id, portfolio_id, "
|
||||
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
|
||||
"created_at, updated_at, instance_key, retired_at, "
|
||||
"timeout_sec, slippage_pct"
|
||||
"timeout_sec, slippage_pct, occupancy_seq"
|
||||
)
|
||||
|
||||
|
||||
@@ -197,7 +202,7 @@ class PostgreSQLSlotRepository:
|
||||
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
|
||||
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
|
||||
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
|
||||
"updated_at=%s WHERE slot_id=%s",
|
||||
"occupancy_seq=%s, updated_at=%s WHERE slot_id=%s",
|
||||
(
|
||||
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
|
||||
slot.limit_price, slot.order_id,
|
||||
@@ -205,7 +210,7 @@ class PostgreSQLSlotRepository:
|
||||
slot.sell_strategy_id, slot.forced_close_at,
|
||||
slot.stop_loss_price, slot.take_profit_price,
|
||||
slot.instance_key, slot.retired_at,
|
||||
slot.timeout_sec, slot.slippage_pct,
|
||||
slot.timeout_sec, slot.slippage_pct, slot.occupancy_seq,
|
||||
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
|
||||
),
|
||||
)
|
||||
@@ -299,6 +304,7 @@ class PostgreSQLSlotRepository:
|
||||
# forced_close_at(11) stop_loss_price(12) take_profit_price(13)
|
||||
# created_at(14) updated_at(15) instance_key(16) retired_at(17)
|
||||
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
|
||||
# occupancy_seq(20) [dcy-028/REQ-081]
|
||||
return Slot(
|
||||
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
|
||||
status=SlotState(row[1]),
|
||||
@@ -320,6 +326,7 @@ class PostgreSQLSlotRepository:
|
||||
retired_at=row[17],
|
||||
timeout_sec=int(row[18]) if row[18] is not None else None,
|
||||
slippage_pct=f(row[19]),
|
||||
occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0,
|
||||
)
|
||||
|
||||
|
||||
@@ -515,3 +522,114 @@ class PostgreSQLLedgerOutbox:
|
||||
updated_at=row[15],
|
||||
next_retry_at=row[16],
|
||||
)
|
||||
|
||||
|
||||
class PgStrategyStateStore:
|
||||
"""slot_strategy_state-backed StrategyStateStore (dcy-028 / REQ-079).
|
||||
|
||||
Stores opaque per-strategy state keyed by (slot_id, strategy_id). The CAS
|
||||
decision is the same `_cas` used by the in-memory double; occupancy_seq and
|
||||
slot_active are supplied by the caller (fetched from the slot) so the store
|
||||
only owns the slot_strategy_state table.
|
||||
"""
|
||||
|
||||
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||
self._psycopg = _require_psycopg(psycopg_module)
|
||||
self._dsn = dsn
|
||||
|
||||
def set(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
strategy_id: str,
|
||||
*,
|
||||
expected_occupancy_seq: int,
|
||||
expected_version: int,
|
||||
data: str,
|
||||
current_occupancy_seq: int,
|
||||
slot_active: bool,
|
||||
) -> SetResult:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT occupancy_seq, version, data, updated_at "
|
||||
"FROM slot_strategy_state "
|
||||
"WHERE slot_id = %s AND strategy_id = %s FOR UPDATE",
|
||||
(slot_id, strategy_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
existing = (
|
||||
StrategyStateEntry(
|
||||
strategy_id=strategy_id,
|
||||
occupancy_seq=int(row[0]),
|
||||
version=int(row[1]),
|
||||
data=row[2],
|
||||
updated_at=row[3],
|
||||
)
|
||||
if row is not None
|
||||
else None
|
||||
)
|
||||
result = _cas(
|
||||
existing,
|
||||
expected_occupancy_seq=expected_occupancy_seq,
|
||||
expected_version=expected_version,
|
||||
data=data,
|
||||
current_occupancy_seq=current_occupancy_seq,
|
||||
slot_active=slot_active,
|
||||
)
|
||||
if not result.ok:
|
||||
conn.rollback()
|
||||
return result
|
||||
cur.execute(
|
||||
"INSERT INTO slot_strategy_state "
|
||||
"(slot_id, strategy_id, occupancy_seq, version, data, updated_at) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s) "
|
||||
"ON CONFLICT (slot_id, strategy_id) DO UPDATE SET "
|
||||
"occupancy_seq = EXCLUDED.occupancy_seq, "
|
||||
"version = EXCLUDED.version, data = EXCLUDED.data, "
|
||||
"updated_at = EXCLUDED.updated_at",
|
||||
(
|
||||
slot_id, strategy_id, current_occupancy_seq,
|
||||
result.version, data, datetime.now(tz=timezone.utc),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def get_for_slots(
|
||||
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
|
||||
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
||||
if not slot_ids:
|
||||
return {}
|
||||
out: dict[UUID, dict[str, StrategyStateEntry]] = {}
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT slot_id, strategy_id, occupancy_seq, version, data, updated_at "
|
||||
"FROM slot_strategy_state WHERE slot_id = ANY(%s)",
|
||||
(list(slot_ids),),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
sid = row[0] if isinstance(row[0], UUID) else UUID(str(row[0]))
|
||||
# stale-occupancy entries are invisible (dcy-028 FR-3 defense)
|
||||
if int(row[2]) != current_occupancy_seq.get(sid):
|
||||
continue
|
||||
out.setdefault(sid, {})[row[1]] = StrategyStateEntry(
|
||||
strategy_id=row[1],
|
||||
occupancy_seq=int(row[2]),
|
||||
version=int(row[3]),
|
||||
data=row[4],
|
||||
updated_at=row[5],
|
||||
)
|
||||
return out
|
||||
|
||||
def delete_all(self, slot_id: UUID) -> None:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- BAT-042 / dcy-028 / REQ-081: per-occupancy guard counter on the slot.
|
||||
--
|
||||
-- The permanent asset slot is reused across occupancies; both a closed and a
|
||||
-- freshly re-entered occupancy of the same slot start strategy_state at version
|
||||
-- 0, so a version-only CAS cannot distinguish them at the first write. A
|
||||
-- monotonic occupancy_seq (bumped on inactive→pending_buy) lets the
|
||||
-- strategy-state write CAS reject a write computed against a prior occupancy
|
||||
-- (occupancy_changed). Additive, idempotent. BIGINT so it never wraps.
|
||||
ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS occupancy_seq BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- BAT-042 / dcy-028 / REQ-079: opaque per-strategy state store on slots.
|
||||
--
|
||||
-- A strategy daemon accumulates opaque state across a slot's occupancy (e.g. a
|
||||
-- TSL peak). trex-core stores it server-side keyed by (slot_id, strategy_id),
|
||||
-- never interpreting `data` (opaque UTF-8, byte-capped at the app layer). The
|
||||
-- state's lifecycle matches the occupancy: trex-core deletes it on close
|
||||
-- (→INACTIVE) so a reused slot starts empty (dcy-028 C-3). occupancy_seq mirrors
|
||||
-- the slot's value at write time for the CAS occupancy guard (dcy-028 R-1).
|
||||
-- version is the compare-and-set counter (idempotent / retry-safe writes).
|
||||
CREATE TABLE IF NOT EXISTS slot_strategy_state (
|
||||
slot_id UUID NOT NULL,
|
||||
strategy_id TEXT NOT NULL,
|
||||
occupancy_seq BIGINT NOT NULL,
|
||||
version BIGINT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
PRIMARY KEY (slot_id, strategy_id)
|
||||
);
|
||||
@@ -29,6 +29,33 @@ from trex_core.domain.slot import (
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---- sell-reject retryability (issue #27 / REQ-007 CHG-20260615-01) ---------
|
||||
# core-owned reason -> retryable mapping for the rejected SellIntent response.
|
||||
# Maintained with the reject vocabulary (intents.py _admit_sell/_dedup) so a new
|
||||
# reason gets a correct flag without the daemon touching a table. Unrecognized
|
||||
# reasons default to retryable=True: a stranded stop-loss (held losing position)
|
||||
# is worse than bounded, observable hammering on a terminal decision.
|
||||
_SELL_REJECT_RETRYABLE: dict[str, bool] = {
|
||||
"slot_state_changed": True,
|
||||
"slot_not_holding:pending_buy": True,
|
||||
"duplicate_in_flight": True,
|
||||
"slot_not_found": False,
|
||||
"slot_not_holding:inactive": False,
|
||||
"slot_not_holding:pending_sell": False,
|
||||
"slot_not_holding:error": False,
|
||||
}
|
||||
|
||||
|
||||
def _sell_reject_retryable(reason: Optional[str]) -> bool:
|
||||
"""Map a rejected-sell reason to retryable (issue #27). Default True (safety)."""
|
||||
if reason is None:
|
||||
return True
|
||||
# malformed payload: client defect — retrying the same bytes is pointless.
|
||||
if reason.startswith("bad_request"):
|
||||
return False
|
||||
return _SELL_REJECT_RETRYABLE.get(reason, True)
|
||||
|
||||
|
||||
# ---- intent RPC handler -----------------------------------------------------
|
||||
|
||||
def make_intent_rpc_handler(
|
||||
@@ -112,16 +139,24 @@ def make_intent_rpc_handler(
|
||||
"slot_id": None, "reason": f"unknown_intent_type:{intent_type}",
|
||||
})
|
||||
except (KeyError, ValueError) as exc:
|
||||
return RpcResponse(payload={
|
||||
payload = {
|
||||
"intent_id": intent_id, "status": "rejected",
|
||||
"slot_id": None, "reason": f"bad_request:{exc}",
|
||||
})
|
||||
return RpcResponse(payload={
|
||||
}
|
||||
# issue #27: sell-side malformed payload carries retryable=False.
|
||||
if intent_type == "sell":
|
||||
payload["retryable"] = _sell_reject_retryable(payload["reason"])
|
||||
return RpcResponse(payload=payload)
|
||||
payload = {
|
||||
"intent_id": d.intent_id,
|
||||
"status": d.status,
|
||||
"slot_id": (str(d.slot_id) if d.slot_id else None),
|
||||
"reason": d.reason,
|
||||
})
|
||||
}
|
||||
# issue #27 / REQ-007 AC-7: additive retryable on rejected sell only.
|
||||
if intent_type == "sell" and d.status == "rejected":
|
||||
payload["retryable"] = _sell_reject_retryable(d.reason)
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
return handler
|
||||
|
||||
@@ -189,6 +224,7 @@ def make_slots_rpc_handler(
|
||||
watchdog_timeout_sec: int = 120,
|
||||
ledger_outbox=None,
|
||||
max_active_slots: Optional[int] = None,
|
||||
strategy_state_store=None,
|
||||
):
|
||||
"""Build the RPC handler for `trex.slots.req` (REQ-073/074).
|
||||
|
||||
@@ -236,6 +272,25 @@ def make_slots_rpc_handler(
|
||||
for s in slots
|
||||
]
|
||||
|
||||
# dcy-028 / REQ-073/074·079 (C-1): attach opaque strategy_state map per
|
||||
# row via one batched store read (read piggyback — no extra round trip).
|
||||
# Only entries matching the slot's current occupancy_seq are surfaced.
|
||||
if strategy_state_store is not None and slots:
|
||||
slot_ids = [s.slot_id for s in slots]
|
||||
occ = {s.slot_id: getattr(s, "occupancy_seq", 0) for s in slots}
|
||||
state_map = strategy_state_store.get_for_slots(slot_ids, occ)
|
||||
for row, s in zip(slot_rows, slots):
|
||||
entries = state_map.get(s.slot_id)
|
||||
if entries:
|
||||
row["strategy_state"] = {
|
||||
strat: {
|
||||
"version": e.version,
|
||||
"data": e.data,
|
||||
"updated_at": e.updated_at.isoformat(),
|
||||
}
|
||||
for strat, e in entries.items()
|
||||
}
|
||||
|
||||
# dcy-018 / REQ-029: cash-estimated overlay (best-effort)
|
||||
if ledger_outbox is not None:
|
||||
try:
|
||||
@@ -277,6 +332,75 @@ def make_slots_rpc_handler(
|
||||
return handler
|
||||
|
||||
|
||||
# ---- strategy-state write RPC handler (dcy-028 / REQ-080) -------------------
|
||||
|
||||
def make_strategy_state_set_handler(
|
||||
slot_repo: SlotRepository,
|
||||
strategy_state_store,
|
||||
):
|
||||
"""Build the RPC handler for `trex.slots.strategy_state.set` (REQ-080).
|
||||
|
||||
Request: {slot_id, strategy_id, expected_version:int,
|
||||
expected_occupancy_seq:int, data:str}
|
||||
Response (ok): {ok:true, slot_id, strategy_id, version:int}
|
||||
Response (fail): {ok:false, slot_id, strategy_id, reason, version?|occupancy_seq?}
|
||||
|
||||
reason ∈ {occupancy_changed, version_conflict, slot_inactive,
|
||||
state_too_large, bad_request:<detail>}. The store owns the CAS
|
||||
(REQ-079); the handler supplies the slot's current occupancy_seq and
|
||||
active-ness and never interprets `data` (opaque).
|
||||
"""
|
||||
_ACTIVE_STATES = {"pending_buy", "holding", "pending_sell"}
|
||||
|
||||
def handler(req: RpcRequest) -> RpcResponse:
|
||||
body = req.payload or {}
|
||||
try:
|
||||
slot_id = UUID(str(body["slot_id"]))
|
||||
strategy_id = str(body["strategy_id"])
|
||||
expected_version = int(body["expected_version"])
|
||||
expected_occupancy_seq = int(body["expected_occupancy_seq"])
|
||||
data = body["data"]
|
||||
if not isinstance(data, str):
|
||||
raise ValueError("data must be a string")
|
||||
except (KeyError, ValueError, TypeError) as exc:
|
||||
return RpcResponse(payload={
|
||||
"ok": False,
|
||||
"slot_id": body.get("slot_id"),
|
||||
"strategy_id": body.get("strategy_id"),
|
||||
"reason": f"bad_request:{exc}",
|
||||
})
|
||||
|
||||
slot = slot_repo.fetch_slot(slot_id)
|
||||
slot_active = slot is not None and slot.status.value in _ACTIVE_STATES
|
||||
current_occupancy_seq = getattr(slot, "occupancy_seq", 0) if slot else 0
|
||||
|
||||
result = strategy_state_store.set(
|
||||
slot_id,
|
||||
strategy_id,
|
||||
expected_occupancy_seq=expected_occupancy_seq,
|
||||
expected_version=expected_version,
|
||||
data=data,
|
||||
current_occupancy_seq=current_occupancy_seq,
|
||||
slot_active=slot_active,
|
||||
)
|
||||
payload = {
|
||||
"ok": result.ok,
|
||||
"slot_id": str(slot_id),
|
||||
"strategy_id": strategy_id,
|
||||
}
|
||||
if result.ok:
|
||||
payload["version"] = result.version
|
||||
else:
|
||||
payload["reason"] = result.reason
|
||||
if result.version is not None:
|
||||
payload["version"] = result.version
|
||||
if result.occupancy_seq is not None:
|
||||
payload["occupancy_seq"] = result.occupancy_seq
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
# ---- pause/resume RPC handler (REQ-032 AC-4) --------------------------------
|
||||
|
||||
def make_pause_rpc_handler(pause_store: PauseStore):
|
||||
|
||||
@@ -272,6 +272,9 @@ def slot_to_dict(
|
||||
"portfolio_id": s.portfolio_id,
|
||||
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
|
||||
"universe_detached": getattr(s, "retired_at", None) is not None,
|
||||
# dcy-028/REQ-081 (BAT-042): per-occupancy guard counter — the strategy
|
||||
# daemon echoes this as expected_occupancy_seq on strategy_state writes.
|
||||
"occupancy_seq": getattr(s, "occupancy_seq", 0),
|
||||
}
|
||||
|
||||
# hub authority: asset_quantity (hub primary) + cash_balance
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Slot strategy-state store (dcy-028 / REQ-079, BAT-042).
|
||||
|
||||
A strategy daemon (e.g. trex-tsl) accumulates opaque state across a slot's
|
||||
occupancy — for a Trailing Stop Loss the peak price since entry. `evaluate()`
|
||||
must stay pure, so the state cannot live in the daemon (lost on restart) nor in
|
||||
an external store (orphan-cleanup problem). Since the state's lifecycle matches
|
||||
the slot occupancy exactly, trex-core stores it server-side keyed by
|
||||
`(slot_id, strategy_id)`, round-trips it on the slots snapshot (read), accepts
|
||||
version-CAS writes via a dedicated RPC, and deletes it when the slot closes.
|
||||
|
||||
trex-core never interprets `data` (opaque UTF-8 string). It validates only
|
||||
(1) valid UTF-8 and (2) a byte size cap — encoding (JSON/base64) is the
|
||||
strategy's business (dcy-028 OQ-1).
|
||||
|
||||
Occupancy guard (dcy-028 R-1 / REQ-081): a permanent slot is reused, so two
|
||||
occupancies of the same slot both start at version 0. A write computed against
|
||||
occupancy X must not land on a later occupancy Y. The slot carries a monotonic
|
||||
`occupancy_seq`; the CAS rejects a write whose `expected_occupancy_seq` differs
|
||||
from the slot's current value (`occupancy_changed`). The version CAS alone
|
||||
cannot catch this at the first write (0 vs 0).
|
||||
|
||||
Single-writer assumption: one daemon operates a given slot·strategy. The CAS is
|
||||
for retry / duplicate / occupancy-turnover safety, not multi-writer concurrency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
# dcy-028 OQ-1: hard ceiling for the opaque `data` blob, measured in UTF-8
|
||||
# bytes. Operators target 1~2KB for the known TSL use case; 4KB is the cap.
|
||||
_STATE_CAP_BYTES = int(os.environ.get("TREX_STRATEGY_STATE_CAP_BYTES", "4096"))
|
||||
|
||||
# Reject reason tokens (dcy-028 / REQ-080). The full enumerated set; consumers
|
||||
# fail-closed on anything unrecognized.
|
||||
REASON_OCCUPANCY_CHANGED = "occupancy_changed"
|
||||
REASON_VERSION_CONFLICT = "version_conflict"
|
||||
REASON_SLOT_INACTIVE = "slot_inactive"
|
||||
REASON_STATE_TOO_LARGE = "state_too_large"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyStateEntry:
|
||||
"""One stored opaque state value for a (slot_id, strategy_id)."""
|
||||
|
||||
strategy_id: str
|
||||
occupancy_seq: int
|
||||
version: int
|
||||
data: str
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetResult:
|
||||
"""Outcome of a CAS set. ok=True carries the new version; else `reason`."""
|
||||
|
||||
ok: bool
|
||||
version: Optional[int] = None
|
||||
reason: Optional[str] = None
|
||||
occupancy_seq: Optional[int] = None # current slot occupancy_seq on conflict
|
||||
|
||||
|
||||
def _byte_len(data: str) -> int:
|
||||
return len(data.encode("utf-8"))
|
||||
|
||||
|
||||
class StrategyStateStore(Protocol):
|
||||
def set(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
strategy_id: str,
|
||||
*,
|
||||
expected_occupancy_seq: int,
|
||||
expected_version: int,
|
||||
data: str,
|
||||
current_occupancy_seq: int,
|
||||
slot_active: bool,
|
||||
) -> SetResult:
|
||||
"""occupancy_seq + version CAS upsert of opaque `data` (dcy-028 FR-2)."""
|
||||
...
|
||||
|
||||
def get_for_slots(
|
||||
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
|
||||
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
||||
"""Batched read for the snapshot (C-1). Returns slot_id -> {strategy_id: entry}.
|
||||
|
||||
Only entries whose stored occupancy_seq matches the slot's current
|
||||
occupancy_seq are returned (stale entries from a prior occupancy are
|
||||
invisible — defense in depth alongside close-delete, dcy-028 FR-3)."""
|
||||
...
|
||||
|
||||
def delete_all(self, slot_id: UUID) -> None:
|
||||
"""Delete all strategy_state for a slot (close hook, dcy-028 C-3)."""
|
||||
...
|
||||
|
||||
|
||||
def _cas(
|
||||
existing: Optional[StrategyStateEntry],
|
||||
*,
|
||||
expected_occupancy_seq: int,
|
||||
expected_version: int,
|
||||
data: str,
|
||||
current_occupancy_seq: int,
|
||||
slot_active: bool,
|
||||
) -> SetResult:
|
||||
"""Pure CAS decision shared by all store implementations (dcy-028 FR-2)."""
|
||||
if not slot_active:
|
||||
return SetResult(ok=False, reason=REASON_SLOT_INACTIVE)
|
||||
if expected_occupancy_seq != current_occupancy_seq:
|
||||
return SetResult(
|
||||
ok=False, reason=REASON_OCCUPANCY_CHANGED, occupancy_seq=current_occupancy_seq
|
||||
)
|
||||
if _byte_len(data) > _STATE_CAP_BYTES:
|
||||
return SetResult(ok=False, reason=REASON_STATE_TOO_LARGE)
|
||||
stored_version = existing.version if existing is not None else 0
|
||||
if expected_version != stored_version:
|
||||
return SetResult(ok=False, reason=REASON_VERSION_CONFLICT, version=stored_version)
|
||||
return SetResult(ok=True, version=stored_version + 1)
|
||||
|
||||
|
||||
class InMemoryStrategyStateStore:
|
||||
"""Process-local strategy-state store (paper / tests / explicit double)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# (slot_id, strategy_id) -> StrategyStateEntry
|
||||
self._store: dict[tuple[UUID, str], StrategyStateEntry] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
strategy_id: str,
|
||||
*,
|
||||
expected_occupancy_seq: int,
|
||||
expected_version: int,
|
||||
data: str,
|
||||
current_occupancy_seq: int,
|
||||
slot_active: bool,
|
||||
) -> SetResult:
|
||||
with self._lock:
|
||||
existing = self._store.get((slot_id, strategy_id))
|
||||
result = _cas(
|
||||
existing,
|
||||
expected_occupancy_seq=expected_occupancy_seq,
|
||||
expected_version=expected_version,
|
||||
data=data,
|
||||
current_occupancy_seq=current_occupancy_seq,
|
||||
slot_active=slot_active,
|
||||
)
|
||||
if not result.ok:
|
||||
return result
|
||||
self._store[(slot_id, strategy_id)] = StrategyStateEntry(
|
||||
strategy_id=strategy_id,
|
||||
occupancy_seq=current_occupancy_seq,
|
||||
version=result.version, # type: ignore[arg-type]
|
||||
data=data,
|
||||
updated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
return result
|
||||
|
||||
def get_for_slots(
|
||||
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
|
||||
) -> dict[UUID, dict[str, StrategyStateEntry]]:
|
||||
wanted = set(slot_ids)
|
||||
out: dict[UUID, dict[str, StrategyStateEntry]] = {}
|
||||
with self._lock:
|
||||
for (sid, strat), entry in self._store.items():
|
||||
if sid not in wanted:
|
||||
continue
|
||||
# stale-occupancy entries are invisible (dcy-028 FR-3 defense)
|
||||
if entry.occupancy_seq != current_occupancy_seq.get(sid):
|
||||
continue
|
||||
out.setdefault(sid, {})[strat] = entry
|
||||
return out
|
||||
|
||||
def delete_all(self, slot_id: UUID) -> None:
|
||||
with self._lock:
|
||||
for key in [k for k in self._store if k[0] == slot_id]:
|
||||
del self._store[key]
|
||||
@@ -51,6 +51,11 @@ class Slot:
|
||||
# slippage_pct: effective limit = entry_price * (1 ± pct/100); None = no band.
|
||||
timeout_sec: Optional[int] = None
|
||||
slippage_pct: Optional[float] = None
|
||||
# dcy-028 / REQ-081 (BAT-042): monotonic occupancy counter. Incremented when
|
||||
# the slot begins a new occupancy (inactive→pending_buy), stable across one
|
||||
# holding episode. Used by the strategy-state CAS to reject writes computed
|
||||
# against a prior occupancy of a reused slot (point-ABA guard, dcy-028 R-1).
|
||||
occupancy_seq: int = 0
|
||||
|
||||
|
||||
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
|
||||
@@ -78,6 +83,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
|
||||
"retired_at", # dcy-020 / REQ-056 (BAT-027)
|
||||
"timeout_sec", # dcy-022 / REQ-068 (BAT-032)
|
||||
"slippage_pct", # dcy-022 / REQ-068 (BAT-032)
|
||||
"occupancy_seq", # dcy-028 / REQ-081 (BAT-042)
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -39,12 +39,21 @@ _UNSET: Any = object()
|
||||
class SlotService:
|
||||
"""Slot lifecycle service. Race-free transitions via advisory lock + transaction."""
|
||||
|
||||
def __init__(self, repository: SlotRepository, instance_key: str = "default") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
repository: SlotRepository,
|
||||
instance_key: str = "default",
|
||||
*,
|
||||
strategy_state_store: Any = None,
|
||||
) -> None:
|
||||
self._repo = repository
|
||||
# dcy-020/REQ-057: advisory lock is scoped per instance so a shared DB
|
||||
# does not serialize transitions across instances (NFR-1).
|
||||
self._instance_key = instance_key
|
||||
self._lock_id = slot_advisory_lock_id(instance_key)
|
||||
# dcy-028/REQ-081 (C-3): when injected, all strategy_state for a slot is
|
||||
# deleted on close (→INACTIVE). None keeps legacy behaviour (no store).
|
||||
self._strategy_state_store = strategy_state_store
|
||||
|
||||
@property
|
||||
def instance_key(self) -> str:
|
||||
@@ -138,6 +147,11 @@ class SlotService:
|
||||
extra=extra or {},
|
||||
)
|
||||
)
|
||||
# dcy-028/REQ-081 (C-3): close deletes the slot's strategy_state so a
|
||||
# reused slot starts empty. Covers every close path (strategy sell,
|
||||
# safety-sell, forced-close, operator) since all funnel to →INACTIVE.
|
||||
if to_state is SlotState.INACTIVE and self._strategy_state_store is not None:
|
||||
self._strategy_state_store.delete_all(slot_id)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
@@ -202,6 +216,14 @@ class SlotService:
|
||||
new_timeout_sec = None
|
||||
new_slippage_pct = None
|
||||
|
||||
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
|
||||
# occupancy (inactive→pending_buy). Stable across the rest of the
|
||||
# holding episode; the INACTIVE branch above preserves it (monotonic).
|
||||
if slot.status is SlotState.INACTIVE and to_state is SlotState.PENDING_BUY:
|
||||
new_occupancy_seq = slot.occupancy_seq + 1
|
||||
else:
|
||||
new_occupancy_seq = slot.occupancy_seq
|
||||
|
||||
# qty semantics during a pending state = the ORDER quantity (dcy-021 D1/D4).
|
||||
# Entering PENDING_SELL takes the caller qty AS-IS so a full sell
|
||||
# (qty=None) records a "sell all" sentinel rather than the stale held
|
||||
@@ -244,6 +266,7 @@ class SlotService:
|
||||
),
|
||||
timeout_sec=new_timeout_sec,
|
||||
slippage_pct=new_slippage_pct,
|
||||
occupancy_seq=new_occupancy_seq,
|
||||
updated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from trex_core.adapters.messaging import (
|
||||
)
|
||||
from trex_core.adapters.persistence.migrate import apply_migrations
|
||||
from trex_core.adapters.persistence.postgresql import (
|
||||
PgStrategyStateStore,
|
||||
PostgreSQLIntentStore,
|
||||
PostgreSQLLedgerOutbox,
|
||||
PostgreSQLSlotRepository,
|
||||
@@ -54,6 +55,7 @@ from trex_core.application.messaging_handlers import (
|
||||
make_control_rpc_handler,
|
||||
make_intent_rpc_handler,
|
||||
make_slots_rpc_handler,
|
||||
make_strategy_state_set_handler,
|
||||
slot_transition_event_payload,
|
||||
)
|
||||
from trex_core.application.portfolio import (
|
||||
@@ -147,7 +149,14 @@ class Daemon:
|
||||
# Persistence + services
|
||||
self.slot_repo = PostgreSQLSlotRepository(config.dsn)
|
||||
self.intent_store = PostgreSQLIntentStore(config.dsn)
|
||||
self.slot_service = SlotService(self.slot_repo, instance_key=config.instance_key)
|
||||
# dcy-028 / REQ-079·081 (BAT-042): opaque strategy-state store. Injected
|
||||
# into the slot service so close (→INACTIVE) deletes the slot's state.
|
||||
self.strategy_state_store = PgStrategyStateStore(config.dsn)
|
||||
self.slot_service = SlotService(
|
||||
self.slot_repo,
|
||||
instance_key=config.instance_key,
|
||||
strategy_state_store=self.strategy_state_store,
|
||||
)
|
||||
self.intent_service = IntentService(
|
||||
self.slot_service, self.slot_repo, self.intent_store,
|
||||
resolve_symbol=self._resolve_symbol,
|
||||
@@ -284,10 +293,25 @@ class Daemon:
|
||||
watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec,
|
||||
ledger_outbox=self.outbox,
|
||||
max_active_slots=self.config.max_active_slots,
|
||||
strategy_state_store=self.strategy_state_store, # dcy-028 C-1
|
||||
)
|
||||
transport.consume_rpc_forever(
|
||||
topology.slots_queue(self.config.instance_key), handler, self.stop)
|
||||
|
||||
def _strategy_state_set_loop(self) -> None:
|
||||
# dcy-028 / REQ-080: opaque strategy-state write RPC (occupancy_seq+version CAS).
|
||||
transport = _build_transport(self.config)
|
||||
self._transports.append(transport)
|
||||
try:
|
||||
transport.declare_topology(self.config.instance_key)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
handler = make_strategy_state_set_handler(
|
||||
self.slot_repo, self.strategy_state_store,
|
||||
)
|
||||
transport.consume_rpc_forever(
|
||||
topology.strategy_state_set_queue(self.config.instance_key), handler, self.stop)
|
||||
|
||||
def _control_rpc_loop(self) -> None:
|
||||
# REQ-043: operator control channel (pause/resume/list + kill_switch/resume_kill_switch).
|
||||
transport = _build_transport(self.config)
|
||||
@@ -338,6 +362,7 @@ class Daemon:
|
||||
("trex-ledger", self._ledger_loop),
|
||||
("trex-intent-rpc", self._intent_rpc_loop),
|
||||
("trex-slots-rpc", self._slots_rpc_loop),
|
||||
("trex-strategy-state-set", self._strategy_state_set_loop),
|
||||
("trex-control-rpc", self._control_rpc_loop),
|
||||
("trex-snapshot-scheduler", self._snapshot_scheduler_loop),
|
||||
]
|
||||
|
||||
@@ -38,6 +38,7 @@ SLOT_CORE_TABLES: tuple[str, ...] = (
|
||||
"ledger_outbox",
|
||||
"snapshot_pause",
|
||||
"killswitch_exclusion",
|
||||
"slot_strategy_state", # dcy-028 / REQ-079 (BAT-042)
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user