File size: 9,249 Bytes
6778532
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""Rolling performance telemetry for ranking a node inside the mesh.

``meshstack_model_endpoints_v2`` has **no** latency, success-rate or load
columns. It has ``capabilities jsonb``, ``resource_requirements jsonb`` and a
``status`` enum. Rather than propose new columns (which would fork the Mesh
backend lane's schema mid-flight), this module packs telemetry into the jsonb
fields the contract already defines, under a namespaced ``thox_telemetry`` key
so it cannot collide with capability flags that ThoxRoute matches on.

That choice has a real consequence worth stating: jsonb values are not indexed
for range queries here, so ranking on latency happens in the router, not in
Postgres. At this tier's endpoint counts that is the right trade; if the mesh
ever holds thousands of endpoints, promoting these to real columns becomes
worthwhile and this module is the single place that changes.

The ``status`` field is the one piece of telemetry the schema *does* model, so
health degradation is mapped onto it: a node whose recent success rate collapses
reports ``degraded`` instead of silently continuing to advertise ``healthy``.
"""

from __future__ import annotations

import threading
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Any, Deque

#: Endpoint status values permitted by the v2 schema's CHECK constraint.
STATUS_HEALTHY = "healthy"
STATUS_DEGRADED = "degraded"
STATUS_OFFLINE = "offline"

#: Success rate below which the node self-reports ``degraded``.
DEGRADED_SUCCESS_RATE = 0.80

#: Minimum completed requests before success rate is trusted for status.
MIN_SAMPLES_FOR_STATUS = 5


@dataclass
class _Sample:
    """One completed inference request."""

    latency_ms: float
    ok: bool
    completion_tokens: int
    at: float = field(default_factory=time.time)


class TelemetryRecorder:
    """Thread-safe rolling window of request outcomes.

    A bounded deque is used rather than cumulative counters so the reported
    figures track *current* behaviour. On a Colab node that matters: a session
    that was fast for an hour and then hits a throttled GPU should start
    advertising the degraded reality within a window, not average it away.
    """

    def __init__(self, window: int = 100, *, clock=time.time) -> None:
        if window < 1:
            raise ValueError("window must be >= 1")
        self._samples: Deque[_Sample] = deque(maxlen=window)
        self._lock = threading.Lock()
        self._clock = clock
        self._in_flight = 0
        self._started_at = clock()
        self._total = 0
        self._total_failed = 0

    # ── Recording ────────────────────────────────────────────────────────

    def request_started(self) -> None:
        with self._lock:
            self._in_flight += 1

    def request_finished(self, *, latency_ms: float, ok: bool, completion_tokens: int = 0) -> None:
        with self._lock:
            self._in_flight = max(0, self._in_flight - 1)
            self._total += 1
            if not ok:
                self._total_failed += 1
            self._samples.append(
                _Sample(
                    latency_ms=float(latency_ms),
                    ok=bool(ok),
                    completion_tokens=int(completion_tokens),
                    at=self._clock(),
                )
            )

    # ── Derived metrics ──────────────────────────────────────────────────

    def snapshot(self) -> dict[str, Any]:
        """Current metrics. Safe to call from the heartbeat thread."""
        with self._lock:
            samples = list(self._samples)
            in_flight = self._in_flight
            total = self._total
            total_failed = self._total_failed
            uptime = max(0.0, self._clock() - self._started_at)

        if not samples:
            return {
                "samples": 0,
                "in_flight": in_flight,
                "total_requests": total,
                "total_failed": total_failed,
                "success_rate": None,
                "latency_ms_p50": None,
                "latency_ms_p95": None,
                "tokens_per_second": None,
                "uptime_seconds": round(uptime, 1),
            }

        latencies = sorted(s.latency_ms for s in samples)
        ok_count = sum(1 for s in samples if s.ok)
        elapsed = max(1e-6, samples[-1].at - samples[0].at) if len(samples) > 1 else None
        tokens = sum(s.completion_tokens for s in samples)
        return {
            "samples": len(samples),
            "in_flight": in_flight,
            "total_requests": total,
            "total_failed": total_failed,
            "success_rate": round(ok_count / len(samples), 4),
            "latency_ms_p50": round(_percentile(latencies, 0.50), 1),
            "latency_ms_p95": round(_percentile(latencies, 0.95), 1),
            "tokens_per_second": round(tokens / elapsed, 2) if elapsed and tokens else None,
            "uptime_seconds": round(uptime, 1),
        }

    def status(self) -> str:
        """Map telemetry onto the schema's ``status`` enum.

        Only demotes once there is enough evidence; a single early failure on a
        cold node should not pull the endpoint out of the candidate set.
        """
        snap = self.snapshot()
        rate = snap["success_rate"]
        if rate is None or snap["samples"] < MIN_SAMPLES_FOR_STATUS:
            return STATUS_HEALTHY
        return STATUS_HEALTHY if rate >= DEGRADED_SUCCESS_RATE else STATUS_DEGRADED

    def load_factor(self) -> float:
        """Concurrent requests in flight, as a plain gauge for the router."""
        with self._lock:
            return float(self._in_flight)


def _percentile(sorted_values: list[float], q: float) -> float:
    """Nearest-rank percentile over a pre-sorted list."""
    if not sorted_values:
        raise ValueError("cannot take a percentile of an empty list")
    if len(sorted_values) == 1:
        return sorted_values[0]
    index = min(len(sorted_values) - 1, max(0, int(round(q * (len(sorted_values) - 1)))))
    return sorted_values[index]


def build_capabilities(
    *,
    base: dict[str, Any],
    recorder: TelemetryRecorder,
    node_kind: str,
    ephemeral: bool,
    heartbeat_seconds: int,
    ttl_seconds: int,
) -> dict[str, Any]:
    """Compose the ``capabilities`` jsonb sent on register and heartbeat.

    ``base`` holds the classification/capability flags ThoxRoute matches on
    (``chat``, ``code``, ``reasoning``, ...). ``resolve_route`` in the edge
    function does a truthiness lookup ``capabilities[requested_capability]``, so
    those flags must stay at the top level as plain booleans β€” telemetry is
    namespaced beneath ``thox_telemetry`` precisely to keep that lookup clean.

    ``thox_lease`` carries the ephemerality contract. The backend does not reap
    stale endpoints today, so the node publishes its own TTL and the router
    treats an endpoint whose ``expires_at`` has passed as ineligible. This is a
    client-side lease: it degrades safely if the backend later grows a reaper.
    """
    now = time.time()
    capabilities = dict(base)
    capabilities["thox_telemetry"] = recorder.snapshot()
    capabilities["thox_node"] = {
        "kind": node_kind,
        "ephemeral": ephemeral,
        "agent": "thoxmesh-node",
    }
    capabilities["thox_lease"] = {
        "heartbeat_seconds": heartbeat_seconds,
        "ttl_seconds": ttl_seconds,
        "renewed_at": _iso(now),
        "expires_at": _iso(now + ttl_seconds),
    }
    return capabilities


def is_lease_expired(capabilities: dict[str, Any] | None, *, now: float | None = None) -> bool:
    """True when an endpoint's self-published lease has lapsed.

    Used by the router to skip endpoints left behind by a Colab session that
    died without deregistering β€” the dominant failure mode in this tier.
    Endpoints with no lease block are treated as live, so endpoints registered
    by other (non-ephemeral) device types are unaffected.
    """
    if not capabilities:
        return False
    lease = capabilities.get("thox_lease")
    if not isinstance(lease, dict):
        return False
    expires_at = lease.get("expires_at")
    if not isinstance(expires_at, str):
        return False
    parsed = _parse_iso(expires_at)
    if parsed is None:
        return False
    return (now if now is not None else time.time()) > parsed


def _iso(epoch_seconds: float) -> str:
    """UTC ISO-8601 with a trailing Z, matching the backend's timestamps."""
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch_seconds))


def _parse_iso(value: str) -> float | None:
    """Parse the subset of ISO-8601 this module emits. ``None`` when unparseable."""
    text = value.strip().replace("Z", "+0000")
    for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
        try:
            import datetime as _dt

            return _dt.datetime.strptime(text, fmt).timestamp()
        except ValueError:
            continue
    return None