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:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user