Setting the file. One moment.
Blob Source Read · Foundry Iq · microsoft/azure-skills · Skills Docs
ContentsBack to the top of the page File Cu Canary
209
def _timestamp
— line 209
This file
Number 18.24
Position 24 of 84
Type Python
Size 39 KB
Lines 765 helpers/ _blob_source_read.py
Python · 765 lines · 39 KB
import
Path
12 from typing import Any, Callable
13 from urllib.parse import urlencode
14
15 try :
16 from ._progress import Progress, reporting
17 from . import blob_inventory, search_reconcile, _embedding, _search_read
18 from ._common import (
19 SEARCH_AUDIENCE , HelperFailure, TokenProvider, Transport, digest, load_approved_input,
20 reject_secrets, require_allowed_fields, model_definition,
21 )
22 except ImportError :
23 from _progress import Progress, reporting
24 import blob_inventory
25 import search_reconcile
26 import _embedding
27 import _search_read
28 from _common import (
29 SEARCH_AUDIENCE , HelperFailure, TokenProvider, Transport, digest, load_approved_input,
30 reject_secrets, require_allowed_fields, model_definition,
31 )
32
33
34 SNAPSHOT_WARNING = (
35 "Before/after inventories detect observed drift, not an atomic Storage snapshot "
36 "or a source lock. Scheduled sources can change after this run."
37 )
38
39
40 RETRY_STATUS = { 408 , 429 , 500 , 502 , 503 , 504 }
41
42
43 def validate_content_understanding (value: Any, * , enabled: bool ) -> dict[ str , Any] | None :
44 if not enabled:
45 if value is not None :
46 raise _failure( "cu-choice-conflict" , "Minimal extraction must omit Content Understanding choices." )
47 return None
48 if not isinstance (value, dict ):
49 raise _failure( "cu-prerequisite-missing" , "Standard extraction requires an existing CU-capable AIServices account." )
50 require_allowed_fields(value, { "endpoint" , "auth" , "prerequisites" }, label = "Content Understanding choices" )
51 endpoint = value.get( "endpoint" )
52 if (
53 not isinstance (endpoint, str )
54 or re.fullmatch( r "https:// [ a-z0-9 ][ a-z0-9- ] {0,62} \. services \. ai \. azure \. com/ ? " , endpoint) is None
55 or value.get( "auth" ) != "system-assigned"
56 ):
57 raise _failure( "cu-auth-unsupported" , "Select an exact AIServices services.ai.azure.com endpoint and existing Search system-assigned identity; no keys." )
58 prerequisites = value.get( "prerequisites" )
59 if not isinstance (prerequisites, dict ):
60 raise _failure( "cu-prerequisite-missing" , "Supply current CU resource, configuration, identity and network evidence references." )
61 fields = { "resource" , "configuration" , "identity" , "network" }
62 require_allowed_fields(prerequisites, fields, label = "Content Understanding prerequisites" )
63 if any ( not isinstance (prerequisites.get(key), str ) or not prerequisites[key].strip()
64 or len (prerequisites[key]) > 4096 for key in fields):
65 raise _failure( "cu-prerequisite-missing" , "Owner-verified CU capability/region, configuration, Search role and reachability evidence is required." )
66 return copy.deepcopy(value)
67
68
69 def verify_content_understanding_readback (choice: dict[ str , Any] | None , current: Any) -> None :
70 if choice is None or current is None :
71 return
72 parameters = current.get( "azureBlobParameters" ) if isinstance (current, dict ) else None
73 ingestion = parameters.get( "ingestionParameters" ) if isinstance (parameters, dict ) else None
74 ai = ingestion.get( "aiServices" ) if isinstance (ingestion, dict ) else None
75 if (
76 not isinstance (ai, dict ) or ingestion.get( "contentExtractionMode" ) != "standard"
77 or not isinstance (ai.get( "uri" ), str )
78 or ai[ "uri" ].rstrip( "/" ) != choice[ "endpoint" ].rstrip( "/" )
79 ):
80 raise _failure( "cu-readback-mismatch" , "Observed standard extraction and CU endpoint must match the selected configuration." )
81 key = ai.get( "apiKey" )
82 if (key is not None and not ( isinstance (key, str ) and key == "" )
83 or ingestion.get( "identity" ) is not None ):
84 raise _failure( "cu-auth-conflict" , "Observed CU authentication does not prove system-assigned mode; credential details withheld." )
85
86
87 def generated_resources (current: dict[ str , Any], * , strict: bool = False ) -> list[dict[ str , Any]]:
88 parameters = current.get( "azureBlobParameters" )
89 created = parameters.get( "createdResources" ) if isinstance (parameters, dict ) else None
90 generated = []
91 if isinstance (created, dict ):
92 for kind, name in created.items():
93 if (
94 kind in { "datasource" , "dataSourceConnection" , "indexer" , "skillset" , "index" }
95 and isinstance (name, str ) and re.fullmatch( r " [ a-zA-Z0-9 ][ a-zA-Z0-9_- ] {0,127} " , name)
96 ):
97 generated.append({
98 "type" : "datasource" if kind == "dataSourceConnection" else kind,
99 "name" : name, "service_managed" : True ,
100 })
101 generated.sort( key =lambda item: (item[ "type" ], item[ "name" ]))
102 if strict and (
103 not isinstance (created, dict ) or len (created) != 4 or len (generated) != 4
104 or {item[ "type" ] for item in generated} != { "datasource" , "indexer" , "skillset" , "index" }
105 ):
106 raise _failure( "generated-resources-unverified" , "Exact service-generated identities are required for reuse." )
107 return generated
108
109
110 def _read_intent (path: Path) -> dict[ str , Any]:
111 try :
112 document = json.loads(path.read_text( encoding = "utf-8" ))
113 except ( OSError , UnicodeError , json.JSONDecodeError) as exc:
114 raise _failure( "input-unreadable" , "Input must be readable UTF-8 JSON." ) from exc
115 if not isinstance (document, dict ):
116 raise _failure( "input-schema-invalid" , "Input must be an object." )
117 return document
118
119
120 def _receipt_paths (request: dict[ str , Any]) -> tuple[ str , str ] | None :
121 paths = [request.get(field) for field in ( "reuse_input_file" , "reuse_result_file" )]
122 if paths == [ None , None ]:
123 return None
124 if not all ( isinstance (path, str ) and path.strip() for path in paths):
125 raise _failure( "reuse-evidence-invalid" , "Select both retained approved input and creation result files." )
126 return paths[ 0 ], paths[ 1 ]
127
128
129 def _reuse_receipts (paths: tuple[ str , str ] | None ) -> tuple[dict[ str , Any], dict[ str , Any]] | None :
130 if paths is None :
131 return None
132 try :
133 _, prior, fingerprint = load_approved_input(Path(paths[ 0 ]))
134 except UnicodeError as exc:
135 raise _failure( "input-unreadable" , "Retained input must be readable UTF-8 JSON." ) from exc
136 _validate_plan(prior)
137 result = _read_intent(Path(paths[ 1 ]))
138 _validate_reuse_receipts(prior, fingerprint, result)
139 return prior, result
140
141
142 def _validate_reuse_receipts (prior, fingerprint, result):
143 reject_secrets(result)
144 if not all ( isinstance (result.get(field), dict ) for field in (
145 "approved_plan" , "source_evidence" , "resources" , "verification" , "source"
146 )):
147 raise _failure( "reuse-evidence-invalid" , "Retained creation result sections must be objects." )
148 if (
149 prior[ "source" ][ "action" ] != "create" or result.get( "status" ) != "completed"
150 or result[ "approved_plan" ].get( "confirmed" ) is not True
151 or result.get( "approved_plan" ) != { "confirmed" : True , "fingerprint" : fingerprint}
152 or result.get( "source_evidence" , {}).get( "boundary" ) != prior[ "boundary" ]
153 or result.get( "source_evidence" , {}).get( "inventory_digest" ) != prior[ "inventory_digest" ]
154 ):
155 raise _failure( "reuse-evidence-invalid" , "Retained records must prove the exact approved source creation." )
156
157
158 def _connection_binding (connection, boundary):
159 if connection is None or isinstance (connection, str ) and connection.lower() in ( "" , "<redacted>" ):
160 return "concealed"
161 if isinstance (connection, str ) and connection.startswith( "ResourceId=" ):
162 if connection.removesuffix( ";" ) != f "ResourceId= { boundary[ 'storage_id' ] } " :
163 raise _failure( "boundary-mismatch" , "Observed connection targets a different Storage account." )
164 return "visible"
165 raise _failure( "source-binding-conflict" , "Observed credentials are not the selected keyless binding or a recognized concealed value; details withheld." )
166
167
168 def _verify_storage_binding (
169 source: dict[ str , Any], boundary: dict[ str , Any], current: dict[ str , Any],
170 generated: list[dict[ str , Any]],
171 load_receipts: Callable[[], tuple[dict[ str , Any], dict[ str , Any]] | None ],
172 ) -> None :
173 connection = current[ "azureBlobParameters" ].get( "connectionString" )
174 if _connection_binding(connection, boundary) == "visible" :
175 return
176 receipts = load_receipts()
177 if receipts is None :
178 raise _failure(
179 "source-binding-unverified" ,
180 "Source account binding is unknown: GET the exact source definition or select its retained approved input/successful creation result pair. Redaction is not a mismatch; snippets are not binding proof." ,
181 )
182 _verify_creation_binding(source, boundary, current, generated, receipts)
183
184
185 def _verify_creation_binding (
186 source: dict[ str , Any], boundary: dict[ str , Any], current: dict[ str , Any],
187 generated: list[dict[ str , Any]], receipts: tuple[dict[ str , Any], dict[ str , Any]],
188 ) -> None :
189 prior, result = receipts
190 observed = {
191 "type" : "knowledge-source" , "name" : source[ "name" ], "etag" : current[ "@odata.etag" ],
192 "definition_digest" : digest(_search_read._definition(current)),
193 }
194 if (
195 prior[ "boundary" ] != boundary
196 or any (prior[ "source" ].get(field) != source[field] for field in ( "endpoint" , "name" , "api_version" ))
197 or not _search_read.definitions_match(prior[ "source" ][ "desired" ], current)
198 or result.get( "resources" , {}).get( "created" ) != [observed]
199 or result.get( "verification" , {}).get( "readback" ) != observed
200 or result.get( "source" , {}).get( "generated" ) != generated
201 ):
202 raise _failure( "source-binding-unverified" , "Retained creation evidence does not match the fresh source identity/ETag." )
203
204
205 def _failure (code: str , message: str , * , request_id: str | None = None ) -> HelperFailure:
206 return HelperFailure(code, message, blocked_at = "verification" , request_id = request_id)
207
208
209 def _timestamp (value: Any) -> datetime:
210 if not isinstance (value, str ):
211 raise _failure( "ingestion-status-invalid" , "Synchronization timestamp is missing." )
212 try :
213 parsed = datetime.fromisoformat(value.replace( "Z" , "+00:00" ))
214 except ValueError as exc:
215 raise _failure( "ingestion-status-invalid" , "Synchronization timestamp is invalid." ) from exc
216 if parsed.tzinfo is None :
217 raise _failure( "ingestion-status-invalid" , "Synchronization timestamp needs a time zone." )
218 return parsed
219
220
221 def _poll_limits (value: Any) -> dict[ str , int ]:
222 if not isinstance (value, dict ):
223 raise _failure( "input-schema-invalid" , "poll must be an object." )
224 require_allowed_fields(
225 value, { "deadline_seconds" , "max_requests" , "interval_seconds" }, label = "poll"
226 )
227 for field, maximum in (
228 ( "deadline_seconds" , 3600 ), ( "max_requests" , 1000 ), ( "interval_seconds" , 60 )
229 ):
230 if type (value.get(field)) is not int or not 1 <= value[field] <= maximum:
231 raise _failure( "input-schema-invalid" , "Polling limits must be bounded positive integers." )
232 return value
233
234
235 def _validate_plan (plan: dict[ str , Any]) -> tuple[dict[ str , Any], dict[ str , Any]]:
236 reject_secrets(plan)
237 require_allowed_fields(
238 plan,
239 { "operation" , "owner" , "cleanup_approved" , "source" , "boundary" , "inventory_digest" ,
240 "inventory_limits" , "poll" , "expected_generated" , "expected_source_absent" , "embedding" ,
241 "content_understanding" , "cu_plan_version" },
242 label = "Blob source plan" ,
243 )
244 if plan.get( "operation" ) != "reconcile-and-monitor" or plan.get( "cleanup_approved" ) is not False :
245 raise _failure( "operation-invalid" , "Blob application requires reconcile-and-monitor without cleanup." )
246 source = plan.get( "source" )
247 if not isinstance (source, dict ) or not isinstance (plan.get( "owner" ), str ) or not plan[ "owner" ]:
248 raise _failure( "input-schema-invalid" , "A source plan and owner are required." )
249 if (
250 source.get( "operation" ) != "reconcile"
251 or source.get( "resource_type" ) != "knowledge-source"
252 or source.get( "action" ) not in ( "create" , "reuse" )
253 or not isinstance (source.get( "api_version" ), str )
254 or source.get( "owner" ) != plan[ "owner" ]
255 or not isinstance (source.get( "desired" ), dict )
256 or source[ "desired" ].get( "kind" ) != "azureBlob"
257 or source.get( "ai_services_api_key_environment" ) is not None
258 or source.get( "ai_services_key_acquisition" ) is not None
259 ):
260 raise _failure( "step-contract-mismatch" , "Only same-owner Blob creation or exact reuse is supported." )
261 search_reconcile._validate_plan(source)
262 _search_read._resource_url(source)
263 boundary = blob_inventory.validate_boundary(plan.get( "boundary" ))
264 blob_inventory.validate_limits(plan.get( "inventory_limits" ))
265 _poll_limits(plan.get( "poll" ))
266 if "expected_source_absent" in plan and (
267 plan[ "expected_source_absent" ] is not True or source[ "action" ] != "create"
268 or source.get( "expected_etag" ) is not None
269 ):
270 raise _failure( "step-contract-mismatch" , "Expected absence is only valid for a creation plan." )
271 if "expected_generated" in plan and (
272 source[ "action" ] != "reuse"
273 or not isinstance (plan[ "expected_generated" ], list )
274 or len (plan[ "expected_generated" ]) != 4
275 or any ( not isinstance (item, dict ) or set (item) != { "type" , "name" , "service_managed" }
276 or item[ "service_managed" ] is not True
277 or not isinstance (item[ "type" ], str )
278 or not isinstance (item[ "name" ], str )
279 or re.fullmatch( r " [ a-zA-Z0-9 ][ a-zA-Z0-9_- ] {0,127} " , item[ "name" ]) is None
280 for item in plan[ "expected_generated" ])
281 or {item[ "type" ] for item in plan[ "expected_generated" ]} != { "datasource" , "indexer" , "skillset" , "index" }
282 ):
283 raise _failure( "generated-resources-unverified" , "Expected generated identities require a complete reuse plan." )
284 parameters = source[ "desired" ][ "azureBlobParameters" ]
285 if (
286 parameters[ "connectionString" ].removesuffix( ";" ) != f "ResourceId= { boundary[ 'storage_id' ] } "
287 or parameters[ "containerName" ] != boundary[ "container" ]
288 or parameters[ "folderPath" ] != (boundary[ "prefix" ] or None )
289 or parameters[ "isADLSGen2" ] != boundary[ "is_adls" ]
290 or "createdResources" in parameters
291 or source[ "source_evidence" ][ "inventory_digest" ] != plan.get( "inventory_digest" )
292 ):
293 raise _failure( "boundary-mismatch" , "Source definition must bind the exact discovered boundary and inventory." )
294 if (
295 not isinstance (plan.get( "inventory_digest" ), str )
296 or _search_read. SHA256 .fullmatch(plan[ "inventory_digest" ]) is None
297 ):
298 raise _failure( "source-evidence-invalid" , "The approved inventory digest is required." )
299 ingestion = parameters.get( "ingestionParameters" )
300 if not isinstance (ingestion, dict ) or ingestion.get( "assetStore" ) is not None :
301 raise _failure(
302 "source-write-forbidden" ,
303 "Ingestion parameters are required; asset-store writes are outside this read-only-source workflow." ,
304 )
305 _embedding.validate_plan_choice(plan)
306 standard = ingestion.get( "contentExtractionMode" ) == "standard"
307 if "cu_plan_version" in plan and (plan[ "cu_plan_version" ] != "1.0" or not standard):
308 raise _failure( "cu-plan-version-invalid" , "CU planner artifacts require version 1.0 and standard extraction." )
309 # Absence/generated guards predate CU planning and remain valid on legacy
310 # wire-only artifacts. Only CU-specific provenance selects the new contract.
311 planner_cu = standard and any (field in plan for field in (
312 "cu_plan_version" , "embedding" , "content_understanding" ,
313 ))
314 cu = validate_content_understanding(
315 plan.get( "content_understanding" ), enabled = planner_cu,
316 )
317 if cu is not None :
318 expected = {
319 "contentExtractionMode" : "standard" , "disableImageVerbalization" : True ,
320 "identity" : None , "ingestionSchedule" : None ,
321 "aiServices" : { "uri" : cu[ "endpoint" ].rstrip( "/" )},
322 }
323 if "embedding" in plan:
324 expected[ "embeddingModel" ] = model_definition(plan[ "embedding" ])
325 if source[ "api_version" ].endswith( "-preview" ):
326 expected.update( networkAccessMode = "public" , ingestionPermissionOptions = [])
327 if ingestion != expected or boundary[ "is_adls" ] and source[ "api_version" ] != "2026-08-01-preview" :
328 raise _failure( "cu-plan-mismatch" , "Standard CU requires the unchanged keyless public definition, matching optional embeddings and no chat, asset store, permissions or schedule." )
329 return source, boundary
330
331
332 def _first_error (state: dict[ str , Any]) -> dict[ str , Any] | None :
333 errors = state.get( "errors" )
334 if errors is None or errors == []:
335 return None
336 if not isinstance (errors, list ) or any ( not isinstance (error, dict ) for error in errors):
337 raise _failure( "ingestion-status-invalid" , "Ingestion errors have an invalid shape." )
338 error = errors[ 0 ]
339 # Document errors can contain content, SAS URLs, and credentials in free text.
340 return {
341 "status" : error.get( "statusCode" ) if type (error.get( "statusCode" )) is int else None ,
342 "message" : "Document-level ingestion error; sensitive service text withheld." ,
343 "diagnostic_digest" : digest(error),
344 }
345
346
347 def _synchronization_state (state, request_id = None ):
348 if state is None :
349 return None
350 if not isinstance (state, dict ):
351 raise _failure( "ingestion-status-invalid" , "Synchronization state must be an object." , request_id = request_id)
352 return dict (state)
353
354
355 def _retry_after (value, * , now = None ):
356 now = now or datetime.now(timezone.utc)
357 kind = getattr (value, "kind" , None )
358 try :
359 if kind is not None :
360 if kind == "overlong" :
361 return math.inf
362 payload = getattr (value, "value" , None )
363 if ( not isinstance (kind, str ) or kind not in { "seconds" , "date" , "date-rfc850" }
364 or type (payload) not in ( int , float )
365 or ( type (payload) is float and not math.isfinite(payload))):
366 return None
367 if kind == "seconds" :
368 return payload if payload >= 0 and payload == int (payload) else None
369 parsed = datetime.fromtimestamp(payload, timezone.utc)
370 obsolete_date = kind == "date-rfc850"
371 else :
372 if not isinstance (value, str ) or len (value) > 128 :
373 return None
374 if value.isascii() and value.isdigit() and len (value) <= 10 :
375 return int (value)
376 parsed = parsedate_to_datetime(value)
377 obsolete_date = bool (re.fullmatch( r " [ A-Za-z ] + , \d {2} - [ A-Za-z ] {3} - \d {2} \d {2} : \d {2} : \d {2} GMT" , value))
378 if obsolete_date:
379 year = now.year // 100 * 100 + parsed.year % 100
380 if year > now.year + 50 :
381 year -= 100
382 parsed = parsed.replace( year = year)
383 if parsed.tzinfo is not None :
384 return max ( 0 , (parsed - now).total_seconds())
385 except ( ValueError , OverflowError , TypeError , OSError ):
386 return None
387 return None
388
389
390 def _execution_progress (body, not_before):
391 if ( not isinstance (body, dict ) or not isinstance (body.get( "status" ), str )
392 or body[ "status" ] not in { "running" , "error" , "unknown" }):
393 raise _failure( "indexer-status-invalid" , "Indexer status must be an object." )
394 if body[ "status" ] == "error" :
395 raise _failure( "indexer-status-error" , "Indexer availability reports an error; execution success is not inferred." )
396 run = body.get( "lastResult" )
397 if run is None :
398 return None
399 if not isinstance (run, dict ):
400 raise _failure( "indexer-status-invalid" , "Latest indexer execution must be an object." )
401 start = _timestamp(run.get( "startTime" ))
402 if start < not_before:
403 return None
404 status = run.get( "status" )
405 if not isinstance (status, str ) or status not in { "inProgress" , "success" , "transientFailure" , "persistentFailure" , "reset" }:
406 raise _failure( "indexer-status-invalid" , "Unknown latest execution status; top-level running is not execution proof." )
407 result = { "startTime" : run[ "startTime" ], "run_status" : status}
408 if run.get( "endTime" ) is not None :
409 if _timestamp(run[ "endTime" ]) < start:
410 raise _failure( "indexer-status-invalid" , "Indexer execution interval is invalid." )
411 result[ "endTime" ] = run[ "endTime" ]
412 for field, target in (( "itemsProcessed" , "items_attempted" ), ( "itemsFailed" , "items_failed" )):
413 if field in run:
414 if type (run[field]) is not int or run[field] < 0 :
415 raise _failure( "indexer-status-invalid" , "Indexer execution counters are invalid." )
416 result[target] = run[field]
417 if result.get( "items_failed" , 0 ) > result.get( "items_attempted" , result.get( "items_failed" , 0 )):
418 raise _failure( "indexer-status-invalid" , "Failed item count exceeds attempted items." )
419 errors = run.get( "errors" , [])
420 if errors is not None and ( not isinstance (errors, list ) or any ( not isinstance (item, dict ) for item in errors)):
421 raise _failure( "indexer-status-invalid" , "Indexer execution errors are malformed." )
422 result[ "error_count" ] = len (errors or [])
423 return result
424
425
426 def _execution_completed_by (execution, end, not_before):
427 required = { "startTime" , "endTime" , "run_status" , "error_count" }
428 if ( not isinstance (execution, dict ) or not required <= execution.keys()
429 or execution.keys() - required - { "items_attempted" , "items_failed" }
430 or execution[ "run_status" ] != "success"
431 or type (execution[ "error_count" ]) is not int or execution[ "error_count" ] != 0
432 or any ( type (execution[key]) is not int or execution[key] < 0
433 for key in ( "items_attempted" , "items_failed" ) if key in execution)
434 or execution.get( "items_failed" , 0 )):
435 return False
436 try :
437 return not_before <= _timestamp(execution[ "startTime" ]) <= _timestamp(execution[ "endTime" ]) <= end
438 except HelperFailure:
439 return False
440
441
442 @reporting ( "blob-monitor" )
443 def monitor (
444 source: dict[ str , Any],
445 * ,
446 not_before: datetime,
447 limits: dict[ str , int ],
448 token_provider: TokenProvider,
449 transport: Transport,
450 require_new_cycle: bool = False ,
451 excluded_cycle: list[ str ] | None = None ,
452 monotonic: Callable[[], float ] = time.monotonic,
453 sleep: Callable[[ float ], None ] = time.sleep,
454 progress: Progress | None = None ,
455 cancelled: Callable[[], bool ] = lambda : False ,
456 indexer_name: str | None = None ,
457 ) -> dict[ str , Any]:
458 _poll_limits(limits)
459 progress.update( "ingestion-cycle" )
460 excluded = tuple (_timestamp(value) for value in excluded_cycle) if excluded_cycle is not None else None
461 url = (
462 f " { source[ 'endpoint' ].rstrip( '/' ) } /knowledgesources(' { _search_read._odata_name(source[ 'name' ]) } ')/status?"
463 + urlencode({ "api-version" : source[ "api_version" ]})
464 )
465 token = token_provider( SEARCH_AUDIENCE )
466 started = monotonic()
467 deadline = started + limits[ "deadline_seconds" ]
468 request_ids: list[ str ] = []
469 first_error: dict[ str , Any] | None = None
470 first_retry: dict[ str , Any] | None = None
471 observed = "no-completed-cycle"
472 initial_cycle: tuple[Any, Any] | None = None
473 first_response = True
474 checks = 0
475 delay = limits[ "interval_seconds" ]
476 latest = {}
477 previous = None
478 previous_execution = None
479 execution_guard = None
480 pause_reason = "request-limit"
481 newest_run = None
482 synchronization_status = "not-reported"
483
484 def report (phase, next_check = None ):
485 value = {
486 "schema_version" : "1.0" , "phase" : phase,
487 "run_start" : _timestamp(latest[ "startTime" ]).isoformat() if latest.get( "startTime" ) else None ,
488 "processed" : latest.get( "itemsUpdatesProcessed" ), "failed" : latest.get( "itemsUpdatesFailed" ),
489 "skipped" : latest.get( "itemsSkipped" ), "unit" : "item-updates" ,
490 "total" : None , "remaining" : None , "denominator" : "not-comparable-to-files" ,
491 "synchronization_status" : synchronization_status,
492 "elapsed_seconds" : max ( 0 , monotonic() - started), "next_check_seconds" : next_check,
493 }
494 progress.counts[ "status_checks" ] = checks
495 progress.blob_update(value)
496 return value
497
498 def failure_result ( * args):
499 result = _readiness_failure( * args)
500 result[ "progress" ] = report( "failed" )
501 result[ "watch" ] = { "schema_version" : "1.0" , "state" : "blocked" , "reason" : "evidence-failure" ,
502 "elapsed_seconds" : max ( 0 , monotonic() - started),
503 "status_checks" : checks, "latest" : copy.deepcopy(latest)}
504 return result
505
506 def read_status (target):
507 nonlocal checks, retry_after
508 remaining = deadline - monotonic()
509 if remaining <= 0 :
510 raise _failure( "ingestion-watch-expired" , "The client watch window elapsed before this read." )
511 checks += 1
512 response = transport(
513 "GET" , target, token, timeout = min ( 30 , remaining),
514 max_response_bytes = 8 * 1024 * 1024 , follow_redirects = False ,
515 response_deadline = monotonic() + min ( 30 , remaining),
516 )
517 if response.request_id:
518 request_ids.append(response.request_id)
519 if response.status != 200 :
520 value = getattr (response, "retry_after" , None )
521 if value is None :
522 value = next ((v for k, v in response.headers.items() if k.lower() == "retry-after" ), None )
523 retry_after = _retry_after(value)
524 raise HelperFailure( "ingestion-inaccessible" , "Status read is inaccessible." ,
525 blocked_at = "verification" , status = response.status, request_id = response.request_id)
526 return response
527
528 while checks < limits[ "max_requests" ]:
529 remaining = deadline - monotonic()
530 if cancelled():
531 pause_reason = "cancelled"
532 break
533 if remaining <= 0 :
534 pause_reason = "deadline"
535 break
536 retry_after = None
537 throttled = False
538 try :
539 response = read_status(url)
540 except KeyboardInterrupt :
541 pause_reason = "cancelled"
542 break
543 except HelperFailure as failure:
544 if failure.request_id and failure.request_id not in request_ids:
545 request_ids.append(failure.request_id)
546 if failure.code == "response-deadline-exceeded" :
547 first_retry = first_retry or { "code" : failure.code, "status" : failure.http_status,
548 "request_id" : failure.request_id}
549 if monotonic() >= deadline:
550 pause_reason = "deadline"
551 break
552 if failure.code == "ingestion-watch-expired" :
553 pause_reason = "deadline"
554 break
555 if retry_after is None :
556 retry_after = _retry_after(failure.retry_after)
557 if (failure.http_status not in RETRY_STATUS
558 and failure.code not in { "azure-response-ambiguous" , "response-deadline-exceeded" }):
559 return failure_result( "ingestion-inaccessible" , failure.request_id, request_ids,
560 first_error, first_retry, failure.http_status)
561 if first_retry is None :
562 first_retry = { "code" : failure.code, "status" : failure.http_status,
563 "request_id" : failure.request_id}
564 throttled = failure.http_status == 429
565 delay = min ( 60 , max (limits[ "interval_seconds" ], delay * 2 ))
566 else :
567 if monotonic() >= deadline:
568 pause_reason = "deadline"
569 break
570 body = response.body
571 if not isinstance (body, dict ) or body.get( "kind" ) != "azureBlob" :
572 raise _failure( "ingestion-status-invalid" , "Expected azureBlob status." , request_id = response.request_id)
573 synchronization_status = body.get( "synchronizationStatus" , "not-reported" )
574 if synchronization_status not in ( "not-reported" , "active" , "creating" , "deleting" ):
575 raise _failure( "ingestion-status-invalid" , "Unknown knowledge-source synchronization availability." ,
576 request_id = response.request_id)
577 current = _synchronization_state(body.get( "currentSynchronizationState" ), response.request_id)
578 last = _synchronization_state(body.get( "lastSynchronizationState" ), response.request_id)
579 for state in (current, last):
580 if state is not None :
581 _timestamp(state.get( "startTime" ))
582 _first_error(state)
583 state = current or last
584 start = _timestamp(state[ "startTime" ]) if state else None
585 if start is not None and start >= not_before and (newest_run is None or start >= newest_run):
586 newest_run = start
587 error = _first_error(state)
588 if first_error is None and error:
589 first_error = { ** error, "request_id" : response.request_id}
590 latest = { "startTime" : state[ "startTime" ],
591 "state" : "in-progress" if current else "completed-cycle-observed" }
592 for field in ( "itemsUpdatesProcessed" , "itemsUpdatesFailed" , "itemsSkipped" ):
593 if field in state:
594 if type (state[field]) is not int or state[field] < 0 :
595 raise _failure( "ingestion-status-invalid" , "Observed synchronization counters are invalid." ,
596 request_id = response.request_id)
597 latest[field] = state[field]
598 if current:
599 observed = "in-progress"
600 signature = digest(latest)
601 delay = (limits[ "interval_seconds" ] if signature != previous
602 else min ( 60 , delay * 2 ))
603 previous = signature
604 else :
605 latest = {}
606 observed = "stale-success-or-unrelated-cycle" if state else "no-completed-cycle"
607 signature = digest({})
608 delay = limits[ "interval_seconds" ] if signature != previous else min ( 60 , delay * 2 )
609 previous = signature
610 if execution_guard is not None :
611 latest[ "indexer_execution" ] = copy.deepcopy(execution_guard)
612 if first_response and require_new_cycle and last:
613 initial_cycle = (last.get( "startTime" ), last.get( "endTime" ))
614 first_response = False
615 if (
616 last and last.get( "endTime" ) is not None
617 and _timestamp(last.get( "startTime" )) >= max (not_before, newest_run or not_before)
618 and (last.get( "startTime" ), last.get( "endTime" )) != initial_cycle
619 and (excluded is None or (_timestamp(last.get( "startTime" )), _timestamp(last.get( "endTime" ))) != excluded)
620 ):
621 status = last.get( "status" )
622 if status is not None and not isinstance (status, str ):
623 raise _failure( "ingestion-status-invalid" , "Synchronization status must be a string when present." )
624 if status in { "failure" , "partialSuccess" }:
625 return failure_result( f "ingestion- { status } " , response.request_id,
626 request_ids, first_error, first_retry)
627 if status in { None , "success" }:
628 start = _timestamp(last.get( "startTime" ))
629 end = _timestamp(last.get( "endTime" ))
630 failed = last.get( "itemsUpdatesFailed" )
631 processed = last.get( "itemsUpdatesProcessed" )
632 skipped = last.get( "itemsSkipped" )
633 if end < start or any ( type (count) is not int or count < 0 for count in (failed, processed, skipped)):
634 raise _failure( "ingestion-status-invalid" , "Completed synchronization counters or interval are invalid." )
635 if failed or first_error:
636 return failure_result( "ingestion-partialSuccess" , response.request_id,
637 request_ids, first_error, first_retry)
638 if ( not current and synchronization_status in ( "active" , "not-reported" )
639 and (execution_guard is None
640 or _execution_completed_by(execution_guard, end, not_before))):
641 return {
642 "status" : "verified" ,
643 "progress" : report( "completed" ),
644 "synchronization" : {
645 field: last[field] for field in
646 ( "startTime" , "endTime" , "itemsUpdatesProcessed" , "itemsUpdatesFailed" , "itemsSkipped" )
647 },
648 "not_before" : not_before.isoformat(),
649 "request_ids" : request_ids,
650 "first_retry" : first_retry,
651 "watch" : { "schema_version" : "1.0" , "state" : "completed" ,
652 "elapsed_seconds" : max ( 0 , monotonic() - started),
653 "status_checks" : checks, "latest" : latest},
654 }
655 else :
656 raise _failure( "ingestion-status-invalid" , "Unknown synchronization completion status." )
657 elif last and not current:
658 observed = "stale-success-or-unrelated-cycle"
659 if indexer_name and checks < limits[ "max_requests" ] and monotonic() < deadline and not cancelled():
660 target = ( f " { source[ 'endpoint' ].rstrip( '/' ) } /indexers(' { _search_read._odata_name(indexer_name) } ')/status?"
661 + urlencode({ "api-version" : source[ "api_version" ]}))
662 run_response = None
663 try :
664 run_response = read_status(target)
665 cutoff = max (not_before, newest_run or not_before)
666 if execution_guard is not None :
667 cutoff = min (cutoff, _timestamp(execution_guard[ "startTime" ]))
668 execution = _execution_progress(run_response.body, cutoff)
669 if (execution is not None and execution_guard is not None
670 and _timestamp(execution[ "startTime" ]) < _timestamp(execution_guard[ "startTime" ])):
671 execution = None
672 if execution is not None :
673 if execution_guard is not None or execution[ "run_status" ] == "inProgress" :
674 execution_guard = copy.deepcopy(execution)
675 execution_signature = digest(execution)
676 if execution_signature != previous_execution:
677 delay = limits[ "interval_seconds" ]
678 previous_execution = execution_signature
679 latest[ "indexer_execution" ] = execution
680 if (execution.get( "items_failed" , 0 ) or execution[ "error_count" ]
681 or execution[ "run_status" ] in { "transientFailure" , "persistentFailure" }):
682 first_error = first_error or {
683 "status" : None , "message" : "Latest relevant indexer execution reports failures; details withheld." ,
684 "request_id" : run_response.request_id,
685 }
686 except KeyboardInterrupt :
687 pause_reason = "cancelled"
688 break
689 except HelperFailure as failure:
690 if failure.request_id is None and run_response is not None :
691 failure.request_id = run_response.request_id
692 if failure.request_id and failure.request_id not in request_ids:
693 request_ids.append(failure.request_id)
694 if failure.code == "response-deadline-exceeded" :
695 first_retry = first_retry or { "code" : failure.code, "status" : failure.http_status,
696 "request_id" : failure.request_id}
697 if monotonic() >= deadline:
698 pause_reason = "deadline"
699 break
700 if failure.code == "ingestion-watch-expired" :
701 pause_reason = "deadline"
702 break
703 if (failure.http_status not in RETRY_STATUS
704 and failure.code not in { "azure-response-ambiguous" , "response-deadline-exceeded" }):
705 code = failure.code if failure.code in { "indexer-status-invalid" , "indexer-status-error" } else "indexer-status-unverified"
706 return failure_result(code, failure.request_id, request_ids,
707 first_error, first_retry, failure.http_status)
708 first_retry = first_retry or { "code" : failure.code, "status" : failure.http_status,
709 "request_id" : failure.request_id}
710 throttled = failure.http_status == 429
711 if retry_after is None :
712 retry_after = _retry_after(failure.retry_after)
713 delay = min ( 60 , delay * 2 )
714 remaining = deadline - monotonic()
715 if cancelled():
716 pause_reason = "cancelled"
717 break
718 if retry_after is not None and retry_after > min ( 60 , remaining):
719 report( "throttled" if throttled else "waiting" )
720 pause_reason = "retry-after"
721 break
722 if remaining > 0 and checks < limits[ "max_requests" ]:
723 try :
724 wait = min ( max (delay, retry_after or 0 ), remaining)
725 report( "throttled" if throttled else
726 "ingesting" if observed == "in-progress" else "waiting" , wait)
727 sleep(wait)
728 except KeyboardInterrupt :
729 pause_reason = "cancelled"
730 break
731 if monotonic() >= deadline:
732 pause_reason = "deadline"
733 result = _readiness_failure(
734 "ingestion-timeout" , request_ids[ - 1 ] if request_ids else None ,
735 request_ids, first_error, first_retry,
736 )
737 result[ "observed" ] = observed
738 result[ "watch" ] = { "schema_version" : "1.0" , "state" : "paused" , "reason" : pause_reason,
739 "elapsed_seconds" : max ( 0 , monotonic() - started),
740 "status_checks" : checks, "latest" : latest,
741 "service_execution" : "not-modified" }
742 if first_error or latest.get( "itemsUpdatesFailed" ):
743 result[ "code" ] = "ingestion-partialSuccess"
744 result[ "progress" ] = report( "failed" if result[ "code" ] == "ingestion-partialSuccess" else "paused" )
745 result[ "safe_next_decision" ] = (
746 "Service reports item failures; retain the partial work and inspect exact private error evidence read-only. "
747 "A new watch does not repair failures; do not replay writes or default to cleanup."
748 ) if result[ "code" ] == "ingestion-partialSuccess" else (
749 "Client watch paused; this is not an ingestion failure. Resume GET-only with the unchanged input "
750 "and readiness receipt using blob_recheck.py --input <original-input> --receipt <checkpoint>. "
751 "Do not recreate, run/reset, delete, or infer corpus completion."
752 )
753 return result
754
755
756 def _readiness_failure (
757 code: str , request_id: str | None , request_ids: list[ str ],
758 first_error: dict[ str , Any] | None , first_retry: dict[ str , Any] | None ,
759 status: int | None = None ,
760 ) -> dict[ str , Any]:
761 return {
762 "status" : "unverified" , "code" : code, "request_id" : request_id,
763 "http_status" : status, "request_ids" : request_ids,
764 "first_error" : first_error, "first_retry" : first_retry,
765 }