backend redesign
This commit is contained in:
216
backend.old/src/trigger/PRIORITIES.md
Normal file
216
backend.old/src/trigger/PRIORITIES.md
Normal 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. 🎯
|
||||
386
backend.old/src/trigger/README.md
Normal file
386
backend.old/src/trigger/README.md
Normal 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
|
||||
35
backend.old/src/trigger/__init__.py
Normal file
35
backend.old/src/trigger/__init__.py
Normal 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",
|
||||
]
|
||||
61
backend.old/src/trigger/context.py
Normal file
61
backend.old/src/trigger/context.py
Normal 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)
|
||||
302
backend.old/src/trigger/coordinator.py
Normal file
302
backend.old/src/trigger/coordinator.py
Normal 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)})"
|
||||
)
|
||||
304
backend.old/src/trigger/handlers.py
Normal file
304
backend.old/src/trigger/handlers.py
Normal 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()
|
||||
224
backend.old/src/trigger/queue.py
Normal file
224
backend.old/src/trigger/queue.py
Normal 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()})"
|
||||
)
|
||||
187
backend.old/src/trigger/scheduler.py
Normal file
187
backend.old/src/trigger/scheduler.py
Normal 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})"
|
||||
301
backend.old/src/trigger/store.py
Normal file
301
backend.old/src/trigger/store.py
Normal 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})"
|
||||
175
backend.old/src/trigger/types.py
Normal file
175
backend.old/src/trigger/types.py
Normal 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})"
|
||||
)
|
||||
Reference in New Issue
Block a user