Files
jbod-monitor/poller.py
adam 0f829e0380 Split into dedicated hardware poller + read-only consumers
Rearchitect so a single poller process is the sole owner of all SAS/SMART/
SES hardware I/O, serialized behind one gate and paced, writing results to
Redis. The API/web and MQTT publisher become pure Redis readers — they no
longer issue any subprocess and can restart freely without touching the bus.

This addresses backplane/expander stress from concurrent + restart-triggered
SMART/SES storms (the prior model re-ran a hardware sweep on every container
start, and polled sg_ses 0x02+0x07 every 60s; 0x07 errored on the IOM6/
Xyratex expanders).

- poller.py: paced sweep (inventory, SMART per-drive w/ gap, SES 0x02, ZFS,
  host/MegaRAID), startup jitter, single-instance Redis lock, LED-queue worker
- services/hwgate.py: global serialization semaphore (POLL_CONCURRENCY=1)
- services/store.py: Redis as the only producer<->consumer interface + LED queue
- services/health.py: shared drive-health classifier (fixes overview double-count)
- gate smartctl/sg_ses/zpool/ledctl; drop sg_ses 0x07 from the hot path
- routers + temps read-only from the store; main.py drops the in-process poller
- compose: 3 services (privileged poller + unprivileged app + redis) w/ pacing knobs
2026-06-16 15:12:34 +00:00

187 lines
6.3 KiB
Python

"""Dedicated hardware poller — the sole owner of SAS/SMART/SES I/O.
Runs as its own (privileged, pid:host) container. Everything that touches
the bus happens here, serialized behind the hardware gate and paced, then
written to Redis. The API and MQTT services are pure Redis readers and
never issue a hardware command — so they can crash-loop freely without
ever storming the backplanes.
Design points that matter for fragile SAS expanders:
- one global semaphore (POLL_CONCURRENCY=1) serializes all hardware I/O
- a gap (POLL_DRIVE_GAP) between each drive keeps the bus mostly quiet
- startup jitter avoids a thundering herd on (re)start
- a single-instance Redis lock means two pollers can't both poll
- last-good results stay in Redis; a failed sweep never blanks them
"""
import asyncio
import logging
import os
import random
import socket
import time
from services import store
from services.cache import close_cache, init_cache, redis_available
from services.enclosure import discover_enclosures, get_enclosure_status, list_slots
from services.host import get_host_drives
from services.leds import run_led
from services.smart import _run_smartctl, sg_ses_available, smartctl_available
from services.zfs import get_zfs_pool_map
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("poller")
SWEEP_INTERVAL = int(os.environ.get("POLL_SWEEP_INTERVAL", "300"))
DRIVE_GAP = float(os.environ.get("POLL_DRIVE_GAP", "0.5"))
STARTUP_JITTER = float(os.environ.get("POLL_STARTUP_JITTER", "10"))
LOCK_TTL = 30
TOKEN = f"{socket.gethostname()}-{os.getpid()}"
async def sweep() -> None:
"""One full, paced pass over all hardware → Redis."""
t0 = time.monotonic()
errors: list[str] = []
# 1. Discover enclosures + slots (sysfs, no bus I/O).
enclosures = discover_enclosures()
inv_enclosures = []
drive_devices: list[str] = []
for enc in enclosures:
slots = list_slots(enc["id"])
inv_enclosures.append({**enc, "slots": slots})
for s in slots:
if s["device"]:
drive_devices.append(s["device"])
# 2. ZFS topology (gated).
pool_map = await store.get_zfs_map()
try:
pool_map = await get_zfs_pool_map()
await store.set_zfs_map(pool_map)
except Exception as e:
errors.append(f"zfs:{e}")
# 3. Per-drive SMART, one at a time with a gap between each.
for dev in drive_devices:
try:
data = await _run_smartctl(dev)
await store.set_smart(dev, data)
except Exception as e:
errors.append(f"smart:{dev}:{e}")
await asyncio.sleep(DRIVE_GAP)
# 4. Per-enclosure SES status (gated), paced.
for enc in enclosures:
if not enc.get("sg_device"):
continue
try:
ses = await get_enclosure_status(enc["sg_device"])
if ses is not None:
await store.set_ses(enc["id"], ses)
except Exception as e:
errors.append(f"ses:{enc['id']}:{e}")
await asyncio.sleep(DRIVE_GAP)
# 5. Host (non-enclosure) drives + MegaRAID (gated, reuses pool_map).
try:
host_drives = await get_host_drives(pool_map)
await store.set_host_drives(host_drives)
except Exception as e:
errors.append(f"host:{e}")
# 6. Inventory + heartbeat.
await store.set_inventory({"enclosures": inv_enclosures, "ts": store.now()})
duration = round(time.monotonic() - t0, 1)
await store.set_meta({
"last_sweep_ts": store.now(),
"last_sweep_duration": duration,
"drive_count": len(drive_devices),
"enclosure_count": len(enclosures),
"errors": errors[:20],
"sweep_interval": SWEEP_INTERVAL,
})
logger.info(
"sweep done: %d drives, %d enclosures, %.1fs, %d error(s)",
len(drive_devices), len(enclosures), duration, len(errors),
)
async def led_consumer() -> None:
"""Drain locate-LED requests from Redis and run them (gated)."""
while True:
try:
cmd = await store.pop_led(timeout=5)
if cmd:
try:
await run_led(cmd["device"], cmd["state"])
logger.info("LED %s -> %s", cmd.get("device"), cmd.get("state"))
except Exception as e:
logger.warning("LED command failed %s: %s", cmd, e)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("LED consumer error: %s", e)
await asyncio.sleep(2)
async def lock_refresher() -> None:
while True:
await asyncio.sleep(LOCK_TTL / 3)
if not await store.refresh_lock(TOKEN, LOCK_TTL):
logger.error("lost poller lock — exiting to avoid double-polling")
os._exit(1)
async def main() -> None:
await init_cache()
while not redis_available():
logger.warning("Redis unavailable — poller needs Redis; retrying in 5s")
await asyncio.sleep(5)
await init_cache()
# Single-instance guard.
while not await store.acquire_lock(TOKEN, LOCK_TTL):
logger.warning("another poller holds the lock; waiting %ds", LOCK_TTL // 2)
await asyncio.sleep(LOCK_TTL // 2)
logger.info("poller lock acquired (%s)", TOKEN)
if not smartctl_available():
logger.warning("smartctl not found — install smartmontools")
if not sg_ses_available():
logger.warning("sg_ses not found — install sg3-utils")
if os.geteuid() != 0:
logger.warning("not running as root — smartctl/sg_ses may fail")
# Stagger startup so a restart doesn't immediately storm the bus.
jitter = random.uniform(0, STARTUP_JITTER)
logger.info("startup jitter %.1fs", jitter)
await asyncio.sleep(jitter)
refresher = asyncio.create_task(lock_refresher())
leds = asyncio.create_task(led_consumer())
try:
while True:
t0 = time.monotonic()
try:
await sweep()
except Exception as e:
logger.error("sweep error: %s", e)
elapsed = time.monotonic() - t0
await asyncio.sleep(max(5, SWEEP_INTERVAL - elapsed))
finally:
refresher.cancel()
leds.cancel()
await close_cache()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass