taskflow 5.8.0__py3-none-any.whl → 5.9.1__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.
@@ -291,6 +291,18 @@ class Runtime(object):
291
291
  """Resets all the provided atoms to the given state and intention."""
292
292
  tweaked = []
293
293
  for atom in atoms:
294
+ cur_intention = self.storage.get_atom_intention(atom.name)
295
+ # Don't trigger a RETRY if the atom needs to be REVERTED.
296
+ # This is a workaround for a bug when REVERT_ALL is applied to
297
+ # unordered flows
298
+ # (https://bugs.launchpad.net/taskflow/+bug/2043808)
299
+ # A subflow may trigger a REVERT_ALL, all the atoms of all the
300
+ # related subflows are marked as REVERT but a task of a related
301
+ # flow may still be running in another thread. If this task
302
+ # triggers a RETRY, it overrides the previously set REVERT status,
303
+ # breaking the revert path of the flow.
304
+ if cur_intention == st.REVERT and intention == st.RETRY:
305
+ continue
294
306
  if state or intention:
295
307
  tweaked.append((atom, state, intention))
296
308
  if state:
@@ -0,0 +1,610 @@
1
+ # Copyright (C) Red Hat
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+ # not use this file except in compliance with the License. You may obtain
5
+ # a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+ # License for the specific language governing permissions and limitations
13
+ # under the License.
14
+
15
+ import threading
16
+ import typing
17
+
18
+ import etcd3gw
19
+ import fasteners
20
+ from oslo_serialization import jsonutils
21
+ from oslo_utils import timeutils
22
+ from oslo_utils import uuidutils
23
+
24
+ from taskflow import exceptions as exc
25
+ from taskflow.jobs import base
26
+ from taskflow import logging
27
+ from taskflow import states
28
+ from taskflow.utils import misc
29
+ if typing.TYPE_CHECKING:
30
+ from taskflow.types import entity
31
+
32
+ LOG = logging.getLogger(__name__)
33
+
34
+
35
+ class EtcdJob(base.Job):
36
+ """An Etcd job."""
37
+
38
+ board: 'EtcdJobBoard'
39
+
40
+ def __init__(self, board: 'EtcdJobBoard', name, client, key,
41
+ uuid=None, details=None, backend=None,
42
+ book=None, book_data=None,
43
+ priority=base.JobPriority.NORMAL,
44
+ sequence=None, created_on=None):
45
+ super().__init__(board, name, uuid=uuid, details=details,
46
+ backend=backend, book=book, book_data=book_data)
47
+
48
+ self._client = client
49
+ self._key = key
50
+ self._priority = priority
51
+ self._sequence = sequence
52
+ self._created_on = created_on
53
+ self._root = board._root_path
54
+
55
+ self._lease = None
56
+
57
+ @property
58
+ def key(self):
59
+ return self._key
60
+
61
+ @property
62
+ def last_modified(self):
63
+ try:
64
+ raw_data = self.board.get_last_modified(self)
65
+ data = jsonutils.loads(raw_data)
66
+ ret = timeutils.parse_strtime(data["last_modified"])
67
+ return ret
68
+ except Exception:
69
+ LOG.exception("Cannot read load_modified key.")
70
+ return 0
71
+
72
+ @property
73
+ def created_on(self):
74
+ return self._created_on
75
+
76
+ @property
77
+ def state(self):
78
+ """Access the current state of this job."""
79
+ owner, data = self.board.get_owner_and_data(self)
80
+ if not data:
81
+ if owner is not None:
82
+ LOG.info(f"Owner key was found for job {self.uuid}, "
83
+ f"but the key {self.key} is missing")
84
+ return states.COMPLETE
85
+ if not owner:
86
+ return states.UNCLAIMED
87
+ return states.CLAIMED
88
+
89
+ @property
90
+ def sequence(self):
91
+ return self._sequence
92
+
93
+ @property
94
+ def priority(self):
95
+ return self._priority
96
+
97
+ @property
98
+ def lease(self):
99
+ if not self._lease:
100
+ owner_data = self.board.get_owner_data(self)
101
+ if 'lease_id' not in owner_data:
102
+ return None
103
+ lease_id = owner_data['lease_id']
104
+ self._lease = etcd3gw.Lease(id=lease_id,
105
+ client=self._client)
106
+ return self._lease
107
+
108
+ def expires_in(self):
109
+ """How many seconds until the claim expires."""
110
+ if self.lease is None:
111
+ return -1
112
+ return self.lease.ttl()
113
+
114
+ def extend_expiry(self, expiry):
115
+ """Extends the owner key (aka the claim) expiry for this job.
116
+
117
+ Returns ``True`` if the expiry request was performed
118
+ otherwise ``False``.
119
+ """
120
+ if self.lease is None:
121
+ return False
122
+ ret = self.lease.refresh()
123
+ return (ret > 0)
124
+
125
+ @property
126
+ def root(self):
127
+ return self._root
128
+
129
+ def __lt__(self, other):
130
+ if not isinstance(other, EtcdJob):
131
+ return NotImplemented
132
+ if self.root == other.root:
133
+ if self.priority == other.priority:
134
+ return self.sequence < other.sequence
135
+ else:
136
+ ordered = base.JobPriority.reorder(
137
+ (self.priority, self), (other.priority, other))
138
+ if ordered[0] is self:
139
+ return False
140
+ return True
141
+ else:
142
+ # Different jobboards with different roots...
143
+ return self.root < other.root
144
+
145
+ def __eq__(self, other):
146
+ if not isinstance(other, EtcdJob):
147
+ return NotImplemented
148
+ return ((self.root, self.sequence, self.priority) ==
149
+ (other.root, other.sequence, other.priority))
150
+
151
+ def __ne__(self, other):
152
+ return not self.__eq__(other)
153
+
154
+ def __hash__(self):
155
+ return hash(self.key)
156
+
157
+
158
+ class EtcdJobBoard(base.JobBoard):
159
+ """A jobboard backed by `etcd`_.
160
+
161
+ This jobboard creates sequenced key/value pairs in etcd. Each key
162
+ represents a job and its associated value contains the parameter of the
163
+ job encoded in
164
+ json.
165
+ The users of the jobboard can iterate over the available job and decide if
166
+ they want to attempt to claim one job by calling the :meth:`.claim` method.
167
+ Claiming a job consists in atomically create a key based on the key of job
168
+ and the ".lock" postfix. If the atomic creation of the key is successful
169
+ the job belongs to the user. Any attempt to lock an already locked job
170
+ will fail.
171
+ When a job is complete, the user consumes the job by calling the
172
+ :meth:`.consume` method, it deletes the job and the lock from etcd.
173
+ Alternatively, a user can trash (:meth:`.trash`) or abandon
174
+ (:meth:`.abandon`) if they want to delete the job or leave it for another
175
+ user.
176
+ Etcd doesn't provide a method for unlocking the jobs when a consumer dies.
177
+ The Etcd jobboard provides timed expirations, based on a global ``ttl``
178
+ configuration setting or the ``expiry`` parameter of the :meth:`.claim`
179
+ method. When this time-to-live/expiry is reached, the job is automatically
180
+ unlocked and another consumer can claim it. If it is expected that a task
181
+ of a job takes more time than the defined time-to-live, the
182
+ consumer can refresh the timer by calling the :meth:`EtcdJob.extend_expiry`
183
+ function.
184
+
185
+ .. _etcd: https://etcd.io/
186
+ """
187
+ ROOT_PATH = "/taskflow/jobs"
188
+
189
+ TRASH_PATH = "/taskflow/.trash"
190
+
191
+ DEFAULT_PATH = "jobboard"
192
+
193
+ JOB_PREFIX = "job"
194
+
195
+ SEQUENCE_KEY = "sequence"
196
+
197
+ DATA_POSTFIX = ".data"
198
+
199
+ LOCK_POSTFIX = ".lock"
200
+
201
+ LAST_MODIFIED_POSTFIX = ".last_modified"
202
+
203
+ ETCD_CONFIG_OPTIONS = (
204
+ ("host", str),
205
+ ("port", int),
206
+ ("protocol", str),
207
+ ("ca_cert", str),
208
+ ("cert_key", str),
209
+ ("cert_cert", str),
210
+ ("timeout", float),
211
+ ("api_path", str),
212
+ )
213
+
214
+ INIT_STATE = 'init'
215
+ CONNECTED_STATE = 'connected'
216
+ FETCH_STATE = 'fetched'
217
+
218
+ _client: etcd3gw.Etcd3Client
219
+
220
+ def __init__(self, name, conf, client=None, persistence=None):
221
+ super().__init__(name, conf)
222
+
223
+ self._client = client
224
+ self._persistence = persistence
225
+ self._state = self.INIT_STATE
226
+
227
+ path_elems = [self.ROOT_PATH,
228
+ self._conf.get("path", self.DEFAULT_PATH)]
229
+ self._root_path = self._create_path(*path_elems)
230
+
231
+ self._job_cache = {}
232
+ self._job_cond = threading.Condition()
233
+
234
+ self._open_close_lock = threading.RLock()
235
+
236
+ self._watcher_thd = None
237
+ self._thread_cancel = None
238
+ self._watcher = None
239
+ self._watcher_cancel = None
240
+
241
+ def _create_path(self, root, *args):
242
+ return "/".join([root] + [a.strip("/") for a in args])
243
+
244
+ def incr(self, key):
245
+ """Atomically increment an integer, create it if it doesn't exist"""
246
+ while True:
247
+ value = self._client.get(key)
248
+ if not value:
249
+ res = self._client.create(key, 1)
250
+ if res:
251
+ return 1
252
+ # Another thread has just created the key after we failed to
253
+ # read it, retry to get the new current value
254
+ continue
255
+
256
+ value = int(value[0])
257
+ next_value = value + 1
258
+
259
+ res = self._client.replace(key, value, next_value)
260
+ if res:
261
+ return next_value
262
+
263
+ def get_one(self, key):
264
+ if self._client is None:
265
+ raise exc.JobFailure(f"Cannot read key {key}, client is closed")
266
+ value = self._client.get(key)
267
+ if not value:
268
+ return None
269
+ return value[0]
270
+
271
+ def _fetch_jobs(self, only_unclaimed=False, ensure_fresh=False):
272
+ # TODO(gthiemonge) only_unclaimed is ignored
273
+ if ensure_fresh or self._state != self.FETCH_STATE:
274
+ self._ensure_fresh()
275
+ return sorted(self._job_cache.values())
276
+
277
+ def _ensure_fresh(self):
278
+ prefix = self._create_path(self._root_path, self.JOB_PREFIX)
279
+ jobs = self._client.get_prefix(prefix)
280
+ listed_jobs = {}
281
+ for job in jobs:
282
+ data, metadata = job
283
+ key = misc.binary_decode(metadata['key'])
284
+ if key.endswith(self.DATA_POSTFIX):
285
+ key = key.rstrip(self.DATA_POSTFIX)
286
+ listed_jobs[key] = data
287
+
288
+ removed_jobs = []
289
+ with self._job_cond:
290
+ for key in self._job_cache.keys():
291
+ if key not in listed_jobs:
292
+ removed_jobs.append(key)
293
+ for key in removed_jobs:
294
+ self._remove_job_from_cache(key)
295
+
296
+ for key, data in listed_jobs.items():
297
+ self._process_incoming_job(key, data)
298
+ self._state = self.FETCH_STATE
299
+
300
+ def _process_incoming_job(self, key, data):
301
+ try:
302
+ job_data = jsonutils.loads(data)
303
+ except jsonutils.json.JSONDecodeError:
304
+ msg = ("Incorrectly formatted job data found at "
305
+ f"key: {key}")
306
+ LOG.warning(msg, exc_info=True)
307
+ LOG.info("Deleting invalid job data at key: %s", key)
308
+ self._client.delete(key)
309
+ raise exc.JobFailure(msg)
310
+
311
+ with self._job_cond:
312
+ if key not in self._job_cache:
313
+ job_priority = base.JobPriority.convert(job_data["priority"])
314
+ new_job = EtcdJob(self,
315
+ job_data["name"],
316
+ self._client,
317
+ key,
318
+ uuid=job_data["uuid"],
319
+ details=job_data.get("details", {}),
320
+ backend=self._persistence,
321
+ book_data=job_data.get("book"),
322
+ priority=job_priority,
323
+ sequence=job_data["sequence"])
324
+ self._job_cache[key] = new_job
325
+ self._job_cond.notify_all()
326
+
327
+ def _remove_job_from_cache(self, key):
328
+ """Remove job from cache."""
329
+ with self._job_cond:
330
+ if key in self._job_cache:
331
+ self._job_cache.pop(key, None)
332
+
333
+ def _board_removal_func(self, job):
334
+ try:
335
+ self._remove_job_from_cache(job.key)
336
+ self._client.delete_prefix(job.key)
337
+ except Exception:
338
+ LOG.exception(f"Failed to delete prefix {job.key}")
339
+
340
+ def iterjobs(self, only_unclaimed=False, ensure_fresh=False):
341
+ """Returns an iterator of jobs that are currently on this board."""
342
+ return base.JobBoardIterator(
343
+ self, LOG, only_unclaimed=only_unclaimed,
344
+ ensure_fresh=ensure_fresh,
345
+ board_fetch_func=self._fetch_jobs,
346
+ board_removal_func=self._board_removal_func)
347
+
348
+ def wait(self, timeout=None):
349
+ """Waits a given amount of time for **any** jobs to be posted."""
350
+ # Wait until timeout expires (or forever) for jobs to appear.
351
+ watch = timeutils.StopWatch(duration=timeout)
352
+ watch.start()
353
+ with self._job_cond:
354
+ while True:
355
+ if not self._job_cache:
356
+ if watch.expired():
357
+ raise exc.NotFound("Expired waiting for jobs to"
358
+ " arrive; waited %s seconds"
359
+ % watch.elapsed())
360
+ # This is done since the given timeout can not be provided
361
+ # to the condition variable, since we can not ensure that
362
+ # when we acquire the condition that there will actually
363
+ # be jobs (especially if we are spuriously awaken), so we
364
+ # must recalculate the amount of time we really have left.
365
+ self._job_cond.wait(watch.leftover(return_none=True))
366
+ else:
367
+ curr_jobs = self._fetch_jobs()
368
+ fetch_func = lambda ensure_fresh: curr_jobs
369
+ removal_func = lambda a_job: self._remove_job_from_cache(
370
+ a_job.key)
371
+ return base.JobBoardIterator(
372
+ self, LOG, board_fetch_func=fetch_func,
373
+ board_removal_func=removal_func)
374
+
375
+ @property
376
+ def job_count(self):
377
+ """Returns how many jobs are on this jobboard."""
378
+ return len(self._job_cache)
379
+
380
+ def get_owner_data(self, job: EtcdJob) -> typing.Optional[dict]:
381
+ owner_key = job.key + self.LOCK_POSTFIX
382
+ owner_data = self.get_one(owner_key)
383
+ if not owner_data:
384
+ return None
385
+ return jsonutils.loads(owner_data)
386
+
387
+ def find_owner(self, job: EtcdJob) -> typing.Optional[dict]:
388
+ """Gets the owner of the job if one exists."""
389
+ data = self.get_owner_data(job)
390
+ if data:
391
+ return data['owner']
392
+ return None
393
+
394
+ def get_data(self, job: EtcdJob) -> bytes:
395
+ key = job.key + self.DATA_POSTFIX
396
+ return self.get_one(key)
397
+
398
+ def get_owner_and_data(self, job: EtcdJob) -> tuple[
399
+ typing.Optional[str], typing.Optional[bytes]]:
400
+ if self._client is None:
401
+ raise exc.JobFailure("Cannot retrieve information, "
402
+ "not connected")
403
+
404
+ job_data = None
405
+ job_owner = None
406
+
407
+ for data, metadata in self._client.get_prefix(job.key + "."):
408
+ key = misc.binary_decode(metadata["key"])
409
+ if key.endswith(self.DATA_POSTFIX):
410
+ # bytes?
411
+ job_data = data
412
+ elif key.endswith(self.LOCK_POSTFIX):
413
+ data = jsonutils.loads(data)
414
+ job_owner = data["owner"]
415
+
416
+ return job_owner, job_data
417
+
418
+ def set_last_modified(self, job: EtcdJob):
419
+ key = job.key + self.LAST_MODIFIED_POSTFIX
420
+
421
+ now = timeutils.utcnow()
422
+ self._client.put(key, jsonutils.dumps({"last_modified": now}))
423
+
424
+ def get_last_modified(self, job: EtcdJob):
425
+ key = job.key + self.LAST_MODIFIED_POSTFIX
426
+
427
+ return self.get_one(key)
428
+
429
+ def post(self, name, book=None, details=None,
430
+ priority=base.JobPriority.NORMAL) -> EtcdJob:
431
+ """Atomically creates and posts a job to the jobboard."""
432
+ job_priority = base.JobPriority.convert(priority)
433
+ job_uuid = uuidutils.generate_uuid()
434
+ job_posting = base.format_posting(job_uuid, name,
435
+ created_on=timeutils.utcnow(),
436
+ book=book, details=details,
437
+ priority=job_priority)
438
+ seq = self.incr(self._create_path(self._root_path, self.SEQUENCE_KEY))
439
+ key = self._create_path(self._root_path, f"{self.JOB_PREFIX}{seq}")
440
+
441
+ job_posting["sequence"] = seq
442
+ raw_job_posting = jsonutils.dumps(job_posting)
443
+
444
+ data_key = key + self.DATA_POSTFIX
445
+
446
+ self._client.create(data_key, raw_job_posting)
447
+ job = EtcdJob(self, name, self._client, key,
448
+ uuid=job_uuid,
449
+ details=details,
450
+ backend=self._persistence,
451
+ book=book,
452
+ book_data=job_posting.get('book'),
453
+ priority=job_priority,
454
+ sequence=seq)
455
+ with self._job_cond:
456
+ self._job_cache[key] = job
457
+ self._job_cond.notify_all()
458
+ return job
459
+
460
+ @base.check_who
461
+ def claim(self, job, who, expiry=None):
462
+ """Atomically attempts to claim the provided job."""
463
+ owner_key = job.key + self.LOCK_POSTFIX
464
+
465
+ ttl = expiry or self._conf.get('ttl', None)
466
+
467
+ if ttl:
468
+ lease = self._client.lease(ttl=ttl)
469
+ else:
470
+ lease = None
471
+
472
+ owner_dict = {
473
+ "owner": who,
474
+ }
475
+ if lease:
476
+ owner_dict["lease_id"] = lease.id
477
+
478
+ owner_value = jsonutils.dumps(owner_dict)
479
+
480
+ # Create a lock for the job, if the lock already exists, the job
481
+ # is owned by another worker
482
+ created = self._client.create(owner_key, owner_value, lease=lease)
483
+ if not created:
484
+ # Creation is denied, revoke the lease, we cannot claim the job.
485
+ if lease:
486
+ lease.revoke()
487
+
488
+ owner = self.find_owner(job)
489
+ if owner:
490
+ message = f"Job {job.uuid} already claimed by '{owner}'"
491
+ else:
492
+ message = f"Job {job.uuid} already claimed"
493
+ raise exc.UnclaimableJob(message)
494
+
495
+ # Ensure that the job still exists, it may have been claimed and
496
+ # consumed by another thread before we enter this function
497
+ if not self.get_data(job):
498
+ # Revoke the lease
499
+ if lease:
500
+ lease.revoke()
501
+ else:
502
+ self._client.delete(owner_key)
503
+ raise exc.UnclaimableJob(f"Job {job.uuid} already deleted.")
504
+
505
+ self.set_last_modified(job)
506
+
507
+ @base.check_who
508
+ def consume(self, job, who):
509
+ """Permanently (and atomically) removes a job from the jobboard."""
510
+ owner, data = self.get_owner_and_data(job)
511
+ if data is None or owner is None:
512
+ raise exc.NotFound(f"Cannot find job {job.uuid}")
513
+ if owner != who:
514
+ raise exc.JobFailure(f"Cannot consume a job {job.uuid}"
515
+ f" which is not owned by {who}")
516
+
517
+ self._client.delete_prefix(job.key + ".")
518
+ self._remove_job_from_cache(job.key)
519
+
520
+ @base.check_who
521
+ def abandon(self, job, who):
522
+ """Atomically attempts to abandon the provided job."""
523
+ owner, data = self.get_owner_and_data(job)
524
+ if data is None or owner is None:
525
+ raise exc.NotFound(f"Cannot find job {job.uuid}")
526
+ if owner != who:
527
+ raise exc.JobFailure(f"Cannot abandon a job {job.uuid}"
528
+ f" which is not owned by {who}")
529
+
530
+ owner_key = job.key + self.LOCK_POSTFIX
531
+ self._client.delete(owner_key)
532
+
533
+ @base.check_who
534
+ def trash(self, job, who):
535
+ """Trash the provided job."""
536
+ owner, data = self.get_owner_and_data(job)
537
+ if data is None or owner is None:
538
+ raise exc.NotFound(f"Cannot find job {job.uuid}")
539
+ if owner != who:
540
+ raise exc.JobFailure(f"Cannot trash a job {job.uuid} "
541
+ f"which is not owned by {who}")
542
+
543
+ trash_key = job.key.replace(self.ROOT_PATH, self.TRASH_PATH)
544
+ self._client.create(trash_key, data)
545
+ self._client.delete_prefix(job.key + ".")
546
+ self._remove_job_from_cache(job.key)
547
+
548
+ def register_entity(self, entity: 'entity.Entity'):
549
+ """Register an entity to the jobboard('s backend), e.g: a conductor"""
550
+ # TODO(gthiemonge) Doesn't seem to be useful with Etcd
551
+
552
+ @property
553
+ def connected(self):
554
+ """Returns if this jobboard is connected."""
555
+ return self._client is not None
556
+
557
+ @fasteners.locked(lock='_open_close_lock')
558
+ def connect(self):
559
+ """Opens the connection to any backend system."""
560
+ if self._client is None:
561
+ etcd_conf = {}
562
+ for config_opts in self.ETCD_CONFIG_OPTIONS:
563
+ key, value_type = config_opts
564
+ if key in self._conf:
565
+ etcd_conf[key] = value_type(self._conf[key])
566
+
567
+ self._client = etcd3gw.Etcd3Client(**etcd_conf)
568
+ self._state = self.CONNECTED_STATE
569
+
570
+ watch_url = self._create_path(self._root_path, self.JOB_PREFIX)
571
+ self._thread_cancel = threading.Event()
572
+ try:
573
+ (self._watcher,
574
+ self._watcher_cancel) = self._client.watch_prefix(watch_url)
575
+ except etcd3gw.exceptions.ConnectionFailedError:
576
+ exc.raise_with_cause(exc.JobFailure,
577
+ "Failed to connect to Etcd")
578
+ self._watcher_thd = threading.Thread(target=self._watcher_thread)
579
+ self._watcher_thd.start()
580
+
581
+ def _watcher_thread(self):
582
+ while not self._thread_cancel.is_set():
583
+ for event in self._watcher:
584
+ if "kv" not in event:
585
+ continue
586
+
587
+ key_value = event["kv"]
588
+ key = misc.binary_decode(key_value["key"])
589
+
590
+ if key.endswith(self.DATA_POSTFIX):
591
+ key = key.rstrip(self.DATA_POSTFIX)
592
+ if event.get("type") == "DELETE":
593
+ self._remove_job_from_cache(key)
594
+ else:
595
+ data = key_value["value"]
596
+ self._process_incoming_job(key, data)
597
+
598
+ @fasteners.locked(lock='_open_close_lock')
599
+ def close(self):
600
+ """Close the connection to any backend system."""
601
+ if self._client is not None:
602
+ if self._watcher_cancel is not None:
603
+ self._watcher_cancel()
604
+ if self._thread_cancel is not None:
605
+ self._thread_cancel.set()
606
+ if self._watcher_thd is not None:
607
+ self._watcher_thd.join()
608
+ del self._client
609
+ self._client = None
610
+ self._state = self.INIT_STATE
@@ -0,0 +1,421 @@
1
+ # Copyright (C) Red Hat
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+ # not use this file except in compliance with the License. You may obtain
5
+ # a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+ # License for the specific language governing permissions and limitations
13
+ # under the License.
14
+
15
+ from unittest import mock
16
+
17
+ from oslo_serialization import jsonutils
18
+ from oslo_utils import uuidutils
19
+ import testtools
20
+
21
+ from taskflow import exceptions as exc
22
+ from taskflow.jobs.backends import impl_etcd
23
+ from taskflow.jobs import base as jobs_base
24
+ from taskflow import test
25
+ from taskflow.tests.unit.jobs import base
26
+ from taskflow.tests import utils as test_utils
27
+
28
+ ETCD_AVAILABLE = test_utils.etcd_available()
29
+
30
+
31
+ class EtcdJobBoardMixin:
32
+ def create_board(self, conf=None, persistence=None):
33
+ self.path = f"test-{uuidutils.generate_uuid()}"
34
+ board_conf = {
35
+ "path": self.path,
36
+ }
37
+ if conf:
38
+ board_conf.update(conf)
39
+ board = impl_etcd.EtcdJobBoard("etcd", board_conf,
40
+ persistence=persistence)
41
+ return board._client, board
42
+
43
+
44
+ class MockedEtcdJobBoard(test.TestCase, EtcdJobBoardMixin):
45
+
46
+ def test_create_board(self):
47
+ _, jobboard = self.create_board()
48
+ self.assertEqual(f"/taskflow/jobs/{self.path}", jobboard._root_path)
49
+
50
+ _, jobboard = self.create_board({"path": "/testpath"})
51
+ self.assertEqual("/taskflow/jobs/testpath", jobboard._root_path)
52
+
53
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard.incr")
54
+ @mock.patch("threading.Condition")
55
+ @mock.patch("oslo_utils.uuidutils.generate_uuid")
56
+ @mock.patch("oslo_utils.timeutils.utcnow")
57
+ def test_post(self,
58
+ mock_utcnow: mock.Mock,
59
+ mock_generated_uuid: mock.Mock,
60
+ mock_cond: mock.Mock,
61
+ mock_incr: mock.Mock):
62
+ mock_incr.return_value = 12
63
+ mock_generated_uuid.return_value = "uuid1"
64
+ mock_utcnow.return_value = "utcnow1"
65
+
66
+ mock_book = mock.Mock()
67
+ mock_book.name = "book1_name"
68
+ mock_book.uuid = "book1_uuid"
69
+ mock_details = mock.Mock()
70
+
71
+ _, jobboard = self.create_board()
72
+ jobboard._client = mock.Mock()
73
+ job = jobboard.post("post1", book=mock_book,
74
+ details=mock_details,
75
+ priority=jobs_base.JobPriority.NORMAL)
76
+
77
+ expected_key = (
78
+ f"/taskflow/jobs/{self.path}/job12")
79
+ expected_data_key = expected_key + jobboard.DATA_POSTFIX
80
+ expected_book_data = {
81
+ "name": "book1_name",
82
+ "uuid": "book1_uuid"
83
+ }
84
+ expected_job_posting = {
85
+ "uuid": "uuid1",
86
+ "name": "post1",
87
+ "priority": "NORMAL",
88
+ "created_on": "utcnow1",
89
+ "details": mock_details,
90
+ "book": expected_book_data,
91
+ "sequence": 12,
92
+ }
93
+
94
+ mock_incr.assert_called_with(f"/taskflow/jobs/{self.path}/sequence")
95
+
96
+ jobboard._client.create.assert_called_with(
97
+ expected_data_key, jsonutils.dumps(expected_job_posting))
98
+
99
+ self.assertEqual("post1", job.name)
100
+ self.assertEqual(expected_key, job.key)
101
+ self.assertEqual(mock_details, job.details)
102
+ self.assertEqual(mock_book, job.book)
103
+ self.assertEqual(expected_book_data, job._book_data)
104
+ self.assertEqual(jobs_base.JobPriority.NORMAL, job.priority)
105
+ self.assertEqual(12, job.sequence)
106
+
107
+ self.assertEqual(1, len(jobboard._job_cache))
108
+ self.assertEqual(job, jobboard._job_cache[expected_key])
109
+
110
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
111
+ "set_last_modified")
112
+ def test_claim(self, mock_set_last_modified):
113
+ who = "owner1"
114
+ lease_id = uuidutils.generate_uuid()
115
+
116
+ _, jobboard = self.create_board(conf={"ttl": 37})
117
+ jobboard._client = mock.Mock()
118
+
119
+ mock_lease = mock.Mock(id=lease_id)
120
+ jobboard._client.lease.return_value = mock_lease
121
+ jobboard._client.create.return_value = True
122
+ jobboard._client.get.return_value = [mock.Mock()]
123
+
124
+ job = impl_etcd.EtcdJob(jobboard,
125
+ "job7",
126
+ jobboard._client,
127
+ f"/taskflow/jobs/{self.path}/job7",
128
+ uuid=uuidutils.generate_uuid(),
129
+ details=mock.Mock(),
130
+ backend="etcd",
131
+ book=mock.Mock(),
132
+ book_data=mock.Mock(),
133
+ priority=jobs_base.JobPriority.NORMAL,
134
+ sequence=7,
135
+ created_on="date")
136
+
137
+ jobboard.claim(job, who)
138
+
139
+ jobboard._client.lease.assert_called_once_with(ttl=37)
140
+
141
+ jobboard._client.create.assert_called_once_with(
142
+ f"{job.key}{jobboard.LOCK_POSTFIX}",
143
+ jsonutils.dumps({"owner": who,
144
+ "lease_id": lease_id}),
145
+ lease=mock_lease)
146
+
147
+ jobboard._client.get.assert_called_once_with(
148
+ job.key + jobboard.DATA_POSTFIX)
149
+ mock_lease.revoke.assert_not_called()
150
+
151
+ mock_set_last_modified.assert_called_once_with(job)
152
+
153
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
154
+ "set_last_modified")
155
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
156
+ "find_owner")
157
+ def test_claim_already_claimed(self, mock_find_owner,
158
+ mock_set_last_modified):
159
+ who = "owner1"
160
+ lease_id = uuidutils.generate_uuid()
161
+
162
+ mock_find_owner.return_value = who
163
+
164
+ _, jobboard = self.create_board({"ttl": 37})
165
+ jobboard._client = mock.Mock()
166
+
167
+ mock_lease = mock.Mock(id=lease_id)
168
+ jobboard._client.lease.return_value = mock_lease
169
+ jobboard._client.create.return_value = False
170
+ jobboard._client.get.return_value = []
171
+
172
+ job = impl_etcd.EtcdJob(jobboard,
173
+ "job7",
174
+ jobboard._client,
175
+ f"/taskflow/jobs/{self.path}/job7",
176
+ uuid=uuidutils.generate_uuid(),
177
+ details=mock.Mock(),
178
+ backend="etcd",
179
+ book=mock.Mock(),
180
+ book_data=mock.Mock(),
181
+ priority=jobs_base.JobPriority.NORMAL,
182
+ sequence=7,
183
+ created_on="date")
184
+
185
+ self.assertRaisesRegex(exc.UnclaimableJob, "already claimed by",
186
+ jobboard.claim, job, who)
187
+
188
+ jobboard._client.lease.assert_called_once_with(ttl=37)
189
+
190
+ jobboard._client.create.assert_called_once_with(
191
+ f"{job.key}{jobboard.LOCK_POSTFIX}",
192
+ jsonutils.dumps({"owner": who,
193
+ "lease_id": lease_id}),
194
+ lease=mock_lease)
195
+
196
+ mock_lease.revoke.assert_called_once()
197
+
198
+ mock_set_last_modified.assert_not_called()
199
+
200
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
201
+ "set_last_modified")
202
+ def test_claim_deleted(self, mock_set_last_modified):
203
+ who = "owner1"
204
+ lease_id = uuidutils.generate_uuid()
205
+
206
+ _, jobboard = self.create_board({"ttl": 37})
207
+ jobboard._client = mock.Mock()
208
+
209
+ mock_lease = mock.Mock(id=lease_id)
210
+ jobboard._client.lease.return_value = mock_lease
211
+ jobboard._client.create.return_value = True
212
+ jobboard._client.get.return_value = []
213
+
214
+ job = impl_etcd.EtcdJob(jobboard,
215
+ "job7",
216
+ jobboard._client,
217
+ f"/taskflow/jobs/{self.path}/job7",
218
+ uuid=uuidutils.generate_uuid(),
219
+ details=mock.Mock(),
220
+ backend="etcd",
221
+ book=mock.Mock(),
222
+ book_data=mock.Mock(),
223
+ priority=jobs_base.JobPriority.NORMAL,
224
+ sequence=7,
225
+ created_on="date")
226
+
227
+ self.assertRaisesRegex(exc.UnclaimableJob, "already deleted",
228
+ jobboard.claim, job, who)
229
+
230
+ jobboard._client.lease.assert_called_once_with(ttl=37)
231
+
232
+ jobboard._client.create.assert_called_once_with(
233
+ f"{job.key}{jobboard.LOCK_POSTFIX}",
234
+ jsonutils.dumps({"owner": who,
235
+ "lease_id": lease_id}),
236
+ lease=mock_lease)
237
+
238
+ jobboard._client.get.assert_called_once_with(
239
+ job.key + jobboard.DATA_POSTFIX)
240
+ mock_lease.revoke.assert_called_once()
241
+
242
+ mock_set_last_modified.assert_not_called()
243
+
244
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
245
+ "get_owner_and_data")
246
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
247
+ "_remove_job_from_cache")
248
+ def test_consume(self, mock__remove_job_from_cache,
249
+ mock_get_owner_and_data):
250
+ mock_get_owner_and_data.return_value = ["owner1", mock.Mock()]
251
+
252
+ _, jobboard = self.create_board()
253
+ jobboard._client = mock.Mock()
254
+
255
+ job = impl_etcd.EtcdJob(jobboard,
256
+ "job7",
257
+ jobboard._client,
258
+ f"/taskflow/jobs/{self.path}/job7")
259
+ jobboard.consume(job, "owner1")
260
+
261
+ jobboard._client.delete_prefix.assert_called_once_with(job.key + ".")
262
+ mock__remove_job_from_cache.assert_called_once_with(job.key)
263
+
264
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
265
+ "get_owner_and_data")
266
+ def test_consume_bad_owner(self, mock_get_owner_and_data):
267
+ mock_get_owner_and_data.return_value = ["owner2", mock.Mock()]
268
+
269
+ _, jobboard = self.create_board()
270
+ jobboard._client = mock.Mock()
271
+
272
+ job = impl_etcd.EtcdJob(jobboard,
273
+ "job7",
274
+ jobboard._client,
275
+ f"/taskflow/jobs/{self.path}/job7")
276
+ self.assertRaisesRegex(exc.JobFailure, "which is not owned",
277
+ jobboard.consume, job, "owner1")
278
+
279
+ jobboard._client.delete_prefix.assert_not_called()
280
+
281
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
282
+ "get_owner_and_data")
283
+ def test_abandon(self, mock_get_owner_and_data):
284
+ mock_get_owner_and_data.return_value = ["owner1", mock.Mock()]
285
+
286
+ _, jobboard = self.create_board()
287
+ jobboard._client = mock.Mock()
288
+
289
+ job = impl_etcd.EtcdJob(jobboard,
290
+ "job7",
291
+ jobboard._client,
292
+ f"/taskflow/jobs/{self.path}/job7")
293
+ jobboard.abandon(job, "owner1")
294
+
295
+ jobboard._client.delete.assert_called_once_with(
296
+ f"{job.key}{jobboard.LOCK_POSTFIX}")
297
+
298
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
299
+ "get_owner_and_data")
300
+ def test_abandon_bad_owner(self, mock_get_owner_and_data):
301
+ mock_get_owner_and_data.return_value = ["owner2", mock.Mock()]
302
+
303
+ _, jobboard = self.create_board()
304
+ jobboard._client = mock.Mock()
305
+
306
+ job = impl_etcd.EtcdJob(jobboard,
307
+ "job7",
308
+ jobboard._client,
309
+ f"/taskflow/jobs/{self.path}/job7")
310
+ self.assertRaisesRegex(exc.JobFailure, "which is not owned",
311
+ jobboard.abandon, job, "owner1")
312
+
313
+ jobboard._client.delete.assert_not_called()
314
+
315
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
316
+ "get_owner_and_data")
317
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
318
+ "_remove_job_from_cache")
319
+ def test_trash(self, mock__remove_job_from_cache,
320
+ mock_get_owner_and_data):
321
+ mock_get_owner_and_data.return_value = ["owner1", mock.Mock()]
322
+
323
+ _, jobboard = self.create_board()
324
+ jobboard._client = mock.Mock()
325
+
326
+ job = impl_etcd.EtcdJob(jobboard,
327
+ "job7",
328
+ jobboard._client,
329
+ f"/taskflow/jobs/{self.path}/job7")
330
+ jobboard.trash(job, "owner1")
331
+
332
+ jobboard._client.create.assert_called_once_with(
333
+ f"/taskflow/.trash/{self.path}/job7", mock.ANY)
334
+ jobboard._client.delete_prefix.assert_called_once_with(job.key + ".")
335
+ mock__remove_job_from_cache.assert_called_once_with(job.key)
336
+
337
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
338
+ "get_owner_and_data")
339
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
340
+ "_remove_job_from_cache")
341
+ def test_trash_bad_owner(self, mock__remove_job_from_cache,
342
+ mock_get_owner_and_data):
343
+ mock_get_owner_and_data.return_value = ["owner2", mock.Mock()]
344
+
345
+ _, jobboard = self.create_board()
346
+ jobboard._client = mock.Mock()
347
+
348
+ job = impl_etcd.EtcdJob(jobboard,
349
+ "job7",
350
+ jobboard._client,
351
+ f"/taskflow/jobs/{self.path}/job7")
352
+ self.assertRaisesRegex(exc.JobFailure, "which is not owned",
353
+ jobboard.trash, job, "owner1")
354
+
355
+ jobboard._client.create.assert_not_called()
356
+ jobboard._client.delete_prefix.assert_not_called()
357
+ mock__remove_job_from_cache.assert_not_called()
358
+
359
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
360
+ "get_owner_and_data")
361
+ @mock.patch("taskflow.jobs.backends.impl_etcd.EtcdJobBoard."
362
+ "_remove_job_from_cache")
363
+ def test_trash_deleted_job(self, mock__remove_job_from_cache,
364
+ mock_get_owner_and_data):
365
+ mock_get_owner_and_data.return_value = ["owner1", None]
366
+
367
+ _, jobboard = self.create_board()
368
+ jobboard._client = mock.Mock()
369
+
370
+ job = impl_etcd.EtcdJob(jobboard,
371
+ "job7",
372
+ jobboard._client,
373
+ f"/taskflow/jobs/{self.path}/job7")
374
+ self.assertRaisesRegex(exc.NotFound, "Cannot find job",
375
+ jobboard.trash, job, "owner1")
376
+
377
+ jobboard._client.create.assert_not_called()
378
+ jobboard._client.delete_prefix.assert_not_called()
379
+ mock__remove_job_from_cache.assert_not_called()
380
+
381
+
382
+ @testtools.skipIf(not ETCD_AVAILABLE, 'Etcd is not available')
383
+ class EtcdJobBoardTest(test.TestCase, base.BoardTestMixin, EtcdJobBoardMixin):
384
+ def setUp(self):
385
+ super().setUp()
386
+ self.client, self.board = self.create_board()
387
+
388
+ def test__incr(self):
389
+ key = uuidutils.generate_uuid()
390
+
391
+ self.board.connect()
392
+ self.addCleanup(self.board.close)
393
+ self.addCleanup(self.board._client.delete, key)
394
+
395
+ self.assertEqual(1, self.board.incr(key))
396
+ self.assertEqual(2, self.board.incr(key))
397
+ self.assertEqual(3, self.board.incr(key))
398
+
399
+ self.assertEqual(b'3', self.board.get_one(key))
400
+ self.board.close()
401
+
402
+ def test_get_one(self):
403
+ key1 = uuidutils.generate_uuid()
404
+
405
+ self.board.connect()
406
+ self.addCleanup(self.board._client.delete, key1)
407
+
408
+ # put data and get it
409
+ self.board._client.put(key1, "testset1")
410
+ self.assertEqual(b"testset1", self.board.get_one(key1))
411
+
412
+ # delete data and check that it's not found
413
+ self.board._client.delete(key1)
414
+ self.assertIsNone(self.board.get_one(key1))
415
+
416
+ # get a non-existant data
417
+ key2 = uuidutils.generate_uuid()
418
+ # (ensure it doesn't exist)
419
+ self.board._client.delete(key2)
420
+ self.assertIsNone(self.board.get_one(key2))
421
+ self.board.close()
@@ -15,8 +15,10 @@
15
15
  # under the License.
16
16
 
17
17
  import testtools
18
+ import time
18
19
 
19
20
  import taskflow.engines
21
+ from taskflow.engines.action_engine import executor
20
22
  from taskflow import exceptions as exc
21
23
  from taskflow.patterns import graph_flow as gf
22
24
  from taskflow.patterns import linear_flow as lf
@@ -502,6 +504,56 @@ class RetryTest(utils.EngineTestBase):
502
504
  self.assertRaisesRegex(RuntimeError, '^Woot', engine.run)
503
505
  self.assertRaisesRegex(RuntimeError, '^Woot', engine.run)
504
506
 
507
+ def test_restart_reverted_unordered_flows_with_retries(self):
508
+ now = time.time()
509
+
510
+ # First flow of an unordered flow:
511
+ subflow1 = lf.Flow('subflow1')
512
+
513
+ # * a task that completes in 3 sec with a few retries
514
+ subsubflow1 = lf.Flow('subflow1.subsubflow1',
515
+ retry=utils.RetryFiveTimes())
516
+ subsubflow1.add(utils.SuccessAfter3Sec('subflow1.fail1',
517
+ inject={'start_time': now}))
518
+ subflow1.add(subsubflow1)
519
+
520
+ # * a task that fails and triggers a revert after 5 retries
521
+ subsubflow2 = lf.Flow('subflow1.subsubflow2',
522
+ retry=utils.RetryFiveTimes())
523
+ subsubflow2.add(utils.FailingTask('subflow1.fail2'))
524
+ subflow1.add(subsubflow2)
525
+
526
+ # Second flow of the unordered flow:
527
+ subflow2 = lf.Flow('subflow2')
528
+
529
+ # * a task that always fails and retries
530
+ subsubflow1 = lf.Flow('subflow2.subsubflow1',
531
+ retry=utils.AlwaysRetry())
532
+ subsubflow1.add(utils.FailingTask('subflow2.fail1'))
533
+ subflow2.add(subsubflow1)
534
+
535
+ unordered_flow = uf.Flow('unordered_flow')
536
+ unordered_flow.add(subflow1, subflow2)
537
+
538
+ # Main flow, contains a simple task and an unordered flow
539
+ flow = lf.Flow('test')
540
+ flow.add(utils.NoopTask('task1'))
541
+ flow.add(unordered_flow)
542
+
543
+ engine = self._make_engine(flow)
544
+
545
+ # This test fails when using Green threads, skipping it for now
546
+ if isinstance(engine._task_executor,
547
+ executor.ParallelGreenThreadTaskExecutor):
548
+ self.skipTest("Skipping this test when using green threads.")
549
+
550
+ with utils.CaptureListener(engine) as capturer:
551
+ self.assertRaisesRegex(exc.WrappedFailure,
552
+ '.*RuntimeError: Woot!',
553
+ engine.run)
554
+ # task1 should have been reverted
555
+ self.assertIn('task1.t REVERTED(None)', capturer.values)
556
+
505
557
  def test_run_just_retry(self):
506
558
  flow = utils.OneReturnRetry(provides='x')
507
559
  engine = self._make_engine(flow)
taskflow/tests/utils.py CHANGED
@@ -19,6 +19,7 @@ import string
19
19
  import threading
20
20
  import time
21
21
 
22
+ import etcd3gw
22
23
  from oslo_utils import timeutils
23
24
  import redis
24
25
 
@@ -88,6 +89,15 @@ def redis_available(min_version):
88
89
  return ok
89
90
 
90
91
 
92
+ def etcd_available():
93
+ client = etcd3gw.Etcd3Client()
94
+ try:
95
+ client.get("/")
96
+ except Exception:
97
+ return False
98
+ return True
99
+
100
+
91
101
  class NoopRetry(retry.AlwaysRevert):
92
102
  pass
93
103
 
@@ -217,6 +227,32 @@ class FailingTask(ProgressingTask):
217
227
  raise RuntimeError('Woot!')
218
228
 
219
229
 
230
+ class SimpleTask(task.Task):
231
+ def execute(self, time_sleep=0, **kwargs):
232
+ time.sleep(time_sleep)
233
+
234
+
235
+ class SuccessAfter3Sec(task.Task):
236
+ def execute(self, start_time, **kwargs):
237
+ now = time.time()
238
+ if now - start_time >= 3:
239
+ return None
240
+ raise RuntimeError('Woot!')
241
+
242
+
243
+ class RetryFiveTimes(retry.Times):
244
+ def on_failure(self, history, *args, **kwargs):
245
+ if len(history) < 5:
246
+ time.sleep(1)
247
+ return retry.RETRY
248
+ return retry.REVERT_ALL
249
+
250
+
251
+ class AlwaysRetry(retry.Times):
252
+ def on_failure(self, history, *args, **kwargs):
253
+ return retry.RETRY
254
+
255
+
220
256
  class OptionalTask(task.Task):
221
257
  def execute(self, a, b=5):
222
258
  result = a * b
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: taskflow
3
- Version: 5.8.0
3
+ Version: 5.9.1
4
4
  Summary: Taskflow structured state management library.
5
5
  Home-page: https://docs.openstack.org/taskflow/latest/
6
6
  Author: OpenStack
@@ -43,6 +43,8 @@ Requires-Dist: SQLAlchemy-Utils (>=0.30.11) ; extra == 'database'
43
43
  Requires-Dist: SQLAlchemy (>=1.0.10) ; extra == 'database'
44
44
  Requires-Dist: alembic (>=0.8.10) ; extra == 'database'
45
45
  Requires-Dist: psycopg2 (>=2.8.0) ; extra == 'database'
46
+ Provides-Extra: etcd
47
+ Requires-Dist: etcd3gw (>=2.0.0) ; extra == 'etcd'
46
48
  Provides-Extra: eventlet
47
49
  Requires-Dist: eventlet (>=0.18.2) ; extra == 'eventlet'
48
50
  Provides-Extra: redis
@@ -29,7 +29,7 @@ taskflow/engines/action_engine/deciders.py,sha256=WEKo189CL9Zdg506Sz3Mlxpfp6dqGv
29
29
  taskflow/engines/action_engine/engine.py,sha256=kz_9BVD22IO2mEHBc735zUDPCF0Y_hzcAuQqZpUUVCk,29817
30
30
  taskflow/engines/action_engine/executor.py,sha256=sBmKwgDQN9fEZz42T7RB-RYUZvWqfHqC-Y2EsWRGkYI,7749
31
31
  taskflow/engines/action_engine/process_executor.py,sha256=if8kc7Dg3lvT7NwKVe2eqHhksv1pxEPNoI5E80naGr4,27975
32
- taskflow/engines/action_engine/runtime.py,sha256=exGFpk7iPPrl7vywAHqdeDW-cyFlYxphPLi8W1umP3o,13845
32
+ taskflow/engines/action_engine/runtime.py,sha256=FAUJZsZaADo4t1kRzs0Pf5z2qRjrlNG5GCycUoe4N8U,14604
33
33
  taskflow/engines/action_engine/scheduler.py,sha256=yZEn2z23N-0XNpHtjOs3kxJh4CTpZl-2ASrPsyLP2t0,4048
34
34
  taskflow/engines/action_engine/scopes.py,sha256=u0cmoMycrNZqMebgKqLW2YXBVLukNi9dVqKOFr79lKw,5432
35
35
  taskflow/engines/action_engine/selector.py,sha256=9FKRKOi6mqIn4kfEY2R6Uq6aVzPPsT0DgI1VDVRaZdU,11558
@@ -105,6 +105,7 @@ taskflow/examples/resume_many_flows/run_flow.py,sha256=MOD78Zg7IaSAVRoLPap36njTZ
105
105
  taskflow/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
106
106
  taskflow/jobs/base.py,sha256=nowX7pKESnTz-_iPmg1vUQJ0bgO3HLOOtytOKOy12kA,22463
107
107
  taskflow/jobs/backends/__init__.py,sha256=ILlqq_U0FPM77nPQgx_OexjHxUvC5Tl4RXkX-IznSfw,2902
108
+ taskflow/jobs/backends/impl_etcd.py,sha256=3o5xBe0UkqPo9b8LYcbHD2L7e5lDlJHjo8VQsyd4iIE,22067
108
109
  taskflow/jobs/backends/impl_redis.py,sha256=ZgxVSlAOncTE089a6c1qjafSIzv8_ekLQKvq1G_n7IE,43925
109
110
  taskflow/jobs/backends/impl_zookeeper.py,sha256=1Noi75aTH0NCx-qlsRExsx-QKBtY5j2PJb6RIi_Cm5o,37644
110
111
  taskflow/listeners/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -148,7 +149,7 @@ taskflow/persistence/backends/sqlalchemy/alembic/versions/README,sha256=AwegmiWc
148
149
  taskflow/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
149
150
  taskflow/tests/fixtures.py,sha256=VfGlusX2q79_ktPME4cm1qt_gN9VNRAwyYy5sDBAX0Y,2004
150
151
  taskflow/tests/test_examples.py,sha256=g2zhguLKE4Tex2e_vSmKMhsNXItnKWj89_VbnmgGejU,4925
151
- taskflow/tests/utils.py,sha256=P6RPD2w5h00KAse7r31LC9tJils0jaeiUx5QiVqLOA0,11157
152
+ taskflow/tests/utils.py,sha256=go8D7cUHQJ31F0n0Ts5ijeHdC7JsCFpsgdHb09QL5A4,11954
152
153
  taskflow/tests/unit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
153
154
  taskflow/tests/unit/test_arguments_passing.py,sha256=jK9-Z7xBfPI4nHB7_Bh_rllBH-X55v6wplxdrjwRS3E,9381
154
155
  taskflow/tests/unit/test_check_transition.py,sha256=DuxJAErCwheWPAdN_43LOOvZg00vmUoC-IzGgh642IM,6173
@@ -166,7 +167,7 @@ taskflow/tests/unit/test_mapfunctor_task.py,sha256=1FI8AJxRUPxT7Lu6h9Mhd62srAnKf
166
167
  taskflow/tests/unit/test_notifier.py,sha256=Q4xGl9e_LZRiJVpGR2FbG2qSp9CqlP65aDsaIKxPqHI,7894
167
168
  taskflow/tests/unit/test_progress.py,sha256=oeYFeWuHde1D2cAahhE1yN1NznNcXk7ajw0Z2yCyZkM,5259
168
169
  taskflow/tests/unit/test_reducefunctor_task.py,sha256=vM4P5h80-Ex4uq2N_dNmTFL49Ks3iFmP5dMbZEk0YQ0,2106
169
- taskflow/tests/unit/test_retries.py,sha256=yRQF_TUtZ-d13sIzFNLjKZXg5DhuSj2xkhvgi8zGgCE,56542
170
+ taskflow/tests/unit/test_retries.py,sha256=xxHEBNoiaDR5WMztzm5CidqGrOkKJ2yqs2J69P1KwkE,58658
170
171
  taskflow/tests/unit/test_states.py,sha256=iYyHo9YzHBMjlYaqlzZJTcKjB53WCqf5Fw3OGoVKVME,3660
171
172
  taskflow/tests/unit/test_storage.py,sha256=SlShgJ4hirdnggDkpB3GoosY--4B0PqMOVvMBG5D6g4,23520
172
173
  taskflow/tests/unit/test_suspend.py,sha256=2d5y15Fw6NESJ4oEUAA7dosECGb2-CLkkgXULqwNtWw,10077
@@ -187,6 +188,7 @@ taskflow/tests/unit/action_engine/test_scoping.py,sha256=wIB9C3LMgfsqO6cdz_ZdhQc
187
188
  taskflow/tests/unit/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
188
189
  taskflow/tests/unit/jobs/base.py,sha256=wYv5ziDeVdYn2nhuMSfYTWU39Xxpv8lRKvJbGsB79lM,8447
189
190
  taskflow/tests/unit/jobs/test_entrypoint.py,sha256=t2m5zeYhjb2pmPxBgGO_0AMWR0dabWxglDBODzLE0fY,2220
191
+ taskflow/tests/unit/jobs/test_etcd_job.py,sha256=oKnZ0nnmpmr9K04x3a4d6STvrE811uPRYdrJAbgdyv0,16512
190
192
  taskflow/tests/unit/jobs/test_redis_job.py,sha256=wHwptqgrafZMk0aDZNvn2h3sJw8ckKY1g5J9I_dzWAM,7394
191
193
  taskflow/tests/unit/jobs/test_zk_job.py,sha256=e5CdkqmiQY_jzNtcHsxKcsS9uY7k-D8CMGkqt1J_bqE,13789
192
194
  taskflow/tests/unit/patterns/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -232,11 +234,11 @@ taskflow/utils/persistence_utils.py,sha256=GWceOcxdfsf-MtrdR74xmC2khClF8im6DpZmR
232
234
  taskflow/utils/redis_utils.py,sha256=zJBvXmlNZUQ_gwGZAaNLySVtCtou3YayHAkGSCNKDUw,4345
233
235
  taskflow/utils/schema_utils.py,sha256=Zf6eL0NK0_TVFD_Sc1yEZYswFz9K0tet1Dmj48F8uMA,1434
234
236
  taskflow/utils/threading_utils.py,sha256=eiaNUK127DOBr_zfj3-j4Oi5a2dsD7VunVeTYN6NjPo,5849
235
- taskflow-5.8.0.dist-info/AUTHORS,sha256=uKVGnRq9UrIKLl5MhXJ9QuqzKXynjWcZcuaW_j9Eh5g,4564
236
- taskflow-5.8.0.dist-info/LICENSE,sha256=0t4vVm0tDgtQn7DqH6Nmn0kGSrHeIcV0U8qzdQojTo8,10143
237
- taskflow-5.8.0.dist-info/METADATA,sha256=kuefeelA76iqoYN42iONSN0BIGMC1kuBl3gbAFUxFZU,5074
238
- taskflow-5.8.0.dist-info/WHEEL,sha256=g4nMs7d-Xl9-xC9XovUrsDHGXt-FT0E17Yqo92DEfvY,92
239
- taskflow-5.8.0.dist-info/entry_points.txt,sha256=MGjjnng_YpSJs9BMAJBC2hJnljMV0pNllXl_5VoHJV0,1183
240
- taskflow-5.8.0.dist-info/pbr.json,sha256=-MpKNBkoFQci3B2CFjUNnVUnce_VqQpi9W83oKFErq4,47
241
- taskflow-5.8.0.dist-info/top_level.txt,sha256=PsdN41vwysesDlqHCSVVXH4mkTMdMiZFW_yHEAXiZE4,9
242
- taskflow-5.8.0.dist-info/RECORD,,
237
+ taskflow-5.9.1.dist-info/AUTHORS,sha256=uKVGnRq9UrIKLl5MhXJ9QuqzKXynjWcZcuaW_j9Eh5g,4564
238
+ taskflow-5.9.1.dist-info/LICENSE,sha256=0t4vVm0tDgtQn7DqH6Nmn0kGSrHeIcV0U8qzdQojTo8,10143
239
+ taskflow-5.9.1.dist-info/METADATA,sha256=cZHxTrZuq1RW6ZopG9g3Yf5nM1d-dIpnW0BIZy2zyoo,5146
240
+ taskflow-5.9.1.dist-info/WHEEL,sha256=g4nMs7d-Xl9-xC9XovUrsDHGXt-FT0E17Yqo92DEfvY,92
241
+ taskflow-5.9.1.dist-info/entry_points.txt,sha256=bIwgsxgCx_nrcv_gMhFDVffkg_r4NHX2O43iEXUKohU,1236
242
+ taskflow-5.9.1.dist-info/pbr.json,sha256=r6eUVJh_ZGPwgjtiFsQha4d7x0jrO255TgbBcTs1G8M,47
243
+ taskflow-5.9.1.dist-info/top_level.txt,sha256=PsdN41vwysesDlqHCSVVXH4mkTMdMiZFW_yHEAXiZE4,9
244
+ taskflow-5.9.1.dist-info/RECORD,,
@@ -10,6 +10,7 @@ worker-based = taskflow.engines.worker_based.engine:WorkerBasedActionEngine
10
10
  workers = taskflow.engines.worker_based.engine:WorkerBasedActionEngine
11
11
 
12
12
  [taskflow.jobboards]
13
+ etcd = taskflow.jobs.backends.impl_etcd:EtcdJobBoard
13
14
  redis = taskflow.jobs.backends.impl_redis:RedisJobBoard
14
15
  zookeeper = taskflow.jobs.backends.impl_zookeeper:ZookeeperJobBoard
15
16
 
@@ -0,0 +1 @@
1
+ {"git_version": "a8b48d6e", "is_release": true}
@@ -1 +0,0 @@
1
- {"git_version": "828e9240", "is_release": true}