horsies 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- horsies/__init__.py +115 -0
- horsies/core/__init__.py +0 -0
- horsies/core/app.py +552 -0
- horsies/core/banner.py +144 -0
- horsies/core/brokers/__init__.py +5 -0
- horsies/core/brokers/listener.py +444 -0
- horsies/core/brokers/postgres.py +864 -0
- horsies/core/cli.py +624 -0
- horsies/core/codec/serde.py +575 -0
- horsies/core/errors.py +535 -0
- horsies/core/logging.py +90 -0
- horsies/core/models/__init__.py +0 -0
- horsies/core/models/app.py +268 -0
- horsies/core/models/broker.py +79 -0
- horsies/core/models/queues.py +23 -0
- horsies/core/models/recovery.py +101 -0
- horsies/core/models/schedule.py +229 -0
- horsies/core/models/task_pg.py +307 -0
- horsies/core/models/tasks.py +332 -0
- horsies/core/models/workflow.py +1988 -0
- horsies/core/models/workflow_pg.py +245 -0
- horsies/core/registry/tasks.py +101 -0
- horsies/core/scheduler/__init__.py +26 -0
- horsies/core/scheduler/calculator.py +267 -0
- horsies/core/scheduler/service.py +569 -0
- horsies/core/scheduler/state.py +260 -0
- horsies/core/task_decorator.py +615 -0
- horsies/core/types/status.py +38 -0
- horsies/core/utils/imports.py +203 -0
- horsies/core/utils/loop_runner.py +44 -0
- horsies/core/worker/current.py +17 -0
- horsies/core/worker/worker.py +1967 -0
- horsies/core/workflows/__init__.py +23 -0
- horsies/core/workflows/engine.py +2344 -0
- horsies/core/workflows/recovery.py +501 -0
- horsies/core/workflows/registry.py +97 -0
- horsies/py.typed +0 -0
- horsies-0.1.0a1.dist-info/METADATA +31 -0
- horsies-0.1.0a1.dist-info/RECORD +42 -0
- horsies-0.1.0a1.dist-info/WHEEL +5 -0
- horsies-0.1.0a1.dist-info/entry_points.txt +2 -0
- horsies-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# horsies/core/scheduler/state.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from sqlalchemy import select, text
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
7
|
+
from horsies.core.models.task_pg import ScheduleStateModel
|
|
8
|
+
from horsies.core.logging import get_logger
|
|
9
|
+
|
|
10
|
+
logger = get_logger('scheduler.state')
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScheduleStateManager:
|
|
14
|
+
"""
|
|
15
|
+
Manages schedule state persistence in PostgreSQL.
|
|
16
|
+
|
|
17
|
+
Provides atomic operations for tracking schedule execution state:
|
|
18
|
+
- Get current state for a schedule
|
|
19
|
+
- Update state after execution
|
|
20
|
+
- Initialize new schedules
|
|
21
|
+
|
|
22
|
+
All operations are async and use SQLAlchemy async sessions.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, session_factory: async_sessionmaker[AsyncSession]):
|
|
26
|
+
self.session_factory = session_factory
|
|
27
|
+
|
|
28
|
+
async def get_state(self, schedule_name: str) -> Optional[ScheduleStateModel]:
|
|
29
|
+
"""
|
|
30
|
+
Retrieve current state for a schedule.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
schedule_name: Unique schedule identifier
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
ScheduleStateModel if exists, None otherwise
|
|
37
|
+
"""
|
|
38
|
+
async with self.session_factory() as session:
|
|
39
|
+
result = await session.get(ScheduleStateModel, schedule_name)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
async def get_due_states(
|
|
43
|
+
self,
|
|
44
|
+
schedule_names: list[str],
|
|
45
|
+
now: datetime,
|
|
46
|
+
) -> list[ScheduleStateModel]:
|
|
47
|
+
"""
|
|
48
|
+
Retrieve states whose next_run_at is due for the provided schedule names.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
schedule_names: Filter to these schedule identifiers
|
|
52
|
+
now: Current time in UTC
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of due ScheduleStateModel records
|
|
56
|
+
"""
|
|
57
|
+
if not schedule_names:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
async with self.session_factory() as session:
|
|
61
|
+
stmt = (
|
|
62
|
+
select(ScheduleStateModel)
|
|
63
|
+
.where(ScheduleStateModel.schedule_name.in_(schedule_names))
|
|
64
|
+
.where(ScheduleStateModel.next_run_at <= now)
|
|
65
|
+
.order_by(ScheduleStateModel.next_run_at.asc())
|
|
66
|
+
)
|
|
67
|
+
result = await session.execute(stmt)
|
|
68
|
+
return list(result.scalars())
|
|
69
|
+
|
|
70
|
+
async def initialize_state(
|
|
71
|
+
self,
|
|
72
|
+
schedule_name: str,
|
|
73
|
+
next_run_at: datetime,
|
|
74
|
+
config_hash: Optional[str] = None,
|
|
75
|
+
) -> ScheduleStateModel:
|
|
76
|
+
"""
|
|
77
|
+
Initialize state for a new schedule.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
schedule_name: Unique schedule identifier
|
|
81
|
+
next_run_at: Calculated next run time
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Created ScheduleStateModel
|
|
85
|
+
"""
|
|
86
|
+
async with self.session_factory() as session:
|
|
87
|
+
# Check if already exists (race condition guard)
|
|
88
|
+
existing = await session.get(ScheduleStateModel, schedule_name)
|
|
89
|
+
if existing:
|
|
90
|
+
logger.debug(f"Schedule '{schedule_name}' already initialized")
|
|
91
|
+
return existing
|
|
92
|
+
|
|
93
|
+
# Create new state
|
|
94
|
+
state = ScheduleStateModel(
|
|
95
|
+
schedule_name=schedule_name,
|
|
96
|
+
last_run_at=None,
|
|
97
|
+
next_run_at=next_run_at,
|
|
98
|
+
last_task_id=None,
|
|
99
|
+
run_count=0,
|
|
100
|
+
config_hash=config_hash,
|
|
101
|
+
updated_at=datetime.now(timezone.utc),
|
|
102
|
+
)
|
|
103
|
+
session.add(state)
|
|
104
|
+
await session.commit()
|
|
105
|
+
await session.refresh(state)
|
|
106
|
+
logger.info(
|
|
107
|
+
f"Initialized schedule state for '{schedule_name}', next_run_at={next_run_at}"
|
|
108
|
+
)
|
|
109
|
+
return state
|
|
110
|
+
|
|
111
|
+
async def update_after_run(
|
|
112
|
+
self,
|
|
113
|
+
schedule_name: str,
|
|
114
|
+
task_id: str,
|
|
115
|
+
executed_at: datetime,
|
|
116
|
+
next_run_at: datetime,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Update schedule state after successful task enqueue.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
schedule_name: Unique schedule identifier
|
|
123
|
+
task_id: ID of the enqueued task
|
|
124
|
+
executed_at: When the schedule was executed (UTC)
|
|
125
|
+
next_run_at: Calculated next run time (UTC)
|
|
126
|
+
"""
|
|
127
|
+
async with self.session_factory() as session:
|
|
128
|
+
# Use raw SQL for atomic update with increment
|
|
129
|
+
result = await session.execute(
|
|
130
|
+
text("""
|
|
131
|
+
UPDATE horsies_schedule_state
|
|
132
|
+
SET last_run_at = :executed_at,
|
|
133
|
+
next_run_at = :next_run_at,
|
|
134
|
+
last_task_id = :task_id,
|
|
135
|
+
run_count = run_count + 1,
|
|
136
|
+
updated_at = :now
|
|
137
|
+
WHERE schedule_name = :schedule_name
|
|
138
|
+
"""),
|
|
139
|
+
{
|
|
140
|
+
'schedule_name': schedule_name,
|
|
141
|
+
'executed_at': executed_at,
|
|
142
|
+
'next_run_at': next_run_at,
|
|
143
|
+
'task_id': task_id,
|
|
144
|
+
'now': datetime.now(timezone.utc),
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
await session.commit()
|
|
148
|
+
|
|
149
|
+
rows_updated = getattr(result, 'rowcount', 0)
|
|
150
|
+
if rows_updated == 0:
|
|
151
|
+
logger.warning(
|
|
152
|
+
f"Failed to update schedule state for '{schedule_name}' - not found"
|
|
153
|
+
)
|
|
154
|
+
else:
|
|
155
|
+
logger.debug(
|
|
156
|
+
f"Updated schedule '{schedule_name}': "
|
|
157
|
+
f'last_run={executed_at}, next_run={next_run_at}, task_id={task_id}'
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
async def update_next_run(
|
|
161
|
+
self,
|
|
162
|
+
schedule_name: str,
|
|
163
|
+
next_run_at: datetime,
|
|
164
|
+
config_hash: Optional[str] = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Update next_run_at and optionally config_hash (used for rescheduling without execution).
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
schedule_name: Unique schedule identifier
|
|
171
|
+
next_run_at: New next run time (UTC)
|
|
172
|
+
config_hash: Optional new config hash
|
|
173
|
+
"""
|
|
174
|
+
async with self.session_factory() as session:
|
|
175
|
+
# Build UPDATE query dynamically based on whether config_hash is provided
|
|
176
|
+
if config_hash is not None:
|
|
177
|
+
query = """
|
|
178
|
+
UPDATE horsies_schedule_state
|
|
179
|
+
SET next_run_at = :next_run_at,
|
|
180
|
+
config_hash = :config_hash,
|
|
181
|
+
updated_at = :now
|
|
182
|
+
WHERE schedule_name = :schedule_name
|
|
183
|
+
"""
|
|
184
|
+
params = {
|
|
185
|
+
'schedule_name': schedule_name,
|
|
186
|
+
'next_run_at': next_run_at,
|
|
187
|
+
'config_hash': config_hash,
|
|
188
|
+
'now': datetime.now(timezone.utc),
|
|
189
|
+
}
|
|
190
|
+
else:
|
|
191
|
+
query = """
|
|
192
|
+
UPDATE horsies_schedule_state
|
|
193
|
+
SET next_run_at = :next_run_at,
|
|
194
|
+
updated_at = :now
|
|
195
|
+
WHERE schedule_name = :schedule_name
|
|
196
|
+
"""
|
|
197
|
+
params = {
|
|
198
|
+
'schedule_name': schedule_name,
|
|
199
|
+
'next_run_at': next_run_at,
|
|
200
|
+
'now': datetime.now(timezone.utc),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
result = await session.execute(text(query), params)
|
|
204
|
+
await session.commit()
|
|
205
|
+
|
|
206
|
+
rows_updated = getattr(result, 'rowcount', 0)
|
|
207
|
+
if rows_updated == 0:
|
|
208
|
+
logger.warning(
|
|
209
|
+
f"Failed to update next_run for '{schedule_name}' - not found"
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
logger.debug(f"Updated next_run for '{schedule_name}': {next_run_at}")
|
|
213
|
+
|
|
214
|
+
async def delete_state(self, schedule_name: str) -> bool:
|
|
215
|
+
"""
|
|
216
|
+
Delete schedule state (used when schedule is removed from config).
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
schedule_name: Unique schedule identifier
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
True if deleted, False if not found
|
|
223
|
+
"""
|
|
224
|
+
async with self.session_factory() as session:
|
|
225
|
+
result = await session.execute(
|
|
226
|
+
text('DELETE FROM horsies_schedule_state WHERE schedule_name = :schedule_name'),
|
|
227
|
+
{'schedule_name': schedule_name},
|
|
228
|
+
)
|
|
229
|
+
await session.commit()
|
|
230
|
+
|
|
231
|
+
rows_deleted = getattr(result, 'rowcount', 0)
|
|
232
|
+
if rows_deleted > 0:
|
|
233
|
+
logger.info(f"Deleted schedule state for '{schedule_name}'")
|
|
234
|
+
return True
|
|
235
|
+
else:
|
|
236
|
+
logger.debug(f"No state found to delete for '{schedule_name}'")
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
async def get_all_states(self) -> list[ScheduleStateModel]:
|
|
240
|
+
"""
|
|
241
|
+
Retrieve all schedule states (useful for monitoring/debugging).
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
List of all ScheduleStateModel records
|
|
245
|
+
"""
|
|
246
|
+
async with self.session_factory() as session:
|
|
247
|
+
result = await session.execute(
|
|
248
|
+
text('SELECT * FROM horsies_schedule_state ORDER BY schedule_name')
|
|
249
|
+
)
|
|
250
|
+
rows = result.fetchall()
|
|
251
|
+
columns = result.keys()
|
|
252
|
+
|
|
253
|
+
# Manually construct models from rows
|
|
254
|
+
states: list[ScheduleStateModel] = []
|
|
255
|
+
for row in rows:
|
|
256
|
+
row_dict = dict(zip(columns, row))
|
|
257
|
+
state = ScheduleStateModel(**row_dict)
|
|
258
|
+
states.append(state)
|
|
259
|
+
|
|
260
|
+
return states
|