11import asyncio
2+ from collections .abc import Coroutine , Iterable
23from datetime import UTC , datetime
3- from typing import Any
4+ from typing import Any , TypeVar
45
56from ..domain .entities .config_snapshot import DeviceSnapshot
67from ..domain .entities .exceptions import BulkOperationError
78from ..domain .value_objects .action_result import ActionResult
89from ..gateways .device import DeviceGateway
910from .capture_device_config import CaptureDeviceConfig
1011
12+ T = TypeVar ("T" )
13+
1114
1215def _unique (device_ips : list [str ]) -> list [str ]:
1316 """The given IPs in order, without repeats.
@@ -20,6 +23,23 @@ def _unique(device_ips: list[str]) -> list[str]:
2023 return list (dict .fromkeys (device_ips ))
2124
2225
26+ async def _gather_settled (coroutines : Iterable [Coroutine [Any , Any , T ]]) -> list [T ]:
27+ """Run every device to completion, in order, then surface the first failure.
28+
29+ Plain ``asyncio.gather`` raises the moment one device fails and does not
30+ cancel its siblings, leaving them issuing requests to real hardware with
31+ nobody awaiting them. Letting every task settle first keeps one bad device
32+ from doing that.
33+ """
34+ results = await asyncio .gather (* coroutines , return_exceptions = True )
35+ settled : list [T ] = []
36+ for result in results :
37+ if isinstance (result , BaseException ):
38+ raise result
39+ settled .append (result )
40+ return settled
41+
42+
2343class BulkOperationsUseCase :
2444
2545 def __init__ (
@@ -116,7 +136,9 @@ async def export_bulk_config(
116136 Dictionary containing export metadata and device configurations
117137 """
118138 targets = _unique (device_ips )
119- snapshots = await self ._capture_all (targets , component_types )
139+ snapshots = await _gather_settled (
140+ self ._capture_device (ip , component_types ) for ip in targets
141+ )
120142
121143 return {
122144 "export_metadata" : {
@@ -131,26 +153,6 @@ async def export_bulk_config(
131153 },
132154 }
133155
134- async def _capture_all (
135- self , device_ips : list [str ], component_types : list [str ]
136- ) -> list [DeviceSnapshot | None ]:
137- """Capture every device concurrently, in the order they were asked for.
138-
139- Gathering with ``return_exceptions`` lets every capture finish before a
140- failure surfaces, so one bad device does not leave the others running
141- against real hardware with nobody awaiting them.
142- """
143- results = await asyncio .gather (
144- * (self ._capture_device (ip , component_types ) for ip in device_ips ),
145- return_exceptions = True ,
146- )
147- snapshots : list [DeviceSnapshot | None ] = []
148- for result in results :
149- if isinstance (result , BaseException ):
150- raise result
151- snapshots .append (result )
152- return snapshots
153-
154156 async def _capture_device (
155157 self , device_ip : str , component_types : list [str ]
156158 ) -> DeviceSnapshot | None :
@@ -183,11 +185,9 @@ async def apply_bulk_config(
183185 Returns:
184186 List of action results
185187 """
186- per_device = await asyncio .gather (
187- * (
188- self ._apply_device_config (device_ip , component_type , config )
189- for device_ip in _unique (device_ips )
190- )
188+ per_device = await _gather_settled (
189+ self ._apply_device_config (device_ip , component_type , config )
190+ for device_ip in _unique (device_ips )
191191 )
192192 return [result for results in per_device for result in results ]
193193
0 commit comments