Coverage for async-durable-execution/src/async_durable_execution/state.py: 99%

262 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 16:54 +0000

1"""Model for execution state.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import json 

7import logging 

8from collections import deque 

9import time 

10from dataclasses import dataclass 

11from typing import TYPE_CHECKING, Any, cast 

12 

13from .exceptions import ( 

14 DurableExecutionsError, 

15 GetExecutionStateError, 

16) 

17from .models import ( 

18 Operation, 

19 OperationAction, 

20 OperationType, 

21 OperationUpdate, 

22) 

23from .client import DurableServiceClient 

24from .primitive.child import OrphanedChildException 

25 

26 

27if TYPE_CHECKING: 

28 from collections.abc import MutableMapping 

29 from .models import LambdaContext 

30 

31logger = logging.getLogger(__name__) 

32 

33 

34@dataclass(frozen=True) 

35class CheckpointBatcherConfig: 

36 """Configuration for checkpoint batching behavior. 

37 

38 Attributes: 

39 max_batch_size_bytes: Maximum batch size in bytes (default: 750KB) 

40 max_batch_time_seconds: Maximum time to wait before flushing batch (default: 1.0 second) 

41 max_batch_operations: Maximum number of operations per batch (default: 250) 

42 """ 

43 

44 max_batch_size_bytes: int = 750 * 1024 # 750KB 

45 max_batch_time_seconds: float = 1.0 

46 max_batch_operations: int = 250 

47 

48 

49@dataclass(frozen=True) 

50class QueuedOperation: 

51 """Wrapper for operations in the checkpoint queue. 

52 

53 Attributes: 

54 operation_update: The operation update to be checkpointed, or None for empty checkpoints 

55 completion_future: Completion future for synchronous operations, or None for async operations 

56 """ 

57 

58 operation_update: OperationUpdate | None 

59 completion_future: asyncio.Future[Operation | None] | None = None 

60 

61 

62def _completion_done(completion) -> bool: 

63 return completion is None or completion.done() 

64 

65 

66def _completion_set_result(completion, operation: Operation | None = None) -> None: 

67 if completion is None: 

68 return 

69 completion.set_result(operation) 

70 

71 

72def _completion_set_exception(completion, error: Exception) -> None: 

73 if completion is None: 

74 return 

75 completion.set_exception(error) 

76 

77 

78class ExecutionState: 

79 """Get, set and maintain execution state. This is mutable. Create and check checkpoints.""" 

80 

81 # Keep operations visible on the class for Mock(spec=ExecutionState). 

82 operations: MutableMapping[str, Operation] = cast( 

83 "MutableMapping[str, Operation]", None 

84 ) 

85 

86 def __init__( 

87 self, 

88 durable_execution_arn: str, 

89 initial_checkpoint_token: str, 

90 service_client: DurableServiceClient, 

91 lambda_context: LambdaContext | None = None, 

92 batcher_config: CheckpointBatcherConfig | None = None, 

93 operations: MutableMapping[str, Operation] | None = None, 

94 ): 

95 self.operations: MutableMapping[str, Operation] = dict(operations or {}) 

96 self.durable_execution_arn: str = durable_execution_arn 

97 self.lambda_context: LambdaContext | None = lambda_context 

98 self._current_checkpoint_token: str = initial_checkpoint_token 

99 self._service_client: DurableServiceClient = service_client 

100 

101 # Checkpoint batching configuration 

102 self._batcher_config = batcher_config or CheckpointBatcherConfig() 

103 

104 # Checkpoint batching components 

105 self._checkpoint_queue: asyncio.Queue[QueuedOperation | None] = asyncio.Queue() 

106 self._overflow_queue: deque[QueuedOperation] = deque() 

107 self._checkpointing_stopped = asyncio.Event() 

108 self._checkpointing_failed = asyncio.Event() 

109 self._checkpointing_failure: Exception | None = None 

110 self._checkpointing_task: asyncio.Task[None] | None = None 

111 

112 # Concurrency management for parallel operations: parent_id -> {child_operation_ids} 

113 self._parent_to_children: dict[str, set[str]] = {} 

114 

115 # Operations whose parent has completed 

116 self._parent_done: set[str] = set() 

117 

118 async def initialize(self, invocation_input): 

119 await self.fetch_paginated_operations( 

120 invocation_input.initial_execution_state.operations, 

121 invocation_input.checkpoint_token, 

122 invocation_input.initial_execution_state.next_marker, 

123 ) 

124 

125 async def fetch_paginated_operations( 

126 self, 

127 initial_operations: list[Operation], 

128 checkpoint_token: str, 

129 next_marker: str | None, 

130 ) -> list[Operation]: 

131 """Add initial operations and fetch all paginated operations from the Durable Functions API. 

132 

133 The checkpoint_token is passed explicitly as a parameter rather than using the 

134 instance variable so pagination continues from the correct checkpoint state. 

135 

136 Args: 

137 initial_operations: initial operations to be added to ExecutionState 

138 checkpoint_token: checkpoint token used to call Durable Functions API. 

139 next_marker: a marker indicates that there are paginated operations. 

140 Returns: 

141 List of all operations fetched from the Durable Functions API 

142 

143 Raises: 

144 GetExecutionStateError: If the API call fails. The error is logged 

145 with structured extras before re-raising. Callers are responsible 

146 for deciding whether to fail the execution or allow Lambda retry 

147 based on is_retryable(). 

148 """ 

149 all_operations: list[Operation] = ( 

150 initial_operations.copy() if initial_operations else [] 

151 ) 

152 try: 

153 while next_marker: 

154 output = await self._service_client.get_execution_state( 

155 durable_execution_arn=self.durable_execution_arn, 

156 checkpoint_token=checkpoint_token, 

157 next_marker=next_marker, 

158 ) 

159 all_operations.extend(output.operations) 

160 next_marker = output.next_marker 

161 except GetExecutionStateError as e: 

162 logger.exception( 

163 "Durable API error during state fetch.", 

164 extra=e.build_logger_extras(), 

165 ) 

166 raise 

167 finally: 

168 # Always store whatever operations we successfully fetched 

169 if all_operations: 

170 self.operations.update({op.operation_id: op for op in all_operations}) 

171 return all_operations 

172 

173 def get_raw_input_payload(self) -> str | None: 

174 # It is possible that backend will not provide an execution operation 

175 # for the initial page of results. 

176 if not (operations := self.get_execution_operation()): 

177 return None 

178 if not (execution_details := operations.execution_details): 

179 return None 

180 return execution_details.input_payload 

181 

182 def get_input_event(self): 

183 # Python RIC LambdaMarshaller just uses standard json deserialization for event 

184 # https://github.com/aws/aws-lambda-python-runtime-interface-client/blob/main/awslambdaric/lambda_runtime_marshaller.py#L46 

185 raw_input_payload: str | None = self.get_raw_input_payload() 

186 input_event: Any = {} 

187 if raw_input_payload and raw_input_payload.strip(): 

188 try: 

189 input_event = json.loads(raw_input_payload) 

190 except json.JSONDecodeError: 

191 logger.exception( 

192 "Failed to parse input payload as JSON: payload: %r", 

193 raw_input_payload, 

194 ) 

195 raise 

196 return input_event 

197 

198 def get_execution_operation(self) -> Operation | None: 

199 # invocation id is id of execution operation 

200 invocation_id = self.durable_execution_arn.split("/")[-1] 

201 candidate = self.operations.get(invocation_id) 

202 if not candidate: 

203 # Due to payload size limitations we may have an empty operations list. 

204 # This will only happen when loading the initial page of results and is 

205 # expected behaviour. We don't fail, but instead return None 

206 # as the execution operation does not exist 

207 msg: str = "No durable operations found in execution state." 

208 logger.debug(msg) 

209 return None 

210 if candidate.operation_type is not OperationType.EXECUTION: 

211 msg = f"The execution operation in execution state does not have EXECUTION type: {candidate.operation_type}" 

212 raise DurableExecutionsError(msg) 

213 

214 return candidate 

215 

216 def has_prior_operations(self) -> bool: 

217 """Return True if loaded state contains any non-execution operation.""" 

218 return any( 

219 op.operation_type is not OperationType.EXECUTION 

220 for op in tuple(self.operations.values()) 

221 ) 

222 

223 async def create_checkpoint( 

224 self, 

225 operation_update: OperationUpdate | None = None, 

226 is_sync: bool = True, # noqa: FBT001, FBT002 

227 ) -> Operation | None: 

228 """Create a checkpoint with optional synchronous behavior. 

229 

230 This method enqueues a checkpoint operation for processing by the background 

231 batching thread. By default, the operation is synchronous (blocking) to ensure 

232 the checkpoint is persisted before continuing. For performance-critical paths 

233 where immediate confirmation is not required, set is_sync=False. 

234 

235 Synchronous checkpoints (is_sync=True, default): 

236 - Block the caller until the checkpoint is processed by the background thread 

237 - Ensure the checkpoint is persisted before continuing 

238 - Safe default for correctness 

239 - Use cases: Most operations requiring confirmation before proceeding 

240 

241 Asynchronous checkpoints (is_sync=False, opt-in): 

242 - Return immediately without waiting for the checkpoint to complete 

243 - Performance optimization for specific use cases 

244 - Use cases: observability checkpoints, fire-and-forget operations 

245 

246 When to use synchronous checkpoints (is_sync=True, default): 

247 1. Step START with AtMostOncePerRetry semantics - prevents duplicate execution 

248 2. Operation completion (SUCCEED/FAIL) - ensures state persisted before returning 

249 3. Retry operations - ensures retry state recorded before continuing 

250 4. Callback START - must wait for API to generate callback ID 

251 5. Invoke START - ensures chained invoke recorded before proceeding 

252 6. Child context results - ensures results persisted before returning 

253 7. Large results - ensures results saved before returning to caller 

254 8. Wait for condition completion - ensures state recorded before proceeding 

255 9. Most operations - safe default 

256 

257 When to use asynchronous checkpoints (is_sync=False, opt-in): 

258 1. Step START with AtLeastOncePerRetry semantics - performance optimization 

259 2. Child context START - fire-and-forget for performance 

260 3. Wait for condition START - observability only, no blocking needed 

261 4. Any checkpoint where immediate confirmation is not required AND performance matters 

262 

263 Args: 

264 operation_update: The checkpoint to create. If None, creates an empty 

265 checkpoint to get a fresh checkpoint token and updated 

266 operations list. 

267 is_sync: If True (default), blocks until the checkpoint is processed. 

268 If False, returns immediately without blocking for performance. 

269 

270 Returns: 

271 The updated operation for synchronous checkpoints with an operation update. 

272 Returns None for asynchronous checkpoints and empty checkpoints. 

273 

274 Raises: 

275 Any exception from checkpoint processing will propagate back to the 

276 awaiting coroutine, terminating the Lambda invocation. 

277 

278 Examples: 

279 # Synchronous checkpoint (default, safe) 

280 execution_state.create_checkpoint(operation_update) 

281 

282 # Explicit synchronous checkpoint 

283 execution_state.create_checkpoint(operation_update, is_sync=True) 

284 

285 # Asynchronous checkpoint (opt-in for performance) 

286 execution_state.create_checkpoint(operation_update, is_sync=False) 

287 

288 # Empty checkpoint (sync by default) 

289 execution_state.create_checkpoint() 

290 

291 # Empty checkpoint (async for performance) 

292 execution_state.create_checkpoint(is_sync=False) 

293 """ 

294 # if this is CONTEXT complete, mark incomplete descendants as orphans so the children can't complete after the parent 

295 if operation_update is not None: 

296 if operation_update.parent_id: 

297 if operation_update.parent_id not in self._parent_to_children: 

298 self._parent_to_children[operation_update.parent_id] = set() 

299 self._parent_to_children[operation_update.parent_id].add( 

300 operation_update.operation_id 

301 ) 

302 

303 if ( 

304 operation_update.operation_type == OperationType.CONTEXT 

305 and operation_update.action 

306 in {OperationAction.SUCCEED, OperationAction.FAIL} 

307 ): 

308 self._mark_orphans(operation_update.operation_id) 

309 

310 if operation_update.operation_id in self._parent_done: 

311 logger.debug( 

312 "Rejecting checkpoint for operation %s - parent is done", 

313 operation_update.operation_id, 

314 ) 

315 error_msg = ( 

316 "Parent context completed, child operation cannot checkpoint" 

317 ) 

318 raise OrphanedChildException( 

319 error_msg, 

320 operation_id=operation_update.operation_id, 

321 ) 

322 

323 if self._checkpointing_failure is not None: 

324 raise self._checkpointing_failure 

325 

326 if self._checkpointing_task is None or self._checkpointing_task.done(): 

327 self.start_checkpointing() 

328 

329 completion_future: asyncio.Future[Operation | None] | None = None 

330 if is_sync: 

331 completion_future = asyncio.get_running_loop().create_future() 

332 

333 # Create wrapper object for queue 

334 queued_op = QueuedOperation(operation_update, completion_future) 

335 

336 # Enqueue the wrapper object (operation_update can be None for empty checkpoints) 

337 await self._checkpoint_queue.put(queued_op) 

338 

339 # Conditionally wait for completion based on is_sync parameter 

340 if is_sync: 

341 logger.debug("Enqueued checkpoint operation for synchronous processing") 

342 if completion_future is None: # pragma: no cover 

343 # this shouldn't ever be possible 

344 msg: str = "completion_future must be set for synchronous execution" 

345 raise DurableExecutionsError(msg) 

346 return await completion_future 

347 else: 

348 logger.debug("Enqueued checkpoint operation for asynchronous processing") 

349 return None 

350 

351 def _mark_orphans(self, context_id: str) -> None: 

352 """Mark all descendants (direct and transitive) as orphaned. 

353 

354 This method uses BFS (Breadth-First Search) to recursively collect all 

355 descendants of the given context operation and marks them as orphaned. 

356 Once marked, these operations will be rejected if they attempt to checkpoint. 

357 

358 Args: 

359 context_id: The operation ID of the CONTEXT that has completed 

360 """ 

361 # Collect all descendants recursively using BFS 

362 all_descendants = set() 

363 # Start with root 

364 to_process: set[str] = {context_id} 

365 

366 while to_process: 

367 current_id = to_process.pop() 

368 

369 # Skip if already processed (avoid cycles, though shouldn't happen) 

370 if current_id in all_descendants: 

371 continue 

372 

373 all_descendants.add(current_id) 

374 

375 # Add all direct children to processing queue 

376 direct_children = self._parent_to_children.get(current_id, set()) 

377 to_process.update(direct_children) 

378 

379 # Remove the root itself (we only want descendants) 

380 all_descendants.discard(context_id) 

381 

382 # Mark all descendants as orphaned 

383 self._parent_done.update(all_descendants) 

384 logger.debug( 

385 "Marked %d descendants as parent-done for context %s", 

386 len(all_descendants), 

387 context_id, 

388 ) 

389 

390 def start_checkpointing(self) -> None: 

391 """Start the checkpoint processor on the current event loop.""" 

392 if self._checkpointing_task is None or self._checkpointing_task.done(): 

393 self._checkpointing_stopped.clear() 

394 self._checkpointing_failed.clear() 

395 self._checkpointing_task = asyncio.create_task( 

396 self.checkpoint_batches_forever() 

397 ) 

398 

399 async def checkpoint_batches_forever(self): 

400 """Background coroutine that batches operations and processes results. 

401 

402 Runs until shutdown is signaled. This method processes checkpoint operations 

403 in batches, makes API calls to persist them, and updates the execution state 

404 with the results. 

405 

406 The method maintains the checkpoint token locally and updates it after each 

407 successful batch processing. It continues running until stop_checkpointing() 

408 is called. 

409 

410 Note: When shutdown is signaled, only non-essential async checkpoints may remain 

411 in the queue. All critical synchronous checkpoints (SUCCEED, FAIL, etc.) will 

412 have already completed because the main thread blocks on them. Therefore, we 

413 don't need to drain the queue - the Lambda timeout will handle cleanup. 

414 

415 Raises: 

416 Any exception from the service client checkpoint call will propagate naturally, 

417 terminating the background thread and signaling an error to the main thread. 

418 """ 

419 await asyncio.sleep(0) 

420 

421 # Keep checkpoint token as local variable in the loop 

422 current_checkpoint_token: str = self._current_checkpoint_token 

423 

424 while not self._checkpointing_stopped.is_set(): 

425 # Collect operations into a batch 

426 batch: list[QueuedOperation] = await self._collect_checkpoint_batch() 

427 

428 if batch: 

429 # Extract OperationUpdates, excluding empty checkpoints from API call 

430 updates: list[OperationUpdate] = [] 

431 empty_count = 0 

432 

433 for q in batch: 

434 if q.operation_update is not None: 

435 updates.append(q.operation_update) 

436 else: 

437 empty_count += 1 

438 

439 logger.debug( 

440 "Sending %d OperationUpdates out of %d operations, excluding %d empty checkpoints", 

441 len(updates), 

442 len(batch), 

443 empty_count, 

444 ) 

445 

446 try: 

447 # Make API call with batched operations 

448 output = await self._service_client.checkpoint( 

449 durable_execution_arn=self.durable_execution_arn, 

450 checkpoint_token=current_checkpoint_token, 

451 updates=updates, 

452 client_token=None, 

453 ) 

454 

455 logger.debug("Checkpoint batch processed successfully") 

456 

457 # Update local token for next iteration 

458 current_checkpoint_token = output.checkpoint_token 

459 

460 # Fetch new operations from the API before unblocking sync waiters 

461 updated_operations = await self.fetch_paginated_operations( 

462 output.new_execution_state.operations, 

463 output.checkpoint_token, 

464 output.new_execution_state.next_marker, 

465 ) 

466 updated_operations_by_id = { 

467 operation.operation_id: operation 

468 for operation in updated_operations 

469 } 

470 

471 # Signal completion for any synchronous operations 

472 for queued_op in batch: 

473 if not _completion_done(queued_op.completion_future): 

474 operation = None 

475 if queued_op.operation_update is not None: 

476 operation = updated_operations_by_id.get( 

477 queued_op.operation_update.operation_id 

478 ) 

479 _completion_set_result( 

480 queued_op.completion_future, operation 

481 ) 

482 except Exception as e: 

483 # Checkpoint failed - wake blocked coroutines so they can raise error 

484 logger.exception("Checkpoint batch processing failed") 

485 self._checkpointing_failure = e 

486 self._checkpointing_failed.set() 

487 

488 # Signal completion futures for the failed batch 

489 for queued_op in batch: 

490 if not _completion_done(queued_op.completion_future): 

491 _completion_set_exception(queued_op.completion_future, e) 

492 

493 while self._overflow_queue: 

494 overflow_item = self._overflow_queue.popleft() 

495 if not _completion_done(overflow_item.completion_future): 

496 _completion_set_exception( 

497 overflow_item.completion_future, e 

498 ) 

499 

500 while not self._checkpoint_queue.empty(): 

501 queued_item: QueuedOperation | None = ( 

502 self._checkpoint_queue.get_nowait() 

503 ) 

504 if queued_item is not None and not _completion_done( 

505 queued_item.completion_future 

506 ): 

507 _completion_set_exception(queued_item.completion_future, e) 

508 break 

509 

510 logger.debug("Background checkpoint processing stopped") 

511 

512 def stop_checkpointing(self) -> None: 

513 """Signal the checkpoint processor to stop. 

514 

515 This method sets the checkpointing stopped event, which signals the background 

516 thread to exit. Any remaining async checkpoints in the queue are non-essential 

517 (observability only) and will be abandoned. All critical synchronous checkpoints 

518 will have already completed before this is called. 

519 """ 

520 logger.debug("Signaling checkpoint processor to stop") 

521 self._checkpointing_stopped.set() 

522 if self._checkpointing_task is not None and not self._checkpoint_queue.full(): 

523 self._checkpoint_queue.put_nowait(None) 

524 

525 async def _collect_checkpoint_batch(self): 

526 """Collect multiple checkpoint operations into a batch for API efficiency. 

527 

528 Processes overflow queue first to maintain FIFO order, then collects from main queue. 

529 Respects configured size, time, and operation count limits. Blocks for the first 

530 operation if queues are empty, then collects additional operations within the time 

531 window. 

532 

533 Empty checkpoints (operation_update=None) are coalesced: the first empty checkpoint 

534 counts toward the batch operation limit, but subsequent empty checkpoints do not. 

535 All empty checkpoints remain in the batch so their completion events are signaled. 

536 This avoids unnecessary batches when many concurrent map/parallel branches resume 

537 simultaneously and each queues an empty checkpoint. 

538 

539 Returns: 

540 List of QueuedOperation objects ready for batch processing. Returns empty list 

541 if no operations are available. 

542 """ 

543 batch: list[QueuedOperation] = [] 

544 has_empty_checkpoint = False 

545 total_size = 0 

546 effective_operation_count = 0 # Operations that count toward batch limit 

547 

548 # First, drain overflow queue (FIFO order preserved) 

549 while ( 

550 self._overflow_queue 

551 and effective_operation_count < self._batcher_config.max_batch_operations 

552 ): 

553 overflow_op = self._overflow_queue.popleft() 

554 

555 if overflow_op.operation_update is None: # Empty checkpoint 

556 batch.append(overflow_op) 

557 if not has_empty_checkpoint: 

558 effective_operation_count += 1 

559 has_empty_checkpoint = True 

560 else: 

561 op_size = self._calculate_operation_size(overflow_op) 

562 if total_size + op_size > self._batcher_config.max_batch_size_bytes: 

563 self._overflow_queue.appendleft(overflow_op) 

564 break 

565 batch.append(overflow_op) 

566 total_size += op_size 

567 effective_operation_count += 1 

568 

569 # If batch is empty, get first operation from main queue 

570 if not batch: 

571 while not self._checkpointing_stopped.is_set(): 

572 try: 

573 first_op = await asyncio.wait_for( 

574 self._checkpoint_queue.get(), timeout=0.1 

575 ) 

576 if first_op is None: 

577 continue 

578 batch.append(first_op) 

579 

580 if first_op.operation_update is None: 

581 has_empty_checkpoint = True 

582 else: 

583 total_size += self._calculate_operation_size(first_op) 

584 

585 effective_operation_count = 1 

586 break 

587 except asyncio.TimeoutError: 

588 continue 

589 

590 # If stopped and no operation retrieved, return empty batch 

591 if not batch: 

592 return batch 

593 

594 # Start batching window using configured time 

595 batch_deadline = time.time() + self._batcher_config.max_batch_time_seconds 

596 

597 # Collect additional operations within the time window. Once the batch 

598 # reaches the real-operation limit, keep accepting empty checkpoints so 

599 # concurrent resubmits can coalesce instead of spilling into a new API call. 

600 while time.time() < batch_deadline and not self._checkpointing_stopped.is_set(): 

601 remaining_time = min( 

602 batch_deadline - time.time(), 

603 0.1, # Check stop signal every 100ms 

604 ) 

605 

606 if remaining_time <= 0: 

607 break 

608 

609 try: 

610 additional_op = await asyncio.wait_for( 

611 self._checkpoint_queue.get(), timeout=remaining_time 

612 ) 

613 if additional_op is None: 

614 continue 

615 

616 if additional_op.operation_update is None: # Empty checkpoint 

617 batch.append(additional_op) 

618 if not has_empty_checkpoint: 

619 effective_operation_count += ( 

620 1 # First empty counts toward limit 

621 ) 

622 has_empty_checkpoint = True 

623 # Subsequent empties don't count toward limit 

624 else: 

625 if ( 

626 effective_operation_count 

627 >= self._batcher_config.max_batch_operations 

628 ): 

629 self._overflow_queue.append(additional_op) 

630 logger.debug( 

631 "Batch operation limit reached, moving operation to overflow queue" 

632 ) 

633 break 

634 

635 op_size = self._calculate_operation_size(additional_op) 

636 # Check if adding this operation would exceed size limit 

637 if total_size + op_size > self._batcher_config.max_batch_size_bytes: 

638 # Put in overflow queue for next batch 

639 self._overflow_queue.append(additional_op) 

640 logger.debug( 

641 "Batch size limit reached, moving operation to overflow queue" 

642 ) 

643 break 

644 batch.append(additional_op) 

645 total_size += op_size 

646 effective_operation_count += 1 

647 

648 except asyncio.TimeoutError: 

649 break 

650 

651 empty_count = sum(1 for q in batch if q.operation_update is None) 

652 logger.debug( 

653 "Collected batch of %d operations (%d effective, %d non-empty, %d empty), total size: %d bytes", 

654 len(batch), 

655 effective_operation_count, 

656 len(batch) - empty_count, 

657 empty_count, 

658 total_size, 

659 ) 

660 return batch 

661 

662 @staticmethod 

663 def _calculate_operation_size(queued_op: QueuedOperation) -> int: 

664 """Calculate the serialized size of a queued operation for batching limits. 

665 

666 Uses JSON serialization to estimate the size of the operation update. Empty 

667 checkpoints (None operation_update) have zero size. 

668 

669 Args: 

670 queued_op: The queued operation to calculate size for 

671 

672 Returns: 

673 Size in bytes of the serialized operation, or 0 for empty checkpoints 

674 """ 

675 # Empty checkpoints have no size 

676 if queued_op.operation_update is None: 

677 return 0 

678 

679 # Use JSON serialization to estimate size 

680 serialized = json.dumps(queued_op.operation_update.to_dict()).encode("utf-8") 

681 return len(serialized) 

682 

683 async def aclose(self) -> None: 

684 self.stop_checkpointing() 

685 if self._checkpointing_task is not None: 

686 await self._checkpointing_task 

687 

688 def close(self): 

689 self.stop_checkpointing()