loom-core 0.1.4__py3-none-any.whl → 0.1.6__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.
loom/core/context.py CHANGED
@@ -82,6 +82,19 @@ class WorkflowContext(Generic[InputT, StateT]):
82
82
  self.cursor += 1
83
83
  return event
84
84
 
85
+ def _skip_step_events(self) -> None:
86
+ """Skip over STEP_START and STEP_END events during replay.
87
+
88
+ These are internal workflow management events that don't affect
89
+ the deterministic execution logic.
90
+ """
91
+ while True:
92
+ event = self._peek()
93
+ if event and event["type"] in ("STEP_START", "STEP_END"):
94
+ self._consume()
95
+ else:
96
+ break
97
+
85
98
  def _match_event(self, expected_type: str) -> Event | None:
86
99
  """
87
100
  Safe helper to check if the NEXT event matches what we expect.
@@ -102,6 +115,15 @@ class WorkflowContext(Generic[InputT, StateT]):
102
115
  """
103
116
  return self.cursor < len(self.history)
104
117
 
118
+ @property
119
+ def is_at_end_of_history(self) -> bool:
120
+ """Check if we've consumed all events in history.
121
+
122
+ Returns:
123
+ True if we're at the end of event history, False otherwise
124
+ """
125
+ return self.cursor >= len(self.history)
126
+
105
127
  def _extract_activity_metadata[FuncReturn](
106
128
  self, fn: Callable[..., Awaitable[FuncReturn]], args: tuple[Any, ...]
107
129
  ) -> ActivityMetadata:
@@ -136,6 +158,8 @@ class WorkflowContext(Generic[InputT, StateT]):
136
158
  ) -> FuncReturn:
137
159
  metadata = self._extract_activity_metadata(fn, args)
138
160
 
161
+ # Skip any step events first
162
+ self._skip_step_events()
139
163
  scheduled_event = self._match_event("ACTIVITY_SCHEDULED")
140
164
 
141
165
  if scheduled_event:
@@ -147,6 +171,8 @@ class WorkflowContext(Generic[InputT, StateT]):
147
171
 
148
172
  self._consume()
149
173
 
174
+ # Skip step events before checking for completion
175
+ self._skip_step_events()
150
176
  completed_event = self._match_event("ACTIVITY_COMPLETED")
151
177
 
152
178
  if completed_event:
@@ -155,6 +181,8 @@ class WorkflowContext(Generic[InputT, StateT]):
155
181
 
156
182
  raise StopReplay
157
183
 
184
+ # Skip step events before checking for unexpected events
185
+ self._skip_step_events()
158
186
  unexpected_event = self._peek()
159
187
  if unexpected_event:
160
188
  raise NonDeterministicWorkflowError(
@@ -180,11 +208,16 @@ class WorkflowContext(Generic[InputT, StateT]):
180
208
  datetime.datetime.now(datetime.timezone.utc) + delta if delta else until # type: ignore
181
209
  )
182
210
 
211
+ # Skip any step events first
212
+ self._skip_step_events()
213
+
183
214
  scheduled_event = self._match_event("TIMER_SCHEDULED")
184
215
 
185
216
  if scheduled_event:
186
217
  self._consume()
187
218
 
219
+ # Skip step events before checking for timer fired
220
+ self._skip_step_events()
188
221
  fired_event = self._match_event("TIMER_FIRED")
189
222
  if fired_event:
190
223
  self._consume()
@@ -192,6 +225,8 @@ class WorkflowContext(Generic[InputT, StateT]):
192
225
 
193
226
  raise StopReplay
194
227
 
228
+ # Skip step events before checking for unexpected events
229
+ self._skip_step_events()
195
230
  unexpected_event = self._peek()
196
231
  if unexpected_event:
197
232
  raise NonDeterministicWorkflowError(
@@ -210,6 +245,8 @@ class WorkflowContext(Generic[InputT, StateT]):
210
245
  If the signal is already in history (replay), it returns the data immediately.
211
246
  If not, it raises StopReplay to suspend execution until the signal arrives.
212
247
  """
248
+ # Skip any step events first
249
+ self._skip_step_events()
213
250
  # 1. Check if the signal is next in history
214
251
  event = self._match_event("SIGNAL_RECEIVED")
215
252
 
@@ -226,6 +263,8 @@ class WorkflowContext(Generic[InputT, StateT]):
226
263
  self._consume()
227
264
  return event["payload"]["data"]
228
265
 
266
+ # Skip step events before checking for unexpected events
267
+ self._skip_step_events()
229
268
  unexpected_event = self._peek()
230
269
  if unexpected_event:
231
270
  raise NonDeterministicWorkflowError(
loom/core/engine.py CHANGED
@@ -93,9 +93,42 @@ class Engine(Generic[InputT, StateT]):
93
93
 
94
94
  try:
95
95
  for step in steps:
96
+ # Check for and consume STEP_START event during replay
97
+ step_start_event = ctx._match_event("STEP_START")
98
+ if step_start_event:
99
+ if step_start_event["payload"]["step_name"] != step["name"]:
100
+ raise StopReplay # Step mismatch, something changed
101
+ ctx._consume()
102
+ else:
103
+ # Not replaying, emit STEP_START event
104
+ if not ctx.is_replaying:
105
+ await ctx._append_event(
106
+ "STEP_START",
107
+ {
108
+ "step_name": step["name"],
109
+ "step_fn": step["fn"],
110
+ "started_at": datetime.now(timezone.utc).isoformat(),
111
+ },
112
+ )
113
+
96
114
  step_fn = getattr(workflow, step["fn"])
97
115
  await step_fn(ctx)
98
116
 
117
+ # Check for and consume STEP_END event during replay
118
+ step_end_event = ctx._match_event("STEP_END")
119
+ if step_end_event:
120
+ ctx._consume()
121
+ else:
122
+ # Not replaying, emit STEP_END event
123
+ if not ctx.is_replaying:
124
+ await ctx._append_event(
125
+ "STEP_END",
126
+ {
127
+ "step_name": step["name"],
128
+ "completed_at": datetime.now(timezone.utc).isoformat(),
129
+ },
130
+ )
131
+
99
132
  except StopReplay:
100
133
  last = ctx.last_emitted_event_type()
101
134
  if last in ("STATE_SET", "STATE_UPDATE"):
loom/core/runner.py CHANGED
@@ -50,4 +50,13 @@ async def run_once() -> bool:
50
50
  except Exception as e:
51
51
  traceback.print_exc()
52
52
  await db.task_failed(task["id"], str(e))
53
+
54
+ # Mark workflow as failed when unhandled exception occurs
55
+ await db.workflow_failed(
56
+ workflow_id=workflow_id,
57
+ error=str(e),
58
+ task_id=task["id"],
59
+ task_kind=task["kind"],
60
+ )
61
+
53
62
  return True
loom/database/db.py CHANGED
@@ -344,6 +344,72 @@ class Database(Generic[InputT, StateT]):
344
344
  (workflow_id, json.dumps(signal_payload)),
345
345
  )
346
346
 
347
+ async def workflow_failed(
348
+ self, workflow_id: str, error: str, task_id: str = None, task_kind: str = None
349
+ ) -> None:
350
+ """Mark a workflow as failed due to an unhandled exception.
351
+
352
+ Creates a WORKFLOW_FAILED event and updates the workflow status.
353
+ Also cancels any remaining pending tasks for the workflow.
354
+
355
+ Args:
356
+ workflow_id: Workflow identifier to mark as failed
357
+ error: Error message describing the failure
358
+ task_id: Optional task ID that caused the failure
359
+ task_kind: Optional task kind that caused the failure
360
+
361
+ Raises:
362
+ WorkflowNotFoundError: If the workflow doesn't exist
363
+ """
364
+ # Get workflow info (this will raise WorkflowNotFoundError if not found)
365
+ workflow = await self.get_workflow_info(workflow_id)
366
+
367
+ # Skip if already in terminal state
368
+ if workflow["status"] in ("COMPLETED", "FAILED", "CANCELED"):
369
+ return
370
+
371
+ # Prepare failure payload
372
+ payload = {
373
+ "error": error,
374
+ "failed_at": datetime.now(timezone.utc).isoformat(),
375
+ }
376
+ if task_id:
377
+ payload["task_id"] = task_id
378
+ if task_kind:
379
+ payload["task_kind"] = task_kind
380
+
381
+ # Create failure event
382
+ await self.execute(
383
+ """
384
+ INSERT INTO events (workflow_id, type, payload)
385
+ VALUES (?, 'WORKFLOW_FAILED', ?)
386
+ """,
387
+ (workflow_id, json.dumps(payload)),
388
+ )
389
+
390
+ # Update workflow status
391
+ await self.execute(
392
+ """
393
+ UPDATE workflows
394
+ SET status = 'FAILED',
395
+ updated_at = CURRENT_TIMESTAMP
396
+ WHERE id = ?
397
+ """,
398
+ (workflow_id,),
399
+ )
400
+
401
+ # Cancel all pending tasks
402
+ await self.execute(
403
+ """
404
+ UPDATE tasks
405
+ SET status = 'FAILED',
406
+ last_error = 'workflow failed'
407
+ WHERE workflow_id = ?
408
+ AND status = 'PENDING'
409
+ """,
410
+ (workflow_id,),
411
+ )
412
+
347
413
  async def cancel_workflow(
348
414
  self, workflow_id: str, reason: str | None = None
349
415
  ) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loom-core
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Durable workflow orchestration engine for Python
5
5
  Home-page: https://github.com/satadeep3927/loom
6
6
  Author: Satadeep Dasgupta
@@ -7,16 +7,16 @@ loom/common/errors.py,sha256=jc7_XS8dUPS8fg5pb7kJaTuIR1e6eKBZyZtbsHW3lgM,1712
7
7
  loom/common/workflow.py,sha256=R4X1vt6duqTJz6kGk6YI866O-CleOiyw0dXdUVo_qAE,2118
8
8
  loom/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  loom/core/compiled.py,sha256=Y2NQDMZVLee65ILgJoK2Ny_OcR1T4LJnOQWQcyg0Z7U,1178
10
- loom/core/context.py,sha256=rVWWSOaAmdVOzKKFh9TwpJVX-4Rm0h7bC76ipEcqVBw,9219
11
- loom/core/engine.py,sha256=twrZ8bABmQS7aVrj6qObPvx4zxhQTTFm2LY-KyGrhYs,4268
10
+ loom/core/context.py,sha256=jxmaA_4Xdmi5FPxnXi3VWNPtyjMbdCKYi4j-Ju6Hi_g,10676
11
+ loom/core/engine.py,sha256=6EvacrZCfVhJQ7yPurRdCeTzlGRaGk5uI-s7yxu9IUs,5822
12
12
  loom/core/handle.py,sha256=beOVmJit-gSrk7II4c2KuSubmr5OIPls82_vJ-pI04U,5532
13
13
  loom/core/logger.py,sha256=4ej95IdHOdBh-y7PQoRFz_H22PJYDXvAioBoId8mOhg,1729
14
- loom/core/runner.py,sha256=pYITY35Q88ItZA4EuSWmNE5sgxL0tExXrm9j5vSaz6o,1718
14
+ loom/core/runner.py,sha256=nNA-EcdJt3UEgwzKHyb22xD7aLT8kI01x5UVdLteSI8,1998
15
15
  loom/core/state.py,sha256=oXiZ442PL2kl_JyQXLZOFeBLs_cTF2iEjykKVVUNiAI,2997
16
16
  loom/core/worker.py,sha256=jpTtvM1rIToVkG4SwJu1W9Q-eL3bw8b3N8KsxxrRovA,5322
17
17
  loom/core/workflow.py,sha256=E_A2rI6iaW3liZmVy3X98oxSeBDXMGiQnRZRq3Cp3BA,5755
18
18
  loom/database/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
- loom/database/db.py,sha256=BtZTNtZ6cXpfvrtdmW8Xb47zoC2NXvVoWVmUILow2eA,23535
19
+ loom/database/db.py,sha256=MSNXWhUzfwvjPXaVWY3-UK1lqw-Qas0Cc1FYcU8qdWw,25700
20
20
  loom/decorators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  loom/decorators/activity.py,sha256=xt7oeYOKJxhKIEcwWsSVA26c_jk4yQB1PHtbcg3tCFI,4824
22
22
  loom/decorators/workflow.py,sha256=rBpbnpLfDTTAWdgiERpUPIgNUJpnYJKvBZPmzx-ECcc,1516
@@ -42,9 +42,9 @@ loom/schemas/database.py,sha256=DlMBvacsJ9oceBHkrMiJC7uD3_rCHFe6Kwa2STFcKOw,283
42
42
  loom/schemas/events.py,sha256=Gz-R836nVXNSsp4Y54idhKs_WTvzFPmx5KlA8PunP28,2216
43
43
  loom/schemas/tasks.py,sha256=DJbLInggIGxI-CfP1kSyK79Bz83e3sZyEKh6C2HE8q4,1341
44
44
  loom/schemas/workflow.py,sha256=F1cNEJRuEWLjPhGinEZE2T6vwchDC8RuTHmxEIaH6Ls,671
45
- loom_core-0.1.4.dist-info/licenses/LICENSE,sha256=8EpC-clAYRUfJQ92T3iQEIIWYjx2A3Kfk28zOd8lh7I,1095
46
- loom_core-0.1.4.dist-info/METADATA,sha256=pRtqEJf1DiwSbXPfnE-cHfuxZs8t2ol1I4LiXfAnm50,8897
47
- loom_core-0.1.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
48
- loom_core-0.1.4.dist-info/entry_points.txt,sha256=Jx5HXHL2y7jvSjkwkH3QqF954cbSxiE6OGwL2coldyE,42
49
- loom_core-0.1.4.dist-info/top_level.txt,sha256=cAfRgAuCuit-cU9iBrf0bS4ovvmq-URykNd9fmYMojg,5
50
- loom_core-0.1.4.dist-info/RECORD,,
45
+ loom_core-0.1.6.dist-info/licenses/LICENSE,sha256=8EpC-clAYRUfJQ92T3iQEIIWYjx2A3Kfk28zOd8lh7I,1095
46
+ loom_core-0.1.6.dist-info/METADATA,sha256=L3FvzaOTy5AXGihpTZcxGeRfXTl4KKGiaYx3uKnCL2k,8897
47
+ loom_core-0.1.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
48
+ loom_core-0.1.6.dist-info/entry_points.txt,sha256=Jx5HXHL2y7jvSjkwkH3QqF954cbSxiE6OGwL2coldyE,42
49
+ loom_core-0.1.6.dist-info/top_level.txt,sha256=cAfRgAuCuit-cU9iBrf0bS4ovvmq-URykNd9fmYMojg,5
50
+ loom_core-0.1.6.dist-info/RECORD,,