-
Notifications
You must be signed in to change notification settings - Fork 502
Crash rollout engines from the soak harness instead of the controller #2120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,13 @@ | ||
| # NOTE: You MUST read tests/e2e/ft/README.md as source-of-truth and documentations | ||
|
|
||
| import dataclasses | ||
| import enum | ||
| import logging | ||
| import random | ||
| import threading | ||
| import time | ||
| from collections.abc import Callable | ||
| from typing import Literal | ||
|
|
||
| import requests | ||
|
|
||
|
|
@@ -50,66 +52,121 @@ def genuinely_alive(self, cells: list[dict]) -> list[dict]: | |
| return [c for c in cells if cell_is_alive(c) and c["metadata"]["name"] not in self._state_of_cell_name] | ||
|
|
||
|
|
||
| class ObservedCellState(enum.Enum): | ||
| SUSPENDED = "Suspended" # torn down, holding no gpu | ||
| PENDING = "Pending" # allocated but gated: no engine serving yet | ||
| RUNNING_NOT_SERVING = "RunningNotServing" # engine is up but not registered in the router | ||
| SERVING = "Serving" # registered in the router, i.e. actually able to answer requests | ||
|
|
||
|
|
||
| _RELAUNCH_STATES: tuple[ObservedCellState, ...] = (ObservedCellState.SUSPENDED, ObservedCellState.PENDING) | ||
|
|
||
|
|
||
| def compute_observed_cell_state(cell: dict) -> ObservedCellState: | ||
| phase = cell["status"]["phase"] | ||
| if phase == "Suspended": | ||
| return ObservedCellState.SUSPENDED | ||
| if phase == "Pending": | ||
| return ObservedCellState.PENDING | ||
| serving = any(cond["type"] == "Serving" and cond["status"] == "True" for cond in cell["status"]["conditions"]) | ||
| return ObservedCellState.SERVING if serving else ObservedCellState.RUNNING_NOT_SERVING | ||
|
|
||
|
|
||
| @dataclasses.dataclass(frozen=True) | ||
| class InjectionOutcome: | ||
| cell_name: str | ||
| injection_index: int | ||
| recovered: bool | ||
| still_down: bool | ||
| class _CellEvent: | ||
| kind: Literal["injected", "observed"] | ||
| state: ObservedCellState | None = None | ||
|
|
||
|
|
||
| @dataclasses.dataclass | ||
| class _CellInfo: | ||
| cell_type: str | None = None | ||
| events: list[_CellEvent] = dataclasses.field(default_factory=list) | ||
|
|
||
| def _find_healing_end(phases: list[str], start: int) -> int | None: | ||
| remaining = ["Running", "Pending", "Running"] | ||
| for index in range(start, len(phases)): | ||
| if phases[index] == remaining[0]: | ||
| remaining.pop(0) | ||
| if not remaining: | ||
| return index | ||
| return None | ||
|
|
||
| class RecoveryWitness: | ||
| """Pairs every accepted injection with one completed relaunch-and-serve cycle of the same cell.""" | ||
|
|
||
| class PhaseHistory: | ||
| def __init__(self) -> None: | ||
| self.phases_of_cell_name: dict[str, list[str]] = {} | ||
| self._cell_type_of_name: dict[str, str] = {} | ||
| self._injection_marks_of_cell_name: dict[str, list[int]] = {} | ||
| self._info_of_cell_name: dict[str, _CellInfo] = {} | ||
|
|
||
| def note_injected(self, cell_name: str) -> None: | ||
| self._info(cell_name).events.append(_CellEvent(kind="injected")) | ||
|
|
||
| def observe(self, cells: list[dict]) -> None: | ||
| for cell in cells: | ||
| name = cell["metadata"]["name"] | ||
| self._cell_type_of_name[name] = _cell_type_of(cell) | ||
| phases = self.phases_of_cell_name.setdefault(name, []) | ||
| phase = cell["status"]["phase"] | ||
| if not phases or phases[-1] != phase: | ||
| phases.append(phase) | ||
| info = self._info(cell["metadata"]["name"]) | ||
| info.cell_type = _cell_type_of(cell) | ||
| info.events.append(_CellEvent(kind="observed", state=compute_observed_cell_state(cell))) | ||
|
|
||
| @property | ||
| def states_of_cell_name(self) -> dict[str, list[ObservedCellState]]: | ||
| return { | ||
| name: states | ||
| for name, info in self._info_of_cell_name.items() | ||
| if (states := _compute_distinct_states(info.events)) | ||
| } | ||
|
|
||
| def num_injections(self, *, cell_type: str | None = None) -> int: | ||
| return sum( | ||
| sum(1 for event in info.events if event.kind == "injected") | ||
| for info in self._matching_infos(cell_type=cell_type) | ||
| ) | ||
|
|
||
| def note_injected(self, cell_name: str) -> None: | ||
| phases = self.phases_of_cell_name.setdefault(cell_name, []) | ||
| marks = self._injection_marks_of_cell_name.setdefault(cell_name, []) | ||
| marks.append(max(len(phases) - 1, 0)) | ||
|
|
||
| def injection_outcomes(self, *, cell_type: str | None = None) -> list[InjectionOutcome]: | ||
| outcomes: list[InjectionOutcome] = [] | ||
| for name, marks in sorted(self._injection_marks_of_cell_name.items()): | ||
| if cell_type is not None and self._cell_type_of_name.get(name) != cell_type: | ||
| continue | ||
|
|
||
| phases = self.phases_of_cell_name[name] | ||
| next_search_start = 0 | ||
| for injection_index, mark in enumerate(marks): | ||
| healing_end = _find_healing_end(phases, max(mark, next_search_start)) | ||
| if healing_end is not None: | ||
| next_search_start = healing_end | ||
| outcomes.append( | ||
| InjectionOutcome( | ||
| cell_name=name, | ||
| injection_index=injection_index, | ||
| recovered=healing_end is not None, | ||
| still_down=healing_end is None and bool(phases) and phases[-1] != "Running", | ||
| ) | ||
| ) | ||
|
|
||
| return outcomes | ||
| def num_completed_recoveries(self, *, cell_type: str | None = None) -> int: | ||
| return sum( | ||
| _compute_recovery_tally(info.events).num_completed for info in self._matching_infos(cell_type=cell_type) | ||
| ) | ||
|
|
||
| def cells_with_unfinished_recovery(self, *, cell_type: str | None = None) -> dict[str, int]: | ||
| return { | ||
| name: tally.num_unfinished | ||
| for name, info in self._info_of_cell_name.items() | ||
| if (cell_type is None or info.cell_type == cell_type) | ||
| and (tally := _compute_recovery_tally(info.events)).num_unfinished | ||
| } | ||
|
|
||
| def _info(self, cell_name: str) -> _CellInfo: | ||
| return self._info_of_cell_name.setdefault(cell_name, _CellInfo()) | ||
|
|
||
| def _matching_infos(self, *, cell_type: str | None) -> list[_CellInfo]: | ||
| return [info for info in self._info_of_cell_name.values() if cell_type is None or info.cell_type == cell_type] | ||
|
|
||
|
|
||
| @dataclasses.dataclass(frozen=True) | ||
| class _RecoveryTally: | ||
| num_completed: int | ||
| num_unfinished: int | ||
|
|
||
|
|
||
| class _RecoveryStage(enum.Enum): | ||
| AWAITING_RELAUNCH = enum.auto() | ||
| AWAITING_SERVING = enum.auto() | ||
|
|
||
|
|
||
| def _compute_recovery_tally(events: list[_CellEvent]) -> _RecoveryTally: | ||
| pending: list[_RecoveryStage] = [] | ||
| num_completed = 0 | ||
| for event in events: | ||
| if event.kind == "injected": | ||
| pending.append(_RecoveryStage.AWAITING_RELAUNCH) | ||
| continue | ||
| if not pending: | ||
| continue | ||
| if pending[0] is _RecoveryStage.AWAITING_RELAUNCH and event.state in _RELAUNCH_STATES: | ||
| pending[0] = _RecoveryStage.AWAITING_SERVING | ||
| elif pending[0] is _RecoveryStage.AWAITING_SERVING and event.state is ObservedCellState.SERVING: | ||
| pending.pop(0) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Coalesce crashes before the next Serving state With events |
||
| num_completed += 1 | ||
| return _RecoveryTally(num_completed=num_completed, num_unfinished=len(pending)) | ||
|
|
||
|
|
||
| def _compute_distinct_states(events: list[_CellEvent]) -> list[ObservedCellState]: | ||
| states: list[ObservedCellState] = [] | ||
| for event in events: | ||
| if event.kind == "observed" and event.state is not None and (not states or states[-1] != event.state): | ||
| states.append(event.state) | ||
| return states | ||
|
|
||
|
|
||
| def _compute_next_injection_time(rng: random.Random, mean_interval_seconds: float) -> float: | ||
|
|
@@ -124,7 +181,7 @@ def run_fault_injection_loop( | |
| stop_event: threading.Event, | ||
| on_successful_injection: Callable[[], None], | ||
| cell_type: str | None, | ||
| phase_history: PhaseHistory, | ||
| recovery_witness: RecoveryWitness, | ||
| poll_interval_seconds: float = POLL_INTERVAL_SECONDS, | ||
| ) -> None: | ||
| rng = random.Random(seed) | ||
|
|
@@ -142,7 +199,7 @@ def run_fault_injection_loop( | |
| # Track recovery on every poll so a crash->detect->heal cycle that completes between two | ||
| # sparse injections is seen, not missed (which would exclude the cell from the live set forever). | ||
| gate.observe({c["metadata"]["name"]: c for c in cells}) | ||
| phase_history.observe(cells) | ||
| recovery_witness.observe(cells) | ||
|
|
||
| if time.monotonic() < next_injection_time: | ||
| continue | ||
|
|
@@ -171,7 +228,7 @@ def run_fault_injection_loop( | |
| ) | ||
| resp.raise_for_status() | ||
| gate.note_injected(cell_name) | ||
| phase_history.note_injected(cell_name) | ||
| recovery_witness.note_injected(cell_name) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Serialize rollout faults with weight updates When the new colocated mode is active, this POST can run while |
||
| on_successful_injection() | ||
| next_injection_time = _compute_next_injection_time(rng, mean_interval_seconds) | ||
| except Exception: | ||
|
|
@@ -199,7 +256,7 @@ def _matches_cell_type(cell: dict, cell_type: str | None) -> bool: | |
| class FaultInjectorHandle: | ||
| def __init__(self, *, base_url: str, seed: int, mean_interval_seconds: float, cell_type: str | None) -> None: | ||
| self.num_successful_injections: int = 0 | ||
| self.phase_history = PhaseHistory() | ||
| self.recovery_witness = RecoveryWitness() | ||
| self._base_url = base_url | ||
| self._cell_type = cell_type | ||
| self._stop_event = threading.Event() | ||
|
|
@@ -212,7 +269,7 @@ def __init__(self, *, base_url: str, seed: int, mean_interval_seconds: float, ce | |
| "stop_event": self._stop_event, | ||
| "on_successful_injection": self._on_successful_injection, | ||
| "cell_type": cell_type, | ||
| "phase_history": self.phase_history, | ||
| "recovery_witness": self.recovery_witness, | ||
| }, | ||
| daemon=True, | ||
| name="ft-random-fault-injector", | ||
|
|
@@ -230,7 +287,7 @@ def _observe_final_snapshot(self) -> None: | |
| cells = list_cells(base_url=self._base_url, cell_type=self._cell_type) | ||
| if cells is None: | ||
| return | ||
| self.phase_history.observe(cells) | ||
| self.recovery_witness.observe(cells) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Stop the injector before checking recovery If shutdown is requested while the 5-second GET is in flight, the loop never rechecks |
||
|
|
||
| def _on_successful_injection(self) -> None: | ||
| self.num_successful_injections += 1 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] Require Serving before counting rollout spares
When a replacement is
StatePendingWeights, it may reportHealthy=Truewhile the newly exposedServingcondition remains false, butRecoveryGate.genuinely_alive()andalive_of_typestill use onlycell_is_alive(). The rollout soak can therefore re-admit it before the update window completes, count a non-serving engine as the spare, and eventually crash the last router-serving replica. Deliver-2 waits for stable all-Serving quiescence; the signal added here is already sufficient for a minimal port, so the later event-log refactor is not a dependency.