Coverage for src / invariant / async_executor.py: 94.59%

148 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-25 08:17 +0000

1"""Async executor: scheduler-driven alternative to the safe sync Executor.""" 

2 

3import asyncio 

4from collections.abc import Iterable 

5from typing import Any 

6 

7from invariant.cacheable import is_cacheable 

8from invariant.executor import Executor 

9from invariant.graph import Graph 

10from invariant.hashing import hash_manifest 

11from invariant.node import Node, SubGraphNode, SwitchNode 

12from invariant.scheduler import InlineScheduler, InvocationRequest, InvocationScheduler 

13 

14 

15class AsyncExecutor(Executor): 

16 """Async runtime engine for executing DAGs. 

17 

18 The async executor preserves the synchronous executor's graph and cache 

19 semantics, while delegating operation placement to an InvocationScheduler. 

20 """ 

21 

22 def __init__( 

23 self, 

24 registry, 

25 store, 

26 scheduler: InvocationScheduler | None = None, 

27 *, 

28 cache: bool = True, 

29 ) -> None: 

30 """Initialize AsyncExecutor.""" 

31 super().__init__(registry, store, cache=cache) 

32 self.scheduler = scheduler or InlineScheduler() 

33 self._singleflight: dict[tuple[str, str], asyncio.Task[Any]] = {} 

34 self._singleflight_lock = asyncio.Lock() 

35 self._waiters = {} 

36 

37 async def __aenter__(self) -> "AsyncExecutor": 

38 """Enter an async context manager.""" 

39 return self 

40 

41 async def __aexit__(self, *exc_info: object) -> None: 

42 """Close scheduler resources on context exit.""" 

43 await self.aclose() 

44 

45 async def aclose(self) -> None: 

46 """Close scheduler resources when supported.""" 

47 close = getattr(self.scheduler, "aclose", None) 

48 if close is not None: 

49 await close() 

50 

51 async def execute( 

52 self, 

53 graph: Graph, 

54 outputs: Iterable[str], 

55 context: dict[str, Any] | None = None, 

56 ) -> dict[str, Any]: 

57 """Execute requested graph outputs and return their artifacts.""" 

58 output_ids = self._normalize_outputs(outputs, graph) 

59 artifacts_by_node, _ = await self._execute_requested_outputs_async( 

60 graph, 

61 output_ids, 

62 context=context, 

63 ) 

64 return {output: artifacts_by_node[output] for output in output_ids} 

65 

66 async def _execute_requested_outputs_async( 

67 self, 

68 graph: Graph, 

69 outputs: tuple[str, ...], 

70 context: dict[str, Any] | None = None, 

71 uncacheable_context_keys: set[str] | None = None, 

72 ) -> tuple[dict[str, Any], set[str]]: 

73 """Demand-execute active paths for requested graph outputs.""" 

74 context = context or {} 

75 uncacheable_nodes = set(uncacheable_context_keys or set()) 

76 artifacts_by_node: dict[str, Any] = {} 

77 node_tasks: dict[str, asyncio.Task[Any]] = {} 

78 waiting_for: dict[str, set[str]] = {} 

79 

80 def check_wait_cycle(waiter: str, target: str) -> None: 

81 stack = [target] 

82 seen: set[str] = set() 

83 while stack: 

84 current = stack.pop() 

85 if current == waiter: 

86 raise ValueError( 

87 "Graph contains cycles on active path: " 

88 f"{waiter} -> {target} -> {waiter}" 

89 ) 

90 if current in seen: 

91 continue 

92 seen.add(current) 

93 stack.extend(waiting_for.get(current, ())) 

94 

95 async def await_existing_task( 

96 node_id: str, 

97 task: asyncio.Task[Any], 

98 waiter: str | None, 

99 ) -> Any: 

100 if task.done() or waiter is None: 

101 return await task 

102 check_wait_cycle(waiter, node_id) 

103 waiting_for.setdefault(waiter, set()).add(node_id) 

104 try: 

105 return await task 

106 finally: 

107 waiters = waiting_for.get(waiter) 

108 if waiters is not None: 

109 waiters.discard(node_id) 

110 if not waiters: 

111 waiting_for.pop(waiter, None) 

112 

113 async def resolve_artifact( 

114 node_id: str, 

115 path: tuple[str, ...] = (), 

116 waiter: str | None = None, 

117 ) -> Any: 

118 if node_id in artifacts_by_node: 

119 return artifacts_by_node[node_id] 

120 

121 if node_id in path: 

122 cycle = " -> ".join([*path, node_id]) 

123 raise ValueError(f"Graph contains cycles on active path: {cycle}") 

124 

125 if node_id in graph: 

126 existing = node_tasks.get(node_id) 

127 if existing is not None: 

128 return await await_existing_task(node_id, existing, waiter) 

129 

130 task = asyncio.create_task(run_graph_node(node_id, (*path, node_id))) 

131 node_tasks[node_id] = task 

132 return await await_existing_task(node_id, task, waiter) 

133 

134 if node_id in context: 

135 value = context[node_id] 

136 if not is_cacheable(value): 

137 raise ValueError( 

138 f"Context value for '{node_id}' is not cacheable, " 

139 f"got {type(value)}" 

140 ) 

141 artifacts_by_node[node_id] = value 

142 return value 

143 

144 raise ValueError( 

145 f"Node depends on '{node_id}' but it doesn't exist in graph " 

146 f"or context. Available: graph={sorted(graph)}, " 

147 f"context={sorted(context)}" 

148 ) 

149 

150 async def run_graph_node(node_id: str, path: tuple[str, ...]) -> Any: 

151 node = graph[node_id] 

152 await asyncio.gather( 

153 *( 

154 resolve_artifact(dep_id, path, waiter=node_id) 

155 for dep_id in node.deps 

156 ) 

157 ) 

158 

159 depends_on_uncacheable = any( 

160 dep_id in uncacheable_nodes for dep_id in node.deps 

161 ) 

162 

163 if isinstance(node, SwitchNode): 

164 self._validate_switch_targets(node, node_id, graph) 

165 target_id = self._select_switch_target( 

166 node, 

167 node_id, 

168 artifacts_by_node, 

169 ) 

170 if target_id not in graph: 

171 raise ValueError( 

172 f"SwitchNode '{node_id}' targets '{target_id}' " 

173 "which doesn't exist in graph" 

174 ) 

175 await resolve_artifact(target_id, path, waiter=node_id) 

176 artifacts_by_node[node_id] = artifacts_by_node[target_id] 

177 if depends_on_uncacheable or target_id in uncacheable_nodes: 

178 uncacheable_nodes.add(node_id) 

179 elif isinstance(node, SubGraphNode): 

180 manifest = self._build_manifest( 

181 node, 

182 node_id, 

183 graph, 

184 artifacts_by_node, 

185 ) 

186 inner_uncacheable_context_keys = ( 

187 set(manifest.keys()) if depends_on_uncacheable else set() 

188 ) 

189 ( 

190 inner_results, 

191 inner_uncacheable_nodes, 

192 ) = await self._execute_requested_outputs_async( 

193 node.graph, 

194 (node.output,), 

195 context=manifest, 

196 uncacheable_context_keys=inner_uncacheable_context_keys, 

197 ) 

198 artifacts_by_node[node_id] = inner_results[node.output] 

199 if depends_on_uncacheable or node.output in inner_uncacheable_nodes: 

200 uncacheable_nodes.add(node_id) 

201 else: 

202 manifest = self._build_manifest( 

203 node, 

204 node_id, 

205 graph, 

206 artifacts_by_node, 

207 ) 

208 artifact = await self._execute_node_async( 

209 node, 

210 node_id, 

211 manifest, 

212 depends_on_uncacheable, 

213 uncacheable_nodes, 

214 ) 

215 artifacts_by_node[node_id] = artifact 

216 

217 return artifacts_by_node[node_id] 

218 

219 try: 

220 await asyncio.gather(*(resolve_artifact(output) for output in outputs)) 

221 except BaseException: 

222 for task in tuple(node_tasks.values()): 

223 if not task.done(): 

224 task.cancel() 

225 await asyncio.gather(*node_tasks.values(), return_exceptions=True) 

226 raise 

227 

228 return artifacts_by_node, uncacheable_nodes 

229 

230 async def _execute_node_async( 

231 self, 

232 node: Node, 

233 node_id: str, 

234 manifest: dict[str, Any], 

235 depends_on_uncacheable: bool, 

236 uncacheable_nodes: set[str], 

237 ) -> Any: 

238 """Execute one Node using scheduler-driven cache semantics.""" 

239 if not self.registry.has(node.op_name): 

240 raise ValueError( 

241 f"Node '{node_id}' references unregistered op '{node.op_name}'" 

242 ) 

243 

244 binding = self.registry.get_binding(node.op_name) 

245 should_cache = self.cache and node.cache and not depends_on_uncacheable 

246 if not should_cache: 

247 artifact = await self.scheduler.invoke( 

248 InvocationRequest( 

249 op_name=node.op_name, 

250 op=binding.op, 

251 manifest=manifest, 

252 traits=binding.traits, 

253 implementation_ref=binding.implementation_ref, 

254 ) 

255 ) 

256 uncacheable_nodes.add(node_id) 

257 return artifact 

258 

259 digest = hash_manifest(manifest) 

260 key = (node.op_name, digest) 

261 

262 async with self._singleflight_lock: 

263 task = self._singleflight.get(key) 

264 if task is None: 

265 if self.store.exists(node.op_name, digest): 

266 return self.store.get(node.op_name, digest) 

267 task = asyncio.create_task( 

268 self._invoke_and_store(binding, manifest, key) 

269 ) 

270 self._singleflight[key] = task 

271 self._waiters[key] = self._waiters.get(key, 0) + 1 

272 

273 try: 

274 # One cancelled graph must not cancel another graph's shared work. 

275 return await asyncio.shield(task) 

276 finally: 

277 abandoned = False 

278 async with self._singleflight_lock: 

279 self._waiters[key] -= 1 

280 if not self._waiters[key]: 

281 self._waiters.pop(key) 

282 self._singleflight.pop(key, None) 

283 if not task.done(): 

284 task.cancel() 

285 abandoned = True 

286 if abandoned: 

287 await asyncio.gather(task, return_exceptions=True) 

288 

289 async def _invoke_and_store(self, binding, manifest, key: tuple[str, str]) -> Any: 

290 """Invoke an op through the scheduler and store the resulting artifact.""" 

291 op_name, digest = key 

292 artifact = await self.scheduler.invoke( 

293 InvocationRequest( 

294 op_name=op_name, 

295 op=binding.op, 

296 manifest=manifest, 

297 traits=binding.traits, 

298 implementation_ref=binding.implementation_ref, 

299 cache_key=key, 

300 ) 

301 ) 

302 self.store.put(op_name, digest, artifact) 

303 return artifact 

304 

305 

306__all__ = ["AsyncExecutor"]