mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 09:52:02 +02:00
c175445251
* 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
593 lines
20 KiB
Python
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",
|
|
]
|