azure-functions-durable 1.2.10__py3-none-any.whl → 1.3.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.
@@ -198,6 +198,17 @@ class Blueprint(TriggerApi, BindingApi, SettingsApi):
198
198
  # Invoke user code with rich DF Client binding
199
199
  return await user_code(*args, **kwargs)
200
200
 
201
+ # Todo: This feels awkward - however, there are two reasons that I can't naively implement
202
+ # this in the same way as entities and orchestrators:
203
+ # 1. We intentionally wrap this exported signature with @wraps, to preserve the original
204
+ # signature of the user code. This means that we can't just assign a new object to the
205
+ # fb._function._func, as that would overwrite the original signature.
206
+ # 2. I have not yet fully tested the behavior of overriding __call__ on an object with an
207
+ # async method.
208
+ # Here we lose type hinting and auto-documentation - not great. Need to find a better way
209
+ # to do this.
210
+ df_client_middleware.client_function = fb._function._func
211
+
201
212
  user_code_with_rich_client = df_client_middleware
202
213
  fb._function._func = user_code_with_rich_client
203
214
 
@@ -1,6 +1,6 @@
1
1
  from .models import DurableEntityContext
2
2
  from .models.entities import OperationResult, EntityState
3
- from datetime import datetime
3
+ from datetime import datetime, timezone
4
4
  from typing import Callable, Any, List, Dict
5
5
 
6
6
 
@@ -49,7 +49,7 @@ class Entity:
49
49
  for operation_data in batch:
50
50
  result: Any = None
51
51
  is_error: bool = False
52
- start_time: datetime = datetime.now()
52
+ start_time: datetime = datetime.now(timezone.utc)
53
53
 
54
54
  try:
55
55
  # populate context
@@ -74,6 +74,7 @@ class Entity:
74
74
  operation_result = OperationResult(
75
75
  is_error=is_error,
76
76
  duration=duration,
77
+ execution_start_time_ms=int(start_time.timestamp() * 1000),
77
78
  result=result
78
79
  )
79
80
  response.results.append(operation_result)
@@ -104,6 +105,9 @@ class Entity:
104
105
  context_body = context
105
106
  ctx, batch = DurableEntityContext.from_json(context_body)
106
107
  return Entity(fn).handle(ctx, batch)
108
+
109
+ handle.entity_function = fn
110
+
107
111
  return handle
108
112
 
109
113
  def _elapsed_milliseconds_since(self, start_time: datetime) -> int:
@@ -119,7 +123,7 @@ class Entity:
119
123
  int
120
124
  The time, in millseconds, from start_time to now
121
125
  """
122
- end_time = datetime.now()
126
+ end_time = datetime.now(timezone.utc)
123
127
  time_diff = end_time - start_time
124
128
  elapsed_time = int(time_diff.total_seconds() * 1000)
125
129
  return elapsed_time
@@ -4,6 +4,7 @@ from typing import List, Any, Optional, Dict, Union
4
4
  from time import time
5
5
  from asyncio import sleep
6
6
  from urllib.parse import urlparse, quote
7
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
7
8
 
8
9
  import azure.functions as func
9
10
 
@@ -71,8 +72,13 @@ class DurableOrchestrationClient:
71
72
  request_url = self._get_start_new_url(
72
73
  instance_id=instance_id, orchestration_function_name=orchestration_function_name)
73
74
 
75
+ trace_parent, trace_state = DurableOrchestrationClient._get_current_activity_context()
76
+
74
77
  response: List[Any] = await self._post_async_request(
75
- request_url, self._get_json_input(client_input))
78
+ request_url,
79
+ self._get_json_input(client_input),
80
+ trace_parent,
81
+ trace_state)
76
82
 
77
83
  status_code: int = response[0]
78
84
  if status_code <= 202 and response[1]:
@@ -545,9 +551,14 @@ class DurableOrchestrationClient:
545
551
  entity_Id=entityId)
546
552
 
547
553
  request_url = options.to_url(self._orchestration_bindings.rpc_base_url)
554
+
555
+ trace_parent, trace_state = DurableOrchestrationClient._get_current_activity_context()
556
+
548
557
  response = await self._post_async_request(
549
558
  request_url,
550
- json.dumps(operation_input) if operation_input else None)
559
+ json.dumps(operation_input) if operation_input else None,
560
+ trace_parent,
561
+ trace_state)
551
562
 
552
563
  switch_statement = {
553
564
  202: lambda: None # signal accepted
@@ -779,3 +790,23 @@ class DurableOrchestrationClient:
779
790
  error_message = has_error_message()
780
791
  if error_message:
781
792
  raise Exception(error_message)
793
+
794
+ """Gets the current trace activity traceparent and tracestate
795
+
796
+ Returns
797
+ -------
798
+ tuple[str, str]
799
+ A tuple containing the (traceparent, tracestate)
800
+ """
801
+ @staticmethod
802
+ def _get_current_activity_context() -> tuple[str, str]:
803
+ carrier = {}
804
+
805
+ # Inject the current trace context into the carrier
806
+ TraceContextTextMapPropagator().inject(carrier)
807
+
808
+ # Extract the traceparent and optionally the tracestate
809
+ trace_parent = carrier.get("traceparent")
810
+ trace_state = carrier.get("tracestate")
811
+
812
+ return trace_parent, trace_state
@@ -1,7 +1,7 @@
1
1
  from collections import defaultdict
2
2
  from azure.durable_functions.models.actions.SignalEntityAction import SignalEntityAction
3
3
  from azure.durable_functions.models.actions.CallEntityAction import CallEntityAction
4
- from azure.durable_functions.models.Task import TaskBase, TimerTask
4
+ from azure.durable_functions.models.Task import LongTimerTask, TaskBase, TimerTask
5
5
  from azure.durable_functions.models.actions.CallHttpAction import CallHttpAction
6
6
  from azure.durable_functions.models.DurableHttpRequest import DurableHttpRequest
7
7
  from azure.durable_functions.models.actions.CallSubOrchestratorWithRetryAction import \
@@ -26,6 +26,8 @@ from typing import DefaultDict, List, Any, Dict, Optional, Tuple, Union, Callabl
26
26
  from uuid import UUID, uuid5, NAMESPACE_URL, NAMESPACE_OID
27
27
  from datetime import timezone
28
28
 
29
+ from azure.durable_functions.models.utils.json_utils import parse_timespan_attrib
30
+
29
31
  from .RetryOptions import RetryOptions
30
32
  from .FunctionContext import FunctionContext
31
33
  from .history import HistoryEvent, HistoryEventType
@@ -48,11 +50,22 @@ class DurableOrchestrationContext:
48
50
  # parameter names are as defined by JSON schema and do not conform to PEP8 naming conventions
49
51
  def __init__(self,
50
52
  history: List[Dict[Any, Any]], instanceId: str, isReplaying: bool,
51
- parentInstanceId: str, input: Any = None, upperSchemaVersion: int = 0, **kwargs):
53
+ parentInstanceId: str, input: Any = None, upperSchemaVersion: int = 0,
54
+ maximumShortTimerDuration: str = None,
55
+ longRunningTimerIntervalDuration: str = None, upperSchemaVersionNew: int = None,
56
+ **kwargs):
52
57
  self._histories: List[HistoryEvent] = [HistoryEvent(**he) for he in history]
53
58
  self._instance_id: str = instanceId
54
59
  self._is_replaying: bool = isReplaying
55
60
  self._parent_instance_id: str = parentInstanceId
61
+ self._maximum_short_timer_duration: datetime.timedelta = None
62
+ if maximumShortTimerDuration is not None:
63
+ max_short_duration = parse_timespan_attrib(maximumShortTimerDuration)
64
+ self._maximum_short_timer_duration = max_short_duration
65
+ self._long_timer_interval_duration: datetime.timedelta = None
66
+ if longRunningTimerIntervalDuration is not None:
67
+ long_interval_duration = parse_timespan_attrib(longRunningTimerIntervalDuration)
68
+ self._long_timer_interval_duration = long_interval_duration
56
69
  self._custom_status: Any = None
57
70
  self._new_uuid_counter: int = 0
58
71
  self._sub_orchestrator_counter: int = 0
@@ -66,6 +79,13 @@ class DurableOrchestrationContext:
66
79
  self._function_context: FunctionContext = FunctionContext(**kwargs)
67
80
  self._sequence_number = 0
68
81
  self._replay_schema = ReplaySchema(upperSchemaVersion)
82
+ if (upperSchemaVersionNew is not None
83
+ and upperSchemaVersionNew > self._replay_schema.value):
84
+ valid_schema_values = [enum_member.value for enum_member in ReplaySchema]
85
+ if upperSchemaVersionNew in valid_schema_values:
86
+ self._replay_schema = ReplaySchema(upperSchemaVersionNew)
87
+ else:
88
+ self._replay_schema = ReplaySchema(max(valid_schema_values))
69
89
 
70
90
  self._action_payload_v1: List[List[Action]] = []
71
91
  self._action_payload_v2: List[Action] = []
@@ -532,10 +552,10 @@ class DurableOrchestrationContext:
532
552
  The action to append
533
553
  """
534
554
  new_action: Union[List[Action], Action]
535
- if self._replay_schema is ReplaySchema.V2:
536
- new_action = action
537
- else:
555
+ if self._replay_schema is ReplaySchema.V1:
538
556
  new_action = [action]
557
+ else:
558
+ new_action = action
539
559
  self._add_to_actions(new_action)
540
560
  self._sequence_number += 1
541
561
 
@@ -580,6 +600,23 @@ class DurableOrchestrationContext:
580
600
  TaskBase
581
601
  A Durable Timer Task that schedules the timer to wake up the activity
582
602
  """
603
+ if self._replay_schema.value >= ReplaySchema.V3.value:
604
+ if not self._maximum_short_timer_duration or not self._long_timer_interval_duration:
605
+ raise Exception(
606
+ "A framework-internal error was detected: "
607
+ "replay schema version >= V3 is being used, "
608
+ "but one or more of the properties `maximumShortTimerDuration`"
609
+ "and `longRunningTimerIntervalDuration` are not defined. "
610
+ "This is likely an issue with the Durable Functions Extension. "
611
+ "Please report this bug here: "
612
+ "https://github.com/Azure/azure-functions-durable-python/issues\n"
613
+ f"maximumShortTimerDuration: {self._maximum_short_timer_duration}\n"
614
+ f"longRunningTimerIntervalDuration: {self._long_timer_interval_duration}"
615
+ )
616
+ if fire_at > self.current_utc_datetime + self._maximum_short_timer_duration:
617
+ action = CreateTimerAction(fire_at)
618
+ return LongTimerTask(None, action, self)
619
+
583
620
  action = CreateTimerAction(fire_at)
584
621
  task = self._generate_task(action, task_constructor=TimerTask)
585
622
  return task
@@ -656,7 +693,8 @@ class DurableOrchestrationContext:
656
693
 
657
694
  if self._replay_schema is ReplaySchema.V1 and isinstance(action_repr, list):
658
695
  self._action_payload_v1.append(action_repr)
659
- elif self._replay_schema is ReplaySchema.V2 and isinstance(action_repr, Action):
696
+ elif (self._replay_schema.value >= ReplaySchema.V2.value
697
+ and isinstance(action_repr, Action)):
660
698
  self._action_payload_v2.append(action_repr)
661
699
  else:
662
700
  raise Exception(f"DF-internal exception: ActionRepr of signature {type(action_repr)}"
@@ -684,7 +722,8 @@ class DurableOrchestrationContext:
684
722
  task.id = self._sequence_number
685
723
  self._sequence_number += 1
686
724
  self.open_tasks[task.id] = task
687
- elif task.id != -1:
725
+ elif task.id != -1 and self.open_tasks[task.id] != task:
726
+ # Case when returning task_any with multiple external events having the same ID
688
727
  self.open_tasks[task.id].append(task)
689
728
 
690
729
  if task.id in self.deferred_tasks:
@@ -6,3 +6,4 @@ class ReplaySchema(Enum):
6
6
 
7
7
  V1 = 0
8
8
  V2 = 1
9
+ V3 = 2
@@ -1,3 +1,4 @@
1
+ from datetime import datetime
1
2
  from azure.durable_functions.models.actions.NoOpAction import NoOpAction
2
3
  from azure.durable_functions.models.actions.CompoundAction import CompoundAction
3
4
  from azure.durable_functions.models.RetryOptions import RetryOptions
@@ -170,7 +171,7 @@ class CompoundTask(TaskBase):
170
171
  child_actions.append(action_repr)
171
172
  if compound_action_constructor is None:
172
173
  self.action_repr = child_actions
173
- else: # replay_schema is ReplaySchema.V2
174
+ else: # replay_schema >= ReplaySchema.V2
174
175
  self.action_repr = compound_action_constructor(child_actions)
175
176
  self._first_error: Optional[Exception] = None
176
177
  self.pending_tasks: Set[TaskBase] = set(tasks)
@@ -292,7 +293,7 @@ class WhenAllTask(CompoundTask):
292
293
  The ReplaySchema, which determines the inner action payload representation
293
294
  """
294
295
  compound_action_constructor = None
295
- if replay_schema is ReplaySchema.V2:
296
+ if replay_schema.value >= ReplaySchema.V2.value:
296
297
  compound_action_constructor = WhenAllAction
297
298
  super().__init__(task, compound_action_constructor)
298
299
 
@@ -317,6 +318,119 @@ class WhenAllTask(CompoundTask):
317
318
  self.set_value(is_error=True, value=self._first_error)
318
319
 
319
320
 
321
+ class LongTimerTask(WhenAllTask):
322
+ """A Timer Task for intervals longer than supported by the storage backend."""
323
+
324
+ def __init__(self, id_, action: CreateTimerAction, orchestration_context):
325
+ """Initialize a LongTimerTask.
326
+
327
+ Parameters
328
+ ----------
329
+ id_ : int
330
+ An ID for the task
331
+ action : CreateTimerAction
332
+ The action this task represents
333
+ orchestration_context: DurableOrchestrationContext
334
+ The orchestration context this task was created in
335
+ """
336
+ current_time = orchestration_context.current_utc_datetime
337
+ final_fire_time = action.fire_at
338
+ duration_until_fire = final_fire_time - current_time
339
+
340
+ if duration_until_fire > orchestration_context._maximum_short_timer_duration:
341
+ next_fire_time = current_time + orchestration_context._long_timer_interval_duration
342
+ else:
343
+ next_fire_time = final_fire_time
344
+
345
+ next_timer_action = CreateTimerAction(next_fire_time)
346
+ next_timer_task = TimerTask(None, next_timer_action)
347
+ super().__init__([next_timer_task], orchestration_context._replay_schema)
348
+
349
+ self.id = id_
350
+ self.action = action
351
+ self._orchestration_context = orchestration_context
352
+ self._max_short_timer_duration = self._orchestration_context._maximum_short_timer_duration
353
+ self._long_timer_interval = self._orchestration_context._long_timer_interval_duration
354
+
355
+ def is_canceled(self) -> bool:
356
+ """Check if the LongTimer is cancelled.
357
+
358
+ Returns
359
+ -------
360
+ bool
361
+ Returns whether the timer has been cancelled or not
362
+ """
363
+ return self.action.is_cancelled
364
+
365
+ def cancel(self):
366
+ """Cancel a timer.
367
+
368
+ Raises
369
+ ------
370
+ ValueError
371
+ Raises an error if the task is already completed and an attempt is made to cancel it
372
+ """
373
+ if (self.result):
374
+ raise Exception("Cannot cancel a completed task.")
375
+ self.action.is_cancelled = True
376
+
377
+ def try_set_value(self, child: TimerTask):
378
+ """Transition this LongTimer Task to a terminal state and set its value.
379
+
380
+ If the LongTimer has not yet reached the designated completion time, starts a new
381
+ TimerTask for the next interval and does not close.
382
+
383
+ Parameters
384
+ ----------
385
+ child : TimerTask
386
+ A timer sub-task that just completed
387
+ """
388
+ current_time = self._orchestration_context.current_utc_datetime
389
+ final_fire_time = self.action.fire_at
390
+ if final_fire_time > current_time:
391
+ next_timer = self.get_next_timer_task(final_fire_time, current_time)
392
+ self.add_new_child(next_timer)
393
+ return super().try_set_value(child)
394
+
395
+ def get_next_timer_task(self, final_fire_time: datetime, current_time: datetime) -> TimerTask:
396
+ """Create a TimerTask to represent the next interval of the LongTimer.
397
+
398
+ Parameters
399
+ ----------
400
+ final_fire_time : datetime.datetime
401
+ The final firing time of the LongTimer
402
+ current_time : datetime.datetime
403
+ The current time
404
+
405
+ Returns
406
+ -------
407
+ TimerTask
408
+ A TimerTask representing the next interval of the LongTimer
409
+ """
410
+ duration_until_fire = final_fire_time - current_time
411
+ if duration_until_fire > self._max_short_timer_duration:
412
+ next_fire_time = current_time + self._long_timer_interval
413
+ else:
414
+ next_fire_time = final_fire_time
415
+ return TimerTask(None, CreateTimerAction(next_fire_time))
416
+
417
+ def add_new_child(self, child_timer: TimerTask):
418
+ """Add the TimerTask to this task's children.
419
+
420
+ Also register the TimerTask with the orchestration context.
421
+
422
+ Parameters
423
+ ----------
424
+ child_timer : TimerTask
425
+ The newly created TimerTask to add
426
+ """
427
+ child_timer.parent = self
428
+ self.pending_tasks.add(child_timer)
429
+ self._orchestration_context._add_to_open_tasks(child_timer)
430
+ self._orchestration_context._add_to_actions(child_timer.action_repr)
431
+ child_timer._set_is_scheduled(True)
432
+
433
+
320
434
  class WhenAnyTask(CompoundTask):
321
435
  """A Task representing `when_any` scenarios."""
322
436
 
@@ -331,7 +445,7 @@ class WhenAnyTask(CompoundTask):
331
445
  The ReplaySchema, which determines the inner action payload representation
332
446
  """
333
447
  compound_action_constructor = None
334
- if replay_schema is ReplaySchema.V2:
448
+ if replay_schema.value >= ReplaySchema.V2.value:
335
449
  compound_action_constructor = WhenAnyAction
336
450
  super().__init__(task, compound_action_constructor)
337
451
 
@@ -245,14 +245,14 @@ class TaskOrchestrationExecutor:
245
245
 
246
246
  self.current_task = new_task
247
247
  if not (new_task is None):
248
+ if not (self.current_task._is_scheduled):
249
+ # new task is received. it needs to be resolved to a value
250
+ self.context._add_to_actions(self.current_task.action_repr)
251
+ self._mark_as_scheduled(self.current_task)
248
252
  if not (new_task.state is TaskState.RUNNING):
249
253
  # user yielded the same task multiple times, continue executing code
250
254
  # until a new/not-previously-yielded task is encountered
251
255
  self.resume_user_code()
252
- elif not (self.current_task._is_scheduled):
253
- # new task is received. it needs to be resolved to a value
254
- self.context._add_to_actions(self.current_task.action_repr)
255
- self._mark_as_scheduled(self.current_task)
256
256
 
257
257
  def _mark_as_scheduled(self, task: TaskBase):
258
258
  if isinstance(task, CompoundTask):
@@ -286,12 +286,18 @@ class TaskOrchestrationExecutor:
286
286
  self.output = None
287
287
  self.exception = e
288
288
 
289
+ exception_str = None
290
+ if self.exception is not None:
291
+ exception_str = str(self.exception)
292
+ if not exception_str:
293
+ exception_str = str(type(self.exception))
294
+
289
295
  state = OrchestratorState(
290
296
  is_done=self.orchestration_invocation_succeeded,
291
297
  actions=self.context._actions,
292
298
  output=self.output,
293
299
  replay_schema=self.context._replay_schema,
294
- error=None if self.exception is None else str(self.exception),
300
+ error=exception_str,
295
301
  custom_status=self.context.custom_status
296
302
  )
297
303
 
@@ -9,6 +9,7 @@ from .RetryOptions import RetryOptions
9
9
  from .DurableHttpRequest import DurableHttpRequest
10
10
  from .TokenSource import ManagedIdentityTokenSource
11
11
  from .DurableEntityContext import DurableEntityContext
12
+ from .Task import TaskBase
12
13
 
13
14
  __all__ = [
14
15
  'DurableOrchestrationBindings',
@@ -20,5 +21,6 @@ __all__ = [
20
21
  'OrchestratorState',
21
22
  'OrchestrationRuntimeStatus',
22
23
  'PurgeHistoryResult',
23
- 'RetryOptions'
24
+ 'RetryOptions',
25
+ 'TaskBase'
24
26
  ]
@@ -12,6 +12,7 @@ class OperationResult:
12
12
  def __init__(self,
13
13
  is_error: bool,
14
14
  duration: int,
15
+ execution_start_time_ms: int,
15
16
  result: Optional[str] = None):
16
17
  """Instantiate an OperationResult.
17
18
 
@@ -21,11 +22,15 @@ class OperationResult:
21
22
  Whether or not the operation resulted in an exception.
22
23
  duration: int
23
24
  How long the operation took, in milliseconds.
25
+ start_time: int
26
+ The start time of this operation's execution, in milliseconds,
27
+ since January 1st 1970 midnight in UTC.
24
28
  result: Optional[str]
25
29
  The operation result. Defaults to None.
26
30
  """
27
31
  self._is_error: bool = is_error
28
32
  self._duration: int = duration
33
+ self._execution_start_time_ms: int = execution_start_time_ms
29
34
  self._result: Optional[str] = result
30
35
 
31
36
  @property
@@ -50,6 +55,18 @@ class OperationResult:
50
55
  """
51
56
  return self._duration
52
57
 
58
+ @property
59
+ def execution_start_time_ms(self) -> int:
60
+ """Get the start time of this operation.
61
+
62
+ Returns
63
+ -------
64
+ int:
65
+ The start time of this operation's execution, in milliseconds,
66
+ since January 1st 1970 midnight in UTC.
67
+ """
68
+ return self._execution_start_time_ms
69
+
53
70
  @property
54
71
  def result(self) -> Any:
55
72
  """Get the operation's result.
@@ -72,5 +89,6 @@ class OperationResult:
72
89
  to_json: Dict[str, Any] = {}
73
90
  to_json["isError"] = self.is_error
74
91
  to_json["duration"] = self.duration
92
+ to_json["startTime"] = self.execution_start_time_ms
75
93
  to_json["result"] = json.dumps(self.result, default=_serialize_custom_object)
76
94
  return to_json
@@ -3,7 +3,10 @@ from typing import Any, List, Union
3
3
  import aiohttp
4
4
 
5
5
 
6
- async def post_async_request(url: str, data: Any = None) -> List[Union[int, Any]]:
6
+ async def post_async_request(url: str,
7
+ data: Any = None,
8
+ trace_parent: str = None,
9
+ trace_state: str = None) -> List[Union[int, Any]]:
7
10
  """Post request with the data provided to the url provided.
8
11
 
9
12
  Parameters
@@ -12,6 +15,10 @@ async def post_async_request(url: str, data: Any = None) -> List[Union[int, Any]
12
15
  url to make the post to
13
16
  data: Any
14
17
  object to post
18
+ trace_parent: str
19
+ traceparent header to send with the request
20
+ trace_state: str
21
+ tracestate header to send with the request
15
22
 
16
23
  Returns
17
24
  -------
@@ -19,8 +26,12 @@ async def post_async_request(url: str, data: Any = None) -> List[Union[int, Any]
19
26
  Tuple with the Response status code and the data returned from the request
20
27
  """
21
28
  async with aiohttp.ClientSession() as session:
22
- async with session.post(url,
23
- json=data) as response:
29
+ headers = {}
30
+ if trace_parent:
31
+ headers["traceparent"] = trace_parent
32
+ if trace_state:
33
+ headers["tracestate"] = trace_state
34
+ async with session.post(url, json=data, headers=headers) as response:
24
35
  # We disable aiohttp's input type validation
25
36
  # as the server may respond with alternative
26
37
  # data encodings. This is potentially unsafe.
@@ -1,3 +1,5 @@
1
+ import datetime
2
+ import re
1
3
  from typing import Dict, Any
2
4
 
3
5
  from ...constants import DATETIME_STRING_FORMAT
@@ -37,6 +39,44 @@ def add_datetime_attrib(json_dict: Dict[str, Any], object_,
37
39
  getattr(object_, attribute_name).strftime(DATETIME_STRING_FORMAT)
38
40
 
39
41
 
42
+ # When we recieve properties from WebJobs extension originally parsed as
43
+ # TimeSpan objects through Newtonsoft, the format complies with the constant
44
+ # format specifier for TimeSpan in .NET.
45
+ # Python offers no convenient way to parse these back into timedeltas,
46
+ # so we use this regex method instead
47
+ def parse_timespan_attrib(from_str: str) -> datetime.timedelta:
48
+ """Convert a string representing TimeSpan.ToString("c") in .NET to a python timedelta.
49
+
50
+ Parameters
51
+ ----------
52
+ from_str: The string format of the TimeSpan to convert
53
+
54
+ Returns
55
+ -------
56
+ timespan.timedelta
57
+ The TimeSpan expressed as a Python datetime.timedelta
58
+
59
+ """
60
+ match = re.match(r"^(?P<negative>-)?(?:(?P<days>[0-9]*)\.)?"
61
+ r"(?P<hours>[0-9]{2}):(?P<minutes>[0-9]{2})"
62
+ r":(?P<seconds>[0-9]{2})(?:\.(?P<ticks>[0-9]{7}))?$",
63
+ from_str)
64
+ if match:
65
+ groups = match.groupdict()
66
+ span = datetime.timedelta(
67
+ days=int(groups['days'] or "0"),
68
+ hours=int(groups['hours']),
69
+ minutes=int(groups['minutes']),
70
+ seconds=int(groups['seconds']),
71
+ microseconds=int(groups['ticks'] or "0") // 10)
72
+
73
+ if groups['negative'] == '-':
74
+ span = -span
75
+ return span
76
+ else:
77
+ raise Exception(f"Format of TimeSpan failed attempted conversion to timedelta: {from_str}")
78
+
79
+
40
80
  def add_json_attrib(json_dict: Dict[str, Any], object_,
41
81
  attribute_name: str, alt_name: str = None):
42
82
  """Add the results of the to_json() function call of the attribute from the object to the dict.
@@ -68,4 +68,6 @@ class Orchestrator:
68
68
  context_body = context
69
69
  return Orchestrator(fn).handle(DurableOrchestrationContext.from_json(context_body))
70
70
 
71
+ handle.orchestrator_function = fn
72
+
71
73
  return handle
@@ -0,0 +1,42 @@
1
+ from typing import Generator, Any, Union
2
+
3
+ from azure.durable_functions.models import TaskBase
4
+
5
+
6
+ def orchestrator_generator_wrapper(
7
+ generator: Generator[TaskBase, Any, Any]) -> Generator[Union[TaskBase, Any], None, None]:
8
+ """Wrap a user-defined orchestrator function in a way that simulates the Durable replay logic.
9
+
10
+ Parameters
11
+ ----------
12
+ generator: Generator[TaskBase, Any, Any]
13
+ Generator orchestrator as defined in the user function app. This generator is expected
14
+ to yield a series of TaskBase objects and receive the results of these tasks until
15
+ returning the result of the orchestrator.
16
+
17
+ Returns
18
+ -------
19
+ Generator[Union[TaskBase, Any], None, None]
20
+ A simplified version of the orchestrator which takes no inputs. This generator will
21
+ yield back the TaskBase objects that are yielded from the user orchestrator as well
22
+ as the final result of the orchestrator. Exception handling is also simulated here
23
+ in the same way as replay, where tasks returning exceptions are thrown back into the
24
+ orchestrator.
25
+ """
26
+ previous = next(generator)
27
+ yield previous
28
+ while True:
29
+ try:
30
+ previous_result = None
31
+ try:
32
+ previous_result = previous.result
33
+ except Exception as e:
34
+ # Simulated activity exceptions, timer interrupted exceptions,
35
+ # or anytime a task would throw.
36
+ previous = generator.throw(e)
37
+ else:
38
+ previous = generator.send(previous_result)
39
+ yield previous
40
+ except StopIteration as e:
41
+ yield e.value
42
+ return
@@ -0,0 +1,6 @@
1
+ """Unit testing utilities for Azure Durable functions."""
2
+ from .OrchestratorGeneratorWrapper import orchestrator_generator_wrapper
3
+
4
+ __all__ = [
5
+ 'orchestrator_generator_wrapper'
6
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: azure-functions-durable
3
- Version: 1.2.10
3
+ Version: 1.3.1
4
4
  Summary: Durable Functions For Python
5
5
  Home-page: https://github.com/Azure/azure-functions-durable-python
6
6
  Author: Azure Functions team at Microsoft Corp.
@@ -16,13 +16,16 @@ Classifier: Operating System :: POSIX
16
16
  Classifier: Operating System :: MacOS :: MacOS X
17
17
  Classifier: Environment :: Web Environment
18
18
  Classifier: Development Status :: 5 - Production/Stable
19
- Requires-Python: >=3.6,<4
19
+ Requires-Python: >=3.9,<4
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: azure-functions >=1.12.0
22
- Requires-Dist: aiohttp >=3.6.2
23
- Requires-Dist: requests ==2.*
24
- Requires-Dist: python-dateutil >=2.8.0
25
- Requires-Dist: furl >=2.1.0
21
+ License-File: LICENSE
22
+ Requires-Dist: azure-functions>=1.12.0
23
+ Requires-Dist: aiohttp>=3.12.9
24
+ Requires-Dist: requests==2.*
25
+ Requires-Dist: python-dateutil>=2.8.0
26
+ Requires-Dist: furl>=2.1.0
27
+ Requires-Dist: opentelemetry-api>=1.32.1
28
+ Requires-Dist: opentelemetry-sdk>=1.32.1
26
29
 
27
30
  |Branch|Status|
28
31
  |---|---|
@@ -1,28 +1,28 @@
1
1
  azure/durable_functions/__init__.py,sha256=Syz9yrT8sHREVyxqvwjg8_c_h8yMTyuBHcDMsuQs1YM,2910
2
2
  azure/durable_functions/constants.py,sha256=JtknDhaVihMeo-ygY9QNofiO2KEqnQvopdfZ6Qatnik,414
3
- azure/durable_functions/entity.py,sha256=jlJrkms0YnMX1XjQd4ycstderj-JjY-fGwfmdRRqxIQ,4497
4
- azure/durable_functions/orchestrator.py,sha256=t4lQZbCR3DK48VwfGyroQANl7E8cWGNRGb0lRtLPYNc,2509
3
+ azure/durable_functions/entity.py,sha256=mUUzb1BZiDrUJjvxOTlnVURnKPyDGPJ3mXXMN0DKT7M,4649
4
+ azure/durable_functions/orchestrator.py,sha256=SZni90Aweq0OZykHyMblfJpUndJ2woJmySarcsDiIK4,2554
5
5
  azure/durable_functions/decorators/__init__.py,sha256=wEubgP2rUUISwidZWgKx6mmzEeGKsSnpGmetjIUi1nw,150
6
- azure/durable_functions/decorators/durable_app.py,sha256=h4Mvcqoy03iy-vp5lIfd5qaC72xysMnQlTd3WPPl2V8,9354
6
+ azure/durable_functions/decorators/durable_app.py,sha256=8K4gevZVYcDvifxXVuvTt8YzF8jx63c1PwdBBk1Vt0g,10114
7
7
  azure/durable_functions/decorators/metadata.py,sha256=p91rdCe6OSRYJaKAXnrfR0QCV3PoHK7aGy1m6WAnPIE,2828
8
8
  azure/durable_functions/models/DurableEntityContext.py,sha256=cyZmjjZu18oV9S4A2NpnXfjd1JQxPxp9EMmAR424UK0,5830
9
9
  azure/durable_functions/models/DurableHttpRequest.py,sha256=a5kgRdg4eA0sgyDcpmQWc0dbwP-o3BwWW2Ive0BYO_Q,2021
10
10
  azure/durable_functions/models/DurableOrchestrationBindings.py,sha256=_hp61WjN3bQYCqYFQuvUaDdRu7C14fPg7lFbaA9TRe4,2408
11
- azure/durable_functions/models/DurableOrchestrationClient.py,sha256=AX4IDga52jcNEsW4h5OP34w63jxTpoQHNzrnXpo1xCA,31995
12
- azure/durable_functions/models/DurableOrchestrationContext.py,sha256=FG8EMpowxWoDE7IuuhXmftWoYGyvAKptSZ6GiC-nALY,29274
11
+ azure/durable_functions/models/DurableOrchestrationClient.py,sha256=kQBqeKugvi2mi-7dDbCxlu67r20doEhDknlchYxcLBE,33018
12
+ azure/durable_functions/models/DurableOrchestrationContext.py,sha256=mbA1Do_1NP_uuqhCUoEsegqBtlNugUB0_tdVWQ1PRoI,31849
13
13
  azure/durable_functions/models/DurableOrchestrationStatus.py,sha256=BXWz9L7np4Q9k6z4NsfLX97i2U2IFh94TVeRSV2BjM4,6049
14
14
  azure/durable_functions/models/EntityStateResponse.py,sha256=f48W8gmlb-D5iJw3eDyUMYVwHpmIxP6k6a7o2TRHwII,674
15
15
  azure/durable_functions/models/FunctionContext.py,sha256=4gHTmIo8DZN-bZLM-hyjoQFlv-AbsfLMT1_X4WxWxqY,274
16
16
  azure/durable_functions/models/OrchestrationRuntimeStatus.py,sha256=lCT31d85B9dHA12n7Twdd9gQ-ISCtt-bkEwi3TXVHoE,969
17
17
  azure/durable_functions/models/OrchestratorState.py,sha256=xgoEz8Ya8V5kFO997GiS70IVS2EXhDcMZwoclVaseu0,4073
18
18
  azure/durable_functions/models/PurgeHistoryResult.py,sha256=C_Ppdk7TEn0fuYZ971u3I_GHQBUqPuLqCrVyvp6YRsY,1092
19
- azure/durable_functions/models/ReplaySchema.py,sha256=ge-uRkqpeBC3AY3o0XV9DqlaFkgOIGMe87EjOAg1d9w,158
19
+ azure/durable_functions/models/ReplaySchema.py,sha256=85YZD-QWgMQSfZsRQlfQfLPzK9FMITcWK241Vofw6pU,170
20
20
  azure/durable_functions/models/RetryOptions.py,sha256=-rmv3mQmzQ_2utFy1d-ontqpcgP139B8MQroanfN54w,1988
21
21
  azure/durable_functions/models/RpcManagementOptions.py,sha256=aEOWx_xUWl4Rwb2-7kpyml8rzTX9Vl4s71LkqYPLnHw,3482
22
- azure/durable_functions/models/Task.py,sha256=Md3TetTwYMvIrmo-QkFt6tGigl3nXxcK9y3wcwpMvzg,15199
23
- azure/durable_functions/models/TaskOrchestrationExecutor.py,sha256=l8JNCa8vuQKTP1pTKQWhotZigVPlsg9yUIs39KtzRiA,15914
22
+ azure/durable_functions/models/Task.py,sha256=e548_wfJkYY_UCpJkwqVVM65tyP6jchox_YNsf8SslM,19559
23
+ azure/durable_functions/models/TaskOrchestrationExecutor.py,sha256=PBbS3aEKn1JCFhvjR9oz4-WmIW_9vh6-ILzImxVN7zw,16085
24
24
  azure/durable_functions/models/TokenSource.py,sha256=9uLxiOV8lcDj--3tD0XxcQnigk9AozjdOoJyskctErU,1822
25
- azure/durable_functions/models/__init__.py,sha256=HSlC2dw7UZPlODbTMNvujntBb7P2Ch3JNwu3nJMNSo4,954
25
+ azure/durable_functions/models/__init__.py,sha256=L7ynxb_mBGCvV1iEAfpJU9_b-8ubKIEJKaZa2aoqjek,999
26
26
  azure/durable_functions/models/actions/Action.py,sha256=0jp-SP_12YmZWWctOXmwl48Ozw3dMMq5-crUAkK8Qk0,598
27
27
  azure/durable_functions/models/actions/ActionType.py,sha256=FAQh_EPcFru7rOPYWKpYFBSbZpFEQc2-jsrapRJvFG0,507
28
28
  azure/durable_functions/models/actions/CallActivityAction.py,sha256=G9O9JML-Z0_A_WWB4iTbDkvE3iNRtNkES2iNsUQ2SVc,1496
@@ -41,7 +41,7 @@ azure/durable_functions/models/actions/WhenAllAction.py,sha256=xfcvSsJ3zsRL6IbpM
41
41
  azure/durable_functions/models/actions/WhenAnyAction.py,sha256=VyKu2iv5ML2862nu4d_ZG0Cbm50546MnhJ6RZyxghso,478
42
42
  azure/durable_functions/models/actions/__init__.py,sha256=q3dRJc1r7q6-IYjOGM5LJqoBPp2PkE4argVJ2SUjne4,861
43
43
  azure/durable_functions/models/entities/EntityState.py,sha256=lVmkM18z7xExdt1wO1IFJc5MYliVBO3k0fiPyIcu97o,2257
44
- azure/durable_functions/models/entities/OperationResult.py,sha256=qcXdSruJtapn_7bpXe-LH11e9F4kDsl-XWjSMYDtKV8,2081
44
+ azure/durable_functions/models/entities/OperationResult.py,sha256=RHGoCcLeAiMtDiZH5aYW-s4-IDuVtXArlxhktf49kWs,2766
45
45
  azure/durable_functions/models/entities/RequestMessage.py,sha256=y2BG1-HQuPbN91qOh_XrFainFp27YbSk9dotA2ePOPA,1742
46
46
  azure/durable_functions/models/entities/ResponseMessage.py,sha256=GC6HnD3mkIfkGptpazq4yOGJfciL2kkVxbcv1R80B7I,1588
47
47
  azure/durable_functions/models/entities/Signal.py,sha256=TjyvkG9kzxyjyRGz1bItxt1MI-QN2QS2YK3e7rFP41g,1313
@@ -51,13 +51,15 @@ azure/durable_functions/models/history/HistoryEventType.py,sha256=NdCQQrqvWFw5Gi
51
51
  azure/durable_functions/models/history/__init__.py,sha256=otJhZJN9OeGtWrW3lKbk2C1Nyf6I2wJfwuXpCZ2oxYM,237
52
52
  azure/durable_functions/models/utils/__init__.py,sha256=dQ6-HRUPsCtDIqGjRJ3TA6NXSYXzhw5yLA2OP-zkm-s,221
53
53
  azure/durable_functions/models/utils/entity_utils.py,sha256=TqNTtRC8VuKFtqWLq9oEAloioV-FyinjgRYVKkCldHo,2881
54
- azure/durable_functions/models/utils/http_utils.py,sha256=p171W9032iBqqiZEfjo0F-sTrGn8C1qJngaXnqfYcKk,2103
55
- azure/durable_functions/models/utils/json_utils.py,sha256=dejxw7msjConjCT4wWX104th-7Z4RXX36YmO_TJNRUA,2323
54
+ azure/durable_functions/models/utils/http_utils.py,sha256=AoCWjCapd_984J_4296iJ8cNJWEG8GIdhRttBPt0HnA,2551
55
+ azure/durable_functions/models/utils/json_utils.py,sha256=zUn62pm3dQw054ZlK7F4uRP-UELjQC8EmZBU1WncHMg,3811
56
+ azure/durable_functions/testing/OrchestratorGeneratorWrapper.py,sha256=cjh-HAq5rVNCoR0pIbfGrqy6cKSf4S1KMQxrBMWU1-s,1728
57
+ azure/durable_functions/testing/__init__.py,sha256=NLbltPtoPXK-0iMTwcKTKPjQlAWrEq55oDYmrhYz6vg,189
56
58
  tests/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
59
  tests/models/test_DecoratorMetadata.py,sha256=0PeUDszF_gAJZMZR-K-Ro7c3I1D960amOLtbT88L_dk,3918
58
60
  tests/models/test_Decorators.py,sha256=y2dhoSlP74J5uAVBDY2JfFkSA-AhyagVBZO5tGi6KaQ,2925
59
61
  tests/models/test_DurableOrchestrationBindings.py,sha256=pjuoKlpEc6KAIL-Nq2taoqW0HYWXoupgUxcsPwc1Psg,2961
60
- tests/models/test_DurableOrchestrationClient.py,sha256=Z17gzBoXEPozUiClAnlFsdA2dUgd7M-tyjtBuhqnmyE,31740
62
+ tests/models/test_DurableOrchestrationClient.py,sha256=7htzuMMfkRU9Hf-9Gr-rYHpJXJdpnAp0WheAFpMKHNo,31791
61
63
  tests/models/test_DurableOrchestrationContext.py,sha256=7fWdnvpSEces0VM4Xm11uLZmshXqGl7wtYo8ELixypc,3930
62
64
  tests/models/test_DurableOrchestrationStatus.py,sha256=fnUZxrHGy771OoaD5TInELhaG836aB8XqtMdNjnEFp8,2485
63
65
  tests/models/test_OrchestrationState.py,sha256=L-k8ScrqoDIZEqIUORbxXA7yCuMbVAUPr-7VmyuQkUc,1272
@@ -68,7 +70,7 @@ tests/orchestrator/orchestrator_test_utils.py,sha256=ldgQGGuOVALcI98enRU08nF_VJd
68
70
  tests/orchestrator/test_call_http.py,sha256=CvemeCayrQLjmjl3lpB1fk_CpV-DRPgfxLgD4iNgStQ,9103
69
71
  tests/orchestrator/test_continue_as_new.py,sha256=k9mS8tHE1GX9uogCgrrgXJynydxExrlAlGIpC4ojNP0,2575
70
72
  tests/orchestrator/test_create_timer.py,sha256=HPaJrYYmyvWqBFqmu2jatLK911DA8knfF88GMRWMhJY,5576
71
- tests/orchestrator/test_entity.py,sha256=ruOtyJcx0-sjINJT9MRjxDfdAZ6SuI_682zUybDpCRk,13857
73
+ tests/orchestrator/test_entity.py,sha256=fcHhh1kcLDv9GXnChMUsFwYWiRiBRXKLlFp823pooWs,13922
72
74
  tests/orchestrator/test_external_event.py,sha256=DfeOFCdt2haxTwVQ1_lQtwkrdhb3EVlXsavVGo8HrmE,2534
73
75
  tests/orchestrator/test_fan_out_fan_in.py,sha256=8pNPA2remGz8GLThqINAuYi5FVIMNdE1Ya5zqhXHDKc,7218
74
76
  tests/orchestrator/test_is_replaying_flag.py,sha256=se8GOmI_5EP7jae8gdWFFSBEIfWbpgaQstyJwje5sdY,4386
@@ -84,6 +86,7 @@ tests/orchestrator/models/OrchestrationInstance.py,sha256=CQ3qyNumjksuFNMujbESsv
84
86
  tests/orchestrator/schemas/OrchetrationStateSchema.py,sha256=EyTsDUZ3K-9mVljLIlg8f_h2zsK228E0PMvvtyEWw24,2718
85
87
  tests/tasks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
86
88
  tests/tasks/tasks_test_utils.py,sha256=Ymc5GESJpzybBq3n2mT4IABDRNTVCerAZrlMilv0Pdk,737
89
+ tests/tasks/test_long_timers.py,sha256=KDN0AM4R1Jqaf3L1i7xUXEUIyPUzP6PFRplmD2IamlI,3494
87
90
  tests/tasks/test_new_uuid.py,sha256=iaQcF5iPZdU3e1SiKSiP6vQrQeMFehUp354ShMRHE7s,1419
88
91
  tests/test_utils/ContextBuilder.py,sha256=l1dDeI1FXdTaiUYwcYNPOtYiivh2c2xUfoOo_AcXgL0,7459
89
92
  tests/test_utils/EntityContextBuilder.py,sha256=Gjh0ERX6ND5RZ3blCmix8z0RrmfOVfoY0xm8LuUoZGo,1939
@@ -93,8 +96,8 @@ tests/test_utils/json_utils.py,sha256=B0q3COMya7TGxbH-7sD_0ypWDSuaF4fpD4QV_oJPgG
93
96
  tests/test_utils/testClasses.py,sha256=U_u5qKxC9U81SzjLo7ejjPjEn_cE5qjaqoq8edGD6l8,1521
94
97
  tests/utils/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
95
98
  tests/utils/test_entity_utils.py,sha256=kdk5_DV_-bFu_5q2mw9o1yjyzh8Lcxv1jo1Q7is_ukA,748
96
- azure_functions_durable-1.2.10.dist-info/LICENSE,sha256=-VS-Izmxdykuae1Xc4vHtVUx02rNQi6SSQlONvvuYeQ,1090
97
- azure_functions_durable-1.2.10.dist-info/METADATA,sha256=h2nOeG_Lz2fnQNOxegbZfGR7xrxplZw_5Kc5XnkPChM,3421
98
- azure_functions_durable-1.2.10.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
99
- azure_functions_durable-1.2.10.dist-info/top_level.txt,sha256=h-L8XDVPJ9YzBbHlPvM7FVo1cqNGToNK9ix99ySGOUY,12
100
- azure_functions_durable-1.2.10.dist-info/RECORD,,
99
+ azure_functions_durable-1.3.1.dist-info/LICENSE,sha256=-VS-Izmxdykuae1Xc4vHtVUx02rNQi6SSQlONvvuYeQ,1090
100
+ azure_functions_durable-1.3.1.dist-info/METADATA,sha256=8XrQVf_FGsfHF1Nz1Uot4pmFUrVZqpF6tCaPoQohxTc,3523
101
+ azure_functions_durable-1.3.1.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
102
+ azure_functions_durable-1.3.1.dist-info/top_level.txt,sha256=h-L8XDVPJ9YzBbHlPvM7FVo1cqNGToNK9ix99ySGOUY,12
103
+ azure_functions_durable-1.3.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.42.0)
2
+ Generator: bdist_wheel (0.45.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -67,7 +67,7 @@ class MockRequest:
67
67
  assert url == self._expected_url
68
68
  return self._response
69
69
 
70
- async def post(self, url: str, data: Any = None):
70
+ async def post(self, url: str, data: Any = None, trace_parent: str = None, trace_state: str = None):
71
71
  assert url == self._expected_url
72
72
  return self._response
73
73
 
@@ -209,9 +209,11 @@ def apply_operation(entity_state: EntityState, result: Any, state: Any, is_error
209
209
  # We cannot control duration, so default it to zero and avoid checking for it
210
210
  # in later asserts
211
211
  duration = 0
212
+ start_time = 0
212
213
  operation_result = OperationResult(
213
214
  is_error=is_error,
214
215
  duration=duration,
216
+ execution_start_time_ms=start_time,
215
217
  result=result
216
218
  )
217
219
  entity_state._results.append(operation_result)
@@ -0,0 +1,70 @@
1
+ import datetime
2
+
3
+ import pytest
4
+ from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext
5
+ from azure.durable_functions.models.Task import LongTimerTask, TaskState, TimerTask
6
+ from azure.durable_functions.models.actions.CreateTimerAction import CreateTimerAction
7
+
8
+
9
+ @pytest.fixture
10
+ def starting_context_v3():
11
+ context = DurableOrchestrationContext.from_json(
12
+ '{"history":[{"EventType":12,"EventId":-1,"IsPlayed":false,'
13
+ '"Timestamp":"'
14
+ f'{datetime.datetime.now(datetime.timezone.utc).isoformat()}'
15
+ '"}, {"OrchestrationInstance":{'
16
+ '"InstanceId":"48d0f95957504c2fa579e810a390b938", '
17
+ '"ExecutionId":"fd183ee02e4b4fd18c95b773cfb5452b"},"EventType":0,'
18
+ '"ParentInstance":null, '
19
+ '"Name":"DurableOrchestratorTrigger","Version":"","Input":"null",'
20
+ '"Tags":null,"EventId":-1,"IsPlayed":false, '
21
+ '"Timestamp":"'
22
+ f'{datetime.datetime.now(datetime.timezone.utc).isoformat()}'
23
+ '"}],"input":null,'
24
+ '"instanceId":"48d0f95957504c2fa579e810a390b938", '
25
+ '"upperSchemaVersion": 2, '
26
+ '"upperSchemaVersionNew": 3, '
27
+ '"isReplaying":false,"parentInstanceId":null, '
28
+ '"maximumShortTimerDuration":"0.16:00:00", '
29
+ '"longRunningTimerIntervalDuration":"0.08:00:00" } ')
30
+ return context
31
+
32
+
33
+ def test_durable_context_creates_correct_timer(starting_context_v3):
34
+ timer = starting_context_v3.create_timer(datetime.datetime.now(datetime.timezone.utc) +
35
+ datetime.timedelta(minutes=30))
36
+ assert isinstance(timer, TimerTask)
37
+
38
+ timer2 = starting_context_v3.create_timer(datetime.datetime.now(datetime.timezone.utc) +
39
+ datetime.timedelta(days=1))
40
+ assert isinstance(timer2, LongTimerTask)
41
+
42
+ def test_long_timer_fires_appropriately(starting_context_v3):
43
+ starting_time = starting_context_v3.current_utc_datetime
44
+ final_fire_time = starting_time + datetime.timedelta(hours=20)
45
+ long_timer_action = CreateTimerAction(final_fire_time)
46
+ long_timer = LongTimerTask(None, long_timer_action, starting_context_v3)
47
+ assert long_timer.action.fire_at == final_fire_time
48
+ assert long_timer.action == long_timer_action
49
+
50
+ # Check the first "inner" timer and simulate firing it
51
+ short_timer = long_timer.pending_tasks.pop()
52
+ assert short_timer.action_repr.fire_at == starting_time + datetime.timedelta(hours=8)
53
+ # This happens when the task is reconstructed during replay, doing it manually for the test
54
+ long_timer._orchestration_context.current_utc_datetime = short_timer.action_repr.fire_at
55
+ short_timer.state = TaskState.SUCCEEDED
56
+ long_timer.try_set_value(short_timer)
57
+
58
+ assert long_timer.state == TaskState.RUNNING
59
+
60
+ # Check the scond "inner" timer and simulate firing it. This one should be set to the final
61
+ # fire time, the remaining time (12 hours) is less than the max long timer duration (16 hours)
62
+ short_timer = long_timer.pending_tasks.pop()
63
+ assert short_timer.action_repr.fire_at == final_fire_time
64
+ long_timer._orchestration_context.current_utc_datetime = short_timer.action_repr.fire_at
65
+ short_timer.state = TaskState.SUCCEEDED
66
+ long_timer.try_set_value(short_timer)
67
+
68
+ # Ensure the LongTimerTask finished
69
+ assert len(long_timer.pending_tasks) == 0
70
+ assert long_timer.state == TaskState.SUCCEEDED