reflex-workflow 0.0.1a1__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.
- reflex_workflow/__init__.py +59 -0
- reflex_workflow/cron.py +209 -0
- reflex_workflow/dataclass.py +134 -0
- reflex_workflow/engine/__init__.py +13 -0
- reflex_workflow/engine/claim.py +646 -0
- reflex_workflow/engine/execute.py +907 -0
- reflex_workflow/engine/handles.py +433 -0
- reflex_workflow/engine/notify.py +180 -0
- reflex_workflow/engine/rows.py +216 -0
- reflex_workflow/engine/runner.py +599 -0
- reflex_workflow/engine/runtime.py +122 -0
- reflex_workflow/model.py +934 -0
- reflex_workflow/otel.py +193 -0
- reflex_workflow/py.typed +0 -0
- reflex_workflow-0.0.1a1.dist-info/METADATA +631 -0
- reflex_workflow-0.0.1a1.dist-info/RECORD +17 -0
- reflex_workflow-0.0.1a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Durable workflows as SQLAlchemy tables on Postgres."""
|
|
2
|
+
|
|
3
|
+
from reflex_workflow.cron import Cron
|
|
4
|
+
from reflex_workflow.engine import (
|
|
5
|
+
OnIdle,
|
|
6
|
+
RunHandle,
|
|
7
|
+
connect_workflows,
|
|
8
|
+
run_workflows,
|
|
9
|
+
wake,
|
|
10
|
+
)
|
|
11
|
+
from reflex_workflow.model import (
|
|
12
|
+
DEFAULT_LANE,
|
|
13
|
+
AttemptLog,
|
|
14
|
+
Call,
|
|
15
|
+
Child,
|
|
16
|
+
Every,
|
|
17
|
+
FanOut,
|
|
18
|
+
Limit,
|
|
19
|
+
RateBucket,
|
|
20
|
+
Schedule,
|
|
21
|
+
Step,
|
|
22
|
+
Wait,
|
|
23
|
+
WakeIn,
|
|
24
|
+
Workflow,
|
|
25
|
+
child,
|
|
26
|
+
every,
|
|
27
|
+
fan_out,
|
|
28
|
+
step,
|
|
29
|
+
wait_for,
|
|
30
|
+
wake_in,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_LANE",
|
|
35
|
+
"AttemptLog",
|
|
36
|
+
"Call",
|
|
37
|
+
"Child",
|
|
38
|
+
"Cron",
|
|
39
|
+
"Every",
|
|
40
|
+
"FanOut",
|
|
41
|
+
"Limit",
|
|
42
|
+
"OnIdle",
|
|
43
|
+
"RateBucket",
|
|
44
|
+
"RunHandle",
|
|
45
|
+
"Schedule",
|
|
46
|
+
"Step",
|
|
47
|
+
"Wait",
|
|
48
|
+
"WakeIn",
|
|
49
|
+
"Workflow",
|
|
50
|
+
"child",
|
|
51
|
+
"connect_workflows",
|
|
52
|
+
"every",
|
|
53
|
+
"fan_out",
|
|
54
|
+
"run_workflows",
|
|
55
|
+
"step",
|
|
56
|
+
"wait_for",
|
|
57
|
+
"wake",
|
|
58
|
+
"wake_in",
|
|
59
|
+
]
|
reflex_workflow/cron.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Standard five-field cron expressions and the times they fire."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import zoneinfo
|
|
7
|
+
|
|
8
|
+
# Each field's bounds, in the order the fields are written.
|
|
9
|
+
BOUNDS = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
|
|
10
|
+
|
|
11
|
+
# fmt: off
|
|
12
|
+
MONTHS = ["jan", "feb", "mar", "apr", "may", "jun",
|
|
13
|
+
"jul", "aug", "sep", "oct", "nov", "dec"]
|
|
14
|
+
DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
|
|
15
|
+
# fmt: on
|
|
16
|
+
|
|
17
|
+
# Days to look ahead before calling an expression unsatisfiable; the sparsest
|
|
18
|
+
# standard expression, February 29th, comes round at most every eight years.
|
|
19
|
+
SEARCH_DAYS = 366 * 9
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_name(value: str, field: int) -> str:
|
|
23
|
+
"""Turn a month or weekday name into its number.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
value: One term of a field, which may be a name.
|
|
27
|
+
field: Which field it belongs to, counting from the minute.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The value with any name replaced by its number.
|
|
31
|
+
"""
|
|
32
|
+
names = MONTHS if field == 3 else DAYS if field == 4 else ()
|
|
33
|
+
lowered = value.lower()
|
|
34
|
+
return (
|
|
35
|
+
str(names.index(lowered) + (1 if field == 3 else 0))
|
|
36
|
+
if lowered in names
|
|
37
|
+
else value
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_field(field: str, index: int) -> frozenset[int]:
|
|
42
|
+
"""Turn one cron field into the set of values it matches.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
field: The field, e.g. ``*/5`` or ``MON-FRI``.
|
|
46
|
+
index: Which field it is, counting from the minute.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The values it matches.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If the field is not a valid cron field.
|
|
53
|
+
"""
|
|
54
|
+
low, high = BOUNDS[index]
|
|
55
|
+
matched: set[int] = set()
|
|
56
|
+
for term in field.split(","):
|
|
57
|
+
part, slash, step_text = term.partition("/")
|
|
58
|
+
if slash and not step_text:
|
|
59
|
+
msg = f"{term!r} has a slash but no step."
|
|
60
|
+
raise ValueError(msg)
|
|
61
|
+
try:
|
|
62
|
+
step = int(step_text) if step_text else 1
|
|
63
|
+
except ValueError:
|
|
64
|
+
msg = f"{term!r} has a step that is not a number."
|
|
65
|
+
raise ValueError(msg) from None
|
|
66
|
+
if step < 1:
|
|
67
|
+
msg = f"{term!r} has a step below one."
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
if part == "*":
|
|
70
|
+
first, last = low, high
|
|
71
|
+
else:
|
|
72
|
+
start, dash, end = part.partition("-")
|
|
73
|
+
try:
|
|
74
|
+
first = int(parse_name(start, index))
|
|
75
|
+
last = (
|
|
76
|
+
int(parse_name(end, index))
|
|
77
|
+
if dash
|
|
78
|
+
else (high if step_text else first)
|
|
79
|
+
)
|
|
80
|
+
# A range that ends on Sunday by name, like FRI-SUN, ends on 7;
|
|
81
|
+
# one that starts there too, SUN-SUN, is Sunday alone.
|
|
82
|
+
if index == 4 and dash and end.lower() == "sun" and first:
|
|
83
|
+
last = 7
|
|
84
|
+
except ValueError:
|
|
85
|
+
msg = f"{term!r} is not a number, a name, or a range."
|
|
86
|
+
raise ValueError(msg) from None
|
|
87
|
+
if not (low <= first <= high and low <= last <= high):
|
|
88
|
+
msg = f"{term!r} is outside {low}-{high}."
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
if first > last:
|
|
91
|
+
msg = f"{term!r} counts down; write it as two terms."
|
|
92
|
+
raise ValueError(msg)
|
|
93
|
+
values = range(first, last + 1, step)
|
|
94
|
+
# Sunday is written as either 0 or 7, so a range like FRI-SUN stays
|
|
95
|
+
# ascending and folds only once it is expanded.
|
|
96
|
+
if index == 4:
|
|
97
|
+
matched.update(value % 7 for value in values)
|
|
98
|
+
else:
|
|
99
|
+
matched.update(values)
|
|
100
|
+
return frozenset(matched)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Cron:
|
|
104
|
+
"""A cron expression, and the times it fires in one timezone.
|
|
105
|
+
|
|
106
|
+
Supports the five standard fields -- minute, hour, day of month, month, day of
|
|
107
|
+
week -- with ``*``, lists, ranges, steps, and month and weekday names. Calling
|
|
108
|
+
it returns the next time it fires after a given moment, so it can be passed to
|
|
109
|
+
``every`` wherever a schedule is wanted.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self, expression: str, tz: str | datetime.tzinfo = datetime.UTC
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Parse an expression, rejecting one that is malformed or never fires.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
expression: A five-field cron expression, e.g. ``0 9 * * MON-FRI``.
|
|
119
|
+
tz: The timezone its times are written in.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ValueError: If the expression is malformed or can never fire.
|
|
123
|
+
"""
|
|
124
|
+
fields = expression.split()
|
|
125
|
+
if len(fields) != 5:
|
|
126
|
+
msg = (
|
|
127
|
+
f"{expression!r} must have five fields: minute, hour, day of month, "
|
|
128
|
+
"month, and day of week."
|
|
129
|
+
)
|
|
130
|
+
raise ValueError(msg)
|
|
131
|
+
self.expression = expression
|
|
132
|
+
self.tz = zoneinfo.ZoneInfo(tz) if isinstance(tz, str) else tz
|
|
133
|
+
self.minutes, self.hours, self.days, self.months, self.weekdays = tuple(
|
|
134
|
+
parse_field(field, index) for index, field in enumerate(fields)
|
|
135
|
+
)
|
|
136
|
+
# Vixie cron matches both day fields when either is written as a star,
|
|
137
|
+
# and either field when both name particular days.
|
|
138
|
+
self.both_days = fields[2].startswith("*") or fields[4].startswith("*")
|
|
139
|
+
try:
|
|
140
|
+
self(datetime.datetime.now(datetime.UTC))
|
|
141
|
+
except ValueError:
|
|
142
|
+
msg = f"{expression!r} never fires."
|
|
143
|
+
raise ValueError(msg) from None
|
|
144
|
+
|
|
145
|
+
def __repr__(self) -> str:
|
|
146
|
+
"""Name the expression in reprs and error messages.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
The expression's repr.
|
|
150
|
+
"""
|
|
151
|
+
return f"Cron({self.expression!r}, {self.tz!s})"
|
|
152
|
+
|
|
153
|
+
def matches_day(self, day: datetime.date) -> bool:
|
|
154
|
+
"""Report whether the expression fires on a date.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
day: The date.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Whether the day's fields match.
|
|
161
|
+
"""
|
|
162
|
+
if day.month not in self.months:
|
|
163
|
+
return False
|
|
164
|
+
# isoweekday is Monday 1 through Sunday 7; cron counts Sunday as 0.
|
|
165
|
+
by_day, by_weekday = day.day in self.days, day.isoweekday() % 7 in self.weekdays
|
|
166
|
+
return (by_day and by_weekday) if self.both_days else (by_day or by_weekday)
|
|
167
|
+
|
|
168
|
+
def __call__(self, after: datetime.datetime) -> datetime.datetime:
|
|
169
|
+
"""Return the first time the expression fires after a moment.
|
|
170
|
+
|
|
171
|
+
Times are matched on the clock in this expression's timezone. A time that
|
|
172
|
+
the clock skips over in spring fires at the equivalent moment after the
|
|
173
|
+
jump, and one the clock repeats in autumn fires on its first pass only, so
|
|
174
|
+
a schedule fires once either way.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
after: The moment to search from.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The next time it fires, always later than ``after``.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: If ``after`` has no timezone, or the expression does not
|
|
184
|
+
fire within the next nine years.
|
|
185
|
+
"""
|
|
186
|
+
if after.tzinfo is None:
|
|
187
|
+
msg = "Cron needs a timezone-aware moment to search from."
|
|
188
|
+
raise ValueError(msg)
|
|
189
|
+
local = after.astimezone(self.tz)
|
|
190
|
+
cursor = local.replace(second=0, microsecond=0) + datetime.timedelta(minutes=1)
|
|
191
|
+
hours, minutes = sorted(self.hours), sorted(self.minutes)
|
|
192
|
+
for _ in range(SEARCH_DAYS):
|
|
193
|
+
if self.matches_day(cursor.date()):
|
|
194
|
+
for hour in hours:
|
|
195
|
+
if hour < cursor.hour:
|
|
196
|
+
continue
|
|
197
|
+
for minute in minutes:
|
|
198
|
+
if hour == cursor.hour and minute < cursor.minute:
|
|
199
|
+
continue
|
|
200
|
+
fires = cursor.replace(hour=hour, minute=minute).astimezone(
|
|
201
|
+
datetime.UTC
|
|
202
|
+
)
|
|
203
|
+
# A repeated hour can put a later clock time at an earlier
|
|
204
|
+
# moment; those have already fired.
|
|
205
|
+
if fires > after:
|
|
206
|
+
return fires
|
|
207
|
+
cursor = (cursor + datetime.timedelta(days=1)).replace(hour=0, minute=0)
|
|
208
|
+
msg = f"{self.expression!r} does not fire within {SEARCH_DAYS // 366} years."
|
|
209
|
+
raise ValueError(msg)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The same mixins, for applications whose models are dataclasses.
|
|
2
|
+
|
|
3
|
+
An application that maps its tables on a ``MappedAsDataclass`` base cannot mix
|
|
4
|
+
in a class that is not one itself: SQLAlchemy warns about it in 2.0 and refuses
|
|
5
|
+
it in 2.1. The mixins here are that same class declared as a dataclass, so::
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass
|
|
8
|
+
from reflex_workflow.dataclass import Workflow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Base(MappedAsDataclass, DeclarativeBase, kw_only=True):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Onboarding(Base, Workflow):
|
|
16
|
+
__tablename__ = "onboarding"
|
|
17
|
+
|
|
18
|
+
id: Mapped[int] = mapped_column(primary_key=True, init=False)
|
|
19
|
+
email: Mapped[str]
|
|
20
|
+
|
|
21
|
+
Everything else is unchanged: the same columns, the same steps, the same
|
|
22
|
+
engine. The columns the engine owns are ``init=False``, so they stay out of the
|
|
23
|
+
constructor and ``Onboarding(email=...)`` reads as it did before the mixin was
|
|
24
|
+
added.
|
|
25
|
+
|
|
26
|
+
Which import to take is decided by the base, not by preference. On a plain
|
|
27
|
+
``DeclarativeBase`` these would make the model a dataclass, which is a change
|
|
28
|
+
the application did not ask for, so that base takes ``reflex_workflow`` itself.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import datetime
|
|
34
|
+
from typing import Any, ClassVar
|
|
35
|
+
|
|
36
|
+
from sqlalchemy import DateTime, Float, Index, Integer, String, Text
|
|
37
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
38
|
+
from sqlalchemy.orm import Mapped, MappedAsDataclass, declared_attr, mapped_column
|
|
39
|
+
|
|
40
|
+
from reflex_workflow import model
|
|
41
|
+
from reflex_workflow.model import ZERO
|
|
42
|
+
|
|
43
|
+
__all__ = ["AttemptLog", "RateBucket", "Workflow"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Workflow(MappedAsDataclass, model.Workflow):
|
|
47
|
+
"""``reflex_workflow.Workflow``, for a model that is a dataclass.
|
|
48
|
+
|
|
49
|
+
Each row is one run. The engine's own columns are ``init=False``: they are
|
|
50
|
+
the engine's to set, and a run is started with ``start`` rather than by
|
|
51
|
+
passing them.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
next_step: Mapped[str | None] = mapped_column(
|
|
55
|
+
String(128), default=None, index=True, init=False
|
|
56
|
+
)
|
|
57
|
+
next_args: Mapped[dict[str, Any] | None] = mapped_column(
|
|
58
|
+
JSONB(none_as_null=True), default=None, init=False
|
|
59
|
+
)
|
|
60
|
+
wake_at: Mapped[datetime.datetime | None] = mapped_column(
|
|
61
|
+
DateTime(timezone=True), default=None, index=True, init=False
|
|
62
|
+
)
|
|
63
|
+
attempts: Mapped[int] = mapped_column(
|
|
64
|
+
Integer, default=0, server_default=ZERO, init=False
|
|
65
|
+
)
|
|
66
|
+
last_error: Mapped[str | None] = mapped_column(Text, default=None, init=False)
|
|
67
|
+
claimed_until: Mapped[datetime.datetime | None] = mapped_column(
|
|
68
|
+
DateTime(timezone=True), default=None, init=False
|
|
69
|
+
)
|
|
70
|
+
waiting_for: Mapped[str | None] = mapped_column(
|
|
71
|
+
String(128), default=None, index=True, init=False
|
|
72
|
+
)
|
|
73
|
+
parent: Mapped[dict[str, Any] | None] = mapped_column(
|
|
74
|
+
JSONB(none_as_null=True), default=None, init=False
|
|
75
|
+
)
|
|
76
|
+
children_left: Mapped[int | None] = mapped_column(Integer, default=None, init=False)
|
|
77
|
+
pending_event: Mapped[dict[str, Any] | None] = mapped_column(
|
|
78
|
+
JSONB(none_as_null=True), default=None, init=False
|
|
79
|
+
)
|
|
80
|
+
recent_event_keys: Mapped[list[str] | None] = mapped_column(
|
|
81
|
+
JSONB(none_as_null=True), default=None, init=False
|
|
82
|
+
)
|
|
83
|
+
wf_version: Mapped[int] = mapped_column(
|
|
84
|
+
Integer, default=0, server_default=ZERO, init=False
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AttemptLog(MappedAsDataclass, model.AttemptLog):
|
|
89
|
+
"""``reflex_workflow.AttemptLog``, for a model that is a dataclass.
|
|
90
|
+
|
|
91
|
+
Every column is ``init=False``: the engine writes these rows with an insert
|
|
92
|
+
of its own, and nothing constructs one.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
__tablename__: ClassVar[str]
|
|
96
|
+
|
|
97
|
+
id: Mapped[int] = mapped_column(
|
|
98
|
+
Integer, primary_key=True, autoincrement=True, init=False
|
|
99
|
+
)
|
|
100
|
+
workflow: Mapped[str] = mapped_column(String(128), init=False)
|
|
101
|
+
run: Mapped[list[Any]] = mapped_column(JSONB, init=False)
|
|
102
|
+
step: Mapped[str] = mapped_column(String(128), init=False)
|
|
103
|
+
attempt: Mapped[int] = mapped_column(Integer, init=False)
|
|
104
|
+
outcome: Mapped[str] = mapped_column(String(16), init=False)
|
|
105
|
+
error: Mapped[str | None] = mapped_column(Text, default=None, init=False)
|
|
106
|
+
took_ms: Mapped[int] = mapped_column(Integer, init=False)
|
|
107
|
+
finished_at: Mapped[datetime.datetime] = mapped_column(
|
|
108
|
+
DateTime(timezone=True), init=False
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@declared_attr.directive
|
|
112
|
+
def __table_args__(cls) -> tuple[Any, ...]: # noqa: N805
|
|
113
|
+
"""Index the history by the run it belongs to.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The table's arguments.
|
|
117
|
+
"""
|
|
118
|
+
return (Index(f"ix_{cls.__tablename__}_run", "workflow", "run"),)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RateBucket(MappedAsDataclass, model.RateBucket):
|
|
122
|
+
"""``reflex_workflow.RateBucket``, for a model that is a dataclass.
|
|
123
|
+
|
|
124
|
+
Every column is ``init=False``: the engine keeps these rows itself, with an
|
|
125
|
+
upsert that creates a group's bucket the first time it is seen.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
key: Mapped[str] = mapped_column(String(256), primary_key=True, init=False)
|
|
129
|
+
tokens: Mapped[float] = mapped_column(
|
|
130
|
+
Float, default=0.0, server_default=ZERO, init=False
|
|
131
|
+
)
|
|
132
|
+
updated_at: Mapped[datetime.datetime] = mapped_column(
|
|
133
|
+
DateTime(timezone=True), init=False
|
|
134
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Execution of workflow steps: scheduling runs, claiming due rows, and running them."""
|
|
2
|
+
|
|
3
|
+
from reflex_workflow.engine.handles import RunHandle, start
|
|
4
|
+
from reflex_workflow.engine.runner import OnIdle, connect_workflows, run_workflows, wake
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"OnIdle",
|
|
8
|
+
"RunHandle",
|
|
9
|
+
"connect_workflows",
|
|
10
|
+
"run_workflows",
|
|
11
|
+
"start",
|
|
12
|
+
"wake",
|
|
13
|
+
]
|