ddeutil-workflow 0.0.72__py3-none-any.whl → 0.0.74__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,19 @@ 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
37
+ from threading import Event as ThreadEvent
38
+ from typing import Any, Optional, Union
28
39
  from zoneinfo import ZoneInfo
29
40
 
30
41
  from pydantic import BaseModel, Field
31
42
  from pydantic.functional_validators import field_validator, model_validator
32
43
  from typing_extensions import Self
33
44
 
34
- from . import get_status_from_error
35
45
  from .__types import DictData
36
- from .audits import Audit, get_audit
46
+ from .audits import Audit, get_audit_model
37
47
  from .conf import YamlParser, dynamic
38
48
  from .errors import WorkflowCancelError, WorkflowError, WorkflowTimeoutError
39
- from .event import Crontab
49
+ from .event import Event
40
50
  from .job import Job
41
51
  from .params import Param
42
52
  from .result import (
@@ -47,17 +57,31 @@ from .result import (
47
57
  WAIT,
48
58
  Result,
49
59
  Status,
60
+ catch,
61
+ get_status_from_error,
50
62
  validate_statuses,
51
63
  )
52
64
  from .reusables import has_template, param2template
65
+ from .traces import Trace, get_trace
53
66
  from .utils import (
54
67
  gen_id,
68
+ get_dt_ntz_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_ntz_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_ntz_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"))
200
-
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.
241
+ Args:
242
+ data: A description string value that want to dedent.
211
243
 
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
- )
247
+ return dedent(data.lstrip("\n"))
221
248
 
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
- )
234
-
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,15 +363,17 @@ 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
372
  release: datetime = replace_sec(dt.replace(tzinfo=None))
354
373
  if not self.on:
355
374
  return release
356
375
 
357
- for on in self.on:
376
+ for on in self.on.schedule:
358
377
  if release == on.cronjob.schedule(release).next:
359
378
  return release
360
379
  raise WorkflowError(
@@ -368,10 +387,8 @@ class Workflow(BaseModel):
368
387
  *,
369
388
  release_type: ReleaseType = NORMAL,
370
389
  run_id: Optional[str] = None,
371
- parent_run_id: Optional[str] = None,
372
390
  audit: type[Audit] = None,
373
391
  override_log_name: Optional[str] = None,
374
- result: Optional[Result] = None,
375
392
  timeout: int = 600,
376
393
  excluded: Optional[list[str]] = None,
377
394
  ) -> Result:
@@ -393,31 +410,28 @@ class Workflow(BaseModel):
393
410
  :param params: A workflow parameter that pass to execute method.
394
411
  :param release_type:
395
412
  :param run_id: (str) A workflow running ID.
396
- :param parent_run_id: (str) A parent workflow running ID.
397
413
  :param audit: An audit class that want to save the execution result.
398
414
  :param override_log_name: (str) An override logging name that use
399
415
  instead the workflow name.
400
- :param result: (Result) A result object for keeping context and status
401
- data.
402
416
  :param timeout: (int) A workflow execution time out in second unit.
403
417
  :param excluded: (list[str]) A list of key that want to exclude from
404
418
  audit data.
405
419
 
406
420
  :rtype: Result
407
421
  """
408
- audit: type[Audit] = audit or get_audit(extras=self.extras)
409
422
  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,
423
+ if run_id:
424
+ parent_run_id: str = run_id
425
+ run_id: str = gen_id(name, unique=True)
426
+ else:
427
+ run_id: str = gen_id(name, unique=True)
428
+ parent_run_id: str = run_id
429
+ context: DictData = {}
430
+ trace: Trace = get_trace(
431
+ run_id, parent_run_id=parent_run_id, extras=self.extras
416
432
  )
417
433
  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
- )
434
+ trace.info(f"[RELEASE]: Start {name!r} : {release:%Y-%m-%d %H:%M:%S}")
421
435
  tz: ZoneInfo = dynamic("tz", extras=self.extras)
422
436
  values: DictData = param2template(
423
437
  params,
@@ -425,58 +439,61 @@ class Workflow(BaseModel):
425
439
  "release": {
426
440
  "logical_date": release,
427
441
  "execute_date": datetime.now(tz=tz),
428
- "run_id": result.run_id,
442
+ "run_id": run_id,
429
443
  }
430
444
  },
431
445
  extras=self.extras,
432
446
  )
433
447
  rs: Result = self.execute(
434
448
  params=values,
435
- parent_run_id=result.run_id,
449
+ run_id=parent_run_id,
436
450
  timeout=timeout,
437
451
  )
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}.")
452
+ catch(context, status=rs.status, updated=rs.context)
453
+ trace.info(f"[RELEASE]: End {name!r} : {release:%Y-%m-%d %H:%M:%S}")
454
+ trace.debug(f"[RELEASE]: Writing audit: {name!r}.")
443
455
  (
444
- audit(
456
+ (audit or get_audit_model(extras=self.extras))(
445
457
  name=name,
446
458
  release=release,
447
459
  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(),
460
+ context=context,
461
+ parent_run_id=parent_run_id,
462
+ run_id=run_id,
463
+ execution_time=rs.info.get("execution_time", 0),
452
464
  extras=self.extras,
453
465
  ).save(excluded=excluded)
454
466
  )
455
- return result.catch(
467
+ return Result(
468
+ run_id=run_id,
469
+ parent_run_id=parent_run_id,
456
470
  status=rs.status,
457
- context={
458
- "params": params,
459
- "release": {
460
- "type": release_type,
461
- "logical_date": release,
471
+ context=catch(
472
+ context,
473
+ status=rs.status,
474
+ updated={
475
+ "params": params,
476
+ "release": {
477
+ "type": release_type,
478
+ "logical_date": release,
479
+ },
480
+ **{"jobs": context.pop("jobs", {})},
481
+ **(context["errors"] if "errors" in context else {}),
462
482
  },
463
- **{"jobs": result.context.pop("jobs", {})},
464
- **(
465
- result.context["errors"]
466
- if "errors" in result.context
467
- else {}
468
- ),
469
- },
483
+ ),
484
+ extras=self.extras,
470
485
  )
471
486
 
472
487
  def execute_job(
473
488
  self,
474
489
  job: Job,
475
490
  params: DictData,
491
+ run_id: str,
492
+ context: DictData,
476
493
  *,
477
- result: Optional[Result] = None,
478
- event: Optional[Event] = None,
479
- ) -> tuple[Status, Result]:
494
+ parent_run_id: Optional[str] = None,
495
+ event: Optional[ThreadEvent] = None,
496
+ ) -> tuple[Status, DictData]:
480
497
  """Job execution with passing dynamic parameters from the main workflow
481
498
  execution to the target job object via job's ID.
482
499
 
@@ -487,42 +504,48 @@ class Workflow(BaseModel):
487
504
  This method do not raise any error, and it will handle all exception
488
505
  from the job execution.
489
506
 
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
507
+ Args:
508
+ job: (Job) A job model that want to execute.
509
+ params: (DictData) A parameter data.
510
+ run_id: A running stage ID.
511
+ context: A context data.
512
+ parent_run_id: A parent running ID. (Default is None)
513
+ event: (Event) An Event manager instance that use to cancel this
494
514
  execution if it forces stopped by parent execution.
495
515
 
496
- :rtype: tuple[Status, Result]
516
+ Returns:
517
+ tuple[Status, DictData]: The pair of status and result context data.
497
518
  """
498
- result: Result = result or Result(run_id=gen_id(self.name, unique=True))
499
-
519
+ trace: Trace = get_trace(
520
+ run_id, parent_run_id=parent_run_id, extras=self.extras
521
+ )
500
522
  if event and event.is_set():
501
523
  error_msg: str = (
502
524
  "Job execution was canceled because the event was set "
503
525
  "before start job execution."
504
526
  )
505
- return CANCEL, result.catch(
527
+ return CANCEL, catch(
528
+ context=context,
506
529
  status=CANCEL,
507
- context={
530
+ updated={
508
531
  "errors": WorkflowCancelError(error_msg).to_dict(),
509
532
  },
510
533
  )
511
534
 
512
- result.trace.info(f"[WORKFLOW]: Execute Job: {job.id!r}")
535
+ trace.info(f"[WORKFLOW]: Execute Job: {job.id!r}")
513
536
  rs: Result = job.execute(
514
537
  params=params,
515
- run_id=result.run_id,
516
- parent_run_id=result.parent_run_id,
538
+ run_id=parent_run_id,
517
539
  event=event,
518
540
  )
519
541
  job.set_outputs(rs.context, to=params)
520
542
 
521
543
  if rs.status == FAILED:
522
544
  error_msg: str = f"Job execution, {job.id!r}, was failed."
523
- return FAILED, result.catch(
545
+ return FAILED, catch(
546
+ context=context,
524
547
  status=FAILED,
525
- context={
548
+ updated={
526
549
  "errors": WorkflowError(error_msg).to_dict(),
527
550
  **params,
528
551
  },
@@ -533,23 +556,25 @@ class Workflow(BaseModel):
533
556
  f"Job execution, {job.id!r}, was canceled from the event after "
534
557
  f"end job execution."
535
558
  )
536
- return CANCEL, result.catch(
559
+ return CANCEL, catch(
560
+ context=context,
537
561
  status=CANCEL,
538
- context={
562
+ updated={
539
563
  "errors": WorkflowCancelError(error_msg).to_dict(),
540
564
  **params,
541
565
  },
542
566
  )
543
567
 
544
- return rs.status, result.catch(status=rs.status, context=params)
568
+ return rs.status, catch(
569
+ context=context, status=rs.status, updated=params
570
+ )
545
571
 
546
572
  def execute(
547
573
  self,
548
574
  params: DictData,
549
575
  *,
550
576
  run_id: Optional[str] = None,
551
- parent_run_id: Optional[str] = None,
552
- event: Optional[Event] = None,
577
+ event: Optional[ThreadEvent] = None,
553
578
  timeout: float = 3600,
554
579
  max_job_parallel: int = 2,
555
580
  ) -> Result:
@@ -598,7 +623,6 @@ class Workflow(BaseModel):
598
623
 
599
624
  :param params: A parameter data that will parameterize before execution.
600
625
  :param run_id: (Optional[str]) A workflow running ID.
601
- :param parent_run_id: (Optional[str]) A parent workflow running ID.
602
626
  :param event: (Event) An Event manager instance that use to cancel this
603
627
  execution if it forces stopped by parent execution.
604
628
  :param timeout: (float) A workflow execution time out in second unit
@@ -611,24 +635,30 @@ class Workflow(BaseModel):
611
635
  :rtype: Result
612
636
  """
613
637
  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,
638
+ parent_run_id: Optional[str] = run_id
639
+ run_id: str = gen_id(self.name, extras=self.extras)
640
+ trace: Trace = get_trace(
641
+ run_id, parent_run_id=parent_run_id, extras=self.extras
619
642
  )
620
643
  context: DictData = self.parameterize(params)
621
- event: Event = event or Event()
644
+ event: ThreadEvent = event or ThreadEvent()
622
645
  max_job_parallel: int = dynamic(
623
646
  "max_job_parallel", f=max_job_parallel, extras=self.extras
624
647
  )
625
- result.trace.info(
648
+ trace.info(
626
649
  f"[WORKFLOW]: Execute: {self.name!r} ("
627
650
  f"{'parallel' if max_job_parallel > 1 else 'sequential'} jobs)"
628
651
  )
629
652
  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)
653
+ trace.warning(f"[WORKFLOW]: {self.name!r} does not set jobs")
654
+ return Result(
655
+ run_id=run_id,
656
+ parent_run_id=parent_run_id,
657
+ status=SUCCESS,
658
+ context=catch(context, status=SUCCESS),
659
+ info={"execution_time": time.monotonic() - ts},
660
+ extras=self.extras,
661
+ )
632
662
 
633
663
  job_queue: Queue = Queue()
634
664
  for job_id in self.jobs:
@@ -642,20 +672,30 @@ class Workflow(BaseModel):
642
672
  timeout: float = dynamic(
643
673
  "max_job_exec_timeout", f=timeout, extras=self.extras
644
674
  )
645
- result.catch(status=WAIT, context=context)
675
+ catch(context, status=WAIT)
646
676
  if event and event.is_set():
647
- return result.catch(
677
+ return Result(
678
+ run_id=run_id,
679
+ parent_run_id=parent_run_id,
648
680
  status=CANCEL,
649
- context={
650
- "errors": WorkflowCancelError(
651
- "Execution was canceled from the event was set before "
652
- "workflow execution."
653
- ).to_dict(),
654
- },
681
+ context=catch(
682
+ context,
683
+ status=CANCEL,
684
+ updated={
685
+ "errors": WorkflowCancelError(
686
+ "Execution was canceled from the event was set "
687
+ "before workflow execution."
688
+ ).to_dict(),
689
+ },
690
+ ),
691
+ info={"execution_time": time.monotonic() - ts},
692
+ extras=self.extras,
655
693
  )
656
694
 
657
695
  with ThreadPoolExecutor(max_job_parallel, "wf") as executor:
658
696
  futures: list[Future] = []
697
+ backoff_sleep = 0.01 # Start with smaller sleep time
698
+ consecutive_waits = 0 # Track consecutive wait states
659
699
 
660
700
  while not job_queue.empty() and (
661
701
  not_timeout_flag := ((time.monotonic() - ts) < timeout)
@@ -665,21 +705,37 @@ class Workflow(BaseModel):
665
705
  if (check := job.check_needs(context["jobs"])) == WAIT:
666
706
  job_queue.task_done()
667
707
  job_queue.put(job_id)
668
- time.sleep(0.15)
708
+ consecutive_waits += 1
709
+ # Exponential backoff up to 0.15s max
710
+ backoff_sleep = min(backoff_sleep * 1.5, 0.15)
711
+ time.sleep(backoff_sleep)
669
712
  continue
670
- elif check == FAILED: # pragma: no cov
671
- return result.catch(
713
+
714
+ # Reset backoff when we can proceed
715
+ consecutive_waits = 0
716
+ backoff_sleep = 0.01
717
+
718
+ if check == FAILED: # pragma: no cov
719
+ return Result(
720
+ run_id=run_id,
721
+ parent_run_id=parent_run_id,
672
722
  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
- },
723
+ context=catch(
724
+ context,
725
+ status=FAILED,
726
+ updated={
727
+ "status": FAILED,
728
+ "errors": WorkflowError(
729
+ f"Validate job trigger rule was failed "
730
+ f"with {job.trigger_rule.value!r}."
731
+ ).to_dict(),
732
+ },
733
+ ),
734
+ info={"execution_time": time.monotonic() - ts},
735
+ extras=self.extras,
680
736
  )
681
737
  elif check == SKIP: # pragma: no cov
682
- result.trace.info(
738
+ trace.info(
683
739
  f"[JOB]: Skip job: {job_id!r} from trigger rule."
684
740
  )
685
741
  job.set_outputs(output={"status": SKIP}, to=context)
@@ -693,7 +749,9 @@ class Workflow(BaseModel):
693
749
  self.execute_job,
694
750
  job=job,
695
751
  params=context,
696
- result=result,
752
+ run_id=run_id,
753
+ context=context,
754
+ parent_run_id=parent_run_id,
697
755
  event=event,
698
756
  ),
699
757
  )
@@ -706,7 +764,9 @@ class Workflow(BaseModel):
706
764
  self.execute_job,
707
765
  job=job,
708
766
  params=context,
709
- result=result,
767
+ run_id=run_id,
768
+ context=context,
769
+ parent_run_id=parent_run_id,
710
770
  event=event,
711
771
  )
712
772
  )
@@ -726,7 +786,7 @@ class Workflow(BaseModel):
726
786
  else: # pragma: no cov
727
787
  job_queue.put(job_id)
728
788
  futures.insert(0, future)
729
- result.trace.warning(
789
+ trace.warning(
730
790
  f"[WORKFLOW]: ... Execution non-threading not "
731
791
  f"handle: {future}."
732
792
  )
@@ -749,44 +809,58 @@ class Workflow(BaseModel):
749
809
  for i, s in enumerate(sequence_statuses, start=0):
750
810
  statuses[total + 1 + skip_count + i] = s
751
811
 
752
- return result.catch(
753
- status=validate_statuses(statuses), context=context
812
+ st: Status = validate_statuses(statuses)
813
+ return Result(
814
+ run_id=run_id,
815
+ parent_run_id=parent_run_id,
816
+ status=st,
817
+ context=catch(context, status=st),
818
+ info={"execution_time": time.monotonic() - ts},
819
+ extras=self.extras,
754
820
  )
755
821
 
756
822
  event.set()
757
823
  for future in futures:
758
824
  future.cancel()
759
825
 
760
- result.trace.error(
826
+ trace.error(
761
827
  f"[WORKFLOW]: {self.name!r} was timeout because it use exec "
762
828
  f"time more than {timeout} seconds."
763
829
  )
764
830
 
765
831
  time.sleep(0.0025)
766
832
 
767
- return result.catch(
833
+ return Result(
834
+ run_id=run_id,
835
+ parent_run_id=parent_run_id,
768
836
  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
- },
837
+ context=catch(
838
+ context,
839
+ status=FAILED,
840
+ updated={
841
+ "errors": WorkflowTimeoutError(
842
+ f"{self.name!r} was timeout because it use exec time "
843
+ f"more than {timeout} seconds."
844
+ ).to_dict(),
845
+ },
846
+ ),
847
+ info={"execution_time": time.monotonic() - ts},
848
+ extras=self.extras,
775
849
  )
776
850
 
777
851
  def rerun(
778
852
  self,
779
853
  context: DictData,
780
854
  *,
781
- parent_run_id: Optional[str] = None,
782
- event: Optional[Event] = None,
855
+ run_id: Optional[str] = None,
856
+ event: Optional[ThreadEvent] = None,
783
857
  timeout: float = 3600,
784
858
  max_job_parallel: int = 2,
785
859
  ) -> Result:
786
860
  """Re-Execute workflow with passing the error context data.
787
861
 
788
862
  :param context: A context result that get the failed status.
789
- :param parent_run_id: (Optional[str]) A parent workflow running ID.
863
+ :param run_id: (Optional[str]) A workflow running ID.
790
864
  :param event: (Event) An Event manager instance that use to cancel this
791
865
  execution if it forces stopped by parent execution.
792
866
  :param timeout: (float) A workflow execution time out in second unit
@@ -796,36 +870,49 @@ class Workflow(BaseModel):
796
870
  :param max_job_parallel: (int) The maximum workers that use for job
797
871
  execution in `ThreadPoolExecutor` object. (Default: 2 workers)
798
872
 
799
- :rtype: Result
873
+ Returns
874
+ Result: Return Result object that create from execution context with
875
+ return mode.
800
876
  """
801
877
  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,
878
+ parent_run_id: str = run_id
879
+ run_id: str = gen_id(self.name, extras=self.extras)
880
+ trace: Trace = get_trace(
881
+ run_id, parent_run_id=parent_run_id, extras=self.extras
807
882
  )
808
883
  if context["status"] == SUCCESS:
809
- result.trace.info(
884
+ trace.info(
810
885
  "[WORKFLOW]: Does not rerun because it already executed with "
811
886
  "success status."
812
887
  )
813
- return result.catch(status=SUCCESS, context=context)
888
+ return Result(
889
+ run_id=run_id,
890
+ parent_run_id=parent_run_id,
891
+ status=SUCCESS,
892
+ context=catch(context=context, status=SUCCESS),
893
+ extras=self.extras,
894
+ )
814
895
 
815
896
  err = context["errors"]
816
- result.trace.info(f"[WORKFLOW]: Previous error: {err}")
897
+ trace.info(f"[WORKFLOW]: Previous error: {err}")
817
898
 
818
- event: Event = event or Event()
899
+ event: ThreadEvent = event or ThreadEvent()
819
900
  max_job_parallel: int = dynamic(
820
901
  "max_job_parallel", f=max_job_parallel, extras=self.extras
821
902
  )
822
- result.trace.info(
903
+ trace.info(
823
904
  f"[WORKFLOW]: Execute: {self.name!r} ("
824
905
  f"{'parallel' if max_job_parallel > 1 else 'sequential'} jobs)"
825
906
  )
826
907
  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)
908
+ trace.warning(f"[WORKFLOW]: {self.name!r} does not set jobs")
909
+ return Result(
910
+ run_id=run_id,
911
+ parent_run_id=parent_run_id,
912
+ status=SUCCESS,
913
+ context=catch(context=context, status=SUCCESS),
914
+ extras=self.extras,
915
+ )
829
916
 
830
917
  # NOTE: Prepare the new context for rerun process.
831
918
  jobs: DictData = context.get("jobs")
@@ -845,8 +932,14 @@ class Workflow(BaseModel):
845
932
  total_job += 1
846
933
 
847
934
  if total_job == 0:
848
- result.trace.warning("[WORKFLOW]: It does not have job to rerun.")
849
- return result.catch(status=SUCCESS, context=context)
935
+ trace.warning("[WORKFLOW]: It does not have job to rerun.")
936
+ return Result(
937
+ run_id=run_id,
938
+ parent_run_id=parent_run_id,
939
+ status=SUCCESS,
940
+ context=catch(context=context, status=SUCCESS),
941
+ extras=self.extras,
942
+ )
850
943
 
851
944
  not_timeout_flag: bool = True
852
945
  statuses: list[Status] = [WAIT] * total_job
@@ -856,20 +949,29 @@ class Workflow(BaseModel):
856
949
  "max_job_exec_timeout", f=timeout, extras=self.extras
857
950
  )
858
951
 
859
- result.catch(status=WAIT, context=new_context)
952
+ catch(new_context, status=WAIT)
860
953
  if event and event.is_set():
861
- return result.catch(
954
+ return Result(
955
+ run_id=run_id,
956
+ parent_run_id=parent_run_id,
862
957
  status=CANCEL,
863
- context={
864
- "errors": WorkflowCancelError(
865
- "Execution was canceled from the event was set before "
866
- "workflow execution."
867
- ).to_dict(),
868
- },
958
+ context=catch(
959
+ new_context,
960
+ status=CANCEL,
961
+ updated={
962
+ "errors": WorkflowCancelError(
963
+ "Execution was canceled from the event was set "
964
+ "before workflow execution."
965
+ ).to_dict(),
966
+ },
967
+ ),
968
+ extras=self.extras,
869
969
  )
870
970
 
871
971
  with ThreadPoolExecutor(max_job_parallel, "wf") as executor:
872
972
  futures: list[Future] = []
973
+ backoff_sleep = 0.01
974
+ consecutive_waits = 0
873
975
 
874
976
  while not job_queue.empty() and (
875
977
  not_timeout_flag := ((time.monotonic() - ts) < timeout)
@@ -879,21 +981,37 @@ class Workflow(BaseModel):
879
981
  if (check := job.check_needs(new_context["jobs"])) == WAIT:
880
982
  job_queue.task_done()
881
983
  job_queue.put(job_id)
882
- time.sleep(0.15)
984
+ consecutive_waits += 1
985
+
986
+ # NOTE: Exponential backoff up to 0.15s max.
987
+ backoff_sleep = min(backoff_sleep * 1.5, 0.15)
988
+ time.sleep(backoff_sleep)
883
989
  continue
884
- elif check == FAILED: # pragma: no cov
885
- return result.catch(
990
+
991
+ # NOTE: Reset backoff when we can proceed
992
+ consecutive_waits = 0
993
+ backoff_sleep = 0.01
994
+
995
+ if check == FAILED: # pragma: no cov
996
+ return Result(
997
+ run_id=run_id,
998
+ parent_run_id=parent_run_id,
886
999
  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
- },
1000
+ context=catch(
1001
+ new_context,
1002
+ status=FAILED,
1003
+ updated={
1004
+ "status": FAILED,
1005
+ "errors": WorkflowError(
1006
+ f"Validate job trigger rule was failed "
1007
+ f"with {job.trigger_rule.value!r}."
1008
+ ).to_dict(),
1009
+ },
1010
+ ),
1011
+ extras=self.extras,
894
1012
  )
895
1013
  elif check == SKIP: # pragma: no cov
896
- result.trace.info(
1014
+ trace.info(
897
1015
  f"[JOB]: Skip job: {job_id!r} from trigger rule."
898
1016
  )
899
1017
  job.set_outputs(output={"status": SKIP}, to=new_context)
@@ -907,7 +1025,9 @@ class Workflow(BaseModel):
907
1025
  self.execute_job,
908
1026
  job=job,
909
1027
  params=new_context,
910
- result=result,
1028
+ run_id=run_id,
1029
+ context=context,
1030
+ parent_run_id=parent_run_id,
911
1031
  event=event,
912
1032
  ),
913
1033
  )
@@ -920,7 +1040,9 @@ class Workflow(BaseModel):
920
1040
  self.execute_job,
921
1041
  job=job,
922
1042
  params=new_context,
923
- result=result,
1043
+ run_id=run_id,
1044
+ context=context,
1045
+ parent_run_id=parent_run_id,
924
1046
  event=event,
925
1047
  )
926
1048
  )
@@ -940,7 +1062,7 @@ class Workflow(BaseModel):
940
1062
  else: # pragma: no cov
941
1063
  job_queue.put(job_id)
942
1064
  futures.insert(0, future)
943
- result.trace.warning(
1065
+ trace.warning(
944
1066
  f"[WORKFLOW]: ... Execution non-threading not "
945
1067
  f"handle: {future}."
946
1068
  )
@@ -963,27 +1085,39 @@ class Workflow(BaseModel):
963
1085
  for i, s in enumerate(sequence_statuses, start=0):
964
1086
  statuses[total + 1 + skip_count + i] = s
965
1087
 
966
- return result.catch(
967
- status=validate_statuses(statuses), context=new_context
1088
+ st: Status = validate_statuses(statuses)
1089
+ return Result(
1090
+ run_id=run_id,
1091
+ parent_run_id=parent_run_id,
1092
+ status=st,
1093
+ context=catch(new_context, status=st),
1094
+ extras=self.extras,
968
1095
  )
969
1096
 
970
1097
  event.set()
971
1098
  for future in futures:
972
1099
  future.cancel()
973
1100
 
974
- result.trace.error(
1101
+ trace.error(
975
1102
  f"[WORKFLOW]: {self.name!r} was timeout because it use exec "
976
1103
  f"time more than {timeout} seconds."
977
1104
  )
978
1105
 
979
1106
  time.sleep(0.0025)
980
1107
 
981
- return result.catch(
1108
+ return Result(
1109
+ run_id=run_id,
1110
+ parent_run_id=parent_run_id,
982
1111
  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
- },
1112
+ context=catch(
1113
+ new_context,
1114
+ status=FAILED,
1115
+ updated={
1116
+ "errors": WorkflowTimeoutError(
1117
+ f"{self.name!r} was timeout because it use exec time "
1118
+ f"more than {timeout} seconds."
1119
+ ).to_dict(),
1120
+ },
1121
+ ),
1122
+ extras=self.extras,
989
1123
  )