fix(modbus): zadne vecne pending v journalu + flock timeout + EV poll backoff

Zivy incident home-01 (TeltoCharge .16): zapis 15/19-20 koncil failed
s prazdnym error_msg, nebo zustal trvale pending a zablokoval exportni ticky.

- _gateway_exclusive: neblokujici flock s deadline (EMS_MODBUS_FLOCK_TIMEOUT_S,
  default 20 s) -> GatewayLockTimeout misto starvation bez limitu
- execute_modbus_commands: invariant written/failed + neprazdny error_msg
  (str(e) or repr(e)); safety net pres BaseException (CancelledError, chyba DB);
  journal update mimo retry cyklus zarizeni; force_disconnect bez zamku brany
- telemetry poll_ev_chargers: po 3 selhanich backoff 5 min per (host,port,unit)
  - mrtvy unit_id drzi branu 4x8=32 s z kazde minuty
- testy backend/tests/test_modbus_execute_failsafe.py; docs
  modbus-command-journal.md (sekce Robustnost zapisu + konfigurace)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-13 00:17:04 +02:00
parent fb9d0f107a
commit b08782525e
5 changed files with 499 additions and 72 deletions

View File

@@ -25,9 +25,27 @@ logger = logging.getLogger(__name__)
_flock_warned = False
class GatewayLockTimeout(TimeoutError):
"""Brána je držena jiným tahem (telemetrie / druhý proces) déle než timeout."""
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
_DEFAULT_LOCK_DIR = _BACKEND_ROOT / ".ems-modbus-locks"
#: Maximální čekání na exkluzivní zámek brány. Dřív se čekalo blokovaně bez
#: limitu — exporter pak mohl na bráně obsazené pollingem mrtvého unit_id
#: viset donekonečna (journal řádky trvale 'pending'). Po timeoutu se vyhodí
#: GatewayLockTimeout a volající označí příkaz failed ('gateway lock timeout').
_FLOCK_TIMEOUT_DEFAULT_S = 20.0
_FLOCK_POLL_INTERVAL_S = 0.25
def _flock_timeout_s() -> float:
try:
return float(os.getenv("EMS_MODBUS_FLOCK_TIMEOUT_S", _FLOCK_TIMEOUT_DEFAULT_S))
except ValueError:
return _FLOCK_TIMEOUT_DEFAULT_S
def _gateway_lock_path(host: str, port: int) -> Path:
# Výchozí = backend/.ems-modbus-locks (v Dockeru /app → mount ./backend), aby flock sdílel
@@ -65,14 +83,32 @@ async def _gateway_exclusive(host: str, port: int):
path = _gateway_lock_path(host_s, port_i)
path.parent.mkdir(parents=True, exist_ok=True)
f = open(path, "a+b") # noqa: SIM115
locked = False
try:
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_EX)
# Neblokující pokusy s deadline místo flock(LOCK_EX) bez limitu:
# blokované čekání v to_thread nejde zrušit a při bráně obsazené
# pollingem mrtvého unit_id (32 s z každé minuty) hrozí starvation.
timeout_s = _flock_timeout_s()
deadline = asyncio.get_running_loop().time() + timeout_s
while True:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = True
break
except OSError:
if asyncio.get_running_loop().time() >= deadline:
raise GatewayLockTimeout(
f"gateway lock timeout {host_s}:{port_i} "
f"after {timeout_s:.0f}s"
) from None
await asyncio.sleep(_FLOCK_POLL_INTERVAL_S)
yield
finally:
try:
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_UN)
except OSError:
pass
if locked:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except OSError:
pass
f.close()
@@ -260,12 +296,17 @@ class PersistentModbusClient:
return await self._write_registers_locked(address, values, device_id)
async def force_disconnect(self) -> None:
"""Uzavře socket pod lockem (např. před retry po chybě)."""
async with _gateway_exclusive(self.host, self.port):
async with self._lock:
if self._client is not None:
self._client.close()
self._client = None
"""Uzavře socket pod lockem (např. před retry po chybě).
Záměrně BEZ _gateway_exclusive: zavření vlastního TCP socketu není
transakce na RS485 sběrnici a čekání na zámek brány tady umělo
protáhnout / shodit retry cestu exporteru (GatewayLockTimeout
uvnitř except větve execute_modbus_commands).
"""
async with self._lock:
if self._client is not None:
self._client.close()
self._client = None
@asynccontextmanager
async def batch(self, device_id: int = 1) -> AsyncIterator[ModbusBatch]: