Skip to content

Commit 100263b

Browse files
authored
Preflight all externalized payload sizes before uploads (#305)
Fixes #298 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c95a41ba-3f18-427d-8a6c-a7438e7ec782
1 parent 14e75af commit 100263b

5 files changed

Lines changed: 254 additions & 158 deletions

File tree

‎CHANGELOG.md‎

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

1010
FIXED
1111

12+
- Large-payload externalization now checks every eligible payload's maximum
13+
size before uploading or replacing any protobuf values, including carryover
14+
events and entity payloads, for both synchronous and asynchronous calls.
15+
Oversized messages remain unchanged and cause no uploads; their size error
16+
now takes precedence over hypothetical earlier storage failures.
1217
- Orchestrator generators are now closed at the end of each dispatch, including
1318
while waiting for durable work, releasing resources held by async bridges.
1419
`finally` blocks and context-manager exits can run before orchestration completion

‎azure-functions-durable/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ CHANGED
2121

2222
FIXED
2323

24+
- With the corresponding core `durabletask` SDK fix, oversized messages fail
25+
large-payload validation before any uploads or payload replacements. Size
26+
errors now take precedence over hypothetical earlier storage failures.
2427
- With the corresponding core `durabletask` SDK fix, orchestrator generators
2528
are closed at the end of each dispatch, including while waiting for durable work.
2629
See [cleanup semantics](../docs/supported-patterns.md#cleanup-in-orchestrator-finally-blocks)

‎durabletask-azuremanaged/CHANGELOG.md‎

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

1010
FIXED
1111

12+
- With the corresponding core `durabletask` SDK fix, oversized messages fail
13+
large-payload validation before any uploads or payload replacements. Size
14+
errors now take precedence over hypothetical earlier storage failures.
1215
- With the corresponding core `durabletask` SDK fix, orchestrator generators
1316
are closed at the end of each dispatch, including while waiting for durable work.
1417
See [cleanup semantics](../docs/supported-patterns.md#cleanup-in-orchestrator-finally-blocks)

‎durabletask/payload/helpers.py‎

Lines changed: 61 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import annotations
1313

1414
import logging
15+
from collections.abc import Iterator
1516
from typing import TYPE_CHECKING, Any
1617

1718
from google.protobuf import message as proto_message
@@ -38,11 +39,23 @@ def externalize_payloads(
3839
3940
Any ``StringValue`` whose UTF-8 byte length exceeds
4041
``store.options.threshold_bytes`` is uploaded and its value replaced
41-
with the token returned by the store.
42+
with the token returned by the store. Empty values and known tokens
43+
are skipped.
44+
45+
All eligible payloads are snapshotted and checked against
46+
``store.options.max_stored_payload_bytes`` before any upload or mutation.
47+
A size violation therefore raises ``ValueError`` before any hypothetical
48+
earlier upload failure. Upload failures can still leave earlier uploads
49+
and token replacements in place; this is not a storage transaction.
4250
"""
43-
threshold = store.options.threshold_bytes
44-
max_bytes = store.options.max_stored_payload_bytes
45-
_walk_and_externalize(msg, store, threshold, max_bytes, instance_id)
51+
payloads = _collect_payloads_to_externalize(msg, store)
52+
for field_name, sv, payload_bytes in payloads:
53+
token = store.upload(payload_bytes, instance_id=instance_id)
54+
sv.value = token
55+
logger.debug(
56+
"Externalized %d-byte payload in field '%s' -> %s",
57+
len(payload_bytes), field_name, token,
58+
)
4659

4760

4861
def deexternalize_payloads(
@@ -66,10 +79,19 @@ async def externalize_payloads_async(
6679
*,
6780
instance_id: str | None = None,
6881
) -> None:
69-
"""Async version of :func:`externalize_payloads`."""
70-
threshold = store.options.threshold_bytes
71-
max_bytes = store.options.max_stored_payload_bytes
72-
await _walk_and_externalize_async(msg, store, threshold, max_bytes, instance_id)
82+
"""Async version of :func:`externalize_payloads`, with the same preflight.
83+
84+
Uploads remain sequential; upload errors and cancellation propagate without
85+
rolling back earlier uploads or token replacements.
86+
"""
87+
payloads = _collect_payloads_to_externalize(msg, store)
88+
for field_name, sv, payload_bytes in payloads:
89+
token = await store.upload_async(payload_bytes, instance_id=instance_id)
90+
sv.value = token
91+
logger.debug(
92+
"Externalized %d-byte payload in field '%s' -> %s",
93+
len(payload_bytes), field_name, token,
94+
)
7395

7496

7597
async def deexternalize_payloads_async(
@@ -81,7 +103,7 @@ async def deexternalize_payloads_async(
81103

82104

83105
# ------------------------------------------------------------------
84-
# Internal recursive walkers – sync
106+
# Internal preflight and recursive walkers – sync
85107
# ------------------------------------------------------------------
86108

87109
def _is_map_field(fd: Any) -> bool:
@@ -90,88 +112,50 @@ def _is_map_field(fd: Any) -> bool:
90112
return mt is not None and fd.is_repeated and mt.GetOptions().map_entry
91113

92114

93-
def _walk_and_externalize(
115+
def _iter_payload_fields(
94116
msg: proto_message.Message,
95-
store: PayloadStore,
96-
threshold: int,
97-
max_bytes: int,
98-
instance_id: str | None,
99-
) -> None:
117+
) -> Iterator[tuple[str, wrappers_pb2.StringValue]]:
100118
for fd in msg.DESCRIPTOR.fields:
101119
if fd.message_type is None:
102120
continue
103121

104122
if _is_map_field(fd):
105-
# Map fields: iterate values. ScalarMap values are not
106-
# messages and will be skipped by the isinstance check.
107-
for map_value in getattr(msg, fd.name).values():
108-
if isinstance(map_value, proto_message.Message):
109-
if isinstance(map_value, wrappers_pb2.StringValue):
110-
_try_externalize_field(
111-
fd.name, map_value, store,
112-
threshold, max_bytes, instance_id,
113-
)
114-
else:
115-
_walk_and_externalize(
116-
map_value, store, threshold, max_bytes, instance_id
117-
)
123+
values = getattr(msg, fd.name).values()
118124
elif fd.is_repeated:
119-
value = getattr(msg, fd.name)
120-
for item in value:
121-
if isinstance(item, proto_message.Message):
122-
if isinstance(item, wrappers_pb2.StringValue):
123-
_try_externalize_field(
124-
fd.name, item, store,
125-
threshold, max_bytes, instance_id,
126-
)
127-
else:
128-
_walk_and_externalize(
129-
item, store, threshold, max_bytes, instance_id
130-
)
125+
values = getattr(msg, fd.name)
131126
else:
132-
# Singular message field — only recurse if actually set
133127
if not msg.HasField(fd.name):
134128
continue
135-
value = getattr(msg, fd.name)
129+
values = (getattr(msg, fd.name),)
130+
131+
for value in values:
136132
if isinstance(value, wrappers_pb2.StringValue):
137-
_try_externalize_field(
138-
fd.name, value, store,
139-
threshold, max_bytes, instance_id,
140-
)
141-
else:
142-
_walk_and_externalize(
143-
value, store, threshold, max_bytes, instance_id
144-
)
133+
yield fd.name, value
134+
elif isinstance(value, proto_message.Message):
135+
yield from _iter_payload_fields(value)
145136

146137

147-
def _try_externalize_field(
148-
field_name: str,
149-
sv: wrappers_pb2.StringValue,
138+
def _collect_payloads_to_externalize(
139+
msg: proto_message.Message,
150140
store: PayloadStore,
151-
threshold: int,
152-
max_bytes: int,
153-
instance_id: str | None,
154-
) -> None:
155-
val = sv.value
156-
if not val:
157-
return
158-
# Already a token – skip
159-
if store.is_known_token(val):
160-
return
161-
payload_bytes = val.encode("utf-8")
162-
if len(payload_bytes) <= threshold:
163-
return
164-
if len(payload_bytes) > max_bytes:
165-
raise ValueError(
166-
f"Payload size {len(payload_bytes)} bytes exceeds the maximum "
167-
f"allowed size of {max_bytes} bytes."
168-
)
169-
token = store.upload(payload_bytes, instance_id=instance_id)
170-
sv.value = token
171-
logger.debug(
172-
"Externalized %d-byte payload in field '%s' -> %s",
173-
len(payload_bytes), field_name, token,
174-
)
141+
) -> list[tuple[str, wrappers_pb2.StringValue, bytes]]:
142+
threshold = store.options.threshold_bytes
143+
max_bytes = store.options.max_stored_payload_bytes
144+
payloads: list[tuple[str, wrappers_pb2.StringValue, bytes]] = []
145+
for field_name, sv in _iter_payload_fields(msg):
146+
val = sv.value
147+
if not val or store.is_known_token(val):
148+
continue
149+
payload_bytes = val.encode("utf-8")
150+
if len(payload_bytes) <= threshold:
151+
continue
152+
if len(payload_bytes) > max_bytes:
153+
raise ValueError(
154+
f"Payload size {len(payload_bytes)} bytes exceeds the maximum "
155+
f"allowed size of {max_bytes} bytes."
156+
)
157+
payloads.append((field_name, sv, payload_bytes))
158+
return payloads
175159

176160

177161
def _walk_and_deexternalize(
@@ -223,87 +207,6 @@ def _try_deexternalize_field(
223207
# Internal recursive walkers – async
224208
# ------------------------------------------------------------------
225209

226-
async def _walk_and_externalize_async(
227-
msg: proto_message.Message,
228-
store: PayloadStore,
229-
threshold: int,
230-
max_bytes: int,
231-
instance_id: str | None,
232-
) -> None:
233-
for fd in msg.DESCRIPTOR.fields:
234-
if fd.message_type is None:
235-
continue
236-
237-
if _is_map_field(fd):
238-
for map_value in getattr(msg, fd.name).values():
239-
if isinstance(map_value, proto_message.Message):
240-
if isinstance(map_value, wrappers_pb2.StringValue):
241-
await _try_externalize_field_async(
242-
fd.name, map_value, store,
243-
threshold, max_bytes, instance_id,
244-
)
245-
else:
246-
await _walk_and_externalize_async(
247-
map_value, store, threshold, max_bytes, instance_id,
248-
)
249-
elif fd.is_repeated:
250-
value = getattr(msg, fd.name)
251-
for item in value:
252-
if isinstance(item, proto_message.Message):
253-
if isinstance(item, wrappers_pb2.StringValue):
254-
await _try_externalize_field_async(
255-
fd.name, item, store,
256-
threshold, max_bytes, instance_id,
257-
)
258-
else:
259-
await _walk_and_externalize_async(
260-
item, store, threshold, max_bytes, instance_id,
261-
)
262-
else:
263-
if not msg.HasField(fd.name):
264-
continue
265-
value = getattr(msg, fd.name)
266-
if isinstance(value, wrappers_pb2.StringValue):
267-
await _try_externalize_field_async(
268-
fd.name, value, store,
269-
threshold, max_bytes, instance_id,
270-
)
271-
else:
272-
await _walk_and_externalize_async(
273-
value, store, threshold, max_bytes, instance_id,
274-
)
275-
276-
277-
async def _try_externalize_field_async(
278-
field_name: str,
279-
sv: wrappers_pb2.StringValue,
280-
store: PayloadStore,
281-
threshold: int,
282-
max_bytes: int,
283-
instance_id: str | None,
284-
) -> None:
285-
val = sv.value
286-
if not val:
287-
return
288-
# Already a token – skip
289-
if store.is_known_token(val):
290-
return
291-
payload_bytes = val.encode("utf-8")
292-
if len(payload_bytes) <= threshold:
293-
return
294-
if len(payload_bytes) > max_bytes:
295-
raise ValueError(
296-
f"Payload size {len(payload_bytes)} bytes exceeds the maximum "
297-
f"allowed size of {max_bytes} bytes."
298-
)
299-
token = await store.upload_async(payload_bytes, instance_id=instance_id)
300-
sv.value = token
301-
logger.debug(
302-
"Externalized %d-byte payload in field '%s' -> %s",
303-
len(payload_bytes), field_name, token,
304-
)
305-
306-
307210
async def _walk_and_deexternalize_async(
308211
msg: proto_message.Message,
309212
store: PayloadStore,

0 commit comments

Comments
 (0)