Files
devplacepy/devplacepy/services/nodes/circuit.py
T
retoorandClaude Sonnet 5.5 02ab747cd0
DevPlace CI / test (push) Failing after 44m45s
Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
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>
2026-10-17 02:23:43 +02:00

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