mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Phase 1 of the desktop relay-latency-health feature: add a rolling-window
latency tracker that decorates the quartz RelayConnectionListener, plus a
pure classifier that flags relays whose per-metric p50 exceeds 2× the cohort
median. No store integration or UI yet — those come in follow-up commits.
commons commonMain (CLI-safe, no Compose runtime, no JVM-only deps):
- LatencyMetric: OK_ACK / EOSE / FIRST_RESULT / PING
- MetricSample: @Immutable (p50Ms, count)
- RelayLatencySnapshot: @Immutable, backed by ImmutableMap so strong
skipping engages when unchanged rows are re-emitted
- SlowReason: @Immutable (metric, relayP50, cohortP50, multiplier)
- HealthReason sealed interface: Unresponsive(gap) | Slow(SlowReason)
- classifySlowRelays(): pure. Honors NIP-11 auth_required /
payment_required (paid/auth-only relays are excluded from both cohort
and target until auth completes — otherwise they'd be perpetually
flagged while CLOSED'ing anonymous queries).
commons jvmAndroid (ConcurrentHashMap is JVM-only):
- LatencyRingBuffer: fixed-capacity (default 50) IntArray ring,
synchronized push, snapshotMedian / snapshotSamples / restore.
- RelayLatencyTracker: pending-eventId / pending-subId / firstResultSeen
maps + per-(relay, metric) ring buffers. Handles every pairing rule
the deepened plan called out:
* onSent EventCmd → record eventId timestamp
* onSent ReqCmd → record subId timestamp; clear firstResultSeen
* onSent CloseCmd → drop pending subId (no sample) — prevents
ComposeSubscriptionManager's sub-id reuse from pairing late
events with a new REQ
* success=false → no-op (websocket buffer was full)
* OkMessage → pair by eventId, push OK_ACK
* EventMessage → first-only, push FIRST_RESULT
* EoseMessage → pair by subId, push EOSE
* ClosedMessage → drop pending (fast negative response, not a
latency signal — was previously recording 300s TTL samples for
any auth-required relay)
* onConnected → push PING
* onDisconnected → drop all pending (no TTL samples)
* sweep(now) → TTL-expire pending entries (60s OK / 300s REQ),
record TTL value as the sample
AUTH retries: the second onSent overwrites the timestamp, so samples
reflect the retry leg — matches the user's mental model of "speed of
the actual publish". Pending maps are size-capped at 256 entries per
relay as a safety net against adversarial relays. Tracker owns no
CoroutineScope — RelayHealthStore drives sweep + snapshot from its
existing 60s reclassify tick (Phase 2).
- RelayLatencyListener: thin RelayConnectionListener decorator,
installInto / uninstallFrom paralleling RelayHealthListener.
Tests:
- LatencyRingBufferTest (7): wrap, median odd/even, restore from larger
or smaller arrays, chronological snapshotSamples.
- RelayLatencyTrackerTest (13): OK pairing, EOSE + FIRST_RESULT pairing,
success=false ignore, CloseCmd drops pending, ClosedMessage drops
pending, AUTH retry overwrites timestamp, disconnect drops all,
sweep TTL semantics (OK vs REQ), FIRST_RESULT only sampled when not
yet seen, ping, per-relay isolation, 256-entry cap, restore
round-trip.
- ClassifySlowRelaysTest (9): empty, Tor short-circuit, cohort < 2,
2× flag, count-below-min excludes from cohort, NIP-11 auth_required
excludes / includes once auth complete, payment_required excludes,
worst-metric-multiplier wins when multiple flag, exact-2× does not
flag (strict greater-than).
Note: a pre-existing RelayHealthStoreCloseTest case on the base branch
(fix/relay-health-threading-and-sleep-resume) hangs in advanceUntilIdle.
Not related to this commit; new tests pass cleanly with a tighter test
filter. Will revisit when integrating Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>