Skip to content

Commit 266ea74

Browse files
committed
refactor(server): pass workflow transitions as commit_transition params
WorkflowTransition added a DTO between every caller and the registry while carrying no behavior of its own. Replace it with keyword-only params on commit_transition so each call site reads as a direct delta. While there, _persist_terminal_locked now collects only terminal tasks, warns when a non-terminal task reaches the path, and skips the commit for a workflow with no terminal move. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
1 parent f577fda commit 266ea74

5 files changed

Lines changed: 95 additions & 115 deletions

File tree

src/server/registries/workflow.py

Lines changed: 36 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import json
22
from collections.abc import Sequence
3-
from dataclasses import dataclass
43
from enum import StrEnum
54
from typing import Any
65

@@ -60,24 +59,6 @@ class WorkflowSched(BaseModel):
6059
epoch_frontier: int = 0
6160

6261

63-
@dataclass(frozen=True, slots=True)
64-
class WorkflowTransition:
65-
"""A durable workflow state delta committed as one atomic Redis transaction.
66-
67-
``records`` are upserted; each status field moves its task ids into that
68-
status's set membership; ``sched`` snapshots the schedule when present.
69-
"""
70-
71-
workflow_id: str
72-
records: Sequence[PersistedTask] = ()
73-
dispatched: Sequence[str] = ()
74-
pending: Sequence[str] = ()
75-
done: Sequence[str] = ()
76-
failed: Sequence[str] = ()
77-
cancelled: Sequence[str] = ()
78-
sched: WorkflowSched | None = None
79-
80-
8162
class WorkflowStatus(StrEnum):
8263
PENDING = "PENDING"
8364
DISPATCHED = "DISPATCHED"
@@ -278,36 +259,47 @@ async def get_workflow_async(self, workflow_id: str) -> Workflow | None:
278259
remaining_tasks,
279260
)
280261

281-
def commit_transition(self, transition: WorkflowTransition) -> None:
262+
def commit_transition(
263+
self,
264+
workflow_id: str,
265+
*,
266+
records: Sequence[PersistedTask] = (),
267+
dispatched: Sequence[str] = (),
268+
pending: Sequence[str] = (),
269+
done: Sequence[str] = (),
270+
failed: Sequence[str] = (),
271+
cancelled: Sequence[str] = (),
272+
sched: WorkflowSched | None = None,
273+
) -> None:
282274
"""Apply a workflow state delta as one atomic control-Redis transaction.
283275
284-
Task records, status-set membership moves, the workflow's ``updated_at``,
285-
and the optional schedule snapshot commit together or not at all, so a
286-
crash mid-persist can never leave durable state half-applied.
276+
``records`` are upserted; ``dispatched`` / ``pending`` / ``done`` /
277+
``failed`` / ``cancelled`` move their task ids into the matching status-set
278+
membership; ``sched`` snapshots the schedule when present. The records,
279+
membership moves, the workflow's ``updated_at``, and the schedule snapshot
280+
commit together or not at all, so a crash mid-persist can never leave
281+
durable state half-applied.
287282
"""
288-
wf = transition.workflow_id
289-
leaving = (*transition.done, *transition.failed, *transition.cancelled)
290-
touched_membership = bool(
291-
transition.dispatched or transition.pending or leaving
292-
)
283+
terminal = (*done, *failed, *cancelled)
284+
touched_membership = bool(dispatched or pending or terminal)
293285
with self._rds.sync.control_pipeline() as pipe:
294-
for item in transition.records:
286+
for item in records:
295287
pipe.set(task_state_key(item.record.task_id), item.model_dump_json())
296-
if transition.dispatched:
297-
pipe.sadd(workflow_dispatched_tasks_key(wf), *transition.dispatched)
298-
if transition.pending:
299-
pipe.srem(workflow_dispatched_tasks_key(wf), *transition.pending)
300-
if leaving:
301-
pipe.srem(workflow_tasks_key(wf), *leaving)
302-
pipe.srem(workflow_dispatched_tasks_key(wf), *leaving)
303-
if transition.failed:
304-
pipe.sadd(workflow_failed_tasks_key(wf), *transition.failed)
305-
if transition.cancelled:
306-
pipe.sadd(workflow_cancelled_tasks_key(wf), *transition.cancelled)
307-
if touched_membership or transition.sched is not None:
308-
pipe.hset(workflow_key(wf), mapping=_workflow_update())
309-
if transition.sched is not None:
310-
pipe.set(workflow_sched_key(wf), transition.sched.model_dump_json())
288+
if dispatched:
289+
pipe.sadd(workflow_dispatched_tasks_key(workflow_id), *dispatched)
290+
if pending:
291+
pipe.srem(workflow_dispatched_tasks_key(workflow_id), *pending)
292+
if terminal:
293+
pipe.srem(workflow_tasks_key(workflow_id), *terminal)
294+
pipe.srem(workflow_dispatched_tasks_key(workflow_id), *terminal)
295+
if failed:
296+
pipe.sadd(workflow_failed_tasks_key(workflow_id), *failed)
297+
if cancelled:
298+
pipe.sadd(workflow_cancelled_tasks_key(workflow_id), *cancelled)
299+
if touched_membership or sched is not None:
300+
pipe.hset(workflow_key(workflow_id), mapping=_workflow_update())
301+
if sched is not None:
302+
pipe.set(workflow_sched_key(workflow_id), sched.model_dump_json())
311303
pipe.execute()
312304

313305
# ---- Durable task state (for restart rehydration) ----------------- #

src/server/task/runtime.py

Lines changed: 34 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,7 @@
1313

1414
from ..hooks import SUPPLIER_RESOLVERS
1515
from ..registries.worker import Worker, WorkerRegistry
16-
from ..registries.workflow import (
17-
PersistedTask,
18-
WorkflowRegistry,
19-
WorkflowSched,
20-
WorkflowTransition,
21-
)
16+
from ..registries.workflow import PersistedTask, WorkflowRegistry, WorkflowSched
2217
from ..utils.time import parse_iso_ts
2318
from .models import (
2419
TERMINAL_TASK_STATUSES,
@@ -393,19 +388,16 @@ def _persist_locked(self, *task_ids: str) -> None:
393388
"""Commit task records (no membership change) atomically, per workflow."""
394389
by_workflow: dict[str, list[str]] = defaultdict(list)
395390
for task_id in dict.fromkeys(task_ids):
396-
record = self._tasks.get(task_id)
397-
if record is not None:
391+
if record := self._tasks.get(task_id):
398392
by_workflow[record.workflow_id].append(task_id)
399393
for workflow_id, ids in by_workflow.items():
400394
self._workflow_registry.commit_transition(
401-
WorkflowTransition(
402-
workflow_id=workflow_id, records=self._records_locked(*ids)
403-
)
395+
workflow_id, records=self._records_locked(*ids)
404396
)
405397

406398
def _persist_terminal_locked(self, *task_ids: str, sched: bool = True) -> None:
407399
"""Commit each task's final state — its record and its done/failed/cancelled
408-
set membership (by current status) — plus the workflow schedule, as one atomic
400+
set membership (by current status) — and the workflow schedule, as one atomic
409401
transaction per workflow and the single last step of a transition.
410402
411403
Committing only after all in-memory mutations means a failed or crashed write
@@ -416,36 +408,36 @@ def _persist_terminal_locked(self, *task_ids: str, sched: bool = True) -> None:
416408
ordered tasks carry ``position_in_epoch`` (so the ready-queue helpers never hit
417409
their guards).
418410
"""
419-
records: dict[str, list[str]] = defaultdict(list)
420411
moves: dict[str, tuple[list[str], list[str], list[str]]] = defaultdict(
421412
lambda: ([], [], [])
422413
)
423414
for task_id in dict.fromkeys(task_ids):
424415
record = self._tasks.get(task_id)
425416
if record is None:
426417
continue
427-
workflow_id = record.workflow_id
428-
records[workflow_id].append(task_id)
429-
done, failed, cancelled = moves[workflow_id]
430418
match record.status:
431419
case TaskStatus.DONE:
432-
done.append(task_id)
420+
moves[record.workflow_id][0].append(task_id)
433421
case TaskStatus.FAILED:
434-
failed.append(task_id)
422+
moves[record.workflow_id][1].append(task_id)
435423
case TaskStatus.CANCELLED:
436-
cancelled.append(task_id)
437-
for workflow_id, ids in records.items():
438-
done, failed, cancelled = moves[workflow_id]
439-
self._workflow_registry.commit_transition(
440-
WorkflowTransition(
441-
workflow_id=workflow_id,
424+
moves[record.workflow_id][2].append(task_id)
425+
case _:
426+
self._logger.warning(
427+
"Non-terminal task %s (%s) skipped in terminal persist",
428+
task_id,
429+
record.status,
430+
)
431+
for workflow_id, (done, failed, cancelled) in moves.items():
432+
if ids := done + failed + cancelled:
433+
self._workflow_registry.commit_transition(
434+
workflow_id,
442435
records=self._records_locked(*ids),
443436
done=done,
444437
failed=failed,
445438
cancelled=cancelled,
446439
sched=self._sched_locked(workflow_id) if sched else None,
447440
)
448-
)
449441

450442
def _repersist_terminal_workflow_locked(self, workflow_id: str) -> None:
451443
"""Re-commit the workflow's already-terminal tasks and schedule state.
@@ -467,9 +459,7 @@ def _repersist_terminal_workflow_locked(self, workflow_id: str) -> None:
467459
self._persist_terminal_locked(*terminal_ids)
468460
else:
469461
self._workflow_registry.commit_transition(
470-
WorkflowTransition(
471-
workflow_id=workflow_id, sched=self._sched_locked(workflow_id)
472-
)
462+
workflow_id, sched=self._sched_locked(workflow_id)
473463
)
474464

475465
# ------------------------------------------------------------------ #
@@ -698,11 +688,9 @@ def mark_pending(self, task_id: str, *, increment_retry: bool = False) -> None:
698688
except Exception:
699689
record.attempts = (record.attempts or 0) + 1
700690
self._workflow_registry.commit_transition(
701-
WorkflowTransition(
702-
workflow_id=record.workflow_id,
703-
records=self._records_locked(task_id),
704-
pending=[task_id],
705-
)
691+
record.workflow_id,
692+
records=self._records_locked(task_id),
693+
pending=[task_id],
706694
)
707695

708696
def requeue(self, task_id: str, *, front: bool = False) -> bool:
@@ -775,11 +763,9 @@ def _plan_merge_locked(
775763
sibling_record.assigned_worker = None
776764
sibling_record.merge_slice = None
777765
self._workflow_registry.commit_transition(
778-
WorkflowTransition(
779-
workflow_id=record.workflow_id,
780-
records=self._records_locked(task_id, *siblings),
781-
dispatched=siblings,
782-
)
766+
record.workflow_id,
767+
records=self._records_locked(task_id, *siblings),
768+
dispatched=siblings,
783769
)
784770

785771
return siblings
@@ -935,11 +921,9 @@ def mark_dispatched(self, task_id: str, worker: Worker) -> None:
935921
self._remove_from_ready_locked(task_id)
936922
self._merge_bucket_remove(task_id)
937923
self._workflow_registry.commit_transition(
938-
WorkflowTransition(
939-
workflow_id=record.workflow_id,
940-
records=self._records_locked(task_id),
941-
dispatched=[task_id],
942-
)
924+
record.workflow_id,
925+
records=self._records_locked(task_id),
926+
dispatched=[task_id],
943927
)
944928

945929
def mark_started(
@@ -962,11 +946,9 @@ def mark_started(
962946
if worker_id:
963947
record.assigned_worker = worker_id
964948
self._workflow_registry.commit_transition(
965-
WorkflowTransition(
966-
workflow_id=record.workflow_id,
967-
records=self._records_locked(task_id),
968-
dispatched=[task_id],
969-
)
949+
record.workflow_id,
950+
records=self._records_locked(task_id),
951+
dispatched=[task_id],
970952
)
971953

972954
def mark_updated(self, task_id: str, payload: dict[str, Any]) -> None:
@@ -1232,12 +1214,10 @@ def cancel_workflow(self, workflow_id: str, reason: str = "cancelled") -> list[s
12321214
for task_id, _ in workflow_tasks:
12331215
self._task_epoch_index.pop(task_id, None)
12341216
self._workflow_registry.commit_transition(
1235-
WorkflowTransition(
1236-
workflow_id=workflow_id,
1237-
records=self._records_locked(*touched),
1238-
cancelled=cancelled,
1239-
sched=self._sched_locked(workflow_id),
1240-
)
1217+
workflow_id,
1218+
records=self._records_locked(*touched),
1219+
cancelled=cancelled,
1220+
sched=self._sched_locked(workflow_id),
12411221
)
12421222

12431223
for interrupt in interrupts:

tests/server/dispatcher/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class WorkflowRegistryStub:
3131
async def register_workflow_async(self, workflow_id: str, tasks: list[Any]) -> None:
3232
return None
3333

34-
def commit_transition(self, transition: Any) -> None: ...
34+
def commit_transition(self, workflow_id: str, **kwargs: Any) -> None: ...
3535
async def save_task_states_async(self, items: Any) -> None: ...
3636
async def save_workflow_sched_async(
3737
self, wid: str, in_epoch_order: bool, frontier: int

tests/server/task/test_runtime_epoch_order.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class _WorkflowRegistryStub:
1313
async def register_workflow_async(self, workflow_id: str, tasks: list[Any]) -> None:
1414
return None
1515

16-
def commit_transition(self, transition: Any) -> None:
16+
def commit_transition(self, workflow_id: str, **kwargs: Any) -> None:
1717
return None
1818

1919
async def save_task_states_async(self, items: Any) -> None:

0 commit comments

Comments
 (0)