backend redesign

This commit is contained in:
2026-03-11 18:47:11 -04:00
parent 8ff277c8c6
commit e99ef5d2dd
210 changed files with 12147 additions and 155 deletions

View File

@@ -0,0 +1,216 @@
# Priority System
Simple tuple-based priorities for deterministic execution ordering.
## Basic Concept
Priorities are just **Python tuples**. Python compares tuples element-by-element, left-to-right:
```python
(0, 1000, 5) < (0, 1001, 3) # True: 0==0, but 1000 < 1001
(0, 1000, 5) < (1, 500, 2) # True: 0 < 1
(0, 1000) < (0, 1000, 5) # True: shorter wins if equal so far
```
**Lower values = higher priority** (processed first).
## Priority Categories
```python
class Priority(IntEnum):
DATA_SOURCE = 0 # Market data, real-time feeds
TIMER = 1 # Scheduled tasks, cron jobs
USER_AGENT = 2 # User-agent interactions (chat)
USER_DATA_REQUEST = 3 # User data requests (charts)
SYSTEM = 4 # Background tasks, cleanup
LOW = 5 # Retries after conflicts
```
## Usage Examples
### Simple Priority
```python
# Just use the Priority enum
trigger = MyTrigger("task", priority=Priority.SYSTEM)
await queue.enqueue(trigger)
# Results in tuple: (4, queue_seq)
```
### Compound Priority (Tuple)
```python
# DataSource: sort by event time (older bars first)
trigger = DataUpdateTrigger(
source_name="binance",
symbol="BTC/USDT",
resolution="1m",
bar_data={"time": 1678896000, "open": 50000, ...}
)
await queue.enqueue(trigger)
# Results in tuple: (0, 1678896000, queue_seq)
# ^ ^ ^
# | | Queue insertion order (FIFO)
# | Event time (candle end time)
# DATA_SOURCE priority
```
### Manual Override
```python
# Override at enqueue time
await queue.enqueue(
trigger,
priority_override=(Priority.DATA_SOURCE, custom_time, custom_sort)
)
# Queue appends queue_seq: (0, custom_time, custom_sort, queue_seq)
```
## Common Patterns
### Market Data (Process Chronologically)
```python
# Bar from 10:00 → (0, 10:00_timestamp, queue_seq)
# Bar from 10:05 → (0, 10:05_timestamp, queue_seq)
#
# 10:00 bar processes first (earlier event_time)
DataUpdateTrigger(
...,
bar_data={"time": event_timestamp, ...}
)
```
### User Messages (FIFO Order)
```python
# Message #1 → (2, msg1_timestamp, queue_seq)
# Message #2 → (2, msg2_timestamp, queue_seq)
#
# Message #1 processes first (earlier timestamp)
AgentTriggerHandler(
session_id="user1",
message_content="...",
message_timestamp=unix_timestamp # Optional, defaults to now
)
```
### Scheduled Tasks (By Schedule Time)
```python
# Job scheduled for 9 AM → (1, 9am_timestamp, queue_seq)
# Job scheduled for 2 PM → (1, 2pm_timestamp, queue_seq)
#
# 9 AM job processes first
CronTrigger(
name="morning_sync",
inner_trigger=...,
scheduled_time=scheduled_timestamp
)
```
## Execution Order Example
```
Queue contains:
1. DataSource (BTC @ 10:00) → (0, 10:00, 1)
2. DataSource (BTC @ 10:05) → (0, 10:05, 2)
3. Timer (scheduled 9 AM) → (1, 09:00, 3)
4. User message #1 → (2, 14:30, 4)
5. User message #2 → (2, 14:35, 5)
Dequeue order:
1. DataSource (BTC @ 10:00) ← 0 < all others
2. DataSource (BTC @ 10:05) ← 0 < all others, 10:05 > 10:00
3. Timer (scheduled 9 AM) ← 1 < remaining
4. User message #1 ← 2 < remaining, 14:30 < 14:35
5. User message #2 ← last
```
## Short Tuple Wins
If tuples are equal up to the length of the shorter one, **shorter tuple has higher priority**:
```python
(0, 1000) < (0, 1000, 5) # True: shorter wins
(0,) < (0, 1000) # True: shorter wins
(Priority.DATA_SOURCE,) < (Priority.DATA_SOURCE, 1000) # True
```
This is Python's default tuple comparison behavior. In practice, we always append `queue_seq`, so this rarely matters (all tuples end up same length).
## Integration with Triggers
### Trigger Sets Its Own Priority
```python
class MyTrigger(Trigger):
def __init__(self, event_time):
super().__init__(
name="my_trigger",
priority=Priority.DATA_SOURCE,
priority_tuple=(Priority.DATA_SOURCE.value, event_time)
)
```
Queue appends `queue_seq` automatically:
```python
# Trigger's tuple: (0, event_time)
# After enqueue: (0, event_time, queue_seq)
```
### Override at Enqueue
```python
# Ignore trigger's priority, use override
await queue.enqueue(
trigger,
priority_override=(Priority.TIMER, scheduled_time)
)
```
## Why Tuples?
**Simple**: No custom classes, just native Python tuples
**Flexible**: Add as many sort keys as needed
**Efficient**: Python's tuple comparison is highly optimized
**Readable**: `(0, 1000, 5)` is obvious what it means
**Debuggable**: Can print and inspect easily
Example:
```python
# Old: CompoundPriority(primary=0, secondary=1000, tertiary=5)
# New: (0, 1000, 5)
# Same semantics, much simpler!
```
## Advanced: Custom Sorting
Want to sort by multiple factors? Just add more elements:
```python
# Sort by: priority → symbol → event_time → queue_seq
priority_tuple = (
Priority.DATA_SOURCE.value,
symbol_id, # e.g., hash("BTC/USDT")
event_time,
# queue_seq appended by queue
)
```
## Summary
- **Priorities are tuples**: `(primary, secondary, ..., queue_seq)`
- **Lower = higher priority**: Processed first
- **Element-by-element comparison**: Left-to-right
- **Shorter tuple wins**: If equal up to shorter length
- **Queue appends queue_seq**: Always last element (FIFO within same priority)
That's it! No complex classes, just tuples. 🎯

View File

@@ -0,0 +1,386 @@
# Trigger System
Lock-free, sequence-based execution system for deterministic event processing.
## Overview
All operations (WebSocket messages, cron tasks, data updates) flow through a **priority queue**, execute in **parallel**, but commit in **strict sequential order** with **optimistic conflict detection**.
### Key Features
- **Lock-free reads**: Snapshots are deep copies, no blocking
- **Sequential commits**: Total ordering via sequence numbers
- **Optimistic concurrency**: Conflicts detected, retry with same seq
- **Priority preservation**: High-priority work never blocked by low-priority
- **Long-running agents**: Execute in parallel, commit sequentially
- **Deterministic replay**: Can reproduce exact system state at any seq
## Architecture
```
┌─────────────┐
│ WebSocket │───┐
│ Messages │ │
└─────────────┘ │
├──→ ┌─────────────────┐
┌─────────────┐ │ │ TriggerQueue │
│ Cron │───┤ │ (Priority Queue)│
│ Scheduled │ │ └────────┬────────┘
└─────────────┘ │ │ Assign seq
│ ↓
┌─────────────┐ │ ┌─────────────────┐
│ DataSource │───┘ │ Execute Trigger│
│ Updates │ │ (Parallel OK) │
└─────────────┘ └────────┬────────┘
│ CommitIntents
┌─────────────────┐
│ CommitCoordinator│
│ (Sequential) │
└────────┬────────┘
│ Commit in seq order
┌─────────────────┐
│ VersionedStores │
│ (w/ Backends) │
└─────────────────┘
```
## Core Components
### 1. ExecutionContext (`context.py`)
Tracks execution seq and store snapshots via `contextvars` (auto-propagates through async calls).
```python
from trigger import get_execution_context
ctx = get_execution_context()
print(f"Running at seq {ctx.seq}")
```
### 2. Trigger Types (`types.py`)
```python
from trigger import Trigger, Priority, CommitIntent
class MyTrigger(Trigger):
async def execute(self) -> list[CommitIntent]:
# Read snapshot
seq, data = some_store.read_snapshot()
# Modify
new_data = modify(data)
# Prepare commit
intent = some_store.prepare_commit(seq, new_data)
return [intent]
```
### 3. VersionedStore (`store.py`)
Stores with pluggable backends and optimistic concurrency:
```python
from trigger import VersionedStore, PydanticStoreBackend
# Wrap existing Pydantic model
backend = PydanticStoreBackend(order_store)
versioned_store = VersionedStore("OrderStore", backend)
# Lock-free snapshot read
seq, snapshot = versioned_store.read_snapshot()
# Prepare commit (does not modify yet)
intent = versioned_store.prepare_commit(seq, modified_snapshot)
```
**Pluggable Backends**:
- `PydanticStoreBackend`: For existing Pydantic models (OrderStore, ChartStore, etc.)
- `FileStoreBackend`: Future - version files (Python scripts, configs)
- `DatabaseStoreBackend`: Future - version database rows
### 4. CommitCoordinator (`coordinator.py`)
Manages sequential commits with conflict detection:
- Waits for seq N to commit before N+1
- Detects conflicts (expected_seq vs committed_seq)
- Re-executes (not re-enqueues) on conflict **with same seq**
- Tracks execution state for debugging
### 5. TriggerQueue (`queue.py`)
Priority queue with seq assignment:
```python
from trigger import TriggerQueue
queue = TriggerQueue(coordinator)
await queue.start()
# Enqueue trigger
await queue.enqueue(my_trigger, Priority.HIGH)
```
### 6. TriggerScheduler (`scheduler.py`)
APScheduler integration for cron triggers:
```python
from trigger.scheduler import TriggerScheduler
scheduler = TriggerScheduler(queue)
scheduler.start()
# Every 5 minutes
scheduler.schedule_interval(
IndicatorUpdateTrigger("rsi_14"),
minutes=5
)
# Daily at 9 AM
scheduler.schedule_cron(
SyncExchangeStateTrigger(),
hour="9",
minute="0"
)
```
## Integration Example
### Basic Setup in `main.py`
```python
from trigger import (
CommitCoordinator,
TriggerQueue,
VersionedStore,
PydanticStoreBackend,
)
from trigger.scheduler import TriggerScheduler
# Create coordinator
coordinator = CommitCoordinator()
# Wrap existing stores
order_store_versioned = VersionedStore(
"OrderStore",
PydanticStoreBackend(order_store)
)
coordinator.register_store(order_store_versioned)
chart_store_versioned = VersionedStore(
"ChartStore",
PydanticStoreBackend(chart_store)
)
coordinator.register_store(chart_store_versioned)
# Create queue and scheduler
trigger_queue = TriggerQueue(coordinator)
await trigger_queue.start()
scheduler = TriggerScheduler(trigger_queue)
scheduler.start()
```
### WebSocket Message Handler
```python
from trigger.handlers import AgentTriggerHandler
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
data = await websocket.receive_json()
if data["type"] == "agent_user_message":
# Enqueue agent trigger instead of direct Gateway call
trigger = AgentTriggerHandler(
session_id=data["session_id"],
message_content=data["content"],
gateway_handler=gateway.route_user_message,
coordinator=coordinator,
)
await trigger_queue.enqueue(trigger)
```
### DataSource Updates
```python
from trigger.handlers import DataUpdateTrigger
# In subscription_manager._on_source_update()
def _on_source_update(self, source_key: tuple, bar: dict):
# Enqueue data update trigger
trigger = DataUpdateTrigger(
source_name=source_key[0],
symbol=source_key[1],
resolution=source_key[2],
bar_data=bar,
coordinator=coordinator,
)
asyncio.create_task(trigger_queue.enqueue(trigger))
```
### Custom Trigger
```python
from trigger import Trigger, CommitIntent, Priority
class RecalculatePortfolioTrigger(Trigger):
def __init__(self, coordinator):
super().__init__("recalc_portfolio", Priority.NORMAL)
self.coordinator = coordinator
async def execute(self) -> list[CommitIntent]:
# Read snapshots from multiple stores
order_seq, orders = self.coordinator.get_store("OrderStore").read_snapshot()
chart_seq, chart = self.coordinator.get_store("ChartStore").read_snapshot()
# Calculate portfolio value
portfolio_value = calculate_portfolio(orders, chart)
# Update chart state with portfolio value
chart.portfolio_value = portfolio_value
# Prepare commit
intent = self.coordinator.get_store("ChartStore").prepare_commit(
chart_seq,
chart
)
return [intent]
# Schedule it
scheduler.schedule_interval(
RecalculatePortfolioTrigger(coordinator),
minutes=1
)
```
## Execution Flow
### Normal Flow (No Conflicts)
```
seq=100: WebSocket message arrives → enqueue → dequeue → assign seq=100 → execute
seq=101: Cron trigger fires → enqueue → dequeue → assign seq=101 → execute
seq=101 finishes first → waits in commit queue
seq=100 finishes → commits immediately (next in order)
seq=101 commits next
```
### Conflict Flow
```
seq=100: reads OrderStore at seq=99 → executes for 30 seconds
seq=101: reads OrderStore at seq=99 → executes for 5 seconds
seq=101 finishes first → tries to commit based on seq=99
seq=100 finishes → commits OrderStore at seq=100
Coordinator detects conflict:
expected_seq=99, committed_seq=100
seq=101 evicted → RE-EXECUTES with same seq=101 (not re-enqueued)
reads OrderStore at seq=100 → executes again
finishes → commits successfully at seq=101
```
## Benefits
### For Agent System
- **Long-running agents work naturally**: Agent starts at seq=100, runs for 60 seconds while market data updates at seq=101-110, commits only if no conflicts
- **No deadlocks**: No locks = no deadlock possibility
- **Deterministic**: Can replay from any seq for debugging
### For Strategy Execution
- **High-frequency data doesn't block strategies**: Data updates enqueued, executed in parallel, commit sequentially
- **Priority preservation**: Critical order execution never blocked by indicator calculations
- **Conflict detection**: If market moved during strategy calculation, automatically retry with fresh data
### For Scaling
- **Single-node first**: Runs on single asyncio event loop, no complex distributed coordination
- **Future-proof**: Can swap queue for Redis/PostgreSQL-backed distributed queue later
- **Event sourcing ready**: All commits have seq numbers, can build event log
## Debugging
### Check Current State
```python
# Coordinator stats
stats = coordinator.get_stats()
print(f"Current seq: {stats['current_seq']}")
print(f"Pending commits: {stats['pending_commits']}")
print(f"Executions by state: {stats['state_counts']}")
# Store state
store = coordinator.get_store("OrderStore")
print(f"Store: {store}") # Shows committed_seq and version
# Execution record
record = coordinator.get_execution_record(100)
print(f"Seq 100: {record}") # Shows state, retry_count, error
```
### Common Issues
**Symptoms: High conflict rate**
- **Cause**: Multiple triggers modifying same store frequently
- **Solution**: Batch updates, use debouncing, or redesign to reduce contention
**Symptoms: Commits stuck (next_commit_seq not advancing)**
- **Cause**: Execution at that seq failed or is taking too long
- **Solution**: Check execution_records for that seq, look for errors in logs
**Symptoms: Queue depth growing**
- **Cause**: Executions slower than enqueue rate
- **Solution**: Profile trigger execution, optimize slow paths, add rate limiting
## Testing
### Unit Test: Conflict Detection
```python
import pytest
from trigger import VersionedStore, PydanticStoreBackend, CommitCoordinator
@pytest.mark.asyncio
async def test_conflict_detection():
coordinator = CommitCoordinator()
store = VersionedStore("TestStore", PydanticStoreBackend(TestModel()))
coordinator.register_store(store)
# Seq 1: read at 0, modify, commit
seq1, data1 = store.read_snapshot()
data1.value = "seq1"
intent1 = store.prepare_commit(seq1, data1)
# Seq 2: read at 0 (same snapshot), modify
seq2, data2 = store.read_snapshot()
data2.value = "seq2"
intent2 = store.prepare_commit(seq2, data2)
# Commit seq 1 (should succeed)
# ... coordinator logic ...
# Commit seq 2 (should conflict and retry)
# ... verify conflict detected ...
```
## Future Enhancements
- **Distributed queue**: Redis-backed queue for multi-worker deployment
- **Event log persistence**: Store all commits for event sourcing/audit
- **Metrics dashboard**: Real-time view of queue depth, conflict rate, latency
- **Transaction snapshots**: Full system state at any seq for replay/debugging
- **Automatic batching**: Coalesce rapid updates to same store

View File

@@ -0,0 +1,35 @@
"""
Sequential execution trigger system with optimistic concurrency control.
All operations (websocket, cron, data events) flow through a priority queue,
execute in parallel, but commit in strict sequential order with conflict detection.
"""
from .context import ExecutionContext, get_execution_context
from .types import Priority, PriorityTuple, Trigger, CommitIntent, ExecutionState
from .store import VersionedStore, StoreBackend, PydanticStoreBackend
from .coordinator import CommitCoordinator
from .queue import TriggerQueue
from .handlers import AgentTriggerHandler, LambdaHandler
__all__ = [
# Context
"ExecutionContext",
"get_execution_context",
# Types
"Priority",
"PriorityTuple",
"Trigger",
"CommitIntent",
"ExecutionState",
# Store
"VersionedStore",
"StoreBackend",
"PydanticStoreBackend",
# Coordination
"CommitCoordinator",
"TriggerQueue",
# Handlers
"AgentTriggerHandler",
"LambdaHandler",
]

View File

@@ -0,0 +1,61 @@
"""
Execution context tracking using Python's contextvars.
Each execution gets a unique seq number that propagates through all async calls,
allowing us to track which execution made which changes for conflict detection.
"""
import logging
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__)
# Context variables - automatically propagate through async call chains
_execution_context: ContextVar[Optional["ExecutionContext"]] = ContextVar(
"execution_context", default=None
)
@dataclass
class ExecutionContext:
"""
Execution context for a single trigger execution.
Automatically propagates through async calls via contextvars.
Tracks the seq number and which store snapshots were read.
"""
seq: int
"""Sequential execution number - determines commit order"""
trigger_name: str
"""Name/type of trigger being executed"""
snapshot_seqs: dict[str, int] = field(default_factory=dict)
"""Store name -> seq number of snapshot that was read"""
def record_snapshot(self, store_name: str, snapshot_seq: int) -> None:
"""Record that we read a snapshot from a store at a specific seq"""
self.snapshot_seqs[store_name] = snapshot_seq
logger.debug(f"Seq {self.seq}: Read {store_name} at seq {snapshot_seq}")
def __str__(self) -> str:
return f"ExecutionContext(seq={self.seq}, trigger={self.trigger_name})"
def get_execution_context() -> Optional[ExecutionContext]:
"""Get the current execution context, or None if not in an execution"""
return _execution_context.get()
def set_execution_context(ctx: ExecutionContext) -> None:
"""Set the execution context for the current async task"""
_execution_context.set(ctx)
logger.debug(f"Set execution context: {ctx}")
def clear_execution_context() -> None:
"""Clear the execution context"""
_execution_context.set(None)

View File

@@ -0,0 +1,302 @@
"""
Commit coordinator - manages sequential commits with conflict detection.
Ensures that commits happen in strict sequence order, even when executions
complete out of order. Detects conflicts and triggers re-execution with the
same seq number (not re-enqueue, just re-execute).
"""
import asyncio
import logging
from typing import Optional
from .context import ExecutionContext
from .store import VersionedStore
from .types import CommitIntent, ExecutionRecord, ExecutionState, Trigger
logger = logging.getLogger(__name__)
class CommitCoordinator:
"""
Manages sequential commits with optimistic concurrency control.
Key responsibilities:
- Maintain strict sequential commit order (seq N+1 commits after seq N)
- Detect conflicts between execution snapshot and committed state
- Trigger re-execution (not re-enqueue) on conflicts with same seq
- Track in-flight executions for debugging and monitoring
"""
def __init__(self):
self._stores: dict[str, VersionedStore] = {}
self._current_seq = 0 # Highest committed seq across all operations
self._next_commit_seq = 1 # Next seq we're waiting to commit
self._pending_commits: dict[int, tuple[ExecutionRecord, list[CommitIntent]]] = {}
self._execution_records: dict[int, ExecutionRecord] = {}
self._lock = asyncio.Lock() # Only for coordinator internal state, not stores
def register_store(self, store: VersionedStore) -> None:
"""Register a versioned store with the coordinator"""
self._stores[store.name] = store
logger.info(f"Registered store: {store.name}")
def get_store(self, name: str) -> Optional[VersionedStore]:
"""Get a registered store by name"""
return self._stores.get(name)
async def start_execution(self, seq: int, trigger: Trigger) -> ExecutionRecord:
"""
Record that an execution is starting.
Args:
seq: Sequence number assigned to this execution
trigger: The trigger being executed
Returns:
ExecutionRecord for tracking
"""
async with self._lock:
record = ExecutionRecord(
seq=seq,
trigger=trigger,
state=ExecutionState.EXECUTING,
)
self._execution_records[seq] = record
logger.info(f"Started execution: seq={seq}, trigger={trigger.name}")
return record
async def submit_for_commit(
self,
seq: int,
commit_intents: list[CommitIntent],
) -> None:
"""
Submit commit intents for sequential commit.
The commit will only happen when:
1. All prior seq numbers have committed
2. No conflicts detected with committed state
Args:
seq: Sequence number of this execution
commit_intents: List of changes to commit (empty if no changes)
"""
async with self._lock:
record = self._execution_records.get(seq)
if not record:
logger.error(f"No execution record found for seq={seq}")
return
record.state = ExecutionState.WAITING_COMMIT
record.commit_intents = commit_intents
self._pending_commits[seq] = (record, commit_intents)
logger.info(
f"Seq {seq} submitted for commit with {len(commit_intents)} intents"
)
# Try to process commits (this will handle sequential ordering)
await self._process_commits()
async def _process_commits(self) -> None:
"""
Process pending commits in strict sequential order.
Only commits seq N if seq N-1 has already committed.
Detects conflicts and triggers re-execution with same seq.
"""
while True:
async with self._lock:
# Check if next expected seq is ready to commit
if self._next_commit_seq not in self._pending_commits:
# Waiting for this seq to complete execution
break
seq = self._next_commit_seq
record, intents = self._pending_commits[seq]
logger.info(
f"Processing commit for seq={seq} (current_seq={self._current_seq})"
)
# Check for conflicts
conflicts = self._check_conflicts(intents)
if conflicts:
# Conflict detected - re-execute with same seq
logger.warning(
f"Seq {seq} has conflicts in stores: {conflicts}. Re-executing..."
)
# Remove from pending (will be re-added when execution completes)
del self._pending_commits[seq]
# Mark as evicted
record.state = ExecutionState.EVICTED
record.retry_count += 1
# Advance to next seq (this seq will be retried in background)
self._next_commit_seq += 1
self._current_seq += 1
# Trigger re-execution (outside lock)
asyncio.create_task(self._retry_execution(record))
continue
# No conflicts - commit all intents atomically
for intent in intents:
store = self._stores.get(intent.store_name)
if not store:
logger.error(
f"Seq {seq}: Store '{intent.store_name}' not found"
)
continue
store.commit(intent.new_data, seq)
# Mark as committed
record.state = ExecutionState.COMMITTED
del self._pending_commits[seq]
# Advance seq counters
self._current_seq = seq
self._next_commit_seq = seq + 1
logger.info(
f"Committed seq={seq}, current_seq now {self._current_seq}"
)
def _check_conflicts(self, intents: list[CommitIntent]) -> list[str]:
"""
Check if any commit intents conflict with current committed state.
Args:
intents: List of commit intents to check
Returns:
List of store names that have conflicts (empty if no conflicts)
"""
conflicts = []
for intent in intents:
store = self._stores.get(intent.store_name)
if not store:
logger.error(f"Store '{intent.store_name}' not found during conflict check")
continue
if store.check_conflict(intent.expected_seq):
conflicts.append(intent.store_name)
return conflicts
async def _retry_execution(self, record: ExecutionRecord) -> None:
"""
Re-execute a trigger that had conflicts.
Executes with the SAME seq number (not re-enqueued, just re-executed).
This ensures the execution order remains deterministic.
Args:
record: Execution record to retry
"""
from .context import ExecutionContext, set_execution_context, clear_execution_context
logger.info(
f"Retrying execution: seq={record.seq}, trigger={record.trigger.name}, "
f"retry_count={record.retry_count}"
)
# Set execution context for retry
ctx = ExecutionContext(
seq=record.seq,
trigger_name=record.trigger.name,
)
set_execution_context(ctx)
try:
# Re-execute trigger
record.state = ExecutionState.EXECUTING
commit_intents = await record.trigger.execute()
# Submit for commit again (with same seq)
await self.submit_for_commit(record.seq, commit_intents)
except Exception as e:
logger.error(
f"Retry execution failed for seq={record.seq}: {e}", exc_info=True
)
record.state = ExecutionState.FAILED
record.error = str(e)
# Still need to advance past this seq
async with self._lock:
if record.seq == self._next_commit_seq:
self._next_commit_seq += 1
self._current_seq += 1
# Try to process any pending commits
await self._process_commits()
finally:
clear_execution_context()
async def execution_failed(self, seq: int, error: Exception) -> None:
"""
Mark an execution as failed.
Args:
seq: Sequence number that failed
error: The exception that caused the failure
"""
async with self._lock:
record = self._execution_records.get(seq)
if record:
record.state = ExecutionState.FAILED
record.error = str(error)
# Remove from pending if present
self._pending_commits.pop(seq, None)
# If this is the next seq to commit, advance past it
if seq == self._next_commit_seq:
self._next_commit_seq += 1
self._current_seq += 1
logger.info(
f"Seq {seq} failed, advancing current_seq to {self._current_seq}"
)
# Try to process any pending commits
await self._process_commits()
def get_current_seq(self) -> int:
"""Get the current committed sequence number"""
return self._current_seq
def get_execution_record(self, seq: int) -> Optional[ExecutionRecord]:
"""Get execution record for a specific seq"""
return self._execution_records.get(seq)
def get_stats(self) -> dict:
"""Get statistics about the coordinator state"""
state_counts = {}
for record in self._execution_records.values():
state_name = record.state.name
state_counts[state_name] = state_counts.get(state_name, 0) + 1
return {
"current_seq": self._current_seq,
"next_commit_seq": self._next_commit_seq,
"pending_commits": len(self._pending_commits),
"total_executions": len(self._execution_records),
"state_counts": state_counts,
"stores": {name: str(store) for name, store in self._stores.items()},
}
def __repr__(self) -> str:
return (
f"CommitCoordinator(current_seq={self._current_seq}, "
f"pending={len(self._pending_commits)}, stores={len(self._stores)})"
)

View File

@@ -0,0 +1,304 @@
"""
Trigger handlers - concrete implementations for common trigger types.
Provides ready-to-use trigger handlers for:
- Agent execution (WebSocket user messages)
- Lambda/callable execution
- Data update triggers
- Indicator updates
"""
import logging
import time
from typing import Any, Awaitable, Callable, Optional
from .coordinator import CommitCoordinator
from .types import CommitIntent, Priority, Trigger
logger = logging.getLogger(__name__)
class AgentTriggerHandler(Trigger):
"""
Trigger for agent execution from WebSocket user messages.
Wraps the Gateway's agent execution flow and captures any
store modifications as commit intents.
Priority tuple: (USER_AGENT, message_timestamp, queue_seq)
"""
def __init__(
self,
session_id: str,
message_content: str,
message_timestamp: Optional[int] = None,
attachments: Optional[list] = None,
gateway_handler: Optional[Callable] = None,
coordinator: Optional[CommitCoordinator] = None,
):
"""
Initialize agent trigger.
Args:
session_id: User session ID
message_content: User message content
message_timestamp: When user sent message (unix timestamp, defaults to now)
attachments: Optional message attachments
gateway_handler: Callable to route to Gateway (set during integration)
coordinator: CommitCoordinator for accessing stores
"""
if message_timestamp is None:
message_timestamp = int(time.time())
# Priority tuple: sort by USER_AGENT priority, then message timestamp
super().__init__(
name=f"agent_{session_id}",
priority=Priority.USER_AGENT,
priority_tuple=(Priority.USER_AGENT.value, message_timestamp)
)
self.session_id = session_id
self.message_content = message_content
self.message_timestamp = message_timestamp
self.attachments = attachments or []
self.gateway_handler = gateway_handler
self.coordinator = coordinator
async def execute(self) -> list[CommitIntent]:
"""
Execute agent interaction.
This will call into the Gateway, which will run the agent.
The agent may read from stores and generate responses.
Any store modifications are captured as commit intents.
Returns:
List of commit intents (typically empty for now, as agent
modifies stores via tools which will be integrated later)
"""
if not self.gateway_handler:
logger.error("No gateway_handler configured for AgentTriggerHandler")
return []
logger.info(
f"Agent trigger executing: session={self.session_id}, "
f"content='{self.message_content[:50]}...'"
)
try:
# Call Gateway to handle message
# In future, Gateway/agent tools will use coordinator stores
await self.gateway_handler(
self.session_id,
self.message_content,
self.attachments,
)
# For now, agent doesn't directly modify stores
# Future: agent tools will return commit intents
return []
except Exception as e:
logger.error(f"Agent execution error: {e}", exc_info=True)
raise
class LambdaHandler(Trigger):
"""
Generic trigger that executes an arbitrary async callable.
Useful for custom triggers, one-off tasks, or testing.
"""
def __init__(
self,
name: str,
func: Callable[[], Awaitable[list[CommitIntent]]],
priority: Priority = Priority.SYSTEM,
):
"""
Initialize lambda handler.
Args:
name: Descriptive name for this trigger
func: Async callable that returns commit intents
priority: Execution priority
"""
super().__init__(name, priority)
self.func = func
async def execute(self) -> list[CommitIntent]:
"""Execute the callable"""
logger.info(f"Lambda trigger executing: {self.name}")
return await self.func()
class DataUpdateTrigger(Trigger):
"""
Trigger for DataSource bar updates.
Fired when new market data arrives. Can update indicators,
trigger strategy logic, or notify the agent of market events.
Priority tuple: (DATA_SOURCE, event_time, queue_seq)
Ensures older bars process before newer ones.
"""
def __init__(
self,
source_name: str,
symbol: str,
resolution: str,
bar_data: dict,
coordinator: Optional[CommitCoordinator] = None,
):
"""
Initialize data update trigger.
Args:
source_name: Name of data source (e.g., "binance")
symbol: Trading pair symbol
resolution: Time resolution
bar_data: Bar data dict (time, open, high, low, close, volume)
coordinator: CommitCoordinator for accessing stores
"""
event_time = bar_data.get('time', int(time.time()))
# Priority tuple: sort by DATA_SOURCE priority, then event time
super().__init__(
name=f"data_{source_name}_{symbol}_{resolution}",
priority=Priority.DATA_SOURCE,
priority_tuple=(Priority.DATA_SOURCE.value, event_time)
)
self.source_name = source_name
self.symbol = symbol
self.resolution = resolution
self.bar_data = bar_data
self.coordinator = coordinator
async def execute(self) -> list[CommitIntent]:
"""
Process bar update.
Future implementations will:
- Update indicator values
- Check strategy conditions
- Trigger alerts/notifications
Returns:
Commit intents for any store updates
"""
logger.info(
f"Data update trigger: {self.source_name}:{self.symbol}@{self.resolution}, "
f"time={self.bar_data.get('time')}"
)
# TODO: Update indicators
# TODO: Check strategy conditions
# TODO: Notify agent of significant events
# For now, just log
return []
class IndicatorUpdateTrigger(Trigger):
"""
Trigger for updating indicator values.
Can be fired by cron (periodic recalculation) or by data updates.
"""
def __init__(
self,
indicator_id: str,
force_full_recalc: bool = False,
coordinator: Optional[CommitCoordinator] = None,
priority: Priority = Priority.SYSTEM,
):
"""
Initialize indicator update trigger.
Args:
indicator_id: ID of indicator to update
force_full_recalc: If True, recalculate entire history
coordinator: CommitCoordinator for accessing stores
priority: Execution priority
"""
super().__init__(f"indicator_{indicator_id}", priority)
self.indicator_id = indicator_id
self.force_full_recalc = force_full_recalc
self.coordinator = coordinator
async def execute(self) -> list[CommitIntent]:
"""
Update indicator value.
Reads from IndicatorStore, recalculates, prepares commit.
Returns:
Commit intents for updated indicator data
"""
if not self.coordinator:
logger.error("No coordinator configured")
return []
# Get indicator store
indicator_store = self.coordinator.get_store("IndicatorStore")
if not indicator_store:
logger.error("IndicatorStore not registered")
return []
# Read snapshot
snapshot_seq, indicator_data = indicator_store.read_snapshot()
logger.info(
f"Indicator update trigger: {self.indicator_id}, "
f"snapshot_seq={snapshot_seq}, force_full={self.force_full_recalc}"
)
# TODO: Implement indicator recalculation logic
# For now, just return empty (no changes)
return []
class CronTrigger(Trigger):
"""
Trigger fired by APScheduler on a schedule.
Wraps another trigger or callable to execute periodically.
Priority tuple: (TIMER, scheduled_time, queue_seq)
Ensures jobs scheduled for earlier times run first.
"""
def __init__(
self,
name: str,
inner_trigger: Trigger,
scheduled_time: Optional[int] = None,
):
"""
Initialize cron trigger.
Args:
name: Descriptive name (e.g., "hourly_sync")
inner_trigger: Trigger to execute on schedule
scheduled_time: When this was scheduled to run (defaults to now)
"""
if scheduled_time is None:
scheduled_time = int(time.time())
# Priority tuple: sort by TIMER priority, then scheduled time
super().__init__(
name=f"cron_{name}",
priority=Priority.TIMER,
priority_tuple=(Priority.TIMER.value, scheduled_time)
)
self.inner_trigger = inner_trigger
self.scheduled_time = scheduled_time
async def execute(self) -> list[CommitIntent]:
"""Execute the wrapped trigger"""
logger.info(f"Cron trigger firing: {self.name}")
return await self.inner_trigger.execute()

View File

@@ -0,0 +1,224 @@
"""
Trigger queue - priority queue with sequence number assignment.
All operations flow through this queue:
- WebSocket messages from users
- Cron scheduled tasks
- DataSource bar updates
- Manual triggers
Queue assigns seq numbers on dequeue, executes triggers, and submits to coordinator.
"""
import asyncio
import logging
from typing import Optional
from .context import ExecutionContext, clear_execution_context, set_execution_context
from .coordinator import CommitCoordinator
from .types import Priority, PriorityTuple, Trigger
logger = logging.getLogger(__name__)
class TriggerQueue:
"""
Priority queue for trigger execution.
Key responsibilities:
- Maintain priority queue (high priority dequeued first)
- Assign sequence numbers on dequeue (determines commit order)
- Execute triggers with context set
- Submit results to CommitCoordinator
- Handle execution errors gracefully
"""
def __init__(self, coordinator: CommitCoordinator):
"""
Initialize trigger queue.
Args:
coordinator: CommitCoordinator for handling commits
"""
self._coordinator = coordinator
self._queue: asyncio.PriorityQueue = asyncio.PriorityQueue()
self._seq_counter = 0
self._seq_lock = asyncio.Lock()
self._processor_task: Optional[asyncio.Task] = None
self._running = False
async def start(self) -> None:
"""Start the queue processor"""
if self._running:
logger.warning("TriggerQueue already running")
return
self._running = True
self._processor_task = asyncio.create_task(self._process_loop())
logger.info("TriggerQueue started")
async def stop(self) -> None:
"""Stop the queue processor gracefully"""
if not self._running:
return
self._running = False
if self._processor_task:
self._processor_task.cancel()
try:
await self._processor_task
except asyncio.CancelledError:
pass
logger.info("TriggerQueue stopped")
async def enqueue(
self,
trigger: Trigger,
priority_override: Optional[Priority | PriorityTuple] = None
) -> int:
"""
Add a trigger to the queue.
Args:
trigger: Trigger to execute
priority_override: Override priority (simple Priority or tuple)
If None, uses trigger's priority/priority_tuple
If Priority enum, creates single-element tuple
If tuple, uses as-is
Returns:
Queue sequence number (appended to priority tuple)
Examples:
# Simple priority
await queue.enqueue(trigger, Priority.USER_AGENT)
# Results in: (Priority.USER_AGENT, queue_seq)
# Tuple priority with event time
await queue.enqueue(
trigger,
(Priority.DATA_SOURCE, bar_data['time'])
)
# Results in: (Priority.DATA_SOURCE, bar_time, queue_seq)
# Let trigger decide
await queue.enqueue(trigger)
"""
# Get monotonic seq for queue ordering (appended to tuple)
async with self._seq_lock:
queue_seq = self._seq_counter
self._seq_counter += 1
# Determine priority tuple
if priority_override is not None:
if isinstance(priority_override, Priority):
# Convert simple priority to tuple
priority_tuple = (priority_override.value, queue_seq)
else:
# Use provided tuple, append queue_seq
priority_tuple = priority_override + (queue_seq,)
else:
# Let trigger determine its own priority tuple
priority_tuple = trigger.get_priority_tuple(queue_seq)
# Priority queue: (priority_tuple, trigger)
# Python's PriorityQueue compares tuples element-by-element
await self._queue.put((priority_tuple, trigger))
logger.debug(
f"Enqueued: {trigger.name} with priority_tuple={priority_tuple}"
)
return queue_seq
async def _process_loop(self) -> None:
"""
Main processing loop.
Dequeues triggers, assigns execution seq, executes, and submits to coordinator.
"""
execution_seq = 0 # Separate counter for execution sequence
while self._running:
try:
# Wait for next trigger (with timeout to check _running flag)
try:
priority_tuple, trigger = await asyncio.wait_for(
self._queue.get(), timeout=1.0
)
except asyncio.TimeoutError:
continue
# Assign execution sequence number
execution_seq += 1
logger.info(
f"Dequeued: seq={execution_seq}, trigger={trigger.name}, "
f"priority_tuple={priority_tuple}"
)
# Execute in background (don't block queue)
asyncio.create_task(
self._execute_trigger(execution_seq, trigger)
)
except Exception as e:
logger.error(f"Error in process loop: {e}", exc_info=True)
async def _execute_trigger(self, seq: int, trigger: Trigger) -> None:
"""
Execute a trigger with proper context and error handling.
Args:
seq: Execution sequence number
trigger: Trigger to execute
"""
# Set up execution context
ctx = ExecutionContext(
seq=seq,
trigger_name=trigger.name,
)
set_execution_context(ctx)
# Record execution start with coordinator
await self._coordinator.start_execution(seq, trigger)
try:
logger.info(f"Executing: seq={seq}, trigger={trigger.name}")
# Execute trigger (can be long-running)
commit_intents = await trigger.execute()
logger.info(
f"Execution complete: seq={seq}, {len(commit_intents)} commit intents"
)
# Submit for sequential commit
await self._coordinator.submit_for_commit(seq, commit_intents)
except Exception as e:
logger.error(
f"Execution failed: seq={seq}, trigger={trigger.name}, error={e}",
exc_info=True,
)
# Notify coordinator of failure
await self._coordinator.execution_failed(seq, e)
finally:
clear_execution_context()
def get_queue_size(self) -> int:
"""Get current queue size (approximate)"""
return self._queue.qsize()
def is_running(self) -> bool:
"""Check if queue processor is running"""
return self._running
def __repr__(self) -> str:
return (
f"TriggerQueue(running={self._running}, queue_size={self.get_queue_size()})"
)

View File

@@ -0,0 +1,187 @@
"""
APScheduler integration for cron-style triggers.
Provides scheduling of periodic triggers (e.g., sync exchange state hourly,
recompute indicators every 5 minutes, daily portfolio reports).
"""
import logging
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger as APSCronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from .queue import TriggerQueue
from .types import Priority, Trigger
logger = logging.getLogger(__name__)
class TriggerScheduler:
"""
Scheduler for periodic trigger execution.
Wraps APScheduler to enqueue triggers at scheduled times.
"""
def __init__(self, trigger_queue: TriggerQueue):
"""
Initialize scheduler.
Args:
trigger_queue: TriggerQueue to enqueue triggers into
"""
self.trigger_queue = trigger_queue
self.scheduler = AsyncIOScheduler()
self._job_counter = 0
def start(self) -> None:
"""Start the scheduler"""
self.scheduler.start()
logger.info("TriggerScheduler started")
def shutdown(self, wait: bool = True) -> None:
"""
Shut down the scheduler.
Args:
wait: If True, wait for running jobs to complete
"""
self.scheduler.shutdown(wait=wait)
logger.info("TriggerScheduler shut down")
def schedule_interval(
self,
trigger: Trigger,
seconds: Optional[int] = None,
minutes: Optional[int] = None,
hours: Optional[int] = None,
priority: Optional[Priority] = None,
) -> str:
"""
Schedule a trigger to run at regular intervals.
Args:
trigger: Trigger to execute
seconds: Interval in seconds
minutes: Interval in minutes
hours: Interval in hours
priority: Priority override for execution
Returns:
Job ID (can be used to remove job later)
Example:
# Run every 5 minutes
scheduler.schedule_interval(
IndicatorUpdateTrigger("rsi_14"),
minutes=5
)
"""
job_id = f"interval_{self._job_counter}"
self._job_counter += 1
async def job_func():
await self.trigger_queue.enqueue(trigger, priority)
self.scheduler.add_job(
job_func,
trigger=IntervalTrigger(seconds=seconds, minutes=minutes, hours=hours),
id=job_id,
name=f"Interval: {trigger.name}",
)
logger.info(
f"Scheduled interval job: {job_id}, trigger={trigger.name}, "
f"interval=(s={seconds}, m={minutes}, h={hours})"
)
return job_id
def schedule_cron(
self,
trigger: Trigger,
minute: Optional[str] = None,
hour: Optional[str] = None,
day: Optional[str] = None,
month: Optional[str] = None,
day_of_week: Optional[str] = None,
priority: Optional[Priority] = None,
) -> str:
"""
Schedule a trigger to run on a cron schedule.
Args:
trigger: Trigger to execute
minute: Minute expression (0-59, *, */5, etc.)
hour: Hour expression (0-23, *, etc.)
day: Day of month expression (1-31, *, etc.)
month: Month expression (1-12, *, etc.)
day_of_week: Day of week expression (0-6, mon-sun, *, etc.)
priority: Priority override for execution
Returns:
Job ID (can be used to remove job later)
Example:
# Run at 9:00 AM every weekday
scheduler.schedule_cron(
SyncExchangeStateTrigger(),
hour="9",
minute="0",
day_of_week="mon-fri"
)
"""
job_id = f"cron_{self._job_counter}"
self._job_counter += 1
async def job_func():
await self.trigger_queue.enqueue(trigger, priority)
self.scheduler.add_job(
job_func,
trigger=APSCronTrigger(
minute=minute,
hour=hour,
day=day,
month=month,
day_of_week=day_of_week,
),
id=job_id,
name=f"Cron: {trigger.name}",
)
logger.info(
f"Scheduled cron job: {job_id}, trigger={trigger.name}, "
f"schedule=(m={minute}, h={hour}, d={day}, dow={day_of_week})"
)
return job_id
def remove_job(self, job_id: str) -> bool:
"""
Remove a scheduled job.
Args:
job_id: Job ID returned from schedule_* methods
Returns:
True if job was removed, False if not found
"""
try:
self.scheduler.remove_job(job_id)
logger.info(f"Removed scheduled job: {job_id}")
return True
except Exception as e:
logger.warning(f"Could not remove job {job_id}: {e}")
return False
def get_jobs(self) -> list:
"""Get list of all scheduled jobs"""
return self.scheduler.get_jobs()
def __repr__(self) -> str:
job_count = len(self.scheduler.get_jobs())
running = self.scheduler.running
return f"TriggerScheduler(running={running}, jobs={job_count})"

View File

@@ -0,0 +1,301 @@
"""
Versioned store with pluggable backends.
Provides optimistic concurrency control via sequence numbers with support
for different storage backends (Pydantic models, files, databases, etc.).
"""
import logging
from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Any, Generic, TypeVar
from .context import get_execution_context
from .types import CommitIntent
logger = logging.getLogger(__name__)
T = TypeVar("T")
class StoreBackend(ABC, Generic[T]):
"""
Abstract backend for versioned stores.
Allows different storage mechanisms (Pydantic models, files, databases)
to be used with the same versioned store infrastructure.
"""
@abstractmethod
def read(self) -> T:
"""
Read the current data.
Returns:
Current data in backend-specific format
"""
pass
@abstractmethod
def write(self, data: T) -> None:
"""
Write new data (replaces existing).
Args:
data: New data to write
"""
pass
@abstractmethod
def snapshot(self) -> T:
"""
Create an immutable snapshot of current data.
Must return a deep copy or immutable version to prevent
modifications from affecting the committed state.
Returns:
Immutable snapshot of data
"""
pass
@abstractmethod
def validate(self, data: T) -> bool:
"""
Validate that data is in correct format for this backend.
Args:
data: Data to validate
Returns:
True if valid
Raises:
ValueError: If invalid with explanation
"""
pass
class PydanticStoreBackend(StoreBackend[T]):
"""
Backend for Pydantic BaseModel stores.
Supports the existing OrderStore, ChartStore, etc. pattern.
"""
def __init__(self, model_instance: T):
"""
Initialize with a Pydantic model instance.
Args:
model_instance: Instance of a Pydantic BaseModel
"""
self._model = model_instance
def read(self) -> T:
return self._model
def write(self, data: T) -> None:
# Replace the internal model
self._model = data
def snapshot(self) -> T:
# Use Pydantic's model_copy for deep copy
if hasattr(self._model, "model_copy"):
return self._model.model_copy(deep=True)
# Fallback for older Pydantic or non-model types
return deepcopy(self._model)
def validate(self, data: T) -> bool:
# Pydantic models validate themselves on construction
# If we got here with a model instance, it's valid
return True
class FileStoreBackend(StoreBackend[str]):
"""
Backend for file-based storage.
Future implementation for versioning files (e.g., Python scripts, configs).
"""
def __init__(self, file_path: str):
self.file_path = file_path
raise NotImplementedError("FileStoreBackend not yet implemented")
def read(self) -> str:
raise NotImplementedError()
def write(self, data: str) -> None:
raise NotImplementedError()
def snapshot(self) -> str:
raise NotImplementedError()
def validate(self, data: str) -> bool:
raise NotImplementedError()
class DatabaseStoreBackend(StoreBackend[dict]):
"""
Backend for database table storage.
Future implementation for versioning database interactions.
"""
def __init__(self, table_name: str, connection):
self.table_name = table_name
self.connection = connection
raise NotImplementedError("DatabaseStoreBackend not yet implemented")
def read(self) -> dict:
raise NotImplementedError()
def write(self, data: dict) -> None:
raise NotImplementedError()
def snapshot(self) -> dict:
raise NotImplementedError()
def validate(self, data: dict) -> bool:
raise NotImplementedError()
class VersionedStore(Generic[T]):
"""
Store with optimistic concurrency control via sequence numbers.
Wraps any StoreBackend and provides:
- Lock-free snapshot reads
- Conflict detection on commit
- Version tracking for debugging
"""
def __init__(self, name: str, backend: StoreBackend[T]):
"""
Initialize versioned store.
Args:
name: Unique name for this store (e.g., "OrderStore")
backend: Backend implementation for storage
"""
self.name = name
self._backend = backend
self._committed_seq = 0 # Highest committed seq
self._version = 0 # Increments on each commit (for debugging)
@property
def committed_seq(self) -> int:
"""Get the current committed sequence number"""
return self._committed_seq
@property
def version(self) -> int:
"""Get the current version (increments on each commit)"""
return self._version
def read_snapshot(self) -> tuple[int, T]:
"""
Read an immutable snapshot of the store.
This is lock-free and can be called concurrently. The snapshot
captures the current committed seq and a deep copy of the data.
Automatically records the snapshot seq in the execution context
for conflict detection during commit.
Returns:
Tuple of (seq, snapshot_data)
"""
snapshot_seq = self._committed_seq
snapshot_data = self._backend.snapshot()
# Record in execution context for conflict detection
ctx = get_execution_context()
if ctx:
ctx.record_snapshot(self.name, snapshot_seq)
logger.debug(
f"Store '{self.name}': read_snapshot() -> seq={snapshot_seq}, version={self._version}"
)
return (snapshot_seq, snapshot_data)
def read_current(self) -> T:
"""
Read the current data without snapshot tracking.
Use this for read-only operations that don't need conflict detection.
Returns:
Current data (not a snapshot, modifications visible)
"""
return self._backend.read()
def prepare_commit(self, expected_seq: int, new_data: T) -> CommitIntent:
"""
Create a commit intent for later sequential commit.
Does NOT modify the store - that happens during the commit phase.
Args:
expected_seq: The seq of the snapshot that was read
new_data: The new data to commit
Returns:
CommitIntent to be submitted to CommitCoordinator
"""
# Validate data before creating intent
self._backend.validate(new_data)
intent = CommitIntent(
store_name=self.name,
expected_seq=expected_seq,
new_data=new_data,
)
logger.debug(
f"Store '{self.name}': prepare_commit(expected_seq={expected_seq}, current_seq={self._committed_seq})"
)
return intent
def commit(self, new_data: T, commit_seq: int) -> None:
"""
Commit new data at a specific seq.
Called by CommitCoordinator during sequential commit phase.
NOT for direct use by triggers.
Args:
new_data: Data to commit
commit_seq: Seq number of this commit
"""
self._backend.write(new_data)
self._committed_seq = commit_seq
self._version += 1
logger.info(
f"Store '{self.name}': committed seq={commit_seq}, version={self._version}"
)
def check_conflict(self, expected_seq: int) -> bool:
"""
Check if committing at expected_seq would conflict.
Args:
expected_seq: The seq that was expected during execution
Returns:
True if conflict (committed_seq has advanced beyond expected_seq)
"""
has_conflict = self._committed_seq != expected_seq
if has_conflict:
logger.warning(
f"Store '{self.name}': conflict detected - "
f"expected_seq={expected_seq}, committed_seq={self._committed_seq}"
)
return has_conflict
def __repr__(self) -> str:
return f"VersionedStore(name='{self.name}', committed_seq={self._committed_seq}, version={self._version})"

View File

@@ -0,0 +1,175 @@
"""
Core types for the trigger system.
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Any, Optional
logger = logging.getLogger(__name__)
class Priority(IntEnum):
"""
Primary execution priority for triggers.
Lower numeric value = higher priority (dequeued first).
Priority hierarchy (highest to lowest):
- DATA_SOURCE: Market data, real-time feeds (most time-sensitive)
- TIMER: Scheduled tasks, cron jobs
- USER_AGENT: User-agent interactions (WebSocket chat)
- USER_DATA_REQUEST: User data requests (chart loads, symbol search)
- SYSTEM: Background tasks, cleanup
- LOW: Retries after conflicts, non-critical tasks
"""
DATA_SOURCE = 0 # Market data updates, real-time feeds
TIMER = 1 # Scheduled tasks, cron jobs
USER_AGENT = 2 # User-agent interactions (WebSocket chat)
USER_DATA_REQUEST = 3 # User data requests (chart loads, etc.)
SYSTEM = 4 # Background tasks, cleanup, etc.
LOW = 5 # Retries after conflicts, non-critical tasks
# Type alias for priority tuples
# Examples:
# (Priority.DATA_SOURCE,) - Simple priority
# (Priority.DATA_SOURCE, event_time) - Priority + event time
# (Priority.DATA_SOURCE, event_time, queue_seq) - Full ordering
#
# Python compares tuples element-by-element, left-to-right.
# Shorter tuple wins if all shared elements are equal.
PriorityTuple = tuple[int, ...]
class ExecutionState(IntEnum):
"""State of an execution in the system"""
QUEUED = 0 # In queue, waiting to be dequeued
EXECUTING = 1 # Currently executing
WAITING_COMMIT = 2 # Finished executing, waiting for sequential commit
COMMITTED = 3 # Successfully committed
EVICTED = 4 # Evicted due to conflict, will retry
FAILED = 5 # Failed with error
@dataclass
class CommitIntent:
"""
Intent to commit changes to a store.
Created during execution, validated and applied during sequential commit phase.
"""
store_name: str
"""Name of the store to commit to"""
expected_seq: int
"""The seq number of the snapshot that was read (for conflict detection)"""
new_data: Any
"""The new data to commit (format depends on store backend)"""
def __repr__(self) -> str:
data_preview = str(self.new_data)[:50]
return f"CommitIntent(store={self.store_name}, expected_seq={self.expected_seq}, data={data_preview}...)"
class Trigger(ABC):
"""
Abstract base class for all triggers.
A trigger represents a unit of work that:
1. Gets assigned a seq number when dequeued
2. Executes (potentially long-running, async)
3. Returns CommitIntents for any state changes
4. Waits for sequential commit
"""
def __init__(
self,
name: str,
priority: Priority = Priority.SYSTEM,
priority_tuple: Optional[PriorityTuple] = None
):
"""
Initialize trigger.
Args:
name: Descriptive name for logging
priority: Simple priority (used if priority_tuple not provided)
priority_tuple: Optional tuple for compound sorting
Examples:
(Priority.DATA_SOURCE, event_time)
(Priority.USER_AGENT, message_timestamp)
(Priority.TIMER, scheduled_time)
"""
self.name = name
self.priority = priority
self._priority_tuple = priority_tuple
def get_priority_tuple(self, queue_seq: int) -> PriorityTuple:
"""
Get the priority tuple for queue ordering.
If a priority tuple was provided at construction, append queue_seq.
Otherwise, create tuple from simple priority.
Args:
queue_seq: Queue insertion order (final sort key)
Returns:
Priority tuple for queue ordering
Examples:
(Priority.DATA_SOURCE,) + (queue_seq,) = (0, queue_seq)
(Priority.DATA_SOURCE, 1000) + (queue_seq,) = (0, 1000, queue_seq)
"""
if self._priority_tuple is not None:
return self._priority_tuple + (queue_seq,)
else:
return (self.priority.value, queue_seq)
@abstractmethod
async def execute(self) -> list[CommitIntent]:
"""
Execute the trigger logic.
Can be long-running and async. Should read from stores via
VersionedStore.read_snapshot() and return CommitIntents for any changes.
Returns:
List of CommitIntents (empty if no state changes)
Raises:
Exception: On execution failure (will be logged, no commit)
"""
pass
def __repr__(self) -> str:
return f"{self.__class__.__name__}(name='{self.name}', priority={self.priority.name})"
@dataclass
class ExecutionRecord:
"""
Record of an execution for tracking and debugging.
Maintained by the CommitCoordinator to track in-flight executions.
"""
seq: int
trigger: Trigger
state: ExecutionState
commit_intents: Optional[list[CommitIntent]] = None
error: Optional[str] = None
retry_count: int = 0
def __repr__(self) -> str:
return (
f"ExecutionRecord(seq={self.seq}, trigger={self.trigger.name}, "
f"state={self.state.name}, retry={self.retry_count})"
)