ddeutil-workflow 0.0.6__py3-none-any.whl → 0.0.7__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.
- ddeutil/workflow/__about__.py +1 -1
- ddeutil/workflow/__init__.py +26 -4
- ddeutil/workflow/__types.py +11 -1
- ddeutil/workflow/api.py +120 -0
- ddeutil/workflow/app.py +41 -0
- ddeutil/workflow/exceptions.py +3 -0
- ddeutil/workflow/log.py +30 -0
- ddeutil/workflow/pipeline.py +341 -105
- ddeutil/workflow/repeat.py +134 -0
- ddeutil/workflow/route.py +78 -0
- ddeutil/workflow/stage.py +41 -12
- ddeutil/workflow/utils.py +280 -56
- {ddeutil_workflow-0.0.6.dist-info → ddeutil_workflow-0.0.7.dist-info}/METADATA +61 -14
- ddeutil_workflow-0.0.7.dist-info/RECORD +20 -0
- {ddeutil_workflow-0.0.6.dist-info → ddeutil_workflow-0.0.7.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.6.dist-info/RECORD +0 -15
- {ddeutil_workflow-0.0.6.dist-info → ddeutil_workflow-0.0.7.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.6.dist-info → ddeutil_workflow-0.0.7.dist-info}/top_level.txt +0 -0
ddeutil/workflow/pipeline.py
CHANGED
@@ -7,20 +7,41 @@ from __future__ import annotations
|
|
7
7
|
|
8
8
|
import copy
|
9
9
|
import logging
|
10
|
+
import os
|
10
11
|
import time
|
12
|
+
from concurrent.futures import (
|
13
|
+
FIRST_EXCEPTION,
|
14
|
+
Future,
|
15
|
+
ProcessPoolExecutor,
|
16
|
+
ThreadPoolExecutor,
|
17
|
+
as_completed,
|
18
|
+
wait,
|
19
|
+
)
|
20
|
+
from datetime import datetime
|
21
|
+
from multiprocessing import Event, Manager
|
22
|
+
from pickle import PickleError
|
11
23
|
from queue import Queue
|
12
24
|
from typing import Optional
|
25
|
+
from zoneinfo import ZoneInfo
|
13
26
|
|
14
27
|
from pydantic import BaseModel, Field
|
15
28
|
from pydantic.functional_validators import model_validator
|
16
29
|
from typing_extensions import Self
|
17
30
|
|
18
31
|
from .__types import DictData, DictStr, Matrix, MatrixExclude, MatrixInclude
|
19
|
-
from .exceptions import JobException, PipelineException
|
32
|
+
from .exceptions import JobException, PipelineException, StageException
|
20
33
|
from .loader import Loader
|
21
34
|
from .on import On
|
35
|
+
from .scheduler import CronRunner
|
22
36
|
from .stage import Stage
|
23
|
-
from .utils import
|
37
|
+
from .utils import (
|
38
|
+
Param,
|
39
|
+
Result,
|
40
|
+
cross_product,
|
41
|
+
dash2underscore,
|
42
|
+
gen_id,
|
43
|
+
get_diff_sec,
|
44
|
+
)
|
24
45
|
|
25
46
|
|
26
47
|
class Strategy(BaseModel):
|
@@ -29,6 +50,8 @@ class Strategy(BaseModel):
|
|
29
50
|
|
30
51
|
Data Validate:
|
31
52
|
>>> strategy = {
|
53
|
+
... 'max-parallel': 1,
|
54
|
+
... 'fail-fast': False,
|
32
55
|
... 'matrix': {
|
33
56
|
... 'first': [1, 2, 3],
|
34
57
|
... 'second': ['foo', 'bar']
|
@@ -39,7 +62,7 @@ class Strategy(BaseModel):
|
|
39
62
|
"""
|
40
63
|
|
41
64
|
fail_fast: bool = Field(default=False)
|
42
|
-
max_parallel: int = Field(default
|
65
|
+
max_parallel: int = Field(default=1, gt=0)
|
43
66
|
matrix: Matrix = Field(default_factory=dict)
|
44
67
|
include: MatrixInclude = Field(
|
45
68
|
default_factory=list,
|
@@ -164,11 +187,47 @@ class Job(BaseModel):
|
|
164
187
|
|
165
188
|
return output[next(iter(output))]
|
166
189
|
|
167
|
-
def strategy_execute(
|
168
|
-
|
169
|
-
|
190
|
+
def strategy_execute(
|
191
|
+
self,
|
192
|
+
strategy: DictData,
|
193
|
+
params: DictData,
|
194
|
+
*,
|
195
|
+
event: Event | None = None,
|
196
|
+
) -> Result:
|
197
|
+
"""Strategy execution with passing dynamic parameters from the pipeline
|
198
|
+
stage execution.
|
199
|
+
|
200
|
+
:param strategy:
|
201
|
+
:param params:
|
202
|
+
:param event: An manger event that pass to the PoolThreadExecutor.
|
203
|
+
:rtype: Result
|
204
|
+
"""
|
205
|
+
_stop_rs: Result = Result(
|
206
|
+
status=1,
|
207
|
+
context={
|
208
|
+
gen_id(strategy): {
|
209
|
+
"matrix": strategy,
|
210
|
+
"stages": {},
|
211
|
+
"error": "Event stopped",
|
212
|
+
},
|
213
|
+
},
|
214
|
+
)
|
215
|
+
if event and event.is_set():
|
216
|
+
return _stop_rs
|
217
|
+
|
218
|
+
# NOTE: Create strategy execution context and update a matrix and copied
|
219
|
+
# of params. So, the context value will have structure like;
|
220
|
+
# ---
|
221
|
+
# {
|
222
|
+
# "params": { ... }, <== Current input params
|
223
|
+
# "jobs": { ... }, <== Current input params
|
224
|
+
# "matrix": { ... } <== Current strategy value
|
225
|
+
# }
|
226
|
+
#
|
227
|
+
context: DictData = params
|
170
228
|
context.update({"matrix": strategy})
|
171
229
|
|
230
|
+
# IMPORTANT: The stage execution only run sequentially one-by-one.
|
172
231
|
for stage in self.stages:
|
173
232
|
_st_name: str = stage.id or stage.name
|
174
233
|
|
@@ -177,6 +236,29 @@ class Job(BaseModel):
|
|
177
236
|
continue
|
178
237
|
logging.info(f"[JOB]: Start execute the stage: {_st_name!r}")
|
179
238
|
|
239
|
+
# NOTE: Logging a matrix that pass on this stage execution.
|
240
|
+
if strategy:
|
241
|
+
logging.info(f"[...]: Matrix: {strategy}")
|
242
|
+
|
243
|
+
# NOTE:
|
244
|
+
# I do not use below syntax because `params` dict be the
|
245
|
+
# reference memory pointer and it was changed when I action
|
246
|
+
# anything like update or re-construct this.
|
247
|
+
#
|
248
|
+
# ... params |= stage.execute(params=params)
|
249
|
+
#
|
250
|
+
# This step will add the stage result to ``stages`` key in
|
251
|
+
# that stage id. It will have structure like;
|
252
|
+
# ---
|
253
|
+
# {
|
254
|
+
# "params": { ... },
|
255
|
+
# "jobs": { ... },
|
256
|
+
# "matrix": { ... },
|
257
|
+
# "stages": { { "stage-id-1": ... }, ... }
|
258
|
+
# }
|
259
|
+
#
|
260
|
+
if event and event.is_set():
|
261
|
+
return _stop_rs
|
180
262
|
rs: Result = stage.execute(params=context)
|
181
263
|
if rs.status == 0:
|
182
264
|
stage.set_outputs(rs.context, params=context)
|
@@ -185,6 +267,8 @@ class Job(BaseModel):
|
|
185
267
|
f"Getting status does not equal zero on stage: "
|
186
268
|
f"{stage.name}."
|
187
269
|
)
|
270
|
+
# TODO: Filter and warning if it pass any objects to context between
|
271
|
+
# strategy job executor like function, etc.
|
188
272
|
return Result(
|
189
273
|
status=0,
|
190
274
|
context={
|
@@ -204,71 +288,104 @@ class Job(BaseModel):
|
|
204
288
|
:rtype: Result
|
205
289
|
"""
|
206
290
|
strategy_context: DictData = {}
|
207
|
-
|
291
|
+
rs = Result(context=strategy_context)
|
208
292
|
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
# }
|
217
|
-
#
|
218
|
-
context: DictData = {}
|
219
|
-
context.update(params)
|
220
|
-
context.update({"matrix": strategy})
|
293
|
+
if self.strategy.max_parallel == 1:
|
294
|
+
for strategy in self.strategy.make():
|
295
|
+
rs: Result = self.strategy_execute(
|
296
|
+
strategy, params=copy.deepcopy(params)
|
297
|
+
)
|
298
|
+
strategy_context.update(rs.context)
|
299
|
+
return rs
|
221
300
|
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
# I do not use below syntax because `params` dict be the
|
242
|
-
# reference memory pointer and it was changed when I action
|
243
|
-
# anything like update or re-construct this.
|
244
|
-
#
|
245
|
-
# ... params |= stage.execute(params=params)
|
246
|
-
#
|
247
|
-
# This step will add the stage result to ``stages`` key in
|
248
|
-
# that stage id. It will have structure like;
|
249
|
-
# ---
|
250
|
-
# {
|
251
|
-
# "params": { ... },
|
252
|
-
# "jobs": { ... },
|
253
|
-
# "matrix": { ... },
|
254
|
-
# "stages": { { "stage-id-1": ... }, ... }
|
255
|
-
# }
|
256
|
-
#
|
257
|
-
rs: Result = stage.execute(params=context)
|
258
|
-
if rs.status == 0:
|
259
|
-
stage.set_outputs(rs.context, params=context)
|
260
|
-
else:
|
261
|
-
raise JobException(
|
262
|
-
f"Getting status does not equal zero on stage: "
|
263
|
-
f"{stage.name}."
|
301
|
+
# FIXME: (WF001) I got error that raise when use
|
302
|
+
# ``ProcessPoolExecutor``;
|
303
|
+
# ---
|
304
|
+
# _pickle.PicklingError: Can't pickle
|
305
|
+
# <function ??? at 0x000001F0BE80F160>: attribute lookup ???
|
306
|
+
# on ddeutil.workflow.stage failed
|
307
|
+
#
|
308
|
+
with Manager() as manager:
|
309
|
+
event: Event = manager.Event()
|
310
|
+
|
311
|
+
with ProcessPoolExecutor(
|
312
|
+
max_workers=self.strategy.max_parallel
|
313
|
+
) as pool:
|
314
|
+
pool_result: list[Future] = [
|
315
|
+
pool.submit(
|
316
|
+
self.strategy_execute,
|
317
|
+
st,
|
318
|
+
params=copy.deepcopy(params),
|
319
|
+
event=event,
|
264
320
|
)
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
321
|
+
for st in self.strategy.make()
|
322
|
+
]
|
323
|
+
if self.strategy.fail_fast:
|
324
|
+
|
325
|
+
# NOTE: Get results from a collection of tasks with a
|
326
|
+
# timeout that has the first exception.
|
327
|
+
done, not_done = wait(
|
328
|
+
pool_result, timeout=60, return_when=FIRST_EXCEPTION
|
329
|
+
)
|
330
|
+
nd: str = (
|
331
|
+
f", the strategies do not run is {not_done}"
|
332
|
+
if not_done
|
333
|
+
else ""
|
334
|
+
)
|
335
|
+
logging.warning(f"[JOB]: Strategy is set Fail Fast{nd}")
|
336
|
+
|
337
|
+
# NOTE: Stop all running tasks
|
338
|
+
event.set()
|
339
|
+
|
340
|
+
# NOTE: Cancel any scheduled tasks
|
341
|
+
for future in pool_result:
|
342
|
+
future.cancel()
|
343
|
+
|
344
|
+
rs.status = 0
|
345
|
+
for f in done:
|
346
|
+
if f.exception():
|
347
|
+
rs.status = 1
|
348
|
+
logging.error(
|
349
|
+
f"One task failed with: {f.exception()}, "
|
350
|
+
f"shutting down"
|
351
|
+
)
|
352
|
+
elif f.cancelled():
|
353
|
+
continue
|
354
|
+
else:
|
355
|
+
rs: Result = f.result(timeout=60)
|
356
|
+
strategy_context.update(rs.context)
|
357
|
+
rs.context = strategy_context
|
358
|
+
return rs
|
359
|
+
|
360
|
+
for pool_rs in as_completed(pool_result):
|
361
|
+
try:
|
362
|
+
rs: Result = pool_rs.result(timeout=60)
|
363
|
+
strategy_context.update(rs.context)
|
364
|
+
except PickleError as err:
|
365
|
+
# NOTE: I do not want to fix this issue because it does
|
366
|
+
# not make sense and over-engineering with this bug
|
367
|
+
# fix process.
|
368
|
+
raise JobException(
|
369
|
+
f"PyStage that create object on locals does use "
|
370
|
+
f"parallel in strategy;\n\t{err}"
|
371
|
+
) from None
|
372
|
+
except TimeoutError:
|
373
|
+
rs.status = 1
|
374
|
+
logging.warning("Task is hanging. Attempting to kill.")
|
375
|
+
pool_rs.cancel()
|
376
|
+
if not pool_rs.cancelled():
|
377
|
+
logging.warning("Failed to cancel the task.")
|
378
|
+
else:
|
379
|
+
logging.warning("Task canceled successfully.")
|
380
|
+
except StageException as err:
|
381
|
+
rs.status = 1
|
382
|
+
logging.warning(
|
383
|
+
f"Get stage exception with fail-fast does not set;"
|
384
|
+
f"\n\t{err}"
|
385
|
+
)
|
386
|
+
rs.status = 0
|
387
|
+
rs.context = strategy_context
|
388
|
+
return rs
|
272
389
|
|
273
390
|
|
274
391
|
class Pipeline(BaseModel):
|
@@ -356,6 +473,18 @@ class Pipeline(BaseModel):
|
|
356
473
|
}
|
357
474
|
return values
|
358
475
|
|
476
|
+
@model_validator(mode="after")
|
477
|
+
def __validate_jobs_need(self):
|
478
|
+
for job in self.jobs:
|
479
|
+
if not_exist := [
|
480
|
+
need for need in self.jobs[job].needs if need not in self.jobs
|
481
|
+
]:
|
482
|
+
raise PipelineException(
|
483
|
+
f"This needed jobs: {not_exist} do not exist in this "
|
484
|
+
f"pipeline."
|
485
|
+
)
|
486
|
+
return self
|
487
|
+
|
359
488
|
def job(self, name: str) -> Job:
|
360
489
|
"""Return Job model that exists on this pipeline.
|
361
490
|
|
@@ -375,6 +504,7 @@ class Pipeline(BaseModel):
|
|
375
504
|
job execution.
|
376
505
|
|
377
506
|
:param params: A parameter mapping that receive from pipeline execution.
|
507
|
+
:rtype: DictData
|
378
508
|
"""
|
379
509
|
# VALIDATE: Incoming params should have keys that set on this pipeline.
|
380
510
|
if check_key := tuple(
|
@@ -382,7 +512,7 @@ class Pipeline(BaseModel):
|
|
382
512
|
for k in self.params
|
383
513
|
if (k not in params and self.params[k].required)
|
384
514
|
):
|
385
|
-
raise
|
515
|
+
raise PipelineException(
|
386
516
|
f"Required Param on this pipeline setting does not set: "
|
387
517
|
f"{', '.join(check_key)}."
|
388
518
|
)
|
@@ -400,6 +530,102 @@ class Pipeline(BaseModel):
|
|
400
530
|
"jobs": {},
|
401
531
|
}
|
402
532
|
|
533
|
+
def release(
|
534
|
+
self,
|
535
|
+
on: On,
|
536
|
+
params: DictData | None = None,
|
537
|
+
*,
|
538
|
+
waiting_sec: int = 600,
|
539
|
+
sleep_interval: int = 10,
|
540
|
+
) -> str:
|
541
|
+
"""Start running pipeline with the on schedule in period of 30 minutes.
|
542
|
+
That mean it will still running at background 30 minutes until the
|
543
|
+
schedule matching with its time.
|
544
|
+
"""
|
545
|
+
params: DictData = params or {}
|
546
|
+
logging.info(f"[CORE] Start release: {self.name!r} : {on.cronjob}")
|
547
|
+
|
548
|
+
gen: CronRunner = on.generate(datetime.now())
|
549
|
+
tz: ZoneInfo = gen.tz
|
550
|
+
next_running_time: datetime = gen.next
|
551
|
+
|
552
|
+
if get_diff_sec(next_running_time, tz=tz) < waiting_sec:
|
553
|
+
logging.debug(
|
554
|
+
f"[CORE]: {self.name} closely to run >> "
|
555
|
+
f"{next_running_time:%Y-%m-%d %H:%M:%S}"
|
556
|
+
)
|
557
|
+
|
558
|
+
# NOTE: Release when the time is nearly to schedule time.
|
559
|
+
while (duration := get_diff_sec(next_running_time, tz=tz)) > 15:
|
560
|
+
time.sleep(sleep_interval)
|
561
|
+
logging.debug(
|
562
|
+
f"[CORE]: {self.name!r} : Sleep until: {duration}"
|
563
|
+
)
|
564
|
+
|
565
|
+
time.sleep(1)
|
566
|
+
rs: Result = self.execute(params=params)
|
567
|
+
logging.debug(f"{rs.context}")
|
568
|
+
|
569
|
+
return f"[CORE]: Start Execute: {self.name}"
|
570
|
+
return f"[CORE]: {self.name} does not closely to run yet."
|
571
|
+
|
572
|
+
def poke(self, params: DictData | None = None):
|
573
|
+
"""Poke pipeline threading task for executing with its schedules that
|
574
|
+
was set on the `on`.
|
575
|
+
"""
|
576
|
+
params: DictData = params or {}
|
577
|
+
logging.info(
|
578
|
+
f"[CORE]: Start Poking: {self.name!r} :"
|
579
|
+
f"{gen_id(self.name, unique=True)}"
|
580
|
+
)
|
581
|
+
results = []
|
582
|
+
with ThreadPoolExecutor(
|
583
|
+
max_workers=int(
|
584
|
+
os.getenv("WORKFLOW_CORE_MAX_PIPELINE_POKING", "4")
|
585
|
+
),
|
586
|
+
) as executor:
|
587
|
+
futures: list[Future] = [
|
588
|
+
executor.submit(
|
589
|
+
self.release,
|
590
|
+
on,
|
591
|
+
params=params,
|
592
|
+
)
|
593
|
+
for on in self.on
|
594
|
+
]
|
595
|
+
for future in as_completed(futures):
|
596
|
+
rs = future.result()
|
597
|
+
logging.info(rs)
|
598
|
+
results.append(rs)
|
599
|
+
return results
|
600
|
+
|
601
|
+
def job_execute(
|
602
|
+
self,
|
603
|
+
job: str,
|
604
|
+
params: DictData,
|
605
|
+
):
|
606
|
+
"""Job Executor that use on pipeline executor.
|
607
|
+
:param job: A job ID that want to execute.
|
608
|
+
:param params: A params that was parameterized from pipeline execution.
|
609
|
+
"""
|
610
|
+
# VALIDATE: check a job ID that exists in this pipeline or not.
|
611
|
+
if job not in self.jobs:
|
612
|
+
raise PipelineException(
|
613
|
+
f"The job ID: {job} does not exists on {self.name!r} pipeline."
|
614
|
+
)
|
615
|
+
|
616
|
+
job_obj: Job = self.jobs[job]
|
617
|
+
|
618
|
+
rs: Result = job_obj.execute(params=params)
|
619
|
+
if rs.status != 0:
|
620
|
+
logging.warning(
|
621
|
+
f"Getting status does not equal zero on job: {job}."
|
622
|
+
)
|
623
|
+
return Result(
|
624
|
+
status=1, context={job: job_obj.set_outputs(rs.context)}
|
625
|
+
)
|
626
|
+
|
627
|
+
return Result(status=0, context={job: job_obj.set_outputs(rs.context)})
|
628
|
+
|
403
629
|
def execute(
|
404
630
|
self,
|
405
631
|
params: DictData | None = None,
|
@@ -430,7 +656,7 @@ class Pipeline(BaseModel):
|
|
430
656
|
|
431
657
|
"""
|
432
658
|
logging.info(
|
433
|
-
f"[CORE]: Start
|
659
|
+
f"[CORE]: Start Execute: {self.name}:"
|
434
660
|
f"{gen_id(self.name, unique=True)}"
|
435
661
|
)
|
436
662
|
params: DictData = params or {}
|
@@ -452,42 +678,52 @@ class Pipeline(BaseModel):
|
|
452
678
|
# NOTE: Create result context that will pass this context to any
|
453
679
|
# execution dependency.
|
454
680
|
rs: Result = Result(context=self.parameterize(params))
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
681
|
+
if (
|
682
|
+
worker := int(os.getenv("WORKFLOW_CORE_MAX_JOB_PARALLEL", "1"))
|
683
|
+
) > 1:
|
684
|
+
# IMPORTANT: The job execution can run parallel and waiting by
|
685
|
+
# needed.
|
686
|
+
with ThreadPoolExecutor(max_workers=worker) as executor:
|
687
|
+
futures: list[Future] = []
|
688
|
+
while not jq.empty() and (
|
689
|
+
not_time_out_flag := ((time.monotonic() - ts) < timeout)
|
690
|
+
):
|
691
|
+
job_id: str = jq.get()
|
692
|
+
logging.info(
|
693
|
+
f"[PIPELINE]: Start execute the job: {job_id!r}"
|
694
|
+
)
|
695
|
+
job: Job = self.jobs[job_id]
|
696
|
+
if any(
|
697
|
+
need not in rs.context["jobs"] for need in job.needs
|
698
|
+
):
|
699
|
+
jq.put(job_id)
|
700
|
+
futures.append(
|
701
|
+
executor.submit(
|
702
|
+
self.job_execute,
|
703
|
+
job_id,
|
704
|
+
params=copy.deepcopy(rs.context),
|
705
|
+
),
|
706
|
+
)
|
707
|
+
for future in as_completed(futures):
|
708
|
+
job_rs: Result = future.result(timeout=20)
|
709
|
+
rs.context["jobs"].update(job_rs.context)
|
710
|
+
else:
|
711
|
+
logging.info(
|
712
|
+
f"[CORE]: Run {self.name} with non-threading job executor"
|
713
|
+
)
|
714
|
+
while not jq.empty() and (
|
715
|
+
not_time_out_flag := ((time.monotonic() - ts) < timeout)
|
716
|
+
):
|
717
|
+
job_id: str = jq.get()
|
718
|
+
logging.info(f"[PIPELINE]: Start execute the job: {job_id!r}")
|
719
|
+
job: Job = self.jobs[job_id]
|
720
|
+
if any(need not in rs.context["jobs"] for need in job.needs):
|
721
|
+
jq.put(job_id)
|
722
|
+
|
723
|
+
job_rs = self.job_execute(
|
724
|
+
job_id, params=copy.deepcopy(rs.context)
|
490
725
|
)
|
726
|
+
rs.context["jobs"].update(job_rs.context)
|
491
727
|
|
492
728
|
if not not_time_out_flag:
|
493
729
|
logging.warning("Execution of pipeline was time out")
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# ------------------------------------------------------------------------------
|
2
|
+
# Copyright (c) 2023 Priyanshu Panwar. All rights reserved.
|
3
|
+
# Licensed under the MIT License.
|
4
|
+
# This code refs from: https://github.com/priyanshu-panwar/fastapi-utilities
|
5
|
+
# ------------------------------------------------------------------------------
|
6
|
+
import asyncio
|
7
|
+
import logging
|
8
|
+
from asyncio import ensure_future
|
9
|
+
from datetime import datetime
|
10
|
+
from functools import wraps
|
11
|
+
|
12
|
+
from croniter import croniter
|
13
|
+
from starlette.concurrency import run_in_threadpool
|
14
|
+
|
15
|
+
|
16
|
+
def get_delta(cron: str):
|
17
|
+
"""This function returns the time delta between now and the next cron
|
18
|
+
execution time.
|
19
|
+
"""
|
20
|
+
now: datetime = datetime.now()
|
21
|
+
cron = croniter(cron, now)
|
22
|
+
return (cron.get_next(datetime) - now).total_seconds()
|
23
|
+
|
24
|
+
|
25
|
+
def repeat_at(
|
26
|
+
*,
|
27
|
+
cron: str,
|
28
|
+
logger: logging.Logger = None,
|
29
|
+
raise_exceptions: bool = False,
|
30
|
+
max_repetitions: int = None,
|
31
|
+
):
|
32
|
+
"""This function returns a decorator that makes a function execute
|
33
|
+
periodically as per the cron expression provided.
|
34
|
+
|
35
|
+
:param cron: str
|
36
|
+
Cron-style string for periodic execution, eg. '0 0 * * *' every midnight
|
37
|
+
:param logger: logging.Logger (default None)
|
38
|
+
Logger object to log exceptions
|
39
|
+
:param raise_exceptions: bool (default False)
|
40
|
+
Whether to raise exceptions or log them
|
41
|
+
:param max_repetitions: int (default None)
|
42
|
+
Maximum number of times to repeat the function. If None, repeat
|
43
|
+
indefinitely.
|
44
|
+
|
45
|
+
"""
|
46
|
+
|
47
|
+
def decorator(func):
|
48
|
+
is_coroutine = asyncio.iscoroutinefunction(func)
|
49
|
+
|
50
|
+
@wraps(func)
|
51
|
+
def wrapper(*_args, **_kwargs):
|
52
|
+
repititions = 0
|
53
|
+
if not croniter.is_valid(cron):
|
54
|
+
raise ValueError("Invalid cron expression")
|
55
|
+
|
56
|
+
async def loop(*args, **kwargs):
|
57
|
+
nonlocal repititions
|
58
|
+
while max_repetitions is None or repititions < max_repetitions:
|
59
|
+
try:
|
60
|
+
sleepTime = get_delta(cron)
|
61
|
+
await asyncio.sleep(sleepTime)
|
62
|
+
if is_coroutine:
|
63
|
+
await func(*args, **kwargs)
|
64
|
+
else:
|
65
|
+
await run_in_threadpool(func, *args, **kwargs)
|
66
|
+
except Exception as e:
|
67
|
+
if logger is not None:
|
68
|
+
logger.exception(e)
|
69
|
+
if raise_exceptions:
|
70
|
+
raise e
|
71
|
+
repititions += 1
|
72
|
+
|
73
|
+
ensure_future(loop(*_args, **_kwargs))
|
74
|
+
|
75
|
+
return wrapper
|
76
|
+
|
77
|
+
return decorator
|
78
|
+
|
79
|
+
|
80
|
+
def repeat_every(
|
81
|
+
*,
|
82
|
+
seconds: float,
|
83
|
+
wait_first: bool = False,
|
84
|
+
logger: logging.Logger = None,
|
85
|
+
raise_exceptions: bool = False,
|
86
|
+
max_repetitions: int = None,
|
87
|
+
):
|
88
|
+
"""This function returns a decorator that schedules a function to execute
|
89
|
+
periodically after every `seconds` seconds.
|
90
|
+
|
91
|
+
:param seconds: float
|
92
|
+
The number of seconds to wait before executing the function again.
|
93
|
+
:param wait_first: bool (default False)
|
94
|
+
Whether to wait `seconds` seconds before executing the function for the
|
95
|
+
first time.
|
96
|
+
:param logger: logging.Logger (default None)
|
97
|
+
The logger to use for logging exceptions.
|
98
|
+
:param raise_exceptions: bool (default False)
|
99
|
+
Whether to raise exceptions instead of logging them.
|
100
|
+
:param max_repetitions: int (default None)
|
101
|
+
The maximum number of times to repeat the function. If None, the
|
102
|
+
function will repeat indefinitely.
|
103
|
+
"""
|
104
|
+
|
105
|
+
def decorator(func):
|
106
|
+
is_coroutine = asyncio.iscoroutinefunction(func)
|
107
|
+
|
108
|
+
@wraps(func)
|
109
|
+
async def wrapper(*_args, **_kwargs):
|
110
|
+
repetitions = 0
|
111
|
+
|
112
|
+
async def loop(*args, **kwargs):
|
113
|
+
nonlocal repetitions
|
114
|
+
if wait_first:
|
115
|
+
await asyncio.sleep(seconds)
|
116
|
+
while max_repetitions is None or repetitions < max_repetitions:
|
117
|
+
try:
|
118
|
+
if is_coroutine:
|
119
|
+
await func(*args, **kwargs)
|
120
|
+
else:
|
121
|
+
await run_in_threadpool(func, *args, **kwargs)
|
122
|
+
except Exception as e:
|
123
|
+
if logger is not None:
|
124
|
+
logger.exception(e)
|
125
|
+
if raise_exceptions:
|
126
|
+
raise e
|
127
|
+
repetitions += 1
|
128
|
+
await asyncio.sleep(seconds)
|
129
|
+
|
130
|
+
ensure_future(loop(*_args, **_kwargs))
|
131
|
+
|
132
|
+
return wrapper
|
133
|
+
|
134
|
+
return decorator
|