227 lines
8.2 KiB
Python
227 lines
8.2 KiB
Python
"""
|
|
PipelineEngine — executes a build DAG.
|
|
|
|
For each action:
|
|
1. Schedule: pick best node via scheduler
|
|
2. Emit task_update(scheduled) with deps + critical info
|
|
3. Check cache: if hit, emit cache_update + skip execution
|
|
4. Execute: emit task_update(running), run via runtime router, emit task_update(done|failed)
|
|
5. On failure: emit failure event + stop downstream actions
|
|
|
|
Emits events on the bus so the UI / WS stream / cause graph / timeline store
|
|
all pick them up.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from backend.events.bus import EventBus
|
|
from backend.events.schema import EventType
|
|
from backend.scheduler.optimizer import choose_best_node
|
|
from backend.executor.runtime_router import execute_action
|
|
from backend.cache.minio_cache import MinioCache
|
|
from backend.graph.plan import build_action_graph
|
|
from backend.graph.critical_path import compute_critical_path
|
|
|
|
|
|
class PipelineEngine:
|
|
"""Runs a build DAG against a cluster of nodes.
|
|
|
|
Constructor:
|
|
nodes: list of node dicts (config + state)
|
|
node_registry: NodeStateRegistry singleton (for live state lookups)
|
|
event_bus: EventBus singleton (for emitting events)
|
|
cache: optional MinioCache (or compatible). If None, no cache.
|
|
build_id: optional build identifier (for correlating events)
|
|
"""
|
|
|
|
def __init__(self, nodes, node_registry, event_bus: EventBus,
|
|
cache: Optional[Any] = None, build_id: Optional[str] = None):
|
|
self.nodes = nodes
|
|
self.node_registry = node_registry
|
|
self.bus = event_bus
|
|
self.cache = cache # may be None
|
|
self.build_id = build_id or f"build-{int(time.time())}"
|
|
|
|
# Pause / step state (used by debugger)
|
|
self._paused = False
|
|
self._step_mode = False
|
|
self._step_event = asyncio.Event() if asyncio.get_event_loop() else None
|
|
|
|
# Results
|
|
self.last_results: List[tuple] = []
|
|
self.critical_path: Optional[Dict[str, Any]] = None
|
|
|
|
# -------------------------------------------------
|
|
# DEBUGGER HOOKS
|
|
# -------------------------------------------------
|
|
def pause(self):
|
|
self._paused = True
|
|
|
|
def resume(self):
|
|
self._paused = False
|
|
if self._step_event:
|
|
self._step_event.set()
|
|
|
|
def step(self):
|
|
"""Advance one action when paused."""
|
|
self._step_mode = True
|
|
if self._step_event:
|
|
self._step_event.set()
|
|
|
|
# -------------------------------------------------
|
|
# MAIN ENTRYPOINT
|
|
# -------------------------------------------------
|
|
async def run(self, project) -> List[tuple]:
|
|
"""Build the project. Returns list of (action_name, state) tuples."""
|
|
|
|
actions = build_action_graph(project)
|
|
self.critical_path = compute_critical_path(actions)
|
|
critical_names = set(self.critical_path.get("score_map", {}).keys()) if self.critical_path else set()
|
|
|
|
self.last_results = []
|
|
completed: Dict[str, str] = {} # name -> state
|
|
failed_actions: set = set()
|
|
|
|
for action in actions:
|
|
# Skip if any dependency failed
|
|
deps = action.get("deps", [])
|
|
failed_deps = [d for d in deps if completed.get(d) == "failed"]
|
|
if failed_deps:
|
|
self.bus.emit(
|
|
EventType.TASK_UPDATE,
|
|
action=action["name"],
|
|
node=None,
|
|
state="skipped",
|
|
reason=f"deps_failed:{','.join(failed_deps)}",
|
|
meta={
|
|
"deps": deps,
|
|
"critical": action["name"] in critical_names,
|
|
"target": action.get("target", "native"),
|
|
"build_id": self.build_id,
|
|
},
|
|
)
|
|
completed[action["name"]] = "skipped"
|
|
self.last_results.append((action["name"], "skipped"))
|
|
continue
|
|
|
|
# Wait if paused
|
|
await self._wait_if_paused()
|
|
|
|
# Schedule
|
|
node = choose_best_node(self.nodes, action, self.node_registry)
|
|
node_name = node["name"] if node else None
|
|
|
|
self.bus.emit(
|
|
EventType.TASK_UPDATE,
|
|
action=action["name"],
|
|
node=node_name,
|
|
state="scheduled",
|
|
meta={
|
|
"deps": deps,
|
|
"critical": action["name"] in critical_names,
|
|
"target": action.get("target", "native"),
|
|
"build_id": self.build_id,
|
|
},
|
|
)
|
|
|
|
# Cache check
|
|
cache_hit = False
|
|
if self.cache and action.get("hash"):
|
|
try:
|
|
if self.cache.exists(action["hash"]):
|
|
cache_hit = True
|
|
self.bus.emit(
|
|
EventType.CACHE_UPDATE,
|
|
action=action["name"],
|
|
node=node_name,
|
|
state="hit",
|
|
meta={"build_id": self.build_id},
|
|
)
|
|
except Exception:
|
|
# Cache errors are non-fatal
|
|
pass
|
|
|
|
# Emit running
|
|
self.bus.emit(
|
|
EventType.TASK_UPDATE,
|
|
action=action["name"],
|
|
node=node_name,
|
|
state="running",
|
|
meta={
|
|
"deps": deps,
|
|
"critical": action["name"] in critical_names,
|
|
"target": action.get("target", "native"),
|
|
"cache": "hit" if cache_hit else None,
|
|
"build_id": self.build_id,
|
|
},
|
|
)
|
|
|
|
if not cache_hit:
|
|
# Execute (in a thread so we don't block the event loop)
|
|
# Pass action itself as workspace hint — execute_action reads
|
|
# action["dir"] and action["env"] for proper cwd + env setup.
|
|
try:
|
|
rc = await asyncio.to_thread(
|
|
execute_action, action, action.get("dir") or "/tmp",
|
|
node or {"name": "localhost"}
|
|
)
|
|
state = "done" if rc == 0 else "failed"
|
|
except Exception as e:
|
|
state = "failed"
|
|
rc = -1
|
|
self.bus.emit(
|
|
EventType.FAILURE,
|
|
action=action["name"],
|
|
node=node_name,
|
|
state="failed",
|
|
reason=f"execution_exception:{type(e).__name__}:{e}",
|
|
meta={"build_id": self.build_id},
|
|
)
|
|
else:
|
|
state = "done"
|
|
rc = 0
|
|
|
|
# Emit terminal state
|
|
self.bus.emit(
|
|
EventType.TASK_UPDATE,
|
|
action=action["name"],
|
|
node=node_name,
|
|
state=state,
|
|
meta={
|
|
"deps": deps,
|
|
"critical": action["name"] in critical_names,
|
|
"target": action.get("target", "native"),
|
|
"cache": "hit" if cache_hit else None,
|
|
"build_id": self.build_id,
|
|
"rc": rc,
|
|
},
|
|
)
|
|
|
|
completed[action["name"]] = state
|
|
self.last_results.append((action["name"], state))
|
|
|
|
if state == "failed":
|
|
failed_actions.add(action["name"])
|
|
|
|
# Brief yield so the WS broadcast has time to fire
|
|
await asyncio.sleep(0.05)
|
|
|
|
return self.last_results
|
|
|
|
# -------------------------------------------------
|
|
# INTERNAL
|
|
# -------------------------------------------------
|
|
async def _wait_if_paused(self):
|
|
if not self._paused:
|
|
return
|
|
# Wait until resumed (or stepped)
|
|
if self._step_event is None:
|
|
self._step_event = asyncio.Event()
|
|
self._step_event.clear()
|
|
while self._paused and not self._step_mode:
|
|
await self._step_event.wait()
|
|
self._step_event.clear()
|
|
self._step_mode = False
|