1212from __future__ import annotations
1313
1414import logging
15+ from collections .abc import Iterator
1516from typing import TYPE_CHECKING , Any
1617
1718from 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
4861def 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
7597async 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
87109def _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
177161def _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-
307210async def _walk_and_deexternalize_async (
308211 msg : proto_message .Message ,
309212 store : PayloadStore ,
0 commit comments