horsies 0.1.0a4__py3-none-any.whl → 0.1.0a6__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/core/app.py +67 -47
- horsies/core/banner.py +158 -44
- horsies/core/brokers/postgres.py +315 -288
- horsies/core/cli.py +7 -2
- horsies/core/errors.py +3 -0
- horsies/core/models/app.py +87 -64
- horsies/core/models/recovery.py +30 -21
- horsies/core/models/schedule.py +30 -19
- horsies/core/models/tasks.py +1 -0
- horsies/core/models/workflow.py +489 -202
- horsies/core/models/workflow_pg.py +3 -1
- horsies/core/scheduler/service.py +5 -1
- horsies/core/scheduler/state.py +39 -27
- horsies/core/task_decorator.py +138 -0
- horsies/core/types/status.py +7 -5
- horsies/core/utils/imports.py +10 -10
- horsies/core/worker/worker.py +197 -139
- horsies/core/workflows/engine.py +487 -352
- horsies/core/workflows/recovery.py +148 -119
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/METADATA +1 -1
- horsies-0.1.0a6.dist-info/RECORD +42 -0
- horsies-0.1.0a4.dist-info/RECORD +0 -42
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/WHEEL +0 -0
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/entry_points.txt +0 -0
- {horsies-0.1.0a4.dist-info → horsies-0.1.0a6.dist-info}/top_level.txt +0 -0
horsies/core/cli.py
CHANGED
|
@@ -18,7 +18,12 @@ import sys
|
|
|
18
18
|
|
|
19
19
|
from horsies.core.app import Horsies
|
|
20
20
|
from horsies.core.banner import print_banner
|
|
21
|
-
from horsies.core.errors import
|
|
21
|
+
from horsies.core.errors import (
|
|
22
|
+
ConfigurationError,
|
|
23
|
+
ErrorCode,
|
|
24
|
+
HorsiesError,
|
|
25
|
+
ValidationReport,
|
|
26
|
+
)
|
|
22
27
|
from horsies.core.logging import get_logger
|
|
23
28
|
from horsies.core.scheduler import Scheduler
|
|
24
29
|
from horsies.core.worker.worker import Worker, WorkerConfig
|
|
@@ -156,7 +161,7 @@ def discover_app(module_locator: str) -> tuple[Horsies, str, str, str | None]:
|
|
|
156
161
|
if not isinstance(obj, Horsies):
|
|
157
162
|
raise TypeError(
|
|
158
163
|
f"'{attr_name}' in module '{module_name}' is not a Horsies instance "
|
|
159
|
-
f
|
|
164
|
+
f'(got {type(obj).__name__})'
|
|
160
165
|
)
|
|
161
166
|
app = obj
|
|
162
167
|
var_name = attr_name
|
horsies/core/errors.py
CHANGED
|
@@ -38,6 +38,9 @@ class ErrorCode(str, Enum):
|
|
|
38
38
|
WORKFLOW_INVALID_JOIN = 'E013'
|
|
39
39
|
WORKFLOW_UNRESOLVED_QUEUE = 'E014'
|
|
40
40
|
WORKFLOW_UNRESOLVED_PRIORITY = 'E015'
|
|
41
|
+
WORKFLOW_ARGS_WITH_INJECTION = 'E016'
|
|
42
|
+
WORKFLOW_INVALID_KWARG_KEY = 'E019'
|
|
43
|
+
WORKFLOW_MISSING_REQUIRED_PARAMS = 'E020'
|
|
41
44
|
WORKFLOW_INVALID_SUBWORKFLOW_RETRY_MODE = 'E017'
|
|
42
45
|
WORKFLOW_SUBWORKFLOW_APP_MISSING = 'E018'
|
|
43
46
|
|
horsies/core/models/app.py
CHANGED
|
@@ -5,7 +5,12 @@ from horsies.core.models.queues import QueueMode, CustomQueueConfig
|
|
|
5
5
|
from horsies.core.models.broker import PostgresConfig
|
|
6
6
|
from horsies.core.models.recovery import RecoveryConfig
|
|
7
7
|
from horsies.core.models.schedule import ScheduleConfig
|
|
8
|
-
from horsies.core.errors import
|
|
8
|
+
from horsies.core.errors import (
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
ErrorCode,
|
|
11
|
+
ValidationReport,
|
|
12
|
+
raise_collected,
|
|
13
|
+
)
|
|
9
14
|
from urllib.parse import urlparse, urlunparse
|
|
10
15
|
import logging
|
|
11
16
|
import os
|
|
@@ -37,91 +42,109 @@ class AppConfig(BaseModel):
|
|
|
37
42
|
|
|
38
43
|
if self.queue_mode == QueueMode.DEFAULT:
|
|
39
44
|
if self.custom_queues is not None:
|
|
40
|
-
report.add(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
report.add(
|
|
46
|
+
ConfigurationError(
|
|
47
|
+
message='custom_queues must be None in DEFAULT mode',
|
|
48
|
+
code=ErrorCode.CONFIG_INVALID_QUEUE_MODE,
|
|
49
|
+
notes=['queue_mode=DEFAULT but custom_queues was provided'],
|
|
50
|
+
help_text='either remove custom_queues or set queue_mode=CUSTOM',
|
|
51
|
+
)
|
|
52
|
+
)
|
|
46
53
|
elif self.queue_mode == QueueMode.CUSTOM:
|
|
47
54
|
if self.custom_queues is None or len(self.custom_queues) == 0:
|
|
48
|
-
report.add(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
report.add(
|
|
56
|
+
ConfigurationError(
|
|
57
|
+
message='custom_queues required in CUSTOM mode',
|
|
58
|
+
code=ErrorCode.CONFIG_INVALID_QUEUE_MODE,
|
|
59
|
+
notes=['queue_mode=CUSTOM but custom_queues is empty or None'],
|
|
60
|
+
help_text='provide at least one CustomQueueConfig in custom_queues',
|
|
61
|
+
)
|
|
62
|
+
)
|
|
54
63
|
else:
|
|
55
64
|
# Validate unique queue names (only if queues exist)
|
|
56
65
|
queue_names = [q.name for q in self.custom_queues]
|
|
57
66
|
if len(queue_names) != len(set(queue_names)):
|
|
58
|
-
report.add(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
report.add(
|
|
68
|
+
ConfigurationError(
|
|
69
|
+
message='duplicate queue names in custom_queues',
|
|
70
|
+
code=ErrorCode.CONFIG_INVALID_QUEUE_MODE,
|
|
71
|
+
notes=[f'queue names: {queue_names}'],
|
|
72
|
+
help_text='each queue name must be unique',
|
|
73
|
+
)
|
|
74
|
+
)
|
|
64
75
|
|
|
65
76
|
# Validate cluster_wide_cap if provided
|
|
66
77
|
if self.cluster_wide_cap is not None and self.cluster_wide_cap <= 0:
|
|
67
|
-
report.add(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
report.add(
|
|
79
|
+
ConfigurationError(
|
|
80
|
+
message='cluster_wide_cap must be positive',
|
|
81
|
+
code=ErrorCode.CONFIG_INVALID_CLUSTER_CAP,
|
|
82
|
+
notes=[f'got cluster_wide_cap={self.cluster_wide_cap}'],
|
|
83
|
+
help_text='use a positive integer or None for unlimited',
|
|
84
|
+
)
|
|
85
|
+
)
|
|
73
86
|
|
|
74
87
|
# Validate prefetch_buffer
|
|
75
88
|
if self.prefetch_buffer < 0:
|
|
76
|
-
report.add(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
report.add(
|
|
90
|
+
ConfigurationError(
|
|
91
|
+
message='prefetch_buffer must be non-negative',
|
|
92
|
+
code=ErrorCode.CONFIG_INVALID_PREFETCH,
|
|
93
|
+
notes=[f'got prefetch_buffer={self.prefetch_buffer}'],
|
|
94
|
+
help_text='use 0 for hard cap mode or positive integer for soft cap',
|
|
95
|
+
)
|
|
96
|
+
)
|
|
82
97
|
|
|
83
98
|
# Validate claim_lease_ms
|
|
84
99
|
if self.prefetch_buffer > 0 and self.claim_lease_ms is None:
|
|
85
|
-
report.add(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
report.add(
|
|
101
|
+
ConfigurationError(
|
|
102
|
+
message='claim_lease_ms required when prefetch_buffer > 0',
|
|
103
|
+
code=ErrorCode.CONFIG_INVALID_PREFETCH,
|
|
104
|
+
notes=[
|
|
105
|
+
f'prefetch_buffer={self.prefetch_buffer} but claim_lease_ms is None',
|
|
106
|
+
],
|
|
107
|
+
help_text='set claim_lease_ms (e.g., 30000 for 30 seconds)',
|
|
108
|
+
)
|
|
109
|
+
)
|
|
93
110
|
if self.claim_lease_ms is not None and self.claim_lease_ms <= 0:
|
|
94
|
-
report.add(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
report.add(
|
|
112
|
+
ConfigurationError(
|
|
113
|
+
message='claim_lease_ms must be positive',
|
|
114
|
+
code=ErrorCode.CONFIG_INVALID_PREFETCH,
|
|
115
|
+
notes=[f'got claim_lease_ms={self.claim_lease_ms}'],
|
|
116
|
+
help_text='use a positive integer in milliseconds',
|
|
117
|
+
)
|
|
118
|
+
)
|
|
100
119
|
|
|
101
120
|
# Forbid claim_lease_ms in hard cap mode
|
|
102
121
|
if self.prefetch_buffer == 0 and self.claim_lease_ms is not None:
|
|
103
|
-
report.add(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
report.add(
|
|
123
|
+
ConfigurationError(
|
|
124
|
+
message='claim_lease_ms incompatible with hard cap mode',
|
|
125
|
+
code=ErrorCode.CONFIG_INVALID_PREFETCH,
|
|
126
|
+
notes=[
|
|
127
|
+
'prefetch_buffer=0 (hard cap mode)',
|
|
128
|
+
f'but claim_lease_ms={self.claim_lease_ms} was set',
|
|
129
|
+
],
|
|
130
|
+
help_text='remove claim_lease_ms or set prefetch_buffer > 0',
|
|
131
|
+
)
|
|
132
|
+
)
|
|
112
133
|
|
|
113
134
|
# Validate prefetch_buffer vs cluster_wide_cap conflict
|
|
114
135
|
if self.cluster_wide_cap is not None and self.prefetch_buffer > 0:
|
|
115
|
-
report.add(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
136
|
+
report.add(
|
|
137
|
+
ConfigurationError(
|
|
138
|
+
message='cluster_wide_cap incompatible with prefetch mode',
|
|
139
|
+
code=ErrorCode.CONFIG_INVALID_CLUSTER_CAP,
|
|
140
|
+
notes=[
|
|
141
|
+
f'cluster_wide_cap={self.cluster_wide_cap}',
|
|
142
|
+
f'prefetch_buffer={self.prefetch_buffer}',
|
|
143
|
+
'cluster_wide_cap requires hard cap mode (prefetch_buffer=0)',
|
|
144
|
+
],
|
|
145
|
+
help_text='set prefetch_buffer=0 when using cluster_wide_cap',
|
|
146
|
+
)
|
|
147
|
+
)
|
|
125
148
|
|
|
126
149
|
raise_collected(report)
|
|
127
150
|
return self
|
horsies/core/models/recovery.py
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
from typing import Annotated, Self
|
|
4
4
|
from pydantic import BaseModel, Field, model_validator
|
|
5
|
-
from horsies.core.errors import
|
|
5
|
+
from horsies.core.errors import (
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
ErrorCode,
|
|
8
|
+
ValidationReport,
|
|
9
|
+
raise_collected,
|
|
10
|
+
)
|
|
6
11
|
|
|
7
12
|
|
|
8
13
|
class RecoveryConfig(BaseModel):
|
|
@@ -73,29 +78,33 @@ class RecoveryConfig(BaseModel):
|
|
|
73
78
|
|
|
74
79
|
# Validate runner heartbeat vs running stale threshold
|
|
75
80
|
if self.running_stale_threshold_ms < min_running:
|
|
76
|
-
report.add(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
report.add(
|
|
82
|
+
ConfigurationError(
|
|
83
|
+
message='running_stale_threshold_ms too low',
|
|
84
|
+
code=ErrorCode.CONFIG_INVALID_RECOVERY,
|
|
85
|
+
notes=[
|
|
86
|
+
f'running_stale_threshold_ms={self.running_stale_threshold_ms}ms ({self.running_stale_threshold_ms/1000:.1f}s)',
|
|
87
|
+
f'runner_heartbeat_interval_ms={self.runner_heartbeat_interval_ms}ms ({self.runner_heartbeat_interval_ms/1000:.1f}s)',
|
|
88
|
+
'threshold must be at least 2x heartbeat interval',
|
|
89
|
+
],
|
|
90
|
+
help_text=f'set running_stale_threshold_ms >= {min_running}ms ({min_running/1000:.1f}s)',
|
|
91
|
+
)
|
|
92
|
+
)
|
|
86
93
|
|
|
87
94
|
# Validate claimer heartbeat vs claimed stale threshold
|
|
88
95
|
if self.claimed_stale_threshold_ms < min_claimed:
|
|
89
|
-
report.add(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
report.add(
|
|
97
|
+
ConfigurationError(
|
|
98
|
+
message='claimed_stale_threshold_ms too low',
|
|
99
|
+
code=ErrorCode.CONFIG_INVALID_RECOVERY,
|
|
100
|
+
notes=[
|
|
101
|
+
f'claimed_stale_threshold_ms={self.claimed_stale_threshold_ms}ms ({self.claimed_stale_threshold_ms/1000:.1f}s)',
|
|
102
|
+
f'claimer_heartbeat_interval_ms={self.claimer_heartbeat_interval_ms}ms ({self.claimer_heartbeat_interval_ms/1000:.1f}s)',
|
|
103
|
+
'threshold must be at least 2x heartbeat interval',
|
|
104
|
+
],
|
|
105
|
+
help_text=f'set claimed_stale_threshold_ms >= {min_claimed}ms ({min_claimed/1000:.1f}s)',
|
|
106
|
+
)
|
|
107
|
+
)
|
|
99
108
|
|
|
100
109
|
raise_collected(report)
|
|
101
110
|
return self
|
horsies/core/models/schedule.py
CHANGED
|
@@ -5,7 +5,12 @@ from typing import Literal, Union, Optional, Any
|
|
|
5
5
|
from pydantic import BaseModel, Field, model_validator
|
|
6
6
|
from typing_extensions import Self
|
|
7
7
|
from enum import Enum
|
|
8
|
-
from horsies.core.errors import
|
|
8
|
+
from horsies.core.errors import (
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
ErrorCode,
|
|
11
|
+
ValidationReport,
|
|
12
|
+
raise_collected,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
class Weekday(str, Enum):
|
|
@@ -54,12 +59,14 @@ class IntervalSchedule(BaseModel):
|
|
|
54
59
|
"""Ensure at least one time unit is specified."""
|
|
55
60
|
report = ValidationReport('schedule')
|
|
56
61
|
if not any([self.seconds, self.minutes, self.hours, self.days]):
|
|
57
|
-
report.add(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
report.add(
|
|
63
|
+
ConfigurationError(
|
|
64
|
+
message='IntervalSchedule requires at least one time unit',
|
|
65
|
+
code=ErrorCode.CONFIG_INVALID_SCHEDULE,
|
|
66
|
+
notes=['all time units (seconds, minutes, hours, days) are None'],
|
|
67
|
+
help_text='specify at least one: seconds, minutes, hours, or days',
|
|
68
|
+
)
|
|
69
|
+
)
|
|
63
70
|
raise_collected(report)
|
|
64
71
|
return self
|
|
65
72
|
|
|
@@ -126,12 +133,14 @@ class WeeklySchedule(BaseModel):
|
|
|
126
133
|
"""Ensure no duplicate days."""
|
|
127
134
|
report = ValidationReport('schedule')
|
|
128
135
|
if len(self.days) != len(set(self.days)):
|
|
129
|
-
report.add(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
report.add(
|
|
137
|
+
ConfigurationError(
|
|
138
|
+
message='WeeklySchedule has duplicate days',
|
|
139
|
+
code=ErrorCode.CONFIG_INVALID_SCHEDULE,
|
|
140
|
+
notes=[f'days: {[d.value for d in self.days]}'],
|
|
141
|
+
help_text='each day should appear only once in the list',
|
|
142
|
+
)
|
|
143
|
+
)
|
|
135
144
|
raise_collected(report)
|
|
136
145
|
return self
|
|
137
146
|
|
|
@@ -219,11 +228,13 @@ class ScheduleConfig(BaseModel):
|
|
|
219
228
|
report = ValidationReport('schedule')
|
|
220
229
|
names: list[str] = [s.name for s in self.schedules]
|
|
221
230
|
if len(names) != len(set(names)):
|
|
222
|
-
report.add(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
231
|
+
report.add(
|
|
232
|
+
ConfigurationError(
|
|
233
|
+
message='duplicate schedule names',
|
|
234
|
+
code=ErrorCode.CONFIG_INVALID_SCHEDULE,
|
|
235
|
+
notes=[f'schedule names: {names}'],
|
|
236
|
+
help_text='each schedule must have a unique name',
|
|
237
|
+
)
|
|
238
|
+
)
|
|
228
239
|
raise_collected(report)
|
|
229
240
|
return self
|
horsies/core/models/tasks.py
CHANGED