ddeutil-workflow 0.0.73__py3-none-any.whl → 0.0.75__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.
@@ -3,14 +3,25 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
- """Workflow module is the core module of this package. It keeps Release,
7
- ReleaseQueue, and Workflow models.
6
+ """Workflow Core Module.
8
7
 
9
- This package implement timeout strategy on the workflow execution layer only
10
- because the main propose of this package is using Workflow to be orchestrator.
11
- """
12
- from __future__ import annotations
8
+ This module contains the core workflow orchestration functionality, including
9
+ the Workflow model, release management, and workflow execution strategies.
10
+
11
+ The workflow system implements timeout strategy at the workflow execution layer
12
+ because the main purpose is to use Workflow as an orchestrator for complex
13
+ job execution scenarios.
13
14
 
15
+ Classes:
16
+ Workflow: Main workflow orchestration class
17
+ ReleaseType: Enumeration for different release types
18
+
19
+ Constants:
20
+ NORMAL: Normal release execution
21
+ RERUN: Re-execution of failed workflows
22
+ EVENT: Event-triggered execution
23
+ FORCE: Force execution regardless of conditions
24
+ """
14
25
  import copy
15
26
  import time
16
27
  from concurrent.futures import (
@@ -23,20 +34,18 @@ from enum import Enum
23
34
  from pathlib import Path
24
35
  from queue import Queue
25
36
  from textwrap import dedent
26
- from threading import Event
27
- from typing import Any, Optional
28
- from zoneinfo import ZoneInfo
37
+ from threading import Event as ThreadEvent
38
+ from typing import Any, Optional, Union
29
39
 
30
40
  from pydantic import BaseModel, Field
31
41
  from pydantic.functional_validators import field_validator, model_validator
32
42
  from typing_extensions import Self
33
43
 
34
- from . import get_status_from_error
35
44
  from .__types import DictData
36
- from .audits import Audit, get_audit
45
+ from .audits import Audit, get_audit_model
37
46
  from .conf import YamlParser, dynamic
38
47
  from .errors import WorkflowCancelError, WorkflowError, WorkflowTimeoutError
39
- from .event import Crontab
48
+ from .event import Event
40
49
  from .job import Job
41
50
  from .params import Param
42
51
  from .result import (
@@ -47,17 +56,32 @@ from .result import (
47
56
  WAIT,
48
57
  Result,
49
58
  Status,
59
+ catch,
60
+ get_status_from_error,
50
61
  validate_statuses,
51
62
  )
52
63
  from .reusables import has_template, param2template
64
+ from .traces import Trace, get_trace
53
65
  from .utils import (
66
+ UTC,
54
67
  gen_id,
68
+ get_dt_now,
55
69
  replace_sec,
56
70
  )
57
71
 
58
72
 
59
73
  class ReleaseType(str, Enum):
60
- """Release Type Enum."""
74
+ """Release type enumeration for workflow execution modes.
75
+
76
+ This enum defines the different types of workflow releases that can be
77
+ triggered, each with specific behavior and use cases.
78
+
79
+ Attributes:
80
+ NORMAL: Standard workflow release execution
81
+ RERUN: Re-execution of previously failed workflow
82
+ EVENT: Event-triggered workflow execution
83
+ FORCE: Forced execution bypassing normal conditions
84
+ """
61
85
 
62
86
  NORMAL = "normal"
63
87
  RERUN = "rerun"
@@ -72,19 +96,43 @@ FORCE = ReleaseType.FORCE
72
96
 
73
97
 
74
98
  class Workflow(BaseModel):
75
- """Workflow model that use to keep the `Job` and `Crontab` models.
76
-
77
- This is the main future of this project because it uses to be workflow
78
- data for running everywhere that you want or using it to scheduler task in
79
- background. It uses lightweight coding line from Pydantic Model and enhance
80
- execute method on it.
99
+ """Main workflow orchestration model for job and schedule management.
100
+
101
+ The Workflow class is the core component of the workflow orchestration system.
102
+ It manages job execution, scheduling via cron expressions, parameter handling,
103
+ and provides comprehensive execution capabilities for complex workflows.
104
+
105
+ This class extends Pydantic BaseModel to provide robust data validation and
106
+ serialization while maintaining lightweight performance characteristics.
107
+
108
+ Attributes:
109
+ extras (dict): Extra parameters for overriding configuration values
110
+ name (str): Unique workflow identifier
111
+ desc (str, optional): Workflow description supporting markdown content
112
+ params (dict[str, Param]): Parameter definitions for the workflow
113
+ on (list[Crontab]): Schedule definitions using cron expressions
114
+ jobs (dict[str, Job]): Collection of jobs within this workflow
115
+
116
+ Example:
117
+ Create and execute a workflow:
118
+
119
+ ```python
120
+ workflow = Workflow.from_conf('my-workflow')
121
+ result = workflow.execute({
122
+ 'param1': 'value1',
123
+ 'param2': 'value2'
124
+ })
125
+ ```
126
+
127
+ Note:
128
+ Workflows can be executed immediately or scheduled for background
129
+ execution using the cron-like scheduling system.
81
130
  """
82
131
 
83
132
  extras: DictData = Field(
84
133
  default_factory=dict,
85
134
  description="An extra parameters that want to override config values.",
86
135
  )
87
-
88
136
  name: str = Field(description="A workflow name.")
89
137
  desc: Optional[str] = Field(
90
138
  default=None,
@@ -96,14 +144,28 @@ class Workflow(BaseModel):
96
144
  default_factory=dict,
97
145
  description="A parameters that need to use on this workflow.",
98
146
  )
99
- on: list[Crontab] = Field(
147
+ on: Event = Field(
100
148
  default_factory=list,
101
- description="A list of Crontab instance for this workflow schedule.",
149
+ description="An events for this workflow.",
102
150
  )
103
151
  jobs: dict[str, Job] = Field(
104
152
  default_factory=dict,
105
153
  description="A mapping of job ID and job model that already loaded.",
106
154
  )
155
+ created_at: datetime = Field(
156
+ default_factory=get_dt_now,
157
+ description=(
158
+ "A created datetime of this workflow template when loading from "
159
+ "file."
160
+ ),
161
+ )
162
+ updated_dt: datetime = Field(
163
+ default_factory=get_dt_now,
164
+ description=(
165
+ "A updated datetime of this workflow template when loading from "
166
+ "file."
167
+ ),
168
+ )
107
169
 
108
170
  @classmethod
109
171
  def from_conf(
@@ -111,20 +173,38 @@ class Workflow(BaseModel):
111
173
  name: str,
112
174
  *,
113
175
  path: Optional[Path] = None,
114
- extras: DictData | None = None,
176
+ extras: Optional[DictData] = None,
115
177
  ) -> Self:
116
- """Create Workflow instance from the Loader object that only receive
117
- an input workflow name. The loader object will use this workflow name to
118
- searching configuration data of this workflow model in conf path.
119
-
120
- :param name: (str) A workflow name that want to pass to Loader object.
121
- :param path: (Path) An override config path.
122
- :param extras: (DictData) An extra parameters that want to override core
123
- config values.
124
-
125
- :raise ValueError: If the type does not match with current object.
126
-
127
- :rtype: Self
178
+ """Create Workflow instance from configuration file.
179
+
180
+ Loads workflow configuration from YAML files and creates a validated
181
+ Workflow instance. The configuration loader searches for workflow
182
+ definitions in the specified path or default configuration directories.
183
+
184
+ Args:
185
+ name: Workflow name to load from configuration
186
+ path: Optional custom configuration path to search
187
+ extras: Additional parameters to override configuration values
188
+
189
+ Returns:
190
+ Self: Validated Workflow instance loaded from configuration
191
+
192
+ Raises:
193
+ ValueError: If workflow type doesn't match or configuration invalid
194
+ FileNotFoundError: If workflow configuration file not found
195
+
196
+ Example:
197
+ ```python
198
+ # Load from default config path
199
+ workflow = Workflow.from_conf('data-pipeline')
200
+
201
+ # Load with custom path and extras
202
+ workflow = Workflow.from_conf(
203
+ 'data-pipeline',
204
+ path=Path('./custom-configs'),
205
+ extras={'environment': 'production'}
206
+ )
207
+ ```
128
208
  """
129
209
  load: YamlParser = YamlParser(name, path=path, extras=extras, obj=cls)
130
210
 
@@ -138,105 +218,38 @@ class Workflow(BaseModel):
138
218
  if extras:
139
219
  data["extras"] = extras
140
220
 
141
- cls.__bypass_on__(data, path=load.path, extras=extras)
142
221
  return cls.model_validate(obj=data)
143
222
 
144
- @classmethod
145
- def __bypass_on__(
146
- cls,
147
- data: DictData,
148
- path: Path,
149
- extras: DictData | None = None,
150
- ) -> DictData:
151
- """Bypass the on data to loaded config data.
152
-
153
- :param data: (DictData) A data to construct to this Workflow model.
154
- :param path: (Path) A config path.
155
- :param extras: (DictData) An extra parameters that want to override core
156
- config values.
157
-
158
- :rtype: DictData
159
- """
160
- if on := data.pop("on", []):
161
- if isinstance(on, str):
162
- on: list[str] = [on]
163
- if any(not isinstance(i, (dict, str)) for i in on):
164
- raise TypeError("The `on` key should be list of str or dict")
165
-
166
- # NOTE: Pass on value to SimLoad and keep on model object to the on
167
- # field.
168
- data["on"] = [
169
- (
170
- YamlParser(n, path=path, extras=extras).data
171
- if isinstance(n, str)
172
- else n
173
- )
174
- for n in on
175
- ]
176
- return data
177
-
178
- @model_validator(mode="before")
179
- def __prepare_model_before__(cls, data: Any) -> Any:
223
+ @field_validator(
224
+ "params",
225
+ mode="before",
226
+ json_schema_input_type=Union[dict[str, Param], dict[str, str]],
227
+ )
228
+ def __prepare_params(cls, data: Any) -> Any:
180
229
  """Prepare the params key in the data model before validating."""
181
- if isinstance(data, dict) and (params := data.pop("params", {})):
182
- data["params"] = {
183
- p: (
184
- {"type": params[p]}
185
- if isinstance(params[p], str)
186
- else params[p]
187
- )
188
- for p in params
230
+ if isinstance(data, dict):
231
+ data = {
232
+ k: ({"type": v} if isinstance(v, str) else v)
233
+ for k, v in data.items()
189
234
  }
190
235
  return data
191
236
 
192
237
  @field_validator("desc", mode="after")
193
- def __dedent_desc__(cls, value: str) -> str:
238
+ def __dedent_desc__(cls, data: str) -> str:
194
239
  """Prepare description string that was created on a template.
195
240
 
196
- :param value: A description string value that want to dedent.
197
- :rtype: str
198
- """
199
- return dedent(value.lstrip("\n"))
241
+ Args:
242
+ data: A description string value that want to dedent.
200
243
 
201
- @field_validator("on", mode="after")
202
- def __on_no_dup_and_reach_limit__(
203
- cls,
204
- value: list[Crontab],
205
- ) -> list[Crontab]:
206
- """Validate the on fields should not contain duplicate values and if it
207
- contains the every minute value more than one value, it will remove to
208
- only one value.
209
-
210
- :raise ValueError: If it has some duplicate value.
211
-
212
- :param value: A list of on object.
213
-
214
- :rtype: list[Crontab]
244
+ Returns:
245
+ str: The de-dented description string.
215
246
  """
216
- set_ons: set[str] = {str(on.cronjob) for on in value}
217
- if len(set_ons) != len(value):
218
- raise ValueError(
219
- "The on fields should not contain duplicate on value."
220
- )
221
-
222
- # WARNING:
223
- # if '* * * * *' in set_ons and len(set_ons) > 1:
224
- # raise ValueError(
225
- # "If it has every minute cronjob on value, it should have "
226
- # "only one value in the on field."
227
- # )
228
- set_tz: set[str] = {on.tz for on in value}
229
- if len(set_tz) > 1:
230
- raise ValueError(
231
- f"The on fields should not contain multiple timezone, "
232
- f"{list(set_tz)}."
233
- )
247
+ return dedent(data.lstrip("\n"))
234
248
 
235
- if len(set_ons) > 10:
236
- raise ValueError(
237
- "The number of the on should not more than 10 crontabs."
238
- )
239
- return value
249
+ @field_validator("created_at", "updated_dt", mode="after")
250
+ def __convert_tz__(cls, dt: datetime) -> datetime:
251
+ """Replace timezone of datetime type to no timezone."""
252
+ return dt.replace(tzinfo=None)
240
253
 
241
254
  @model_validator(mode="after")
242
255
  def __validate_jobs_need__(self) -> Self:
@@ -277,13 +290,15 @@ class Workflow(BaseModel):
277
290
  or job's ID. This method will pass an extra parameter from this model
278
291
  to the returned Job model.
279
292
 
280
- :param name: (str) A job name or ID that want to get from a mapping of
281
- job models.
293
+ Args:
294
+ name: A job name or ID that want to get from a mapping of
295
+ job models.
282
296
 
283
- :raise ValueError: If a name or ID does not exist on the jobs field.
297
+ Returns:
298
+ Job: A job model that exists on this workflow by input name.
284
299
 
285
- :rtype: Job
286
- :return: A job model that exists on this workflow by input name.
300
+ Raises:
301
+ ValueError: If a name or ID does not exist on the jobs field.
287
302
  """
288
303
  if name not in self.jobs:
289
304
  raise ValueError(
@@ -306,15 +321,17 @@ class Workflow(BaseModel):
306
321
  ... "jobs": {}
307
322
  ... }
308
323
 
309
- :param params: (DictData) A parameter data that receive from workflow
310
- execute method.
324
+ Args:
325
+ params: A parameter data that receive from workflow
326
+ execute method.
311
327
 
312
- :raise WorkflowError: If parameter value that want to validate does
313
- not include the necessary parameter that had required flag.
328
+ Returns:
329
+ DictData: The parameter value that validate with its parameter fields and
330
+ adding jobs key to this parameter.
314
331
 
315
- :rtype: DictData
316
- :return: The parameter value that validate with its parameter fields and
317
- adding jobs key to this parameter.
332
+ Raises:
333
+ WorkflowError: If parameter value that want to validate does
334
+ not include the necessary parameter that had required flag.
318
335
  """
319
336
  # VALIDATE: Incoming params should have keys that set on this workflow.
320
337
  check_key: list[str] = [
@@ -346,16 +363,21 @@ class Workflow(BaseModel):
346
363
  millisecond to 0 and replaced timezone to None before checking it match
347
364
  with the set `on` field.
348
365
 
349
- :param dt: (datetime) A datetime object that want to validate.
366
+ Args:
367
+ dt: A datetime object that want to validate.
350
368
 
351
- :rtype: datetime
369
+ Returns:
370
+ datetime: The validated release datetime.
352
371
  """
353
- release: datetime = replace_sec(dt.replace(tzinfo=None))
372
+ if dt.tzinfo is None:
373
+ dt = dt.replace(tzinfo=UTC)
374
+
375
+ release: datetime = replace_sec(dt.astimezone(UTC))
354
376
  if not self.on:
355
377
  return release
356
378
 
357
- for on in self.on:
358
- if release == on.cronjob.schedule(release).next:
379
+ for on in self.on.schedule:
380
+ if release == on.cronjob.schedule(release, tz=UTC).next:
359
381
  return release
360
382
  raise WorkflowError(
361
383
  "Release datetime does not support for this workflow"
@@ -366,12 +388,10 @@ class Workflow(BaseModel):
366
388
  release: datetime,
367
389
  params: DictData,
368
390
  *,
369
- release_type: ReleaseType = NORMAL,
370
391
  run_id: Optional[str] = None,
371
- parent_run_id: Optional[str] = None,
392
+ release_type: ReleaseType = NORMAL,
372
393
  audit: type[Audit] = None,
373
394
  override_log_name: Optional[str] = None,
374
- result: Optional[Result] = None,
375
395
  timeout: int = 600,
376
396
  excluded: Optional[list[str]] = None,
377
397
  ) -> Result:
@@ -393,90 +413,92 @@ class Workflow(BaseModel):
393
413
  :param params: A workflow parameter that pass to execute method.
394
414
  :param release_type:
395
415
  :param run_id: (str) A workflow running ID.
396
- :param parent_run_id: (str) A parent workflow running ID.
397
416
  :param audit: An audit class that want to save the execution result.
398
417
  :param override_log_name: (str) An override logging name that use
399
418
  instead the workflow name.
400
- :param result: (Result) A result object for keeping context and status
401
- data.
402
419
  :param timeout: (int) A workflow execution time out in second unit.
403
420
  :param excluded: (list[str]) A list of key that want to exclude from
404
421
  audit data.
405
422
 
406
423
  :rtype: Result
407
424
  """
408
- audit: type[Audit] = audit or get_audit(extras=self.extras)
409
425
  name: str = override_log_name or self.name
410
- result: Result = Result.construct_with_rs_or_id(
411
- result,
412
- run_id=run_id,
413
- parent_run_id=parent_run_id,
414
- id_logic=name,
415
- extras=self.extras,
426
+
427
+ # NOTE: Generate the parent running ID with not None value.
428
+ if run_id:
429
+ parent_run_id: str = run_id
430
+ run_id: str = gen_id(name, unique=True)
431
+ else:
432
+ run_id: str = gen_id(name, unique=True)
433
+ parent_run_id: str = run_id
434
+
435
+ context: DictData = {}
436
+ trace: Trace = get_trace(
437
+ run_id, parent_run_id=parent_run_id, extras=self.extras
416
438
  )
417
439
  release: datetime = self.validate_release(dt=release)
418
- result.trace.info(
419
- f"[RELEASE]: Start {name!r} : {release:%Y-%m-%d %H:%M:%S}"
420
- )
421
- tz: ZoneInfo = dynamic("tz", extras=self.extras)
440
+ trace.info(f"[RELEASE]: Start {name!r} : {release:%Y-%m-%d %H:%M:%S}")
422
441
  values: DictData = param2template(
423
442
  params,
424
443
  params={
425
444
  "release": {
426
445
  "logical_date": release,
427
- "execute_date": datetime.now(tz=tz),
428
- "run_id": result.run_id,
446
+ "execute_date": get_dt_now(),
447
+ "run_id": run_id,
429
448
  }
430
449
  },
431
450
  extras=self.extras,
432
451
  )
433
452
  rs: Result = self.execute(
434
453
  params=values,
435
- parent_run_id=result.run_id,
454
+ run_id=parent_run_id,
436
455
  timeout=timeout,
437
456
  )
438
- result.catch(status=rs.status, context=rs.context)
439
- result.trace.info(
440
- f"[RELEASE]: End {name!r} : {release:%Y-%m-%d %H:%M:%S}"
441
- )
442
- result.trace.debug(f"[RELEASE]: Writing audit: {name!r}.")
457
+ catch(context, status=rs.status, updated=rs.context)
458
+ trace.info(f"[RELEASE]: End {name!r} : {release:%Y-%m-%d %H:%M:%S}")
459
+ trace.debug(f"[RELEASE]: Writing audit: {name!r}.")
443
460
  (
444
- audit(
461
+ (audit or get_audit_model(extras=self.extras))(
445
462
  name=name,
446
463
  release=release,
447
464
  type=release_type,
448
- context=result.context,
449
- parent_run_id=result.parent_run_id,
450
- run_id=result.run_id,
451
- execution_time=result.alive_time(),
465
+ context=context,
466
+ parent_run_id=parent_run_id,
467
+ run_id=run_id,
468
+ execution_time=rs.info.get("execution_time", 0),
452
469
  extras=self.extras,
453
470
  ).save(excluded=excluded)
454
471
  )
455
- return result.catch(
472
+ return Result(
473
+ run_id=run_id,
474
+ parent_run_id=parent_run_id,
456
475
  status=rs.status,
457
- context={
458
- "params": params,
459
- "release": {
460
- "type": release_type,
461
- "logical_date": release,
476
+ context=catch(
477
+ context,
478
+ status=rs.status,
479
+ updated={
480
+ "params": params,
481
+ "release": {
482
+ "type": release_type,
483
+ "logical_date": release,
484
+ },
485
+ **{"jobs": context.pop("jobs", {})},
486
+ **(context["errors"] if "errors" in context else {}),
462
487
  },
463
- **{"jobs": result.context.pop("jobs", {})},
464
- **(
465
- result.context["errors"]
466
- if "errors" in result.context
467
- else {}
468
- ),
469
- },
488
+ ),
489
+ extras=self.extras,
470
490
  )
471
491
 
472
492
  def execute_job(
473
493
  self,
474
494
  job: Job,
475
495
  params: DictData,
496
+ run_id: str,
497
+ context: DictData,
476
498
  *,
477
- result: Optional[Result] = None,
478
- event: Optional[Event] = None,
479
- ) -> tuple[Status, Result]:
499
+ parent_run_id: Optional[str] = None,
500
+ event: Optional[ThreadEvent] = None,
501
+ ) -> tuple[Status, DictData]:
480
502
  """Job execution with passing dynamic parameters from the main workflow
481
503
  execution to the target job object via job's ID.
482
504
 
@@ -487,42 +509,48 @@ class Workflow(BaseModel):
487
509
  This method do not raise any error, and it will handle all exception
488
510
  from the job execution.
489
511
 
490
- :param job: (Job) A job model that want to execute.
491
- :param params: (DictData) A parameter data.
492
- :param result: (Result) A Result instance for return context and status.
493
- :param event: (Event) An Event manager instance that use to cancel this
512
+ Args:
513
+ job: (Job) A job model that want to execute.
514
+ params: (DictData) A parameter data.
515
+ run_id: A running stage ID.
516
+ context: A context data.
517
+ parent_run_id: A parent running ID. (Default is None)
518
+ event: (Event) An Event manager instance that use to cancel this
494
519
  execution if it forces stopped by parent execution.
495
520
 
496
- :rtype: tuple[Status, Result]
521
+ Returns:
522
+ tuple[Status, DictData]: The pair of status and result context data.
497
523
  """
498
- result: Result = result or Result(run_id=gen_id(self.name, unique=True))
499
-
524
+ trace: Trace = get_trace(
525
+ run_id, parent_run_id=parent_run_id, extras=self.extras
526
+ )
500
527
  if event and event.is_set():
501
528
  error_msg: str = (
502
529
  "Job execution was canceled because the event was set "
503
530
  "before start job execution."
504
531
  )
505
- return CANCEL, result.catch(
532
+ return CANCEL, catch(
533
+ context=context,
506
534
  status=CANCEL,
507
- context={
535
+ updated={
508
536
  "errors": WorkflowCancelError(error_msg).to_dict(),
509
537
  },
510
538
  )
511
539
 
512
- result.trace.info(f"[WORKFLOW]: Execute Job: {job.id!r}")
540
+ trace.info(f"[WORKFLOW]: Execute Job: {job.id!r}")
513
541
  rs: Result = job.execute(
514
542
  params=params,
515
- run_id=result.run_id,
516
- parent_run_id=result.parent_run_id,
543
+ run_id=parent_run_id,
517
544
  event=event,
518
545
  )
519
546
  job.set_outputs(rs.context, to=params)
520
547
 
521
548
  if rs.status == FAILED:
522
549
  error_msg: str = f"Job execution, {job.id!r}, was failed."
523
- return FAILED, result.catch(
550
+ return FAILED, catch(
551
+ context=context,
524
552
  status=FAILED,
525
- context={
553
+ updated={
526
554
  "errors": WorkflowError(error_msg).to_dict(),
527
555
  **params,
528
556
  },
@@ -533,23 +561,25 @@ class Workflow(BaseModel):
533
561
  f"Job execution, {job.id!r}, was canceled from the event after "
534
562
  f"end job execution."
535
563
  )
536
- return CANCEL, result.catch(
564
+ return CANCEL, catch(
565
+ context=context,
537
566
  status=CANCEL,
538
- context={
567
+ updated={
539
568
  "errors": WorkflowCancelError(error_msg).to_dict(),
540
569
  **params,
541
570
  },
542
571
  )
543
572
 
544
- return rs.status, result.catch(status=rs.status, context=params)
573
+ return rs.status, catch(
574
+ context=context, status=rs.status, updated=params
575
+ )
545
576
 
546
577
  def execute(
547
578
  self,
548
579
  params: DictData,
549
580
  *,
550
581
  run_id: Optional[str] = None,
551
- parent_run_id: Optional[str] = None,
552
- event: Optional[Event] = None,
582
+ event: Optional[ThreadEvent] = None,
553
583
  timeout: float = 3600,
554
584
  max_job_parallel: int = 2,
555
585
  ) -> Result:
@@ -598,7 +628,6 @@ class Workflow(BaseModel):
598
628
 
599
629
  :param params: A parameter data that will parameterize before execution.
600
630
  :param run_id: (Optional[str]) A workflow running ID.
601
- :param parent_run_id: (Optional[str]) A parent workflow running ID.
602
631
  :param event: (Event) An Event manager instance that use to cancel this
603
632
  execution if it forces stopped by parent execution.
604
633
  :param timeout: (float) A workflow execution time out in second unit
@@ -611,24 +640,30 @@ class Workflow(BaseModel):
611
640
  :rtype: Result
612
641
  """
613
642
  ts: float = time.monotonic()
614
- result: Result = Result.construct_with_rs_or_id(
615
- run_id=run_id,
616
- parent_run_id=parent_run_id,
617
- id_logic=self.name,
618
- extras=self.extras,
643
+ parent_run_id: Optional[str] = run_id
644
+ run_id: str = gen_id(self.name, extras=self.extras)
645
+ trace: Trace = get_trace(
646
+ run_id, parent_run_id=parent_run_id, extras=self.extras
619
647
  )
620
648
  context: DictData = self.parameterize(params)
621
- event: Event = event or Event()
649
+ event: ThreadEvent = event or ThreadEvent()
622
650
  max_job_parallel: int = dynamic(
623
651
  "max_job_parallel", f=max_job_parallel, extras=self.extras
624
652
  )
625
- result.trace.info(
653
+ trace.info(
626
654
  f"[WORKFLOW]: Execute: {self.name!r} ("
627
655
  f"{'parallel' if max_job_parallel > 1 else 'sequential'} jobs)"
628
656
  )
629
657
  if not self.jobs:
630
- result.trace.warning(f"[WORKFLOW]: {self.name!r} does not set jobs")
631
- return result.catch(status=SUCCESS, context=context)
658
+ trace.warning(f"[WORKFLOW]: {self.name!r} does not set jobs")
659
+ return Result(
660
+ run_id=run_id,
661
+ parent_run_id=parent_run_id,
662
+ status=SUCCESS,
663
+ context=catch(context, status=SUCCESS),
664
+ info={"execution_time": time.monotonic() - ts},
665
+ extras=self.extras,
666
+ )
632
667
 
633
668
  job_queue: Queue = Queue()
634
669
  for job_id in self.jobs:
@@ -642,20 +677,30 @@ class Workflow(BaseModel):
642
677
  timeout: float = dynamic(
643
678
  "max_job_exec_timeout", f=timeout, extras=self.extras
644
679
  )
645
- result.catch(status=WAIT, context=context)
680
+ catch(context, status=WAIT)
646
681
  if event and event.is_set():
647
- return result.catch(
682
+ return Result(
683
+ run_id=run_id,
684
+ parent_run_id=parent_run_id,
648
685
  status=CANCEL,
649
- context={
650
- "errors": WorkflowCancelError(
651
- "Execution was canceled from the event was set before "
652
- "workflow execution."
653
- ).to_dict(),
654
- },
686
+ context=catch(
687
+ context,
688
+ status=CANCEL,
689
+ updated={
690
+ "errors": WorkflowCancelError(
691
+ "Execution was canceled from the event was set "
692
+ "before workflow execution."
693
+ ).to_dict(),
694
+ },
695
+ ),
696
+ info={"execution_time": time.monotonic() - ts},
697
+ extras=self.extras,
655
698
  )
656
699
 
657
700
  with ThreadPoolExecutor(max_job_parallel, "wf") as executor:
658
701
  futures: list[Future] = []
702
+ backoff_sleep = 0.01 # Start with smaller sleep time
703
+ consecutive_waits = 0 # Track consecutive wait states
659
704
 
660
705
  while not job_queue.empty() and (
661
706
  not_timeout_flag := ((time.monotonic() - ts) < timeout)
@@ -665,21 +710,37 @@ class Workflow(BaseModel):
665
710
  if (check := job.check_needs(context["jobs"])) == WAIT:
666
711
  job_queue.task_done()
667
712
  job_queue.put(job_id)
668
- time.sleep(0.15)
713
+ consecutive_waits += 1
714
+ # Exponential backoff up to 0.15s max
715
+ backoff_sleep = min(backoff_sleep * 1.5, 0.15)
716
+ time.sleep(backoff_sleep)
669
717
  continue
670
- elif check == FAILED: # pragma: no cov
671
- return result.catch(
718
+
719
+ # Reset backoff when we can proceed
720
+ consecutive_waits = 0
721
+ backoff_sleep = 0.01
722
+
723
+ if check == FAILED: # pragma: no cov
724
+ return Result(
725
+ run_id=run_id,
726
+ parent_run_id=parent_run_id,
672
727
  status=FAILED,
673
- context={
674
- "status": FAILED,
675
- "errors": WorkflowError(
676
- f"Validate job trigger rule was failed with "
677
- f"{job.trigger_rule.value!r}."
678
- ).to_dict(),
679
- },
728
+ context=catch(
729
+ context,
730
+ status=FAILED,
731
+ updated={
732
+ "status": FAILED,
733
+ "errors": WorkflowError(
734
+ f"Validate job trigger rule was failed "
735
+ f"with {job.trigger_rule.value!r}."
736
+ ).to_dict(),
737
+ },
738
+ ),
739
+ info={"execution_time": time.monotonic() - ts},
740
+ extras=self.extras,
680
741
  )
681
742
  elif check == SKIP: # pragma: no cov
682
- result.trace.info(
743
+ trace.info(
683
744
  f"[JOB]: Skip job: {job_id!r} from trigger rule."
684
745
  )
685
746
  job.set_outputs(output={"status": SKIP}, to=context)
@@ -693,7 +754,9 @@ class Workflow(BaseModel):
693
754
  self.execute_job,
694
755
  job=job,
695
756
  params=context,
696
- result=result,
757
+ run_id=run_id,
758
+ context=context,
759
+ parent_run_id=parent_run_id,
697
760
  event=event,
698
761
  ),
699
762
  )
@@ -706,7 +769,9 @@ class Workflow(BaseModel):
706
769
  self.execute_job,
707
770
  job=job,
708
771
  params=context,
709
- result=result,
772
+ run_id=run_id,
773
+ context=context,
774
+ parent_run_id=parent_run_id,
710
775
  event=event,
711
776
  )
712
777
  )
@@ -726,7 +791,7 @@ class Workflow(BaseModel):
726
791
  else: # pragma: no cov
727
792
  job_queue.put(job_id)
728
793
  futures.insert(0, future)
729
- result.trace.warning(
794
+ trace.warning(
730
795
  f"[WORKFLOW]: ... Execution non-threading not "
731
796
  f"handle: {future}."
732
797
  )
@@ -749,44 +814,58 @@ class Workflow(BaseModel):
749
814
  for i, s in enumerate(sequence_statuses, start=0):
750
815
  statuses[total + 1 + skip_count + i] = s
751
816
 
752
- return result.catch(
753
- status=validate_statuses(statuses), context=context
817
+ st: Status = validate_statuses(statuses)
818
+ return Result(
819
+ run_id=run_id,
820
+ parent_run_id=parent_run_id,
821
+ status=st,
822
+ context=catch(context, status=st),
823
+ info={"execution_time": time.monotonic() - ts},
824
+ extras=self.extras,
754
825
  )
755
826
 
756
827
  event.set()
757
828
  for future in futures:
758
829
  future.cancel()
759
830
 
760
- result.trace.error(
831
+ trace.error(
761
832
  f"[WORKFLOW]: {self.name!r} was timeout because it use exec "
762
833
  f"time more than {timeout} seconds."
763
834
  )
764
835
 
765
836
  time.sleep(0.0025)
766
837
 
767
- return result.catch(
838
+ return Result(
839
+ run_id=run_id,
840
+ parent_run_id=parent_run_id,
768
841
  status=FAILED,
769
- context={
770
- "errors": WorkflowTimeoutError(
771
- f"{self.name!r} was timeout because it use exec time more "
772
- f"than {timeout} seconds."
773
- ).to_dict(),
774
- },
842
+ context=catch(
843
+ context,
844
+ status=FAILED,
845
+ updated={
846
+ "errors": WorkflowTimeoutError(
847
+ f"{self.name!r} was timeout because it use exec time "
848
+ f"more than {timeout} seconds."
849
+ ).to_dict(),
850
+ },
851
+ ),
852
+ info={"execution_time": time.monotonic() - ts},
853
+ extras=self.extras,
775
854
  )
776
855
 
777
856
  def rerun(
778
857
  self,
779
858
  context: DictData,
780
859
  *,
781
- parent_run_id: Optional[str] = None,
782
- event: Optional[Event] = None,
860
+ run_id: Optional[str] = None,
861
+ event: Optional[ThreadEvent] = None,
783
862
  timeout: float = 3600,
784
863
  max_job_parallel: int = 2,
785
864
  ) -> Result:
786
865
  """Re-Execute workflow with passing the error context data.
787
866
 
788
867
  :param context: A context result that get the failed status.
789
- :param parent_run_id: (Optional[str]) A parent workflow running ID.
868
+ :param run_id: (Optional[str]) A workflow running ID.
790
869
  :param event: (Event) An Event manager instance that use to cancel this
791
870
  execution if it forces stopped by parent execution.
792
871
  :param timeout: (float) A workflow execution time out in second unit
@@ -796,36 +875,49 @@ class Workflow(BaseModel):
796
875
  :param max_job_parallel: (int) The maximum workers that use for job
797
876
  execution in `ThreadPoolExecutor` object. (Default: 2 workers)
798
877
 
799
- :rtype: Result
878
+ Returns
879
+ Result: Return Result object that create from execution context with
880
+ return mode.
800
881
  """
801
882
  ts: float = time.monotonic()
802
-
803
- result: Result = Result.construct_with_rs_or_id(
804
- parent_run_id=parent_run_id,
805
- id_logic=self.name,
806
- extras=self.extras,
883
+ parent_run_id: str = run_id
884
+ run_id: str = gen_id(self.name, extras=self.extras)
885
+ trace: Trace = get_trace(
886
+ run_id, parent_run_id=parent_run_id, extras=self.extras
807
887
  )
808
888
  if context["status"] == SUCCESS:
809
- result.trace.info(
889
+ trace.info(
810
890
  "[WORKFLOW]: Does not rerun because it already executed with "
811
891
  "success status."
812
892
  )
813
- return result.catch(status=SUCCESS, context=context)
893
+ return Result(
894
+ run_id=run_id,
895
+ parent_run_id=parent_run_id,
896
+ status=SUCCESS,
897
+ context=catch(context=context, status=SUCCESS),
898
+ extras=self.extras,
899
+ )
814
900
 
815
901
  err = context["errors"]
816
- result.trace.info(f"[WORKFLOW]: Previous error: {err}")
902
+ trace.info(f"[WORKFLOW]: Previous error: {err}")
817
903
 
818
- event: Event = event or Event()
904
+ event: ThreadEvent = event or ThreadEvent()
819
905
  max_job_parallel: int = dynamic(
820
906
  "max_job_parallel", f=max_job_parallel, extras=self.extras
821
907
  )
822
- result.trace.info(
908
+ trace.info(
823
909
  f"[WORKFLOW]: Execute: {self.name!r} ("
824
910
  f"{'parallel' if max_job_parallel > 1 else 'sequential'} jobs)"
825
911
  )
826
912
  if not self.jobs:
827
- result.trace.warning(f"[WORKFLOW]: {self.name!r} does not set jobs")
828
- return result.catch(status=SUCCESS, context=context)
913
+ trace.warning(f"[WORKFLOW]: {self.name!r} does not set jobs")
914
+ return Result(
915
+ run_id=run_id,
916
+ parent_run_id=parent_run_id,
917
+ status=SUCCESS,
918
+ context=catch(context=context, status=SUCCESS),
919
+ extras=self.extras,
920
+ )
829
921
 
830
922
  # NOTE: Prepare the new context for rerun process.
831
923
  jobs: DictData = context.get("jobs")
@@ -845,8 +937,14 @@ class Workflow(BaseModel):
845
937
  total_job += 1
846
938
 
847
939
  if total_job == 0:
848
- result.trace.warning("[WORKFLOW]: It does not have job to rerun.")
849
- return result.catch(status=SUCCESS, context=context)
940
+ trace.warning("[WORKFLOW]: It does not have job to rerun.")
941
+ return Result(
942
+ run_id=run_id,
943
+ parent_run_id=parent_run_id,
944
+ status=SUCCESS,
945
+ context=catch(context=context, status=SUCCESS),
946
+ extras=self.extras,
947
+ )
850
948
 
851
949
  not_timeout_flag: bool = True
852
950
  statuses: list[Status] = [WAIT] * total_job
@@ -856,20 +954,29 @@ class Workflow(BaseModel):
856
954
  "max_job_exec_timeout", f=timeout, extras=self.extras
857
955
  )
858
956
 
859
- result.catch(status=WAIT, context=new_context)
957
+ catch(new_context, status=WAIT)
860
958
  if event and event.is_set():
861
- return result.catch(
959
+ return Result(
960
+ run_id=run_id,
961
+ parent_run_id=parent_run_id,
862
962
  status=CANCEL,
863
- context={
864
- "errors": WorkflowCancelError(
865
- "Execution was canceled from the event was set before "
866
- "workflow execution."
867
- ).to_dict(),
868
- },
963
+ context=catch(
964
+ new_context,
965
+ status=CANCEL,
966
+ updated={
967
+ "errors": WorkflowCancelError(
968
+ "Execution was canceled from the event was set "
969
+ "before workflow execution."
970
+ ).to_dict(),
971
+ },
972
+ ),
973
+ extras=self.extras,
869
974
  )
870
975
 
871
976
  with ThreadPoolExecutor(max_job_parallel, "wf") as executor:
872
977
  futures: list[Future] = []
978
+ backoff_sleep = 0.01
979
+ consecutive_waits = 0
873
980
 
874
981
  while not job_queue.empty() and (
875
982
  not_timeout_flag := ((time.monotonic() - ts) < timeout)
@@ -879,21 +986,37 @@ class Workflow(BaseModel):
879
986
  if (check := job.check_needs(new_context["jobs"])) == WAIT:
880
987
  job_queue.task_done()
881
988
  job_queue.put(job_id)
882
- time.sleep(0.15)
989
+ consecutive_waits += 1
990
+
991
+ # NOTE: Exponential backoff up to 0.15s max.
992
+ backoff_sleep = min(backoff_sleep * 1.5, 0.15)
993
+ time.sleep(backoff_sleep)
883
994
  continue
884
- elif check == FAILED: # pragma: no cov
885
- return result.catch(
995
+
996
+ # NOTE: Reset backoff when we can proceed
997
+ consecutive_waits = 0
998
+ backoff_sleep = 0.01
999
+
1000
+ if check == FAILED: # pragma: no cov
1001
+ return Result(
1002
+ run_id=run_id,
1003
+ parent_run_id=parent_run_id,
886
1004
  status=FAILED,
887
- context={
888
- "status": FAILED,
889
- "errors": WorkflowError(
890
- f"Validate job trigger rule was failed with "
891
- f"{job.trigger_rule.value!r}."
892
- ).to_dict(),
893
- },
1005
+ context=catch(
1006
+ new_context,
1007
+ status=FAILED,
1008
+ updated={
1009
+ "status": FAILED,
1010
+ "errors": WorkflowError(
1011
+ f"Validate job trigger rule was failed "
1012
+ f"with {job.trigger_rule.value!r}."
1013
+ ).to_dict(),
1014
+ },
1015
+ ),
1016
+ extras=self.extras,
894
1017
  )
895
1018
  elif check == SKIP: # pragma: no cov
896
- result.trace.info(
1019
+ trace.info(
897
1020
  f"[JOB]: Skip job: {job_id!r} from trigger rule."
898
1021
  )
899
1022
  job.set_outputs(output={"status": SKIP}, to=new_context)
@@ -907,7 +1030,9 @@ class Workflow(BaseModel):
907
1030
  self.execute_job,
908
1031
  job=job,
909
1032
  params=new_context,
910
- result=result,
1033
+ run_id=run_id,
1034
+ context=context,
1035
+ parent_run_id=parent_run_id,
911
1036
  event=event,
912
1037
  ),
913
1038
  )
@@ -920,7 +1045,9 @@ class Workflow(BaseModel):
920
1045
  self.execute_job,
921
1046
  job=job,
922
1047
  params=new_context,
923
- result=result,
1048
+ run_id=run_id,
1049
+ context=context,
1050
+ parent_run_id=parent_run_id,
924
1051
  event=event,
925
1052
  )
926
1053
  )
@@ -940,7 +1067,7 @@ class Workflow(BaseModel):
940
1067
  else: # pragma: no cov
941
1068
  job_queue.put(job_id)
942
1069
  futures.insert(0, future)
943
- result.trace.warning(
1070
+ trace.warning(
944
1071
  f"[WORKFLOW]: ... Execution non-threading not "
945
1072
  f"handle: {future}."
946
1073
  )
@@ -963,27 +1090,39 @@ class Workflow(BaseModel):
963
1090
  for i, s in enumerate(sequence_statuses, start=0):
964
1091
  statuses[total + 1 + skip_count + i] = s
965
1092
 
966
- return result.catch(
967
- status=validate_statuses(statuses), context=new_context
1093
+ st: Status = validate_statuses(statuses)
1094
+ return Result(
1095
+ run_id=run_id,
1096
+ parent_run_id=parent_run_id,
1097
+ status=st,
1098
+ context=catch(new_context, status=st),
1099
+ extras=self.extras,
968
1100
  )
969
1101
 
970
1102
  event.set()
971
1103
  for future in futures:
972
1104
  future.cancel()
973
1105
 
974
- result.trace.error(
1106
+ trace.error(
975
1107
  f"[WORKFLOW]: {self.name!r} was timeout because it use exec "
976
1108
  f"time more than {timeout} seconds."
977
1109
  )
978
1110
 
979
1111
  time.sleep(0.0025)
980
1112
 
981
- return result.catch(
1113
+ return Result(
1114
+ run_id=run_id,
1115
+ parent_run_id=parent_run_id,
982
1116
  status=FAILED,
983
- context={
984
- "errors": WorkflowTimeoutError(
985
- f"{self.name!r} was timeout because it use exec time more "
986
- f"than {timeout} seconds."
987
- ).to_dict(),
988
- },
1117
+ context=catch(
1118
+ new_context,
1119
+ status=FAILED,
1120
+ updated={
1121
+ "errors": WorkflowTimeoutError(
1122
+ f"{self.name!r} was timeout because it use exec time "
1123
+ f"more than {timeout} seconds."
1124
+ ).to_dict(),
1125
+ },
1126
+ ),
1127
+ extras=self.extras,
989
1128
  )