horsies 0.1.0a4__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.
Files changed (42) hide show
  1. horsies/__init__.py +117 -0
  2. horsies/core/__init__.py +0 -0
  3. horsies/core/app.py +552 -0
  4. horsies/core/banner.py +144 -0
  5. horsies/core/brokers/__init__.py +5 -0
  6. horsies/core/brokers/listener.py +444 -0
  7. horsies/core/brokers/postgres.py +993 -0
  8. horsies/core/cli.py +624 -0
  9. horsies/core/codec/serde.py +596 -0
  10. horsies/core/errors.py +535 -0
  11. horsies/core/logging.py +90 -0
  12. horsies/core/models/__init__.py +0 -0
  13. horsies/core/models/app.py +268 -0
  14. horsies/core/models/broker.py +79 -0
  15. horsies/core/models/queues.py +23 -0
  16. horsies/core/models/recovery.py +101 -0
  17. horsies/core/models/schedule.py +229 -0
  18. horsies/core/models/task_pg.py +307 -0
  19. horsies/core/models/tasks.py +358 -0
  20. horsies/core/models/workflow.py +1990 -0
  21. horsies/core/models/workflow_pg.py +245 -0
  22. horsies/core/registry/tasks.py +101 -0
  23. horsies/core/scheduler/__init__.py +26 -0
  24. horsies/core/scheduler/calculator.py +267 -0
  25. horsies/core/scheduler/service.py +569 -0
  26. horsies/core/scheduler/state.py +260 -0
  27. horsies/core/task_decorator.py +656 -0
  28. horsies/core/types/status.py +38 -0
  29. horsies/core/utils/imports.py +203 -0
  30. horsies/core/utils/loop_runner.py +44 -0
  31. horsies/core/worker/current.py +17 -0
  32. horsies/core/worker/worker.py +1967 -0
  33. horsies/core/workflows/__init__.py +23 -0
  34. horsies/core/workflows/engine.py +2344 -0
  35. horsies/core/workflows/recovery.py +501 -0
  36. horsies/core/workflows/registry.py +97 -0
  37. horsies/py.typed +0 -0
  38. horsies-0.1.0a4.dist-info/METADATA +35 -0
  39. horsies-0.1.0a4.dist-info/RECORD +42 -0
  40. horsies-0.1.0a4.dist-info/WHEEL +5 -0
  41. horsies-0.1.0a4.dist-info/entry_points.txt +2 -0
  42. horsies-0.1.0a4.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