Skip to content

Commit 256ffbd

Browse files
andystaplesCopilot
andauthored
Warn when Durable Functions dependencies allow major upgrades (#350)
* Warn when Functions dependency allows major upgrades Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Detect future major prerelease upgrades Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c735508 commit 256ffbd

7 files changed

Lines changed: 653 additions & 2 deletions

File tree

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
ADDED
1111

12+
- A best-effort background check warns when a Function App's requirements allow
13+
a future major version, which may introduce replay-breaking changes.
14+
1215
- Added opt-in `HttpRetryOptions` on `context.call_http()` for durable initial
1316
request retries with configurable timing and status selection. Empty or omitted
1417
status lists select all non-2xx responses; exhausted retries fail the call.

‎azure-functions-durable/README.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,34 @@ built on top of the [`durabletask`](https://pypi.org/project/durabletask/) SDK.
1717
## Installation
1818

1919
```bash
20-
pip install azure-functions-durable
20+
pip install "azure-functions-durable>=2.0.0b1,<3"
2121
```
2222

23+
For deployments, also constrain `azure-functions-durable` in the Function
24+
App's `requirements.txt`:
25+
26+
```text
27+
azure-functions-durable>=2.0.0b1,<3
28+
```
29+
30+
Future major versions may include broad, replay-breaking changes. An automatic
31+
upgrade during deployment can cause existing orchestrations to fail
32+
unexpectedly. Review the [migration guide](MIGRATION_GUIDE.md) and test replay
33+
before upgrading. A major-version constraint does not pin the `durabletask`
34+
dependency or guarantee replay compatibility; lock the complete dependency
35+
graph for reproducible deployments.
36+
37+
After the first durable-client, orchestrator, or entity invocation,
38+
each Python worker checks accessible `requirements.txt` files in the
39+
background and writes a host worker log warning when the declared constraints
40+
permit a later major version. The check follows local `-r` and `-c` files and
41+
applicable environment markers. It is best-effort and may miss uncommon version
42+
ranges; missing or unsupported requirements are treated as unknown. A
43+
lockfile's presence does not indicate whether deployment uses it, so the warning
44+
describes the risk of installing directly from the requirements. The check does
45+
not read dependency files during app startup or wait for the check before
46+
returning from an invocation.
47+
2348
## Overview
2449

2550
Author orchestrations, activities, and entities as Azure Functions and let the
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Best-effort check for Function App dependencies that allow a new major."""
5+
6+
import os
7+
import re
8+
import shlex
9+
from importlib.metadata import PackageNotFoundError, version
10+
from logging import Logger
11+
from pathlib import Path
12+
13+
from packaging.markers import UndefinedComparison, UndefinedEnvironmentName
14+
from packaging.requirements import InvalidRequirement, Requirement
15+
from packaging.specifiers import SpecifierSet
16+
from packaging.utils import canonicalize_name
17+
from packaging.version import InvalidVersion, Version
18+
19+
_PACKAGE = "azure-functions-durable"
20+
_INDEX_OPTIONS_WITH_VALUE = {
21+
"-i", "-f", "--index-url", "--extra-index-url", "--find-links",
22+
"--trusted-host", "--only-binary", "--no-binary",
23+
}
24+
_INDEX_FLAGS = {"--no-index", "--pre", "--prefer-binary"}
25+
26+
27+
def _read_specifiers(root: Path, logger: Logger) -> tuple[SpecifierSet, bool] | None:
28+
specifiers: list[str] = []
29+
required = False
30+
allow_prereleases = False
31+
active: set[tuple[Path, bool]] = set()
32+
visited: set[tuple[Path, bool]] = set()
33+
34+
def read_file(path: Path, constraint: bool) -> bool:
35+
nonlocal allow_prereleases, required
36+
try:
37+
path = path.resolve()
38+
key = (path, constraint)
39+
if key in active:
40+
logger.debug("Cannot check Durable Functions dependencies: cyclic requirements in %s.", path)
41+
return False
42+
if key in visited:
43+
return True
44+
content = path.read_text(encoding="utf-8-sig")
45+
except (OSError, UnicodeError) as exc:
46+
logger.debug("Cannot read Function App requirements from %s: %s", path, exc)
47+
return False
48+
49+
active.add(key)
50+
try:
51+
for raw_line in content.splitlines():
52+
line = re.sub(r"\s+#.*$", "", raw_line).strip()
53+
if not line or line.startswith("#"):
54+
continue
55+
if line.endswith("\\"):
56+
logger.debug("Cannot check multiline requirements in %s.", path)
57+
return False
58+
if line.startswith("-"):
59+
try:
60+
parts = shlex.split(line)
61+
except ValueError:
62+
logger.debug("Cannot parse a requirements option in %s.", path)
63+
return False
64+
option = parts[0]
65+
argument = None
66+
if option in ("-r", "--requirement", "-c", "--constraint") and len(parts) == 2:
67+
argument = parts[1]
68+
elif option.startswith(("--requirement=", "--constraint=")) and len(parts) == 1:
69+
option, argument = option.split("=", 1)
70+
elif option.startswith(("-r", "-c")) and len(option) > 2 and len(parts) == 1:
71+
option, argument = option[:2], option[2:]
72+
elif option in _INDEX_FLAGS and len(parts) == 1:
73+
allow_prereleases = allow_prereleases or option == "--pre"
74+
continue
75+
elif (
76+
option in _INDEX_OPTIONS_WITH_VALUE and len(parts) == 2) or (
77+
option.split("=", 1)[0] in _INDEX_OPTIONS_WITH_VALUE
78+
and "=" in option and len(parts) == 1):
79+
continue
80+
if not argument or "://" in argument or "${" in argument:
81+
logger.debug("Cannot inspect a requirements option in %s.", path)
82+
return False
83+
nested_constraint = option in ("-c", "--constraint")
84+
if not read_file(path.parent / argument, nested_constraint):
85+
return False
86+
continue
87+
try:
88+
requirement = Requirement(line)
89+
except InvalidRequirement:
90+
logger.debug("Cannot parse a requirement in %s.", path)
91+
return False
92+
if canonicalize_name(requirement.name) != _PACKAGE:
93+
continue
94+
try:
95+
if requirement.marker is not None and not requirement.marker.evaluate():
96+
continue
97+
except (UndefinedComparison, UndefinedEnvironmentName, InvalidVersion) as exc:
98+
logger.debug("Cannot evaluate a requirement marker in %s: %s", path, exc)
99+
return False
100+
if requirement.url is not None:
101+
logger.debug("Cannot determine the Durable Functions version from a URL in %s.", path)
102+
return False
103+
required = required or not constraint
104+
specifiers.append(str(requirement.specifier))
105+
finally:
106+
active.remove(key)
107+
visited.add(key)
108+
return True
109+
110+
if not read_file(root / "requirements.txt", False) or not required:
111+
return None
112+
package_specifiers = SpecifierSet(",".join(specifiers))
113+
allow_prereleases = allow_prereleases or any(specifier.prereleases for specifier in package_specifiers)
114+
return package_specifiers, allow_prereleases
115+
116+
117+
def _allows_later_major(
118+
specifiers: SpecifierSet,
119+
installed: Version,
120+
allow_prereleases: bool = False) -> bool:
121+
candidates: set[Version] = set()
122+
123+
def add_release(epoch: int, major: int, minor: int, patch: int) -> None:
124+
prefix = f"{epoch}!" if epoch else ""
125+
candidates.add(Version(f"{prefix}{major}.{minor}.{patch}"))
126+
127+
for major in (installed.major, installed.major + 1, installed.major + 2):
128+
add_release(installed.epoch, major, 0, 0)
129+
add_release(installed.epoch, major, 0, 1)
130+
add_release(installed.epoch, major, 1, 0)
131+
132+
for specifier in specifiers:
133+
try:
134+
bound = Version(specifier.version.removesuffix(".*"))
135+
except InvalidVersion:
136+
continue
137+
candidates.add(bound)
138+
add_release(bound.epoch, bound.major, bound.minor, bound.micro + 1)
139+
add_release(bound.epoch, bound.major, bound.minor + 1, 0)
140+
add_release(bound.epoch, bound.major + 1, 0, 0)
141+
142+
for candidate in tuple(candidates):
143+
base = candidate.base_version
144+
candidates.update(Version(f"{base}{suffix}") for suffix in (".dev0", "a0", "b0", "rc0"))
145+
if candidate.pre and candidate.pre[1] > 0:
146+
label, number = candidate.pre
147+
candidates.add(Version(f"{base}{label}{number - 1}"))
148+
149+
allowed = [
150+
candidate for candidate in candidates
151+
if specifiers.contains(candidate, prereleases=allow_prereleases)
152+
]
153+
return (
154+
any(candidate.major == installed.major for candidate in allowed)
155+
and any(candidate.major > installed.major and candidate > installed for candidate in allowed)
156+
)
157+
158+
159+
def warn_for_function_directory(function_directory: str, logger: Logger) -> None:
160+
"""Find the Function App root before checking its dependencies."""
161+
roots: list[Path] = []
162+
configured_root = os.environ.get("AzureWebJobsScriptRoot")
163+
if configured_root:
164+
roots.append(Path(configured_root))
165+
directory = Path(function_directory)
166+
roots.extend((directory, *directory.parents))
167+
try:
168+
roots.append(Path.cwd())
169+
except OSError as exc:
170+
logger.debug("Cannot determine the Function App working directory: %s", exc)
171+
for root in roots:
172+
if (root / "host.json").is_file():
173+
warn_if_unbounded(str(root), logger)
174+
return
175+
logger.debug("Cannot find the Function App root for %s.", function_directory)
176+
177+
178+
def warn_if_unbounded(function_directory: str, logger: Logger) -> None:
179+
"""Warn when the app's requirements demonstrably permit a later major."""
180+
root = Path(function_directory)
181+
try:
182+
requirements = _read_specifiers(root, logger)
183+
except RecursionError as exc:
184+
logger.debug("Cannot check deeply nested Function App requirements: %s", exc)
185+
return
186+
if requirements is None:
187+
return
188+
specifiers, allow_prereleases = requirements
189+
try:
190+
installed = Version(version(_PACKAGE))
191+
allows_later_major = _allows_later_major(specifiers, installed, allow_prereleases)
192+
except (PackageNotFoundError, InvalidVersion) as exc:
193+
logger.debug("Cannot determine the installed Durable Functions version: %s", exc)
194+
return
195+
if allows_later_major:
196+
next_major = f"{installed.epoch}!" if installed.epoch else ""
197+
next_major += str(installed.major + 1)
198+
logger.warning(
199+
"The Function App requirements allow azure-functions-durable to upgrade beyond major version %d. "
200+
"Future major releases may include broad, replay-breaking changes. If a deployment installs these "
201+
"requirements without a lock, an automatic upgrade could cause existing orchestrations to fail "
202+
"unexpectedly. Pin the major version, for example azure-functions-durable>=%s,<%s, "
203+
"or deploy using a complete dependency lock.",
204+
installed.major, installed.public, next_major,
205+
)

‎azure-functions-durable/azure/durable_functions/internal/invocation.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,50 @@
1111
from concurrent.futures import ThreadPoolExecutor
1212
from contextvars import ContextVar, copy_context
1313
from functools import lru_cache, wraps
14+
from threading import Lock, Thread
1415
from types import FunctionType
1516
from typing import Any, Callable, ParamSpec, TypeVar
1617

1718
import azure.functions as func
1819

1920
_invocation_context: ContextVar[func.Context | None] = ContextVar("durable_invocation_context", default=None)
21+
_dependency_check_lock = Lock()
22+
_dependency_check_scheduled = False
23+
_logger = logging.getLogger(__name__)
24+
# The worker routes SDK logs through the host even after an invocation ends.
25+
_dependency_logger = logging.getLogger("azure.functions.durable")
2026
_Parameters = ParamSpec("_Parameters")
2127
_Result = TypeVar("_Result")
2228

2329

30+
def _check_dependency_in_background(directory: str) -> None:
31+
from .dependency_warning import warn_for_function_directory
32+
33+
warn_for_function_directory(directory, _dependency_logger)
34+
35+
36+
def _schedule_dependency_check(context: func.Context | None) -> None:
37+
global _dependency_check_scheduled
38+
if _dependency_check_scheduled or context is None:
39+
return
40+
directory = getattr(context, "function_directory", None)
41+
if not isinstance(directory, str) or not directory:
42+
return
43+
with _dependency_check_lock:
44+
if _dependency_check_scheduled:
45+
return
46+
_dependency_check_scheduled = True
47+
try:
48+
Thread(
49+
target=_check_dependency_in_background,
50+
args=(directory,),
51+
name="durable-dependency-check",
52+
daemon=True,
53+
).start()
54+
except RuntimeError:
55+
_logger.warning("Unable to start the Durable Functions dependency check.", exc_info=True)
56+
57+
2458
def preserve_function_source(wrapper: Callable[..., Any], original: Callable[..., Any]) -> None:
2559
"""Preserve the filename used by the Functions worker to index the app directory."""
2660
if isinstance(wrapper, FunctionType) and isinstance(original, FunctionType):
@@ -131,6 +165,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
131165
return await function(*original.args, **original.kwargs)
132166
finally:
133167
_invocation_context.reset(token)
168+
_schedule_dependency_check(context)
134169

135170
wrapper = async_wrapper
136171
else:
@@ -142,6 +177,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
142177
return function(*original.args, **original.kwargs)
143178
finally:
144179
_invocation_context.reset(token)
180+
_schedule_dependency_check(context)
145181

146182
wrapper = sync_wrapper
147183

‎azure-functions-durable/pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ readme = "README.md"
3232
dependencies = [
3333
"durabletask[opentelemetry]>=1.13.1",
3434
"azure-identity>=1.19.0",
35-
"azure-functions>=2.4.0b1"
35+
"azure-functions>=2.4.0b1",
36+
"packaging"
3637
]
3738

3839
[project.urls]

‎tests/azure-functions-durable/e2e/test_logging_e2e.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ def _assert_host_routed(line: str, message: str) -> None:
1616
assert re.fullmatch(rf"\[[^\]]+Z\] {re.escape(message)}", line)
1717

1818

19+
def test_unpinned_dependency_warning_reaches_the_host(dtask_app):
20+
dtask_app.start_orchestration("activity_chain")
21+
warning = dtask_app.wait_for_host_log(
22+
"The Function App requirements allow azure-functions-durable to upgrade beyond major version")
23+
assert "replay-breaking changes" in warning
24+
assert "existing orchestrations to fail unexpectedly" in warning
25+
26+
1927
def test_client_and_worker_logs_flow_through_host(dtask_app):
2028
instance_id = dtask_app.start_orchestration("activity_chain")
2129
status = dtask_app.wait_for_completion(instance_id)

0 commit comments

Comments
 (0)