Skip to content

Commit 48dfe03

Browse files
andystaplesCopilot
andcommitted
Merge main and resolve Functions changelog conflict
Preserve the orchestration ID reuse policy entry alongside the new dependency warning entry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents c809917 + 256ffbd commit 48dfe03

10 files changed

Lines changed: 1092 additions & 6 deletions

File tree

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ asynchronous clients accept public `OrchestrationIdReusePolicy` values through
1414
`schedule_new_orchestration()`, preserving backend defaults when omitted.
1515
See [ID reuse](../docs/features.md#orchestration-id-reuse). Core release and
1616
dependency minimum updates are coordinated in the dedicated release PR.
17+
- A best-effort background check warns when a Function App's requirements allow
18+
a future major version, which may introduce replay-breaking changes.
19+
1720
- Added opt-in `HttpRetryOptions` on `context.call_http()` for durable initial
1821
request retries with configurable timing and status selection. Empty or omitted
1922
status lists select all non-2xx responses; exhausted retries fail the call.

‎azure-functions-durable/README.md‎

Lines changed: 61 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
@@ -48,6 +73,41 @@ their existing page sizes.
4873
so they can be used as dictionary keys or set elements. Construct a new ID
4974
instead of assigning or deleting attributes on an existing ID.
5075

76+
## JSON payload typing and legacy compatibility
77+
78+
The provider uses the shared `azure-functions` JSON codec. Its default loose
79+
mode writes custom objects with `to_json` and reconstructs legacy envelopes,
80+
including nested objects, with `from_json`. Classes are resolved only from
81+
already-loaded modules. Where available, destination types are passed to the
82+
codec, such as a native orchestration input annotation, the v1-style
83+
`orchestration_trigger` `input_type` option, or an explicit `expected_type` on
84+
an API that accepts one. Loose mode can still reconstruct a different class
85+
from the one requested; it logs a warning rather than rejecting the mismatch.
86+
87+
Setting `AZURE_FUNCTIONS_DURABLE_STRICT_TYPING=1` opts into strict mode; the
88+
provider does not enable it automatically. To reconstruct a top-level
89+
custom-object envelope, strict mode requires a matching destination type with
90+
both `to_json` and `from_json` methods. Nested legacy envelopes in otherwise
91+
plain JSON remain dictionaries, and strict serialization does not support
92+
custom objects nested in plain containers.
93+
For a v1-style orchestration input, `get_input(expected_type=...)` alone is too
94+
late for the initial decode: declare `input_type` on `orchestration_trigger`
95+
when strict mode must reconstruct a top-level custom-object input.
96+
97+
The Functions worker does not pass activity parameter annotations to the
98+
activity trigger converter. Consequently, strict mode rejects a top-level
99+
custom-object activity input even when the activity has an annotation; this
100+
also applies to externalized payloads. Plain JSON activity inputs still decode
101+
without a destination type.
102+
103+
The shared codec emits one-time `DeprecationWarning` advisories in loose mode
104+
for decoding without a destination type and for reconstructing custom objects.
105+
These compatibility paths remain supported.
106+
107+
> [!WARNING]
108+
> Changing typing modes can change values returned during orchestration replay.
109+
> Validate existing payloads and drain affected orchestrations before switching.
110+
51111
## Durable HTTP failure retries
52112

53113
Opt in to durable retries for the **initial** `context.call_http()` request with
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]

‎pyproject.toml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,11 @@ asyncio_default_fixture_loop_scope = "function"
105105
filterwarnings = [
106106
"error::DeprecationWarning",
107107
"error::PendingDeprecationWarning",
108-
# Legacy Functions payloads still require the upstream loose codec.
109-
# Report only these known warnings without failing; see issue #325.
110-
'default:azure\.functions df_loads was called without expected_type\.:DeprecationWarning:azure\.functions\._durable_functions$',
111-
'default:azure\.functions Durable JSON codec reconstructed a custom object via the loose-mode object_hook path\.:DeprecationWarning:azure\.functions\._durable_functions$',
108+
# Activity triggers can lack a destination type, and even typed loose-mode
109+
# decoding uses the object hook for legacy custom objects. Report only
110+
# these exact upstream advisories without failing; see issue #325.
111+
'default:^azure\.functions df_loads was called without expected_type\. Pass the destination type to enable validation and prepare for strict typing \(AZURE_FUNCTIONS_DURABLE_STRICT_TYPING=1\)\. This message will not be repeated\.\Z:DeprecationWarning:azure\.functions\._durable_functions\Z',
112+
'default:^azure\.functions Durable JSON codec reconstructed a custom object via the loose-mode object_hook path\. Set AZURE_FUNCTIONS_DURABLE_STRICT_TYPING=1 and supply expected_type at decode call sites to enable type-validated deserialization\. This message will not be repeated\.\Z:DeprecationWarning:azure\.functions\._durable_functions\Z',
112113
]
113114
markers = [
114115
"azurite: tests that require Azurite (local Azure Storage emulator)",

0 commit comments

Comments
 (0)