Skip to content

Commit 3c1ecad

Browse files
authored
Merge pull request #248 from Azure/dev
Promote dev to main for release
2 parents e7be548 + d8f2c06 commit 3c1ecad

File tree

6 files changed

+193
-13
lines changed

6 files changed

+193
-13
lines changed

azure/durable_functions/__init__.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,50 @@
1010
from .models.DurableEntityContext import DurableEntityContext
1111
from .models.RetryOptions import RetryOptions
1212
from .models.TokenSource import ManagedIdentityTokenSource
13+
import json
14+
from pathlib import Path
15+
import sys
16+
17+
18+
def validate_extension_bundles():
19+
"""Throw an exception if host.json contains bundle-range V1.
20+
21+
Raises
22+
------
23+
Exception: Exception prompting the user to update to bundles V2
24+
"""
25+
# No need to validate if we're running tests
26+
if "pytest" in sys.modules:
27+
return
28+
29+
host_path = "host.json"
30+
bundles_key = "extensionBundle"
31+
version_key = "version"
32+
host_file = Path(host_path)
33+
34+
if not host_file.exists():
35+
# If it doesn't exist, we ignore it
36+
return
37+
38+
with open(host_path) as f:
39+
host_settings = json.loads(f.read())
40+
try:
41+
version_range = host_settings[bundles_key][version_key]
42+
except Exception:
43+
# If bundle info is not available, we ignore it.
44+
# For example: it's possible the user is using a manual extension install
45+
return
46+
# We do a best-effort attempt to detect bundles V1
47+
# This is the string hard-coded into the bundles V1 template in VSCode
48+
if version_range == "[1.*, 2.0.0)":
49+
message = "Durable Functions for Python does not support Bundles V1."\
50+
" Please update to Bundles V2 in your `host.json`."\
51+
" You can set extensionBundles version to be: [2.*, 3.0.0)"
52+
raise Exception(message)
53+
54+
55+
# Validate that users are not in extension bundles V1
56+
validate_extension_bundles()
1357

1458
__all__ = [
1559
'Orchestrator',

azure/durable_functions/models/DurableOrchestrationContext.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(self,
3939
self.decision_started_event: HistoryEvent = \
4040
[e_ for e_ in self.histories
4141
if e_.event_type == HistoryEventType.ORCHESTRATOR_STARTED][0]
42-
self._current_utc_datetime = \
42+
self._current_utc_datetime: datetime.datetime = \
4343
self.decision_started_event.timestamp
4444
self._new_uuid_counter = 0
4545
self.actions: List[List[Action]] = []

azure/durable_functions/orchestrator.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def handle(self, context: DurableOrchestrationContext):
8989
generation_state.exception)
9090
continue
9191

92-
self._reset_timestamp()
92+
self._update_timestamp()
9393
self.durable_context._is_replaying = generation_state._is_played
9494
generation_state = self._generate_next(generation_state)
9595

@@ -141,16 +141,13 @@ def _add_to_actions(self, generation_state):
141141
and hasattr(generation_state, "actions")):
142142
self.durable_context.actions.append(generation_state.actions)
143143

144-
def _reset_timestamp(self):
144+
def _update_timestamp(self):
145145
last_timestamp = self.durable_context.decision_started_event.timestamp
146146
decision_started_events = [e_ for e_ in self.durable_context.histories
147147
if e_.event_type == HistoryEventType.ORCHESTRATOR_STARTED
148148
and e_.timestamp > last_timestamp]
149-
if len(decision_started_events) == 0:
150-
self.durable_context.current_utc_datetime = None
151-
else:
152-
self.durable_context.decision_started_event = \
153-
decision_started_events[0]
149+
if len(decision_started_events) != 0:
150+
self.durable_context.decision_started_event = decision_started_events[0]
154151
self.durable_context.current_utc_datetime = \
155152
self.durable_context.decision_started_event.timestamp
156153

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from setuptools import setup, find_packages
1010
from distutils.command import build
1111

12-
with open("README.md", "r") as fh:
12+
with open("README.md", "r", encoding="utf8") as fh:
1313
long_description = fh.read()
1414

1515
class BuildModule(build.build):

tests/orchestrator/test_sequential_orchestrator.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import datetime, timedelta
12
from .orchestrator_test_utils \
23
import assert_orchestration_state_equals, get_orchestration_state_result, assert_valid_schema
34
from tests.test_utils.ContextBuilder import ContextBuilder
@@ -20,6 +21,49 @@ def generator_function(context):
2021

2122
return outputs
2223

24+
def generator_function_time_is_not_none(context):
25+
outputs = []
26+
27+
now = context.current_utc_datetime
28+
if not now:
29+
raise Exception("No time! 1st attempt")
30+
task1 = yield context.call_activity("Hello", "Tokyo")
31+
32+
now = context.current_utc_datetime
33+
if not now:
34+
raise Exception("No time! 2nd attempt")
35+
task2 = yield context.call_activity("Hello", "Seattle")
36+
37+
now = context.current_utc_datetime
38+
if not now:
39+
raise Exception("No time! 3rd attempt")
40+
task3 = yield context.call_activity("Hello", "London")
41+
42+
now = context.current_utc_datetime
43+
if not now:
44+
raise Exception("No time! 4th attempt")
45+
46+
outputs.append(task1)
47+
outputs.append(task2)
48+
outputs.append(task3)
49+
50+
return outputs
51+
52+
def generator_function_time_gather(context):
53+
outputs = []
54+
55+
outputs.append(context.current_utc_datetime.strftime("%m/%d/%Y, %H:%M:%S"))
56+
yield context.call_activity("Hello", "Tokyo")
57+
58+
outputs.append(context.current_utc_datetime.strftime("%m/%d/%Y, %H:%M:%S"))
59+
yield context.call_activity("Hello", "Seattle")
60+
61+
outputs.append(context.current_utc_datetime.strftime("%m/%d/%Y, %H:%M:%S"))
62+
yield context.call_activity("Hello", "London")
63+
64+
outputs.append(context.current_utc_datetime.strftime("%m/%d/%Y, %H:%M:%S"))
65+
return outputs
66+
2367
def generator_function_rasing_ex(context):
2468
outputs = []
2569

@@ -220,3 +264,92 @@ def test_tokyo_and_seattle_and_london_with_serialization_state():
220264

221265
assert_valid_schema(result)
222266
assert_orchestration_state_equals(expected, result)
267+
268+
def test_utc_time_is_never_none():
269+
"""Tests an orchestrator that errors out if its current_utc_datetime is ever None.
270+
271+
If we receive all activity results, it means we never error'ed out. Our test has
272+
a history events array with identical timestamps, simulating events arriving
273+
very close to one another."""
274+
275+
# we set `increase_time` to False to make sure the changes are resilient
276+
# to undistinguishable timestamps (events arrive very close to each other)
277+
context_builder = ContextBuilder('test_simple_function', increase_time=False)
278+
add_hello_completed_events(context_builder, 0, "\"Hello Tokyo!\"")
279+
add_hello_completed_events(context_builder, 1, "\"Hello Seattle!\"")
280+
add_hello_completed_events(context_builder, 2, "\"Hello London!\"")
281+
282+
result = get_orchestration_state_result(
283+
context_builder, generator_function_deterministic_utc_time)
284+
285+
expected_state = base_expected_state(
286+
['Hello Tokyo!', 'Hello Seattle!', 'Hello London!'])
287+
add_hello_action(expected_state, 'Tokyo')
288+
add_hello_action(expected_state, 'Seattle')
289+
add_hello_action(expected_state, 'London')
290+
expected_state._is_done = True
291+
expected = expected_state.to_json()
292+
293+
assert_valid_schema(result)
294+
assert_orchestration_state_equals(expected, result)
295+
296+
def test_utc_time_is_never_none():
297+
"""Tests an orchestrator that errors out if its current_utc_datetime is ever None.
298+
299+
If we receive all activity results, it means we never error'ed out. Our test has
300+
a history events array with identical timestamps, simulating events arriving
301+
very close to one another."""
302+
303+
# we set `increase_time` to False to make sure the changes are resilient
304+
# to undistinguishable timestamps (events arrive very close to each other)
305+
context_builder = ContextBuilder('test_simple_function', increase_time=False)
306+
add_hello_completed_events(context_builder, 0, "\"Hello Tokyo!\"")
307+
add_hello_completed_events(context_builder, 1, "\"Hello Seattle!\"")
308+
add_hello_completed_events(context_builder, 2, "\"Hello London!\"")
309+
310+
result = get_orchestration_state_result(
311+
context_builder, generator_function_time_is_not_none)
312+
313+
expected_state = base_expected_state(
314+
['Hello Tokyo!', 'Hello Seattle!', 'Hello London!'])
315+
add_hello_action(expected_state, 'Tokyo')
316+
add_hello_action(expected_state, 'Seattle')
317+
add_hello_action(expected_state, 'London')
318+
expected_state._is_done = True
319+
expected = expected_state.to_json()
320+
321+
assert_valid_schema(result)
322+
assert_orchestration_state_equals(expected, result)
323+
324+
def test_utc_time_updates_correctly():
325+
"""Tests that current_utc_datetime updates correctly"""
326+
327+
now = datetime.utcnow()
328+
# the first orchestrator-started event starts 1 second after `now`
329+
context_builder = ContextBuilder('test_simple_function', starting_time=now)
330+
add_hello_completed_events(context_builder, 0, "\"Hello Tokyo!\"")
331+
add_hello_completed_events(context_builder, 1, "\"Hello Seattle!\"")
332+
add_hello_completed_events(context_builder, 2, "\"Hello London!\"")
333+
334+
result = get_orchestration_state_result(
335+
context_builder, generator_function_time_gather)
336+
337+
# In the expected history, the orchestrator starts again every 4 seconds
338+
# The current_utc_datetime should update to the orchestrator start event timestamp
339+
num_restarts = 3
340+
expected_utc_time = now + timedelta(seconds=1)
341+
outputs = [expected_utc_time.strftime("%m/%d/%Y, %H:%M:%S")]
342+
for _ in range(num_restarts):
343+
expected_utc_time += timedelta(seconds=4)
344+
outputs.append(expected_utc_time.strftime("%m/%d/%Y, %H:%M:%S"))
345+
346+
expected_state = base_expected_state(outputs)
347+
add_hello_action(expected_state, 'Tokyo')
348+
add_hello_action(expected_state, 'Seattle')
349+
add_hello_action(expected_state, 'London')
350+
expected_state._is_done = True
351+
expected = expected_state.to_json()
352+
353+
assert_valid_schema(result)
354+
assert_orchestration_state_equals(expected, result)
355+

tests/test_utils/ContextBuilder.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import uuid
22
import json
33
from datetime import datetime, timedelta
4-
from typing import List, Dict, Any
4+
from typing import List, Dict, Any, Optional
55

66
from .json_utils import add_attrib, convert_history_event_to_json_dict
77
from azure.durable_functions.constants import DATETIME_STRING_FORMAT
@@ -13,20 +13,26 @@
1313

1414

1515
class ContextBuilder:
16-
def __init__(self, name: str=""):
16+
def __init__(self, name: str="", increase_time: bool = True, starting_time: Optional[datetime] = None):
17+
self.increase_time = increase_time
1718
self.instance_id = uuid.uuid4()
1819
self.is_replaying: bool = False
1920
self.input_ = None
2021
self.parent_instance_id = None
2122
self.history_events: List[HistoryEvent] = []
22-
self.current_datetime: datetime = datetime.now()
23+
24+
if starting_time is None:
25+
starting_time = datetime.now()
26+
self.current_datetime: datetime = starting_time
27+
2328
self.add_orchestrator_started_event()
2429
self.add_execution_started_event(name)
2530

2631
def get_base_event(
2732
self, event_type: HistoryEventType, id_: int = -1,
2833
is_played: bool = False, timestamp=None) -> HistoryEvent:
29-
self.current_datetime = self.current_datetime + timedelta(seconds=1)
34+
if self.increase_time:
35+
self.current_datetime = self.current_datetime + timedelta(seconds=1)
3036
if not timestamp:
3137
timestamp = self.current_datetime
3238
event = HistoryEvent(EventType=event_type, EventId=id_,

0 commit comments

Comments
 (0)