DevPlace CI / test (push) Failing after 44m45s
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
116 lines
3.7 KiB
Python
116 lines
3.7 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Callable, Hashable
|
|
|
|
from devplacepy.services.nodes.spec import SPEC
|
|
|
|
LIMITS = SPEC.limits
|
|
|
|
CLOSED = "closed"
|
|
OPEN = "open"
|
|
HALF_OPEN = "half_open"
|
|
|
|
|
|
@dataclass
|
|
class _Circuit:
|
|
failures: int = 0
|
|
opened_at: float = 0.0
|
|
probing: bool = False
|
|
|
|
|
|
class CircuitBreaker:
|
|
def __init__(
|
|
self,
|
|
failures: int = LIMITS.breaker_failures,
|
|
open_seconds: float = LIMITS.breaker_open_seconds,
|
|
clock: Callable[[], float] = time.monotonic,
|
|
wall: Callable[[], float] = time.time,
|
|
) -> None:
|
|
self.failures = max(1, failures)
|
|
self.open_seconds = open_seconds
|
|
self._clock = clock
|
|
self._wall = wall
|
|
self._circuits: dict[Hashable, _Circuit] = {}
|
|
self.dirty = False
|
|
|
|
def state(self, key: Hashable) -> str:
|
|
circuit = self._circuits.get(key)
|
|
if circuit is None or circuit.failures < self.failures:
|
|
return CLOSED
|
|
if self._clock() - circuit.opened_at < self.open_seconds:
|
|
return OPEN
|
|
return HALF_OPEN
|
|
|
|
def allows(self, key: Hashable) -> bool:
|
|
state = self.state(key)
|
|
if state == CLOSED:
|
|
return True
|
|
if state == OPEN:
|
|
return False
|
|
return not self._circuits[key].probing
|
|
|
|
def begin(self, key: Hashable) -> None:
|
|
if self.state(key) == HALF_OPEN:
|
|
self._circuits[key].probing = True
|
|
|
|
def success(self, key: Hashable) -> None:
|
|
if self._circuits.pop(key, None) is not None:
|
|
self.dirty = True
|
|
|
|
def failure(self, key: Hashable) -> None:
|
|
circuit = self._circuits.setdefault(key, _Circuit())
|
|
circuit.probing = False
|
|
circuit.failures += 1
|
|
if circuit.failures >= self.failures:
|
|
circuit.opened_at = self._clock()
|
|
self.dirty = True
|
|
|
|
def forget(self, key: Hashable) -> None:
|
|
if self._circuits.pop(key, None) is not None:
|
|
self.dirty = True
|
|
|
|
def open_count(self) -> int:
|
|
return sum(1 for key in list(self._circuits) if self.state(key) != CLOSED)
|
|
|
|
def snapshot(self) -> list[dict]:
|
|
now_clock, now_wall = self._clock(), self._wall()
|
|
rows = []
|
|
for key, circuit in list(self._circuits.items()):
|
|
state = self.state(key)
|
|
rows.append(
|
|
{
|
|
"key": ":".join(str(part) for part in key) if isinstance(key, tuple) else str(key),
|
|
"failures": circuit.failures,
|
|
"state": state,
|
|
"opened_at": now_wall - (now_clock - circuit.opened_at) if circuit.failures >= self.failures else 0.0,
|
|
"seconds_left": max(0.0, self.open_seconds - (now_clock - circuit.opened_at))
|
|
if state == OPEN
|
|
else 0.0,
|
|
}
|
|
)
|
|
rows.sort(key=lambda item: (-item["failures"], item["key"]))
|
|
return rows
|
|
|
|
def restore(self, rows: list[dict]) -> int:
|
|
now_clock, now_wall = self._clock(), self._wall()
|
|
restored = 0
|
|
for row in rows:
|
|
key = tuple(str(row["key"]).split(":", 1))
|
|
if len(key) != 2:
|
|
continue
|
|
failures = int(row.get("failures") or 0)
|
|
opened_wall = float(row.get("opened_at") or 0.0)
|
|
if failures < self.failures or opened_wall <= 0:
|
|
continue
|
|
age = max(0.0, now_wall - opened_wall)
|
|
if age >= self.open_seconds * 2:
|
|
continue
|
|
circuit = _Circuit(failures=failures, opened_at=now_clock - age)
|
|
self._circuits[key] = circuit
|
|
restored += 1
|
|
return restored
|