Coverage for async-durable-execution/src/async_durable_execution/composite/map.py: 100%
65 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
1"""Implementation for Durable Map operation."""
3from __future__ import annotations
5import asyncio
6import json
7import logging
8from dataclasses import dataclass, field
9from typing import (
10 TYPE_CHECKING,
11 Generic,
12 TypeVar,
13 Sequence,
14 Iterable,
15 Callable,
16 Any,
17 Awaitable,
18)
20from .parallel import (
21 BatchResult,
22 CompletionConfig,
23 NestingType,
24)
25from .parallel import parallel_handler
26from ..context import bind_current_context
27from ..execution import durable_callable
28from ..models import OperationIdentifier, OperationSubType
29from ..primitive.child import (
30 DurableContext,
31 _create_child_context_task as _run_in_child_context,
32 get_durable_context,
33)
35if TYPE_CHECKING:
36 from .parallel import SummaryGenerator
37 from ..serdes import SerDes
38 from ..state import ExecutionState
40logger = logging.getLogger(__name__)
42# Input item type
43T = TypeVar("T")
44# Result type
45R = TypeVar("R")
46U = TypeVar("U")
49@dataclass(frozen=True)
50class BatchedInput(Generic[T, U]):
51 """Wrapper passed to batched map handlers."""
53 batch_input: T
54 items: list[U]
57@dataclass(frozen=True)
58class MapItemContext(DurableContext, Generic[T]):
59 """Context exposed while a map item function is executing."""
61 index: int = 0
62 items: Sequence[T] = field(default_factory=tuple)
65def _bind_map_item_to_branch(
66 items: Sequence[T],
67 index: int,
68 func: Callable[[T], Awaitable[R]],
69) -> Callable[[], Awaitable[R]]:
70 async def run_branch() -> R:
71 logger.debug("🗺️ Processing map item: %s", index)
72 item = items[index]
73 child_context = get_durable_context("map")
74 map_item_context = MapItemContext(
75 execution_state=child_context.execution_state,
76 operation_identifier=child_context.operation_identifier,
77 step_id_prefix=child_context.step_id_prefix,
78 replaying=child_context.is_replaying(),
79 index=index,
80 items=items,
81 )
82 with bind_current_context(map_item_context):
83 result: R = await func(item)
84 logger.debug("✅ Processed map item: %s", index)
85 return result
87 return run_branch
90def _create_map_branches(
91 items: Sequence[T],
92 func: Callable[[T], Awaitable[R]],
93) -> list[Callable[[], Awaitable[R]]]:
94 return [
95 _bind_map_item_to_branch(items=items, index=index, func=func)
96 for index in range(len(items))
97 ]
100def _create_map_branch_namer(
101 items: Sequence[T],
102 item_namer: Callable[[T, int], str] | None,
103) -> Callable[[int], str] | None:
104 if item_namer is None:
105 return None
107 def name_branch(index: int) -> str:
108 return item_namer(items[index], index)
110 return name_branch
113class MapSummaryGenerator:
114 """Default summary generator for oversized `BatchResult` map payloads."""
116 def __call__(self, result: BatchResult) -> str:
117 fields = {
118 "totalCount": result.total_count,
119 "successCount": result.success_count,
120 "failureCount": result.failure_count,
121 "completionReason": result.completion_reason.value,
122 "status": result.status.value,
123 "type": "MapResult",
124 }
125 return json.dumps(fields)
128@durable_callable
129async def map_handler(
130 items: Sequence[T],
131 func: Callable[[T], Awaitable[R]],
132 execution_state: ExecutionState,
133 map_context: DurableContext,
134 operation_identifier: OperationIdentifier,
135 *,
136 max_concurrency: int | None = None,
137 completion_config: CompletionConfig | None = None,
138 serdes: SerDes | None = None,
139 item_serdes: SerDes | None = None,
140 summary_generator: SummaryGenerator | None = MapSummaryGenerator(),
141 nesting_type: NestingType = NestingType.NESTED,
142 item_namer: Callable[[T, int], str] | None = None,
143):
144 """Execute a callable for each item through the parallel handler."""
145 handler = parallel_handler(
146 callables=_create_map_branches(items, func),
147 max_concurrency=max_concurrency,
148 completion_config=completion_config or CompletionConfig(),
149 serdes=serdes,
150 summary_generator=summary_generator,
151 item_serdes=item_serdes,
152 nesting_type=nesting_type,
153 execution_state=execution_state,
154 parallel_context=map_context,
155 operation_identifier=operation_identifier,
156 top_level_sub_type=OperationSubType.MAP,
157 iteration_sub_type=OperationSubType.MAP_ITERATION,
158 name_prefix="map-item-",
159 branch_namer=_create_map_branch_namer(items, item_namer),
160 )
162 return await handler()
165def map(
166 func: Callable[[U | BatchedInput[Any, U]], Awaitable[T]],
167 items: Iterable[U],
168 *,
169 name: str | None = None,
170 max_concurrency: int | None = None,
171 completion_config: CompletionConfig | None = None,
172 serdes: SerDes | None = None,
173 item_serdes: SerDes | None = None,
174 summary_generator: SummaryGenerator | None = MapSummaryGenerator(),
175 nesting_type: NestingType = NestingType.NESTED,
176 item_namer: Callable[[U, int], str] | None = None,
177) -> asyncio.Task[BatchResult[T]]:
178 """Process a collection durably with optional concurrency controls.
180 Args:
181 func: Async callable that processes each item.
182 items: Items to process.
183 name: Optional durable operation name.
184 max_concurrency: Optional limit for concurrent item processing.
185 completion_config: Optional completion criteria.
186 serdes: Optional serializer for the map result.
187 item_serdes: Optional serializer for individual map item results.
188 summary_generator: Optional summary generator for large map results.
189 nesting_type: Whether map iterations use nested or flat operation ids.
190 item_namer: Optional callable for naming map item iterations.
191 """
192 context = get_durable_context("map")
193 items_sequence = list(items)
194 map_name = name if name is not None else getattr(func, "__name__", None)
196 async def run_map_handler() -> BatchResult[T]:
197 map_context = get_durable_context("map")
198 operation_id = map_context.step_id_prefix
199 if operation_id is None:
200 msg = "map operation id is not available in the current context"
201 raise RuntimeError(msg)
202 operation_identifier = OperationIdentifier(
203 operation_id=operation_id,
204 sub_type=OperationSubType.MAP,
205 parent_id=map_context.parent_id,
206 name=map_name,
207 )
209 handler = map_handler(
210 items=items_sequence,
211 func=func,
212 execution_state=context.execution_state,
213 map_context=map_context,
214 operation_identifier=operation_identifier,
215 max_concurrency=max_concurrency,
216 completion_config=completion_config,
217 serdes=serdes,
218 item_serdes=item_serdes,
219 summary_generator=summary_generator,
220 nesting_type=nesting_type,
221 item_namer=item_namer,
222 )
223 return await handler()
225 return _run_in_child_context(
226 run_map_handler,
227 sub_type=OperationSubType.MAP,
228 name=map_name,
229 serdes=serdes,
230 operation_name="map",
231 )