durabletask 1.3.0.dev27__py3-none-any.whl → 1.3.0.dev28__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.
durabletask/__init__.py CHANGED
@@ -4,13 +4,24 @@
4
4
  """Durable Task SDK for Python"""
5
5
 
6
6
  from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
7
- from durabletask.worker import ConcurrencyOptions, VersioningOptions
7
+ from durabletask.worker import (
8
+ ActivityWorkItemFilter,
9
+ ConcurrencyOptions,
10
+ EntityWorkItemFilter,
11
+ OrchestrationWorkItemFilter,
12
+ VersioningOptions,
13
+ WorkItemFilters,
14
+ )
8
15
 
9
16
  __all__ = [
17
+ "ActivityWorkItemFilter",
10
18
  "ConcurrencyOptions",
19
+ "EntityWorkItemFilter",
11
20
  "LargePayloadStorageOptions",
21
+ "OrchestrationWorkItemFilter",
12
22
  "PayloadStore",
13
23
  "VersioningOptions",
24
+ "WorkItemFilters",
14
25
  ]
15
26
 
16
27
  PACKAGE_NAME = "durabletask"
@@ -26,6 +26,7 @@ from google.protobuf import empty_pb2, timestamp_pb2, wrappers_pb2
26
26
  import durabletask.internal.orchestrator_service_pb2 as pb
27
27
  import durabletask.internal.orchestrator_service_pb2_grpc as stubs
28
28
  import durabletask.internal.helpers as helpers
29
+ from durabletask.entities.entity_instance_id import EntityInstanceId
29
30
 
30
31
 
31
32
  @dataclass
@@ -56,6 +57,7 @@ class ActivityWorkItem:
56
57
  task_id: int
57
58
  input: Optional[str]
58
59
  completion_token: int
60
+ version: Optional[str] = None
59
61
 
60
62
 
61
63
  @dataclass
@@ -451,9 +453,57 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
451
453
  f"Restarted instance '{request.instanceId}' as '{new_instance_id}'")
452
454
  return pb.RestartInstanceResponse(instanceId=new_instance_id)
453
455
 
456
+ @staticmethod
457
+ def _parse_work_item_filters(request: pb.GetWorkItemsRequest):
458
+ """Extract filters from the request.
459
+
460
+ Returns a tuple of three values, one per work-item category. Each
461
+ value is either ``None`` (no filtering -- dispatch everything) or a
462
+ ``dict`` mapping a task name to a ``frozenset`` of accepted versions
463
+ (empty frozenset means *any* version of that name is accepted).
464
+ An empty ``dict`` means the worker opted into filtering for that
465
+ category but listed no names, so *nothing* should match.
466
+ """
467
+ if not request.HasField("workItemFilters"):
468
+ return None, None, None
469
+ wf = request.workItemFilters
470
+
471
+ def _build_filter(filters):
472
+ result: dict[str, frozenset[str]] = {}
473
+ for f in filters:
474
+ versions = frozenset(f.versions) if f.versions else frozenset()
475
+ existing = result.get(f.name, frozenset())
476
+ result[f.name] = existing | versions
477
+ return result
478
+
479
+ orch_filter = _build_filter(wf.orchestrations)
480
+ activity_filter = _build_filter(wf.activities)
481
+ entity_filter = {f.name: frozenset() for f in wf.entities}
482
+ return orch_filter, activity_filter, entity_filter
483
+
484
+ @staticmethod
485
+ def _matches_filter(name: str, version: Optional[str],
486
+ filt: Optional[dict[str, frozenset[str]]]) -> bool:
487
+ """Check whether a work item matches the parsed filter.
488
+
489
+ *filt* is ``None`` when the worker did not opt into filtering
490
+ (everything matches). Otherwise it is a dict mapping accepted
491
+ names to a frozenset of accepted versions. An empty frozenset
492
+ means any version of that name is accepted.
493
+ """
494
+ if filt is None:
495
+ return True
496
+ accepted_versions = filt.get(name)
497
+ if accepted_versions is None:
498
+ return False
499
+ if not accepted_versions:
500
+ return True # empty set -- any version
501
+ return (version or "") in accepted_versions
502
+
454
503
  def GetWorkItems(self, request: pb.GetWorkItemsRequest, context):
455
504
  """Streams work items to the worker (orchestration and activity work items)."""
456
505
  self._logger.info("Worker connected and requesting work items")
506
+ orch_filter, activity_filter, entity_filter = self._parse_work_item_filters(request)
457
507
 
458
508
  try:
459
509
  while context.is_active() and not self._shutdown_event.is_set():
@@ -461,6 +511,7 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
461
511
 
462
512
  with self._lock:
463
513
  # Check for orchestration work
514
+ skipped_orchs: list[str] = []
464
515
  while self._orchestration_queue:
465
516
  instance_id = self._orchestration_queue.popleft()
466
517
  self._orchestration_queue_set.discard(instance_id)
@@ -469,11 +520,15 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
469
520
  if not instance or not instance.pending_events:
470
521
  continue
471
522
 
523
+ # Skip if orchestration doesn't match filters
524
+ if not self._matches_filter(
525
+ instance.name, instance.version, orch_filter):
526
+ skipped_orchs.append(instance_id)
527
+ continue
528
+
472
529
  if instance_id in self._orchestration_in_flight:
473
530
  # Already being processed — re-add to queue
474
- if instance_id not in self._orchestration_queue_set:
475
- self._orchestration_queue.append(instance_id)
476
- self._orchestration_queue_set.add(instance_id)
531
+ skipped_orchs.append(instance_id)
477
532
  break
478
533
 
479
534
  # Move pending events to dispatched_events
@@ -500,27 +555,66 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
500
555
  )
501
556
  break
502
557
 
558
+ # Re-queue skipped orchestrations for other workers
559
+ for s in skipped_orchs:
560
+ if s not in self._orchestration_queue_set:
561
+ self._orchestration_queue.append(s)
562
+ self._orchestration_queue_set.add(s)
563
+
503
564
  # Check for activity work
504
565
  if not work_item and self._activity_queue:
505
- activity = self._activity_queue.popleft()
506
- work_item = pb.WorkItem(
507
- completionToken=str(activity.completion_token),
508
- activityRequest=pb.ActivityRequest(
509
- name=activity.name,
510
- taskId=activity.task_id,
511
- input=wrappers_pb2.StringValue(value=activity.input) if activity.input else None,
512
- orchestrationInstance=pb.OrchestrationInstance(instanceId=activity.instance_id)
566
+ # Scan for the first matching activity
567
+ skipped: list = []
568
+ matched_activity = None
569
+ while self._activity_queue:
570
+ candidate = self._activity_queue.popleft()
571
+ if not self._matches_filter(
572
+ candidate.name, candidate.version,
573
+ activity_filter):
574
+ skipped.append(candidate)
575
+ continue
576
+ matched_activity = candidate
577
+ break
578
+ # Put back non-matching items
579
+ for s in skipped:
580
+ self._activity_queue.append(s)
581
+
582
+ if matched_activity is not None:
583
+ work_item = pb.WorkItem(
584
+ completionToken=str(matched_activity.completion_token),
585
+ activityRequest=pb.ActivityRequest(
586
+ name=matched_activity.name,
587
+ taskId=matched_activity.task_id,
588
+ input=wrappers_pb2.StringValue(value=matched_activity.input) if matched_activity.input else None,
589
+ orchestrationInstance=pb.OrchestrationInstance(instanceId=matched_activity.instance_id)
590
+ )
513
591
  )
514
- )
515
592
 
516
593
  # Check for entity work
517
594
  if not work_item:
595
+ skipped_entities: list[str] = []
518
596
  while self._entity_queue:
519
597
  entity_id = self._entity_queue.popleft()
520
598
  self._entity_queue_set.discard(entity_id)
521
599
  entity = self._entities.get(entity_id)
522
600
 
523
601
  if entity and entity.pending_operations:
602
+ # Skip if entity name doesn't match filters
603
+ if entity_filter is not None:
604
+ try:
605
+ parsed = EntityInstanceId.parse(entity_id)
606
+ if not self._matches_filter(
607
+ parsed.entity, None,
608
+ entity_filter):
609
+ skipped_entities.append(entity_id)
610
+ continue
611
+ except ValueError:
612
+ self._logger.warning(
613
+ f"Cannot parse entity ID '{entity_id}' "
614
+ f"for filter matching; skipping")
615
+ skipped_entities.append(entity_id)
616
+ continue
617
+
524
618
  # Skip if this entity is already being processed
525
619
  if entity_id in self._entity_in_flight:
526
620
  continue
@@ -547,6 +641,12 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
547
641
  )
548
642
  break
549
643
 
644
+ # Re-queue skipped entities for other workers
645
+ for s in skipped_entities:
646
+ if s not in self._entity_queue_set:
647
+ self._entity_queue.append(s)
648
+ self._entity_queue_set.add(s)
649
+
550
650
  if work_item:
551
651
  yield work_item
552
652
  else:
@@ -1274,12 +1374,15 @@ class InMemoryOrchestrationBackend(stubs.TaskHubSidecarServiceServicer):
1274
1374
  instance.status = pb.ORCHESTRATION_STATUS_RUNNING
1275
1375
 
1276
1376
  # Queue activity for execution
1377
+ task_version = schedule_task.version.value \
1378
+ if schedule_task.HasField("version") else None
1277
1379
  self._activity_queue.append(ActivityWorkItem(
1278
1380
  instance_id=instance.instance_id,
1279
1381
  name=task_name,
1280
1382
  task_id=task_id,
1281
1383
  input=input_value,
1282
- completion_token=instance.completion_token
1384
+ completion_token=instance.completion_token,
1385
+ version=task_version,
1283
1386
  ))
1284
1387
  self._work_available.set()
1285
1388
 
durabletask/worker.py CHANGED
@@ -9,11 +9,12 @@ import os
9
9
  import random
10
10
  import time
11
11
  from concurrent.futures import ThreadPoolExecutor
12
+ from dataclasses import dataclass, field
12
13
  from datetime import datetime, timedelta, timezone
13
14
  from threading import Event, Thread
14
15
  from types import GeneratorType
15
16
  from enum import Enum
16
- from typing import Any, Generator, Optional, Sequence, Tuple, TypeVar, Union
17
+ from typing import Any, Generator, Optional, Sequence, Tuple, TypeVar, Union, overload
17
18
  import uuid
18
19
  from packaging.version import InvalidVersion, parse
19
20
 
@@ -143,6 +144,109 @@ class VersioningOptions:
143
144
  self.failure_strategy = failure_strategy
144
145
 
145
146
 
147
+ # Sentinel object used to distinguish "auto-generate filters" from "clear filters (None)".
148
+ _AUTO_GENERATE_FILTERS = object()
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class OrchestrationWorkItemFilter:
153
+ """Specifies a filter for orchestration work items."""
154
+
155
+ name: str
156
+ """The name of the orchestration to filter."""
157
+ versions: list[str] = field(default_factory=list)
158
+ """Optional list of versions to filter."""
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class ActivityWorkItemFilter:
163
+ """Specifies a filter for activity work items."""
164
+
165
+ name: str
166
+ """The name of the activity to filter."""
167
+ versions: list[str] = field(default_factory=list)
168
+ """Optional list of versions to filter."""
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class EntityWorkItemFilter:
173
+ """Specifies a filter for entity work items.
174
+
175
+ The name is normalized to lowercase to match entity registration
176
+ and instance ID conventions.
177
+ """
178
+
179
+ name: str
180
+ """The name of the entity to filter."""
181
+
182
+ def __post_init__(self):
183
+ EntityInstanceId.validate_entity_name(self.name)
184
+ object.__setattr__(self, 'name', self.name.lower())
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class WorkItemFilters:
189
+ """Work item filters for a Durable Task Worker.
190
+
191
+ These filters are passed to the backend and only work items matching the
192
+ filters will be processed by the worker. If no filters are provided, the
193
+ worker will process all work items.
194
+
195
+ By default, no filters are applied. Call
196
+ :meth:`TaskHubGrpcWorker.use_work_item_filters` to enable filtering.
197
+ """
198
+
199
+ orchestrations: list[OrchestrationWorkItemFilter] = field(default_factory=list)
200
+ """List of orchestration filters."""
201
+ activities: list[ActivityWorkItemFilter] = field(default_factory=list)
202
+ """List of activity filters."""
203
+ entities: list[EntityWorkItemFilter] = field(default_factory=list)
204
+ """List of entity filters."""
205
+
206
+ @classmethod
207
+ def _from_registry(cls, registry: '_Registry') -> 'WorkItemFilters':
208
+ """Auto-generate work item filters from the task registry."""
209
+ versions: list[str] = []
210
+ v = registry.versioning
211
+ if v and v.match_strategy == VersionMatchStrategy.STRICT and v.version:
212
+ versions = [registry.versioning.version]
213
+
214
+ orchestrations = [
215
+ OrchestrationWorkItemFilter(name=name, versions=list(versions))
216
+ for name in registry.orchestrators
217
+ ]
218
+ activities = [
219
+ ActivityWorkItemFilter(name=name, versions=list(versions))
220
+ for name in registry.activities
221
+ ]
222
+ entities = [
223
+ EntityWorkItemFilter(name=name)
224
+ for name in registry.entities
225
+ ]
226
+ return cls(
227
+ orchestrations=orchestrations,
228
+ activities=activities,
229
+ entities=entities,
230
+ )
231
+
232
+ def _to_grpc(self) -> pb.WorkItemFilters:
233
+ """Convert to a gRPC WorkItemFilters message."""
234
+ grpc_filters = pb.WorkItemFilters()
235
+ for f in self.orchestrations:
236
+ grpc_filters.orchestrations.append(
237
+ pb.OrchestrationFilter(name=f.name, versions=f.versions)
238
+ )
239
+ for f in self.activities:
240
+ grpc_filters.activities.append(
241
+ pb.ActivityFilter(name=f.name, versions=f.versions)
242
+ )
243
+ for f in self.entities:
244
+ grpc_filters.entities.append(
245
+ pb.EntityFilter(name=f.name)
246
+ )
247
+ return grpc_filters
248
+
249
+
146
250
  class _Registry:
147
251
  orchestrators: dict[str, task.Orchestrator]
148
252
  activities: dict[str, task.Activity]
@@ -354,6 +458,8 @@ class TaskHubGrpcWorker:
354
458
  self._interceptors = None
355
459
 
356
460
  self._async_worker_manager = _AsyncWorkerManager(self._concurrency_options, self._logger)
461
+ self._work_item_filters: Optional[WorkItemFilters] = None
462
+ self._auto_generate_work_item_filters: bool = False
357
463
 
358
464
  @property
359
465
  def concurrency_options(self) -> ConcurrencyOptions:
@@ -396,11 +502,65 @@ class TaskHubGrpcWorker:
396
502
  raise RuntimeError("Cannot set default version while the worker is running.")
397
503
  self._registry.versioning = version
398
504
 
505
+ @overload
506
+ def use_work_item_filters(self) -> None:
507
+ ...
508
+
509
+ @overload
510
+ def use_work_item_filters(self, filters: WorkItemFilters) -> None:
511
+ ...
512
+
513
+ @overload
514
+ def use_work_item_filters(self, filters: None) -> None:
515
+ ...
516
+
517
+ def use_work_item_filters(
518
+ self,
519
+ filters: Union[WorkItemFilters, None, object] = _AUTO_GENERATE_FILTERS,
520
+ ) -> None:
521
+ """Configures work item filters for the worker.
522
+
523
+ Work item filters tell the backend which orchestrations, activities,
524
+ and entities this worker can handle. When enabled, only matching work
525
+ items are dispatched to this worker.
526
+
527
+ By default no filters are applied and the worker processes all work
528
+ items. Calling this method enables filtering.
529
+
530
+ Args:
531
+ filters: The filters to apply. If omitted (default), filters are
532
+ auto-generated from registered orchestrations, activities, and
533
+ entities at :meth:`start` time. Pass a :class:`WorkItemFilters`
534
+ instance to provide explicit filters. Pass ``None`` to clear
535
+ any previously configured filters.
536
+ """
537
+ if self._is_running:
538
+ raise RuntimeError(
539
+ "Work item filters cannot be changed while the worker is running."
540
+ )
541
+ if filters is _AUTO_GENERATE_FILTERS:
542
+ self._auto_generate_work_item_filters = True
543
+ self._work_item_filters = None
544
+ elif filters is None:
545
+ self._auto_generate_work_item_filters = False
546
+ self._work_item_filters = None
547
+ elif isinstance(filters, WorkItemFilters):
548
+ self._auto_generate_work_item_filters = False
549
+ self._work_item_filters = filters
550
+ else:
551
+ raise TypeError(
552
+ "filters must be a WorkItemFilters instance, None, or omitted."
553
+ )
554
+
399
555
  def start(self):
400
556
  """Starts the worker on a background thread and begins listening for work items."""
401
557
  if self._is_running:
402
558
  raise RuntimeError("The worker is already running.")
403
559
 
560
+ # Auto-generate work item filters from registry if opted in
561
+ if self._auto_generate_work_item_filters:
562
+ self._work_item_filters = WorkItemFilters._from_registry(self._registry)
563
+
404
564
  def run_loop():
405
565
  loop = asyncio.new_event_loop()
406
566
  asyncio.set_event_loop(loop)
@@ -510,6 +670,10 @@ class TaskHubGrpcWorker:
510
670
  maxConcurrentActivityWorkItems=self._concurrency_options.maximum_concurrent_activity_work_items,
511
671
  capabilities=capabilities,
512
672
  )
673
+ if self._work_item_filters is not None:
674
+ get_work_items_request.workItemFilters.CopyFrom(
675
+ self._work_item_filters._to_grpc()
676
+ )
513
677
  self._response_stream = stub.GetWorkItems(get_work_items_request)
514
678
  self._logger.info(
515
679
  f"Successfully connected to {self._host_address}. Waiting for work items..."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.3.0.dev27
3
+ Version: 1.3.0.dev28
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -1,8 +1,8 @@
1
- durabletask/__init__.py,sha256=Xb-2zUIwDbCCl4Q_89TqMJ-3zckHFrGtSMcVhEeSseQ,407
1
+ durabletask/__init__.py,sha256=OdfKCNlS_NJawRfLWsFNj7YIHeGSQkh2VH3OzG0Oric,644
2
2
  durabletask/client.py,sha256=NbIdDTQR7XI_ZiqsGMP0q5vmbe5-ShyGUQre1qgB-Ag,34107
3
3
  durabletask/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  durabletask/task.py,sha256=YeZj-tYHiLml672hAMtri8_hqnpmR5in4AHMi5owFQo,21793
5
- durabletask/worker.py,sha256=TNc1WJTmh42sLWyD_FuwUw7YqrIPA7AFpJKd0iHOn5M,124479
5
+ durabletask/worker.py,sha256=CUiZG071JA99HlMWnFCe3hw9OuXzail9dhbzBd8ecek,130407
6
6
  durabletask/entities/__init__.py,sha256=DbNd5riqWZaj3tG6gN82O8Q6wTmFpe6QaH0pQgDSPHs,721
7
7
  durabletask/entities/durable_entity.py,sha256=LQPWnUlRsHiFVRoTdpeSK--eXtjf2UGbVQwEEKf7QwI,3318
8
8
  durabletask/entities/entity_context.py,sha256=U-B3i9QP34N-6Fikx_tMp8zo0YLdmwhwpdxwjHd7z-M,5346
@@ -31,9 +31,9 @@ durabletask/payload/__init__.py,sha256=1h68pQvgk8JUp5LBJuBq9W4GUPYkdlhqmCCQEg6YB
31
31
  durabletask/payload/helpers.py,sha256=RYG5MEVAqHjm4zfFHs3Td91FVQHUoCcb5hbEJ4sYj5s,12350
32
32
  durabletask/payload/store.py,sha256=3qJMvKxRUkr6ScWUzxpKAVgzuhFLywRW8a2_5OOmNk4,3000
33
33
  durabletask/testing/__init__.py,sha256=rXbcSFtzuaRAbDNX-HmdgbxLTegvKJ1FRjZfSOIAMgA,323
34
- durabletask/testing/in_memory_backend.py,sha256=REmzhgAAw_AOpxrRAEbZlU4jqQLZo6QdbWYoWaruBDo,74102
35
- durabletask-1.3.0.dev27.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
36
- durabletask-1.3.0.dev27.dist-info/METADATA,sha256=uGCKNOJQtqqay5ULKwLclHtAQh3zHYj39_ZP37C1lMU,4404
37
- durabletask-1.3.0.dev27.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
38
- durabletask-1.3.0.dev27.dist-info/top_level.txt,sha256=EBVyuKWnjOwq8bJI1Uvb9U3c4fzQxACWj9p83he6fik,12
39
- durabletask-1.3.0.dev27.dist-info/RECORD,,
34
+ durabletask/testing/in_memory_backend.py,sha256=ELxyCDRDNOabygIvw9ZeRUpP3MzeM5Hdbu6QlRwKdno,79249
35
+ durabletask-1.3.0.dev28.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
36
+ durabletask-1.3.0.dev28.dist-info/METADATA,sha256=wpEaMonvGsFrkYUgvXjLvHGlySCqjXwwlX9DOQDiTRI,4404
37
+ durabletask-1.3.0.dev28.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
38
+ durabletask-1.3.0.dev28.dist-info/top_level.txt,sha256=EBVyuKWnjOwq8bJI1Uvb9U3c4fzQxACWj9p83he6fik,12
39
+ durabletask-1.3.0.dev28.dist-info/RECORD,,