Files
l5y c175445251 ingestor: fix serial connection failures (#736)
* ingestor: fix serial connection failures

* ingestor: address review comments

* ingestor: address review comments

* ingestor: further hardening

* ingestor: add tests

* ingestor: address review comments

* ingestor: address review comments
2026-04-13 23:42:07 +02:00

593 lines
20 KiB
Python

# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Priority queue for POST operations."""
from __future__ import annotations
import heapq
import itertools
import json
import threading
import urllib.request
from dataclasses import dataclass, field
from typing import Callable, Iterable, Mapping, Tuple
from . import config
def _stringify_payload_value(value: object) -> str:
"""Return a stable string representation for ``value``."""
if isinstance(value, Mapping):
try:
return json.dumps(
{
str(key): value[key]
for key in sorted(value, key=lambda item: str(item))
},
sort_keys=True,
ensure_ascii=False,
default=str,
)
except Exception: # pragma: no cover - defensive guard
return str(value)
if isinstance(value, (list, tuple)):
try:
return json.dumps(list(value), ensure_ascii=False, default=str)
except Exception: # pragma: no cover - defensive guard
return str(value)
if isinstance(value, set):
try:
return json.dumps(sorted(value, key=str), ensure_ascii=False, default=str)
except Exception: # pragma: no cover - defensive guard
return str(value)
if isinstance(value, bytes):
return json.dumps(value.decode("utf-8", "replace"), ensure_ascii=False)
if isinstance(value, str):
return json.dumps(value, ensure_ascii=False)
return str(value)
def _payload_key_value_pairs(payload: Mapping[str, object]) -> str:
"""Serialise ``payload`` into ``key=value`` pairs for debug logs."""
pairs: list[str] = []
for key in sorted(payload):
try:
formatted = _stringify_payload_value(payload[key])
except Exception: # pragma: no cover - defensive guard
formatted = str(payload[key])
pairs.append(f"{key}={formatted}")
return " ".join(pairs)
_INGESTOR_POST_PRIORITY = 0
_CHANNEL_POST_PRIORITY = 10
_NODE_POST_PRIORITY = 20
_MESSAGE_POST_PRIORITY = 30
_NEIGHBOR_POST_PRIORITY = 40
_TRACE_POST_PRIORITY = 50
_POSITION_POST_PRIORITY = 60
_TELEMETRY_POST_PRIORITY = 70
_DEFAULT_POST_PRIORITY = 90
_MAX_SEND_RETRIES = 3
"""Maximum number of times a failed POST item is re-queued before being dropped."""
@dataclass
class QueueState:
"""Mutable state for the HTTP POST priority queue."""
lock: threading.Lock = field(default_factory=threading.Lock)
# Heap tuple: (priority, counter, path, payload, retries).
queue: list[tuple[int, int, str, dict, int]] = field(default_factory=list)
counter: Iterable[int] = field(default_factory=itertools.count)
active: bool = False
# Background drain thread. When the drainer is alive, _queue_post_json
# signals drain_event instead of blocking the caller with HTTP calls.
drain_event: threading.Event = field(default_factory=threading.Event)
drainer: threading.Thread | None = None
# Set to request the drainer thread to exit its loop cleanly.
shutdown: threading.Event = field(default_factory=threading.Event)
STATE = QueueState()
def _send_single(
instance: str,
api_token: str,
path: str,
payload: dict,
) -> bool:
"""Transmit a single JSON payload to one instance.
Parameters:
instance: Base URL of the target instance.
api_token: Bearer token for this instance (may be empty).
path: API path relative to the instance root.
payload: JSON-serialisable body to transmit.
Returns:
``True`` when the request succeeded, ``False`` on failure.
"""
if not instance:
return True
url = f"{instance}{path}"
data = json.dumps(payload).encode("utf-8")
# Add full headers to avoid Cloudflare blocks on instances behind cloudflare proxy
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Origin": f"{instance}",
"Referer": f"{instance}",
}
if api_token:
headers["Authorization"] = f"Bearer {api_token}"
req = urllib.request.Request(
url,
data=data,
headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()
return True
except Exception as exc:
config._debug_log(
"POST request failed",
context="queue.post_json",
severity="warn",
always=True,
url=url,
error_class=exc.__class__.__name__,
error_message=str(exc),
)
return False
def _post_json(
path: str,
payload: dict,
*,
instance: str | None = None,
api_token: str | None = None,
) -> bool:
"""Send a JSON payload to one or more configured web API instances.
When ``instance`` is provided explicitly the payload is sent to that
single target. Otherwise every ``(url, token)`` pair in
:data:`config.INSTANCES` receives the payload independently so that
one failure does not block delivery to the remaining targets.
Parameters:
path: API path relative to the instance root.
payload: JSON-serialisable body to transmit.
instance: Optional single-instance override.
api_token: Optional token override (only used with ``instance``).
Returns:
``True`` when at least one instance received the payload
successfully, ``False`` when all targets failed. A missing
configuration is not a transient failure and returns ``True``
(retrying would not help).
"""
if instance is not None:
if not instance:
return True
return _send_single(instance, api_token or "", path, payload)
targets: tuple[tuple[str, str], ...] = config.INSTANCES
if not targets:
# Backward-compatible fallback for callers that only set
# config.INSTANCE / config.API_TOKEN directly.
inst = config.INSTANCE
if not inst:
try:
config._debug_log(
"No target instances configured; discarding payload",
context="queue.post_json",
severity="error",
always=True,
path=path,
)
except Exception:
pass
return False
return _send_single(inst, api_token or config.API_TOKEN, path, payload)
any_ok = False
any_attempted = False
for inst, token in targets:
if not inst:
continue
any_attempted = True
if _send_single(inst, token, path, payload):
any_ok = True
return any_ok or not any_attempted
def _enqueue_post_json(
path: str,
payload: dict,
priority: int,
*,
state: QueueState = STATE,
retries: int = 0,
) -> None:
"""Store a POST request in the priority queue.
Parameters:
path: API path for the queued request.
payload: JSON-serialisable body.
priority: Lower values execute first.
state: Shared queue state, injectable for testing.
retries: Number of prior failed send attempts for this item.
"""
with state.lock:
counter = next(state.counter)
# Heap tuple: (priority, counter, path, payload, retries). Lower
# priority values are dequeued first (min-heap semantics). The
# monotonically increasing counter breaks ties so equal-priority
# items are processed in FIFO order without comparing the
# non-orderable payload dict.
heapq.heappush(state.queue, (priority, counter, path, payload, retries))
def _drain_post_queue(
state: QueueState = STATE, send: Callable[[str, dict], None] | None = None
) -> None:
"""Process queued POST requests in priority order.
When the *send* callable returns ``False`` (transient failure) the item
is re-queued up to :data:`_MAX_SEND_RETRIES` times. Items exceeding
the limit are dropped with a warning. Custom *send* callables that
return ``None`` (the typical test/heartbeat pattern) are never retried
— the ``result is False`` identity check ensures backward compatibility.
Parameters:
state: Queue container holding pending items.
send: Optional callable used to transmit requests.
"""
if send is None:
send = _post_json
try:
while True:
with state.lock:
if not state.queue:
state.active = False
return
item = heapq.heappop(state.queue)
# Support both 5-tuple (current) and 4-tuple (legacy/test) items.
if len(item) >= 5:
priority, _idx, path, payload, retries = item[:5]
else:
priority, _idx, path, payload = item[:4]
retries = 0
result = send(path, payload)
# Only retry when the send callable explicitly signals failure
# (returns False). Custom send callables (tests, heartbeat)
# return None and must NOT be treated as failures.
if result is False:
if retries < _MAX_SEND_RETRIES:
_enqueue_post_json(
path, payload, priority, state=state, retries=retries + 1
)
else:
try:
config._debug_log(
"Dropping item after max retries",
context="queue.drain",
severity="warn",
always=True,
path=path,
retries=retries,
)
except Exception:
pass
finally:
with state.lock:
state.active = False
_QUEUE_DEPTH_WARNING_THRESHOLD = 100
"""Log a warning when the queue grows past this many items."""
def _queue_drainer_loop(state: QueueState = STATE) -> None:
"""Body of the background queue-drain daemon thread.
Blocks on :attr:`QueueState.drain_event`, clears it, then empties the
queue by calling :func:`_drain_post_queue`. The thread is created as a
daemon so it terminates automatically when the process exits.
The loop exits cleanly when :attr:`QueueState.shutdown` is set, allowing
tests (and graceful-shutdown paths) to join the thread instead of leaking
daemon threads that accumulate across a test run.
The loop is deliberately hardened so that **no** :class:`Exception` can
kill the thread. The ``_debug_log`` calls inside the error handler are
themselves wrapped in ``try/except`` to prevent cascading failures
(e.g. ``BrokenPipeError`` from ``print()`` to a closed stdout).
.. note::
There is a benign race between ``drain_event.clear()`` and the end
of :func:`_drain_post_queue`: a signal arriving in that window is
consumed by ``clear()`` but the item is still drained because the
drain loop empties the queue completely. However, an item enqueued
*after* the drain loop finds the queue empty and *before*
``wait()`` re-blocks will sit until the next ``drain_event.set()``
call (i.e. the next enqueue). This is acceptable for a best-effort
ingestor — maximum extra latency equals the inter-packet interval.
Parameters:
state: Queue state instance to drain.
"""
try:
config._debug_log(
"Queue drainer thread started",
context="queue.drainer",
severity="info",
always=True,
)
except Exception:
pass
while not state.shutdown.is_set():
state.drain_event.wait(timeout=1.0)
if state.shutdown.is_set():
break
state.drain_event.clear()
depth = len(state.queue)
if depth > _QUEUE_DEPTH_WARNING_THRESHOLD:
try:
config._debug_log(
"Queue depth warning",
context="queue.drainer",
severity="warn",
always=True,
depth=depth,
)
except Exception:
pass
try:
_drain_post_queue(state)
except Exception as exc:
try:
config._debug_log(
"Queue drainer error",
context="queue.drainer",
severity="error",
always=True,
error_class=exc.__class__.__name__,
error_message=str(exc),
)
except Exception:
pass
try:
config._debug_log(
"Queue drainer thread exiting",
context="queue.drainer",
severity="info",
always=True,
)
except Exception:
pass
def _start_queue_drainer(state: QueueState = STATE) -> None:
"""Idempotently start the background queue-drain thread.
Calling this function when a drainer thread is already alive is a
no-op. The thread is created as a daemon so it does not prevent
process exit. The check-and-start is performed under :attr:`state.lock`
to avoid starting duplicate threads under concurrent callers.
If items are already in the queue when the drainer is started,
:attr:`QueueState.drain_event` is signalled immediately so they are not
stranded waiting for the next packet to arrive.
Parameters:
state: Queue state whose :func:`_queue_drainer_loop` to start.
"""
with state.lock:
if state.drainer is not None and state.drainer.is_alive():
return
# Reset in case the prior thread was stopped or crashed while
# shutdown was already set.
state.shutdown.clear()
t = threading.Thread(
target=_queue_drainer_loop,
args=(state,),
name="queue-drainer",
daemon=True,
)
t.start()
state.drainer = t
if state.queue:
state.drain_event.set()
def _stop_queue_drainer(state: QueueState = STATE, timeout: float = 5.0) -> None:
"""Signal the drainer thread to exit and wait for it to finish.
Sets :attr:`QueueState.shutdown` and :attr:`QueueState.drain_event` so
the loop wakes up, observes the shutdown flag, and terminates. After
joining (up to *timeout* seconds) the drainer reference is cleared.
Safe to call when no drainer is running (no-op).
Parameters:
state: Queue state whose drainer to stop.
timeout: Maximum seconds to wait for the thread to finish.
"""
if state.drainer is None or not state.drainer.is_alive():
return
state.shutdown.set()
state.drain_event.set()
state.drainer.join(timeout=timeout)
state.drainer = None
def _queue_post_json(
path: str,
payload: dict,
*,
priority: int = _DEFAULT_POST_PRIORITY,
state: QueueState = STATE,
send: Callable[[str, dict], None] | None = None,
) -> None:
"""Queue a POST request and wake the drain thread (or drain inline).
When a background drainer thread is running (started via
:func:`_start_queue_drainer`), this function enqueues the item and
signals :attr:`QueueState.drain_event` without blocking — the drain
happens on the dedicated thread. This keeps the caller's thread (which
may be the Meshtastic asyncio I/O thread) free to process serial events.
When no background drainer is alive the call falls back to a
synchronous inline drain. This path is used by tests (which pass a
``send`` override via :func:`_fresh_state`) and for any standalone use
without calling :func:`_start_queue_drainer`.
.. note::
The background drainer is used **only** when no custom ``send``
override is provided (i.e. the production ``_post_json`` path).
Any caller that supplies a custom ``send`` (tests, heartbeat
helpers) always gets the synchronous inline drain so its transport
is honoured correctly.
Parameters:
path: API path for the request.
payload: JSON payload to send.
priority: Scheduling priority where lower values run first.
state: Queue container used to store pending requests.
send: Optional transport override (synchronous fallback only).
"""
if send is None:
send = _post_json
if config.DEBUG:
formatted_payload = (
_payload_key_value_pairs(payload)
if isinstance(payload, Mapping)
else str(payload)
)
config._debug_log(
f"Forwarding payload to API: {formatted_payload}",
context="queue.queue_post_json",
path=path,
priority=priority,
)
_enqueue_post_json(path, payload, priority, state=state)
# Use the background drainer only when it is alive AND no custom send
# override is in play. A custom send (used by tests and callers such as
# ingestors.queue_ingestor_heartbeat) must be honoured synchronously
# because the background drainer always calls _drain_post_queue without
# a send override.
#
# The ``is`` check is intentional: _post_json is a module-level function
# so identity comparison reliably detects the "no override" default that
# was assigned at the top of this function.
if send is _post_json:
if state.drainer is not None and state.drainer.is_alive():
state.drain_event.set()
return
# The drainer was previously started but has died (e.g. unhandled
# exception). Restart it so the caller stays non-blocking and the
# MeshCore asyncio event loop is not stalled by inline HTTP calls.
if state.drainer is not None:
try:
config._debug_log(
"Restarting dead queue drainer thread",
context="queue.queue_post_json",
severity="warn",
always=True,
)
except Exception:
pass
_start_queue_drainer(state)
# If the restart succeeded, delegate to the background thread.
if state.drainer is not None and state.drainer.is_alive():
state.drain_event.set()
return
# Synchronous fallback: no drainer was ever started, the restart
# failed, or a custom send override is in play.
with state.lock:
if state.active:
return
state.active = True
_drain_post_queue(state, send=send)
def _clear_post_queue(state: QueueState = STATE) -> None:
"""Clear the pending POST queue.
Parameters:
state: Queue state to reset. Defaults to the global queue.
"""
with state.lock:
state.queue.clear()
state.active = False
__all__ = [
"STATE",
"QueueState",
"_CHANNEL_POST_PRIORITY",
"_DEFAULT_POST_PRIORITY",
"_INGESTOR_POST_PRIORITY",
"_MAX_SEND_RETRIES",
"_MESSAGE_POST_PRIORITY",
"_NEIGHBOR_POST_PRIORITY",
"_NODE_POST_PRIORITY",
"_POSITION_POST_PRIORITY",
"_QUEUE_DEPTH_WARNING_THRESHOLD",
"_TRACE_POST_PRIORITY",
"_TELEMETRY_POST_PRIORITY",
"_clear_post_queue",
"_drain_post_queue",
"_enqueue_post_json",
"_post_json",
"_queue_drainer_loop",
"_queue_post_json",
"_start_queue_drainer",
"_stop_queue_drainer",
]