Coverage for async_durable_execution/_core/state.py: 97%

284 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-08-30 23:43 +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 CheckpointError, 

15 DurableExecutionsError, 

16 GetExecutionStateError, 

17 OrphanedChildException, 

18) 

19from .models import ( 

20 Operation, 

21 OperationAction, 

22 OperationType, 

23 OperationUpdate, 

24) 

25from .client import DurableServiceClient 

26 

27 

28if TYPE_CHECKING: 

29 from collections.abc import MutableMapping 

30 from .models import LambdaContext 

31 

32logger = logging.getLogger(__name__) 

33 

34RECURSIVE_LEVEL_INPUT_FIELD = "__recursive_level" 

35 

36 

37@dataclass(frozen=True) 

38class CheckpointBatcherConfig: 

39 """Configuration for checkpoint batching behavior. 

40 

41 Attributes: 

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

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

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

45 """ 

46 

47 max_batch_size_bytes: int = 750 * 1024 # 750KB 

48 max_batch_time_seconds: float = 1.0 

49 max_batch_operations: int = 250 

50 

51 

52@dataclass(frozen=True) 

53class QueuedOperation: 

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

55 

56 Attributes: 

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

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

59 """ 

60 

61 operation_update: OperationUpdate | None 

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

63 

64 

65def _completion_done(completion) -> bool: 

66 return completion is None or completion.done() 

67 

68 

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

70 if completion is None: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 return 

72 completion.set_result(operation) 

73 

74 

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

76 if completion is None: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true

77 return 

78 completion.set_exception(error) 

79 

80 

81class ExecutionState: 

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

83 

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

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

86 "MutableMapping[str, Operation]", None 

87 ) 

88 

89 def __init__( 

90 self, 

91 durable_execution_arn: str, 

92 initial_checkpoint_token: str, 

93 service_client: DurableServiceClient, 

94 lambda_context: LambdaContext | None = None, 

95 batcher_config: CheckpointBatcherConfig | None = None, 

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

97 ) -> None: 

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

99 self.durable_execution_arn: str = durable_execution_arn 

100 self.lambda_context: LambdaContext | None = lambda_context 

101 self._current_checkpoint_token: str = initial_checkpoint_token 

102 self._service_client: DurableServiceClient = service_client 

103 

104 # Checkpoint batching configuration 

105 self._batcher_config = batcher_config or CheckpointBatcherConfig() 

106 

107 # Checkpoint batching components 

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

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

110 self._checkpointing_stopped = asyncio.Event() 

111 self._checkpointing_failed = asyncio.Event() 

112 self._checkpointing_failure: Exception | None = None 

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

114 

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

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

117 

118 # Operations whose parent has completed 

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

120 

121 async def initialize(self, invocation_input) -> None: 

122 await self.fetch_paginated_operations( 

123 invocation_input.initial_execution_state.operations, 

124 invocation_input.checkpoint_token, 

125 invocation_input.initial_execution_state.next_marker, 

126 ) 

127 

128 async def fetch_paginated_operations( 

129 self, 

130 initial_operations: list[Operation], 

131 checkpoint_token: str, 

132 next_marker: str | None, 

133 ) -> list[Operation]: 

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

135 

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

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

138 

139 Args: 

140 initial_operations: initial operations to be added to ExecutionState 

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

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

143 Returns: 

144 List of all operations fetched from the Durable Functions API 

145 

146 Raises: 

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

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

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

150 based on is_retryable(). 

151 """ 

152 all_operations: list[Operation] = ( 

153 initial_operations.copy() if initial_operations else [] 

154 ) 

155 try: 

156 while next_marker: 

157 output = await self._service_client.get_execution_state( 

158 durable_execution_arn=self.durable_execution_arn, 

159 checkpoint_token=checkpoint_token, 

160 next_marker=next_marker, 

161 ) 

162 all_operations.extend(output.operations) 

163 next_marker = output.next_marker 

164 except GetExecutionStateError as e: 

165 logger.exception( 

166 "Durable API error during state fetch.", 

167 extra=e.build_logger_extras(), 

168 ) 

169 raise 

170 finally: 

171 # Always store whatever operations we successfully fetched 

172 if all_operations: 

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

174 return all_operations 

175 

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

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

178 # for the initial page of results. 

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

180 return None 

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

182 return None 

183 return execution_details.input_payload 

184 

185 def get_input_event(self) -> Any: 

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

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

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

189 input_event: Any = {} 

190 if raw_input_payload and raw_input_payload.strip(): 

191 try: 

192 input_event = json.loads(raw_input_payload) 

193 except json.JSONDecodeError: 

194 logger.exception( 

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

196 raw_input_payload, 

197 ) 

198 raise 

199 return input_event 

200 

201 @property 

202 def recursive_level(self) -> int: 

203 input_event = self.get_input_event() 

204 if not isinstance(input_event, dict): 

205 return 0 

206 

207 value = input_event.get(RECURSIVE_LEVEL_INPUT_FIELD, 0) 

208 if isinstance(value, bool): 

209 return 0 

210 if isinstance(value, int): 

211 return value 

212 

213 try: 

214 return int(value) 

215 except (TypeError, ValueError): 

216 return 0 

217 

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

219 # invocation id is id of execution operation 

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

221 candidate = self.operations.get(invocation_id) 

222 if not candidate: 

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

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

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

226 # as the execution operation does not exist 

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

228 logger.debug(msg) 

229 return None 

230 if candidate.operation_type is not OperationType.EXECUTION: 

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

232 raise DurableExecutionsError(msg) 

233 

234 return candidate 

235 

236 def has_prior_operations(self) -> bool: 

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

238 return any( 

239 op.operation_type is not OperationType.EXECUTION 

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

241 ) 

242 

243 async def create_checkpoint( 

244 self, 

245 operation_update: OperationUpdate | None = None, 

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

247 ) -> Operation | None: 

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

249 

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

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

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

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

254 

255 Synchronous checkpoints (is_sync=True, default): 

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

257 - Ensure the checkpoint is persisted before continuing 

258 - Safe default for correctness 

259 - Use cases: Most operations requiring confirmation before proceeding 

260 

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

262 - Return immediately without waiting for the checkpoint to complete 

263 - Performance optimization for specific use cases 

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

265 

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

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

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

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

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

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

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

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

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

275 9. Most operations - safe default 

276 

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

278 1. Step START with AtLeastOncePerRetry semantics - performance optimization 

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

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

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

282 

283 Args: 

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

285 checkpoint to get a fresh checkpoint token and updated 

286 operations list. 

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

288 If False, returns immediately without blocking for performance. 

289 

290 Returns: 

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

292 Returns None for asynchronous checkpoints and empty checkpoints. 

293 

294 Raises: 

295 Any exception from checkpoint processing will propagate back to the 

296 awaiting coroutine, terminating the Lambda invocation. 

297 

298 Examples: 

299 # Synchronous checkpoint (default, safe) 

300 execution_state.create_checkpoint(operation_update) 

301 

302 # Explicit synchronous checkpoint 

303 execution_state.create_checkpoint(operation_update, is_sync=True) 

304 

305 # Asynchronous checkpoint (opt-in for performance) 

306 execution_state.create_checkpoint(operation_update, is_sync=False) 

307 

308 # Empty checkpoint (sync by default) 

309 execution_state.create_checkpoint() 

310 

311 # Empty checkpoint (async for performance) 

312 execution_state.create_checkpoint(is_sync=False) 

313 """ 

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

315 if operation_update is not None: 

316 if operation_update.parent_id: 

317 if operation_update.parent_id not in self._parent_to_children: 

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

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

320 operation_update.operation_id 

321 ) 

322 

323 if ( 

324 operation_update.operation_type == OperationType.CONTEXT 

325 and operation_update.action 

326 in {OperationAction.SUCCEED, OperationAction.FAIL} 

327 ): 

328 self._mark_orphans(operation_update.operation_id) 

329 

330 if operation_update.operation_id in self._parent_done: 

331 logger.debug( 

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

333 operation_update.operation_id, 

334 ) 

335 error_msg = ( 

336 "Parent context completed, child operation cannot checkpoint" 

337 ) 

338 raise OrphanedChildException( 

339 error_msg, 

340 operation_id=operation_update.operation_id, 

341 ) 

342 

343 if self._checkpointing_failure is not None: 

344 raise self._checkpointing_failure 

345 

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

347 self.start_checkpointing() 

348 

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

350 if is_sync: 

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

352 

353 # Create wrapper object for queue 

354 queued_op = QueuedOperation(operation_update, completion_future) 

355 

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

357 await self._checkpoint_queue.put(queued_op) 

358 

359 # Conditionally wait for completion based on is_sync parameter 

360 if is_sync: 

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

362 if completion_future is None: 362 ↛ 364line 362 didn't jump to line 364 because the condition on line 362 was never true

363 # this shouldn't ever be possible 

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

365 raise DurableExecutionsError(msg) 

366 return await completion_future 

367 else: 

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

369 return None 

370 

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

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

373 

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

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

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

377 

378 Args: 

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

380 """ 

381 # Collect all descendants recursively using BFS 

382 all_descendants = set() 

383 # Start with root 

384 to_process: set[str] = {context_id} 

385 

386 while to_process: 

387 current_id = to_process.pop() 

388 

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

390 if current_id in all_descendants: 

391 continue 

392 

393 all_descendants.add(current_id) 

394 

395 # Add all direct children to processing queue 

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

397 to_process.update(direct_children) 

398 

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

400 all_descendants.discard(context_id) 

401 

402 # Mark all descendants as orphaned 

403 self._parent_done.update(all_descendants) 

404 logger.debug( 

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

406 len(all_descendants), 

407 context_id, 

408 ) 

409 

410 def start_checkpointing(self) -> None: 

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

412 if self._checkpointing_task is None or self._checkpointing_task.done(): 412 ↛ exitline 412 didn't return from function 'start_checkpointing' because the condition on line 412 was always true

413 self._checkpointing_stopped.clear() 

414 self._checkpointing_failed.clear() 

415 self._checkpointing_task = asyncio.create_task( 

416 self.checkpoint_batches_forever() 

417 ) 

418 

419 async def checkpoint_batches_forever(self) -> None: 

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

421 

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

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

424 with the results. 

425 

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

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

428 is called. 

429 

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

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

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

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

434 

435 Raises: 

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

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

438 """ 

439 await asyncio.sleep(0) 

440 

441 # Keep checkpoint token as local variable in the loop 

442 current_checkpoint_token: str = self._current_checkpoint_token 

443 

444 while not self._checkpointing_stopped.is_set(): 

445 # Collect operations into a batch 

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

447 

448 if batch: 

449 # Extract OperationUpdates, excluding empty checkpoints from API call 

450 updates: list[OperationUpdate] = [] 

451 empty_count = 0 

452 

453 for q in batch: 

454 if q.operation_update is not None: 

455 updates.append(q.operation_update) 

456 else: 

457 empty_count += 1 

458 

459 logger.debug( 

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

461 len(updates), 

462 len(batch), 

463 empty_count, 

464 ) 

465 

466 try: 

467 # Make API call with batched operations 

468 output = await self._service_client.checkpoint( 

469 durable_execution_arn=self.durable_execution_arn, 

470 checkpoint_token=current_checkpoint_token, 

471 updates=updates, 

472 client_token=None, 

473 ) 

474 

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

476 

477 if output.checkpoint_token: 

478 current_checkpoint_token = output.checkpoint_token 

479 else: 

480 batch_is_terminal = any( 

481 update.operation_type is OperationType.EXECUTION 

482 and update.action 

483 in {OperationAction.SUCCEED, OperationAction.FAIL} 

484 for update in updates 

485 ) 

486 if not batch_is_terminal: 

487 msg = ( 

488 "Checkpoint response omitted the token outside of " 

489 "execution completion." 

490 ) 

491 raise CheckpointError(msg) 

492 

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

494 updated_operations = await self.fetch_paginated_operations( 

495 output.new_execution_state.operations, 

496 current_checkpoint_token, 

497 output.new_execution_state.next_marker, 

498 ) 

499 updated_operations_by_id = { 

500 operation.operation_id: operation 

501 for operation in updated_operations 

502 } 

503 

504 # Signal completion for any synchronous operations 

505 for queued_op in batch: 

506 if not _completion_done(queued_op.completion_future): 

507 operation = None 

508 if queued_op.operation_update is not None: 

509 operation = updated_operations_by_id.get( 

510 queued_op.operation_update.operation_id 

511 ) 

512 _completion_set_result( 

513 queued_op.completion_future, operation 

514 ) 

515 except Exception as e: 

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

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

518 self._checkpointing_failure = e 

519 self._checkpointing_failed.set() 

520 

521 # Signal completion futures for the failed batch 

522 for queued_op in batch: 

523 if not _completion_done(queued_op.completion_future): 

524 _completion_set_exception(queued_op.completion_future, e) 

525 

526 while self._overflow_queue: 

527 overflow_item = self._overflow_queue.popleft() 

528 if not _completion_done(overflow_item.completion_future): 528 ↛ 526line 528 didn't jump to line 526 because the condition on line 528 was always true

529 _completion_set_exception( 

530 overflow_item.completion_future, e 

531 ) 

532 

533 while not self._checkpoint_queue.empty(): 

534 queued_item: QueuedOperation | None = ( 

535 self._checkpoint_queue.get_nowait() 

536 ) 

537 if queued_item is not None and not _completion_done( 537 ↛ 533line 537 didn't jump to line 533 because the condition on line 537 was always true

538 queued_item.completion_future 

539 ): 

540 _completion_set_exception(queued_item.completion_future, e) 

541 break 

542 

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

544 

545 def stop_checkpointing(self) -> None: 

546 """Signal the checkpoint processor to stop. 

547 

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

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

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

551 will have already completed before this is called. 

552 """ 

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

554 self._checkpointing_stopped.set() 

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

556 self._checkpoint_queue.put_nowait(None) 

557 

558 async def _collect_checkpoint_batch(self) -> list[QueuedOperation]: 

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

560 

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

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

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

564 window. 

565 

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

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

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

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

570 simultaneously and each queues an empty checkpoint. 

571 

572 Returns: 

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

574 if no operations are available. 

575 """ 

576 batch: list[QueuedOperation] = [] 

577 has_empty_checkpoint = False 

578 total_size = 0 

579 effective_operation_count = 0 # Operations that count toward batch limit 

580 

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

582 while ( 

583 self._overflow_queue 

584 and effective_operation_count < self._batcher_config.max_batch_operations 

585 ): 

586 overflow_op = self._overflow_queue.popleft() 

587 

588 if overflow_op.operation_update is None: # Empty checkpoint 

589 batch.append(overflow_op) 

590 if not has_empty_checkpoint: 

591 effective_operation_count += 1 

592 has_empty_checkpoint = True 

593 else: 

594 op_size = self._calculate_operation_size(overflow_op) 

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

596 self._overflow_queue.appendleft(overflow_op) 

597 break 

598 batch.append(overflow_op) 

599 total_size += op_size 

600 effective_operation_count += 1 

601 

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

603 if not batch: 

604 while not self._checkpointing_stopped.is_set(): 

605 try: 

606 first_op = await asyncio.wait_for( 

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

608 ) 

609 if first_op is None: 

610 continue 

611 batch.append(first_op) 

612 

613 if first_op.operation_update is None: 

614 has_empty_checkpoint = True 

615 else: 

616 total_size += self._calculate_operation_size(first_op) 

617 

618 effective_operation_count = 1 

619 break 

620 except asyncio.TimeoutError: 

621 continue 

622 

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

624 if not batch: 

625 return batch 

626 

627 # Start batching window using configured time 

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

629 

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

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

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

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

634 remaining_time = min( 

635 batch_deadline - time.time(), 

636 0.1, # Check stop signal every 100ms 

637 ) 

638 

639 if remaining_time <= 0: 

640 break 

641 

642 try: 

643 additional_op = await asyncio.wait_for( 

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

645 ) 

646 if additional_op is None: 

647 continue 

648 

649 if additional_op.operation_update is None: # Empty checkpoint 

650 batch.append(additional_op) 

651 if not has_empty_checkpoint: 

652 effective_operation_count += ( 

653 1 # First empty counts toward limit 

654 ) 

655 has_empty_checkpoint = True 

656 # Subsequent empties don't count toward limit 

657 else: 

658 if ( 

659 effective_operation_count 

660 >= self._batcher_config.max_batch_operations 

661 ): 

662 self._overflow_queue.append(additional_op) 

663 logger.debug( 

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

665 ) 

666 break 

667 

668 op_size = self._calculate_operation_size(additional_op) 

669 # Check if adding this operation would exceed size limit 

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

671 # Put in overflow queue for next batch 

672 self._overflow_queue.append(additional_op) 

673 logger.debug( 

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

675 ) 

676 break 

677 batch.append(additional_op) 

678 total_size += op_size 

679 effective_operation_count += 1 

680 

681 except asyncio.TimeoutError: 

682 break 

683 

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

685 logger.debug( 

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

687 len(batch), 

688 effective_operation_count, 

689 len(batch) - empty_count, 

690 empty_count, 

691 total_size, 

692 ) 

693 return batch 

694 

695 @staticmethod 

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

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

698 

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

700 checkpoints (None operation_update) have zero size. 

701 

702 Args: 

703 queued_op: The queued operation to calculate size for 

704 

705 Returns: 

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

707 """ 

708 # Empty checkpoints have no size 

709 if queued_op.operation_update is None: 

710 return 0 

711 

712 # Use JSON serialization to estimate size 

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

714 return len(serialized) 

715 

716 async def aclose(self) -> None: 

717 self.stop_checkpointing() 

718 if self._checkpointing_task is not None: 

719 await self._checkpointing_task 

720 

721 def close(self) -> None: 

722 self.stop_checkpointing()