Skip to content

Commit 87feb73

Browse files
authored
Merge pull request #371 from Azure/dev
Promote dev to main for 1.1.4 release
2 parents 7934318 + 609dd3e commit 87feb73

File tree

10 files changed

+187
-27
lines changed

10 files changed

+187
-27
lines changed

.github/ISSUE_TEMPLATE/---bug-report.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ assignees: ''
77

88
---
99

10+
<!--
11+
Please read.
12+
13+
Before posting, please be sure to review whether your issue matches the description of a known regression by using [this](https://github.com/Azure/azure-functions-durable-python/issues?q=is%3Aissue+is%3Aopen+label%3Aknown-regression) query.
14+
15+
If your issue corresponds to a known regression, please try the suggested workarounds listed in the issue before filing an issue. Thanks!
16+
-->
1017
🐛 **Describe the bug**
1118
> A clear and concise description of what the bug is.
1219
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Sync submodule pipeline
2+
3+
on:
4+
push:
5+
branches: [ submodule ]
6+
jobs:
7+
build:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v3
11+
with:
12+
repository: azure-functions-python-library
13+
submodules: true
14+
- id: Go to submodule
15+
run: |
16+
cd azure/functions/durable
17+
git submodule update --remote --merge
18+
git add .
19+
- name: Create Pull Request
20+
id: createPullRequest
21+
uses: peter-evans/create-pull-request@v4
22+
with:
23+
commit-message: Update durable submodule
24+
committer: GitHub <[email protected]>
25+
branch: submodule-sync
26+
delete-branch: true
27+
title: 'Update durable submodule'
28+
body: |
29+
Updated submodule
30+
31+
[1]: https://github.com/peter-evans/create-pull-request
32+
labels: |
33+
required submodule update
34+
automated pr
35+
reviewers: vameru
36+
- name: Check outputs
37+
run: |
38+
echo "Pull Request Number - ${{ steps.createPullRequest.outputs.pull-request-number }}"
39+
echo "Pull Request URL - ${{ steps.createPullRequest.outputs.pull-request-url }}"

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include ./_manifest/*

azure-pipelines-release.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
trigger: none
2+
pr: none
3+
4+
resources:
5+
pipelines:
6+
- pipeline: DurablePyCI
7+
project: "Azure Functions"
8+
source: Azure.azure-functions-durable-python
9+
branch: main
10+
11+
jobs:
12+
- job: Release
13+
pool:
14+
name: "1ES-Hosted-AzFunc"
15+
demands:
16+
- ImageOverride -equals MMSUbuntu20.04TLS
17+
steps:
18+
- task: UsePythonVersion@0
19+
inputs:
20+
versionSpec: '3.6'
21+
- download: DurablePyCI
22+
- script: "rm -r ./azure_functions_durable/_manifest"
23+
displayName: 'Remove _manifest folder'
24+
workingDirectory: "$(Pipeline.Workspace)/DurablePyCI"
25+
- script: 'pip install twine'
26+
displayName: "Install twine"
27+
- script: "twine upload azure_functions_durable/*"
28+
displayName: "Publish to PyPI"
29+
workingDirectory: "$(Pipeline.Workspace)/DurablePyCI"

azure-pipelines.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ trigger:
1313
- v*
1414

1515
variables:
16-
agentPool: 'ubuntu-latest' # 'Ubuntu-16.04'
1716
python.version: '3.6'
1817
baseFolder: .
1918
componentArtifactName: 'azure_functions_durable'
@@ -28,7 +27,9 @@ stages:
2827
- job: Build_Durable_Functions
2928
displayName: Build Python Package
3029
pool:
31-
vmImage: $(agentPool)
30+
name: "1ES-Hosted-AzFunc"
31+
demands:
32+
- ImageOverride -equals MMSUbuntu20.04TLS
3233
steps:
3334
- task: UsePythonVersion@0
3435
inputs:
@@ -49,7 +50,12 @@ stages:
4950
pip install pytest pytest-azurepipelines
5051
pytest
5152
displayName: 'pytest'
52-
53+
- task: ManifestGeneratorTask@0
54+
displayName: "SBOM Generation Task"
55+
inputs:
56+
BuildComponentPath: '$(baseFolder)'
57+
BuildDropPath: $(baseFolder)
58+
Verbosity: "Information"
5359
- script: |
5460
python setup.py sdist bdist_wheel
5561
workingDirectory: $(baseFolder)

azure/durable_functions/models/Task.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ def __init__(self, child: TaskBase, retry_options: RetryOptions, context):
347347
self.context = context
348348
self.actions = child.action_repr
349349
self.is_waiting_on_timer = False
350+
self.error = None
350351

351352
@property
352353
def id_(self):
@@ -373,10 +374,21 @@ def try_set_value(self, child: TaskBase):
373374
if self.is_waiting_on_timer:
374375
# timer fired, re-scheduling original task
375376
self.is_waiting_on_timer = False
376-
rescheduled_task = self.context._generate_task(
377-
action=NoOpAction("rescheduled task"), parent=self)
378-
self.pending_tasks.add(rescheduled_task)
379-
self.context._add_to_open_tasks(rescheduled_task)
377+
# As per DTFx semantics: we need to check the number of retires only after the final
378+
# timer has fired. This means we essentially have to wait for one "extra" timer after
379+
# the maximum number of attempts has been reached. Removing this extra timer will cause
380+
# stuck orchestrators as we need to be "in sync" with the replay logic of DTFx.
381+
if self.num_attempts >= self.retry_options.max_number_of_attempts:
382+
self.is_waiting_on_timer = True
383+
# we have reached the maximum number of attempts, set error
384+
self.set_value(is_error=True, value=self.error)
385+
else:
386+
rescheduled_task = self.context._generate_task(
387+
action=NoOpAction("rescheduled task"), parent=self)
388+
self.pending_tasks.add(rescheduled_task)
389+
self.context._add_to_open_tasks(rescheduled_task)
390+
self.num_attempts += 1
391+
380392
return
381393
if child.state is TaskState.SUCCEEDED:
382394
if len(self.pending_tasks) == 0:
@@ -386,17 +398,11 @@ def try_set_value(self, child: TaskBase):
386398
self.set_value(is_error=False, value=child.result)
387399

388400
else: # child.state is TaskState.FAILED:
389-
if self.num_attempts >= self.retry_options.max_number_of_attempts:
390-
# we have reached the maximum number of attempts, set error
391-
self.set_value(is_error=True, value=child.result)
392-
else:
393-
# still have some retries left.
394-
# increase size of pending tasks by adding a timer task
395-
# when it completes, we'll retry the original task
396-
timer_task = self.context._generate_task(
397-
action=NoOpAction("-WithRetry timer"), parent=self)
398-
self.pending_tasks.add(timer_task)
399-
self.context._add_to_open_tasks(timer_task)
400-
self.is_waiting_on_timer = True
401-
402-
self.num_attempts += 1
401+
# increase size of pending tasks by adding a timer task
402+
# when it completes, we'll retry the original task
403+
timer_task = self.context._generate_task(
404+
action=NoOpAction("-WithRetry timer"), parent=self)
405+
self.pending_tasks.add(timer_task)
406+
self.context._add_to_open_tasks(timer_task)
407+
self.is_waiting_on_timer = True
408+
self.error = child.result

azure/durable_functions/models/actions/CompoundAction.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ class CompoundAction(Action):
1212
Provides the information needed by the durable extension to be able to invoke WhenAll tasks.
1313
"""
1414

15-
def __init__(self, compoundTasks: List[Action]):
16-
self.compound_actions = list(map(lambda x: x.to_json(), compoundTasks))
15+
def __init__(self, compound_tasks: List[Action]):
16+
self.compound_tasks = compound_tasks
1717

1818
@property
1919
@abstractmethod
@@ -31,5 +31,5 @@ def to_json(self) -> Dict[str, Union[str, int]]:
3131
"""
3232
json_dict: Dict[str, Union[str, int]] = {}
3333
add_attrib(json_dict, self, 'action_type', 'actionType')
34-
add_attrib(json_dict, self, 'compound_actions', 'compoundActions')
34+
json_dict['compoundActions'] = list(map(lambda x: x.to_json(), self.compound_tasks))
3535
return json_dict

setup.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
import shutil
55
import subprocess
66
import sys
7-
import glob
87

8+
from glob import glob
99
from setuptools import setup, find_packages
1010
from distutils.command import build
1111

@@ -65,6 +65,9 @@ def run(self, *args, **kwargs):
6565
'pytest-asyncio==0.10.0'
6666
],
6767
include_package_data=True,
68+
data_files= [
69+
('_manifest', list(filter(os.path.isfile, glob('_manifest/**/*', recursive=True)))),
70+
],
6871
cmdclass={
6972
'build': BuildModule
7073
},

tests/orchestrator/test_create_timer.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from azure.durable_functions.models.ReplaySchema import ReplaySchema
2+
from azure.durable_functions.models.actions.WhenAnyAction import WhenAnyAction
23
from tests.test_utils.ContextBuilder import ContextBuilder
34
from .orchestrator_test_utils \
45
import get_orchestration_state_result, assert_orchestration_state_equals, assert_valid_schema
@@ -9,7 +10,7 @@
910

1011

1112
def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState:
12-
return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema.value)
13+
return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema)
1314

1415
def add_timer_fired_events(context_builder: ContextBuilder, id_: int, timestamp: str):
1516
fire_at: str = context_builder.add_timer_created_event(id_, timestamp)
@@ -99,4 +100,28 @@ def test_timers_can_be_cancelled():
99100
expected = expected_state.to_json()
100101

101102
assert_orchestration_state_equals(expected, result)
102-
assert result["actions"][0][1]["isCanceled"]
103+
assert result["actions"][0][1]["isCanceled"]
104+
105+
def test_timers_can_be_cancelled_replayV2():
106+
107+
context_builder = ContextBuilder("test_timers_can_be_cancelled", replay_schema=ReplaySchema.V2)
108+
fire_at1 = context_builder.current_datetime + timedelta(minutes=5)
109+
fire_at2 = context_builder.current_datetime + timedelta(minutes=10)
110+
add_timer_fired_events(context_builder, 0, str(fire_at1))
111+
add_timer_fired_events(context_builder, 1, str(fire_at2))
112+
113+
result = get_orchestration_state_result(
114+
context_builder, generator_function_timer_can_be_cancelled)
115+
116+
expected_state = base_expected_state(output='Done!', replay_schema=ReplaySchema.V2)
117+
expected_state._actions = [
118+
[WhenAnyAction(
119+
[CreateTimerAction(fire_at=fire_at1), CreateTimerAction(fire_at=fire_at2, is_cancelled=True)]
120+
)]
121+
]
122+
123+
expected_state._is_done = True
124+
expected = expected_state.to_json()
125+
126+
assert_orchestration_state_equals(expected, result)
127+
assert result["actions"][0][0]['compoundActions'][1]["isCanceled"]

tests/orchestrator/test_sequential_orchestrator_with_retry.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ def generator_function(context):
2929

3030
return outputs
3131

32+
def generator_function_try_catch(context):
33+
outputs = []
34+
35+
retry_options = RETRY_OPTIONS
36+
result = None
37+
try:
38+
result = yield context.call_activity_with_retry(
39+
"Hello", retry_options, "Tokyo")
40+
except:
41+
result = yield context.call_activity_with_retry(
42+
"Hello", retry_options, "Seattle")
43+
return result
44+
3245
def generator_function_concurrent_retries(context):
3346
outputs = []
3447

@@ -305,6 +318,37 @@ def test_failed_tokyo_hit_max_attempts():
305318
expected_error_str = f"{error_msg}{error_label}{state_str}"
306319
assert expected_error_str == error_str
307320

321+
def test_failed_tokyo_hit_max_attempts_in_try_catch():
322+
# This test ensures that APIs can still be invoked after a failed CallActivityWithRetry invocation
323+
failed_reason = 'Reasons'
324+
failed_details = 'Stuff and Things'
325+
context_builder = ContextBuilder('test_simple_function')
326+
327+
# events for first task: "Hello Tokyo"
328+
add_hello_failed_events(context_builder, 0, failed_reason, failed_details)
329+
add_retry_timer_events(context_builder, 1)
330+
add_hello_failed_events(context_builder, 2, failed_reason, failed_details)
331+
add_retry_timer_events(context_builder, 3)
332+
add_hello_failed_events(context_builder, 4, failed_reason, failed_details)
333+
# we have an "extra" timer to wait for, due to legacy behavior in DTFx.
334+
add_retry_timer_events(context_builder, 5)
335+
336+
# events to task in except block
337+
add_hello_completed_events(context_builder, 6, "\"Hello Seattle!\"")
338+
339+
result = get_orchestration_state_result(
340+
context_builder, generator_function_try_catch)
341+
342+
expected_state = base_expected_state()
343+
add_hello_action(expected_state, 'Tokyo')
344+
add_hello_action(expected_state, 'Seattle')
345+
expected_state._output = "Hello Seattle!"
346+
expected_state._is_done = True
347+
expected = expected_state.to_json()
348+
349+
assert_valid_schema(result)
350+
assert_orchestration_state_equals(expected, result)
351+
308352
def test_concurrent_retriable_results():
309353
failed_reason = 'Reasons'
310354
failed_details = 'Stuff and Things'

0 commit comments

Comments
 (0)