🚧 Move imei_mapping and app_tokens to database

This commit is contained in:
Jonas Bengtsson
2026-09-04 22:16:06 +02:00
parent 8d4ffc1074
commit 46639a837c
10 changed files with 781 additions and 338 deletions
+84
View File
@@ -2,6 +2,9 @@
The network thread writes rows; writes happen only on that thread so a plain
(no pool) blocking connection is used, with reconnection on failure.
The IMEI -> device mapping lives in the imei_map table and is polled by a
separate daemon thread (ImeiMapReader) with its own connection.
"""
from __future__ import annotations
@@ -78,6 +81,87 @@ class Inserter:
self._conn.close()
except psycopg.Error:
pass
class ImeiMapReader:
"""Thread that polls the imei_map table for the IMEI -> device mapping.
Runs its OWN persistent autocommit connection (never shared with
Inserter, whose connection is funneled through the MQTT network thread).
Reconnects on failure; a failed poll keeps the previously read mapping,
mirroring the old broken-file-keeps-last-good-map semantics.
"""
POLL_INTERVAL_SECONDS = 5.0
def __init__(self, dsn: str) -> None:
self._dsn = dsn
self._lock = threading.Lock()
self._conn: Connection | None = None
self._imap: dict[str, str] = {}
self._stop = threading.Event()
self._thread = threading.Thread(
target=self._run, name="imei-map-poll", daemon=True
)
def _connection(self) -> Connection:
if self._conn is None or self._conn.closed:
logger.info("connecting to database (imei map poll)")
self._conn = psycopg.connect(self._dsn, autocommit=True)
return self._conn
def read(self) -> dict[str, str]:
"""Read the mapping from the database (single SELECT).
Raises psycopg.Error on connection/query failure; the caller keeps
the last good mapping."""
conn = self._connection()
with conn.cursor() as cur:
cur.execute("SELECT imei, device FROM imei_map")
return dict(cur.fetchall())
def _run(self) -> None:
"""Poll loop; runs in its own daemon thread."""
while not self._stop.wait(timeout=self.POLL_INTERVAL_SECONDS):
try:
fresh = self.read()
except psycopg.Error:
logger.exception("IMEI map poll failed; keeping previous mapping")
self._close()
continue
with self._lock:
if fresh != self._imap:
self._imap = fresh
logger.info("IMEI map updated: %d mappings", len(fresh))
def start(self) -> None:
self._thread.start()
def prime(self, mapping: dict[str, str]) -> None:
"""Set the initial mapping (call before start())."""
with self._lock:
self._imap = dict(mapping)
def snapshot(self) -> dict[str, str]:
"""Thread-safe copy of the current mapping for the ingest path."""
with self._lock:
return dict(self._imap)
def _close(self) -> None:
if self._conn is not None:
try:
self._conn.close()
except psycopg.Error:
pass
self._conn = None
def close(self) -> None:
"""Stop the poll thread and close the connection."""
self._stop.set()
if self._thread.is_alive():
self._thread.join(timeout=self.POLL_INTERVAL_SECONDS)
with self._lock:
self._close()
self._conn = None
def close(self) -> None:
+16 -37
View File
@@ -9,7 +9,6 @@ may be retried or dropped depending on QoS.
from __future__ import annotations
import json
import logging
import os
import signal
@@ -17,10 +16,11 @@ import threading
from pathlib import Path
import paho.mqtt.client as mqtt
import psycopg
from paho.mqtt.enums import CallbackAPIVersion
from listener.db import Inserter
from listener.process import DEFAULT_TIME_KEYS, normalize_imei, process_message, validate_imei_map
from listener.db import ImeiMapReader, Inserter
from listener.process import DEFAULT_TIME_KEYS, process_message
logger = logging.getLogger("listener")
@@ -28,15 +28,6 @@ _stop_event = threading.Event()
_shutdown_complete = threading.Event()
def load_imei_map(path: str, log: bool = True) -> dict[str, str]:
"""Load and validate the IMEI -> device name map from a JSON file."""
data = json.loads(Path(path).read_text(encoding="utf-8"))
result = validate_imei_map(data, path)
if log:
logger.info("loaded %d IMEI mappings from %s", len(result), path)
return result
def _env(name: str, default: str | None = None) -> str:
value = os.environ.get(name)
if not value:
@@ -61,9 +52,17 @@ def main() -> None:
topic_filter = _env("MQTT_TOPIC_FILTER", "/+/+")
qos = int(_env("MQTT_QOS", "1"))
map_file = _env("IMEI_MAP_FILE")
imei_map = load_imei_map(map_file)
imei_lock = threading.Lock() # guards imei_map against the reload thread
map_reader = ImeiMapReader(_env("DB_DSN"))
# Fail fast when the db (or the imei_map table) is unreachable: without a
# mapping the listener would silently store every device name as its IMEI.
try:
initial_map = map_reader.read()
except psycopg.Error:
logger.exception("initial IMEI map read failed")
raise
map_reader.prime(initial_map)
map_reader.start()
logger.info("loaded %d IMEI mappings from the database", len(initial_map))
imei_key = _env("INJECT_IMEI_KEY", "imei")
device_key = _env("INJECT_DEVICE_KEY", "device")
time_keys = tuple(
@@ -78,25 +77,6 @@ def main() -> None:
Path("/tmp/listener.pid").write_text(str(os.getpid()), encoding="utf-8")
logger.info("listener starting, pid %d", os.getpid())
def reload_imei_map() -> None:
"""Periodically reload the IMEI map so edits via the API take effect
without a container restart. A broken file is logged and skipped; the
previously loaded mapping stays in effect."""
while not _stop_event.wait(timeout=5.0):
try:
fresh = load_imei_map(map_file, log=False)
except (OSError, ValueError, json.JSONDecodeError):
logger.exception("IMEI map reload failed; keeping previous mapping")
continue
nonlocal imei_map
with imei_lock:
if fresh != imei_map:
imei_map = fresh
logger.info("IMEI map reloaded: %d mappings", len(fresh))
reload_thread = threading.Thread(target=reload_imei_map, name="imei-map-reload", daemon=True)
reload_thread.start()
def on_connect(client, userdata, flags, reason_code, properties): # noqa: ANN001
if reason_code.is_failure:
logger.error("broker refused connection: %s", reason_code)
@@ -107,12 +87,10 @@ def main() -> None:
def on_message(client, userdata, message): # noqa: ANN001
try:
topic = message.topic
with imei_lock:
current_map = imei_map
results = process_message(
topic,
bytes(message.payload),
current_map,
map_reader.snapshot(),
imei_key=imei_key,
device_key=device_key,
time_keys=time_keys,
@@ -157,6 +135,7 @@ def main() -> None:
client.loop_stop()
client.disconnect()
inserter.close()
map_reader.close()
Path("/tmp/listener.pid").unlink(missing_ok=True)
logger.info("shutdown complete")