@@ -137,7 +137,7 @@ sessionExpiry = 3600000 -- 1 hour
137maxSessionLifetime : Int<Ms>
137maxSessionLifetime : Int<Ms>
138maxSessionLifetime = 86400000 -- 24 hours; absolute session lifetime (forces re-auth even if sliding window keeps refreshing)
138maxSessionLifetime = 86400000 -- 24 hours; absolute session lifetime (forces re-auth even if sliding window keeps refreshing)
139onlineGossipTtl : Int<S>
139onlineGossipTtl : Int<S>
140−onlineGossipTtl = 3600 -- 1 hour; TTL announced when a key authenticates
140+onlineGossipTtl = 5400 -- 1.5 hours; 1.5x sessionExpiry to prevent presence expiring before session refresh gossips
141offlineGossipMinTtl : Int<S>
141offlineGossipMinTtl : Int<S>
142offlineGossipMinTtl = 60 -- minimum TTL for offline gossip events
142offlineGossipMinTtl = 60 -- minimum TTL for offline gossip events
143maxFederationConcurrency = 10 -- max concurrent outbound federation requests per cycle
143maxFederationConcurrency = 10 -- max concurrent outbound federation requests per cycle
@@ -206,13 +206,13 @@ federationLatencyT3 = 2000 -- slow (degraded or distant peer)
207-- Server build version: included in /health and /stats responses for deployment verification.
207-- Server build version: included in /health and /stats responses for deployment verification.
208-- Update this string when deploying a new build so operators can confirm which version is running.
208-- Update this string when deploying a new build so operators can confirm which version is running.
209−serverBuildVersion = "2026-04-26f"
209+serverBuildVersion = "2026-04-26g"
211-- Federation protocol version: included in gossip/forward/ack requests.
211-- Federation protocol version: included in gossip/forward/ack requests.
212-- Peers log warnings when they receive requests from older versions.
212-- Peers log warnings when they receive requests from older versions.
213-- Backward compatible: older peers ignore unknown fields (Knot's JSON parser
213-- Backward compatible: older peers ignore unknown fields (Knot's JSON parser
214-- drops unknown keys). 0 = pre-versioning peer (field missing or unset).
214-- drops unknown keys). 0 = pre-versioning peer (field missing or unset).
215−federationProtocolVersion = 14
215+federationProtocolVersion = 15
217-- Max seenBy list length in gossip events (bounds memory and bandwidth per event)
217-- Max seenBy list length in gossip events (bounds memory and bandwidth per event)
218maxSeenByLen = 20
218maxSeenByLen = 20
@@ -753,6 +753,10 @@ type CancelNotification = {toKey: PubkeyHex, messageId: MessageIdHex, cancelledA
754*pruneGeneration : Int
754*pruneGeneration : Int
756+-- Tracks the duration of the last completed prune cycle (ms) for dynamic stuck detection.
757+-- Updated after each successful prune. Initial 0 = use fixed 5x fallback.
758+*lastPruneDuration : Int<Ms>
756*ipRates : [IpRateEntry]
760*ipRates : [IpRateEntry]
758*lookupRates : [LookupRateEntry]
762*lookupRates : [LookupRateEntry]
@@ -968,6 +972,30 @@ maxPeerLatencyEntries = 100
968*clientSendLatencyCount : Int
972*clientSendLatencyCount : Int
969*clientSendLatencyMax : Int<Ms>
973*clientSendLatencyMax : Int<Ms>
975+-- Client latency histogram buckets (poll and send): [0,50ms), [50,200ms), [200,1000ms), [1000+ms)
976+-- Approximates P50/P95/P99 for client-facing operations. Reset with federation latency window.
977+*clientPollLatencyBucket0 : Int
978+*clientPollLatencyBucket1 : Int
979+*clientPollLatencyBucket2 : Int
980+*clientPollLatencyBucket3 : Int
981+*clientSendLatencyBucket0 : Int
982+*clientSendLatencyBucket1 : Int
983+*clientSendLatencyBucket2 : Int
984+*clientSendLatencyBucket3 : Int
986+-- Client latency histogram thresholds (ms)
987+clientLatencyT1 : Int<Ms>
989+clientLatencyT2 : Int<Ms>
990+clientLatencyT2 = 200
991+clientLatencyT3 : Int<Ms>
992+clientLatencyT3 = 1000
994+-- Auth (challenge + verify) latency tracking (for /stats, reset with latency window)
995+*clientAuthLatencyTotal : Int<Ms>
996+*clientAuthLatencyCount : Int
997+*clientAuthLatencyMax : Int<Ms>
971-- Last time the background watchdog loop ran (for health check liveness detection)
999-- Last time the background watchdog loop ran (for health check liveness detection)
972*lastWatchdogAt : Timestamp
1000*lastWatchdogAt : Timestamp
@@ -984,6 +1012,8 @@ maxPeerLatencyEntries = 100
984*cachedHealthForwardQueue : Bool
1012*cachedHealthForwardQueue : Bool
985-- Cached count of federation peers with active failure records (degraded connectivity)
1013-- Cached count of federation peers with active failure records (degraded connectivity)
986*cachedHealthDegradedPeers : Int
1014*cachedHealthDegradedPeers : Int
1015+-- Cached request pressure health (activeRequests < 90% of maxActiveRequests)
1016+*cachedHealthRequestPressure : Bool
987-- Cached count of destination servers with stale forward queues (updated during prune cycle).
1017-- Cached count of destination servers with stale forward queues (updated during prune cycle).
988-- Used by health checks to avoid scanning all 16 forward buckets on every /health request.
1018-- Used by health checks to avoid scanning all 16 forward buckets on every /health request.
989*cachedForwardStaleCount : Int
1019*cachedForwardStaleCount : Int
@@ -1122,6 +1152,13 @@ route FederationApi where
1122 POST {forwards: [{toKey: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, originServer: ServerName, messageId: MessageIdHex, expiresAt: Timestamp, priority: Int}], fromServer: ServerName, requestTimestamp: Timestamp, correlationId: Text, protocolVersion: Int} /forward-batch headers {authorization: Text}
1152 POST {forwards: [{toKey: PubkeyHex, encryptedBlob: BlobHex, timestamp: Timestamp, originServer: ServerName, messageId: MessageIdHex, expiresAt: Timestamp, priority: Int}], fromServer: ServerName, requestTimestamp: Timestamp, correlationId: Text, protocolVersion: Int} /forward-batch headers {authorization: Text}
1123 -> {ok: Bool, error: Text, results: [{messageId: MessageIdHex, ok: Bool}], retryAfterMs: Int}
1153 -> {ok: Bool, error: Text, results: [{messageId: MessageIdHex, ok: Bool}], retryAfterMs: Int}
1124 = FederationBatchForward
1154 = FederationBatchForward
1155+ -- Delivery receipt: destination server notifies origin that a federated message was
1156+ -- polled/acked by the recipient. Origin server records it as a sender delivery receipt.
1157+ -- Best-effort: failures are not retried (ack path already confirms delivery at the
1158+ -- protocol level; receipts are a UX enhancement for sender-side delivery indicators).
1159+ POST {messageIds: [MessageIdHex], toKeys: [PubkeyHex], fromServer: ServerName, requestTimestamp: Timestamp, correlationId: Text, protocolVersion: Int} /delivery-receipt headers {authorization: Text}
1160+ -> {ok: Bool, error: Text, receivedCount: Int, retryAfterMs: Int}
1161+ = FederationDeliveryReceipt
1126-- Federation callback verification: the receiving server calls back to the sender
1163-- Federation callback verification: the receiving server calls back to the sender
1127-- to confirm a request token is genuine. No shared secret needed.
1164-- to confirm a request token is genuine. No shared secret needed.
@@ -1232,7 +1269,7 @@ route MaintenanceApi where
1232 = RotateSecret
1269 = RotateSecret
1234route HealthApi where
1271route HealthApi where
1235− GET /health headers {authorization: Text} -> {ok: Bool, version: Text, pruneHealthy: Bool, heartbeatHealthy: Bool, capacityHealthy: Bool, capacityWarning: Bool, federationHealthy: Bool, watchdogHealthy: Bool, nonceHealthy: Bool, forwardQueueHealthy: Bool, draining: Bool, degradedPeers: Int} = Health
1272+ GET /health headers {authorization: Text} -> {ok: Bool, version: Text, pruneHealthy: Bool, heartbeatHealthy: Bool, capacityHealthy: Bool, capacityWarning: Bool, federationHealthy: Bool, watchdogHealthy: Bool, nonceHealthy: Bool, forwardQueueHealthy: Bool, requestPressureHealthy: Bool, draining: Bool, degradedPeers: Int} = Health
1237-- Returns counts of in-memory collections (requires maintenance secret)
1274-- Returns counts of in-memory collections (requires maintenance secret)
1238-- TODO: If Knot adds raw text response support, add a /metrics endpoint
1275-- TODO: If Knot adds raw text response support, add a /metrics endpoint
@@ -1242,7 +1279,7 @@ route HealthApi where
1242-- BREAKING CHANGE from flat format — existing scrapers must update field paths.
1279-- BREAKING CHANGE from flat format — existing scrapers must update field paths.
1243route StatsApi where
1280route StatsApi where
1244 GET /stats headers {authorization: Text}
1281 GET /stats headers {authorization: Text}
1245− -> {ok: Bool, version: Text, capacity: {sessions: Int, sessionsMax: Int, sessionsPct: Int, messages: Int, messagesMax: Int, messagesPct: Int, totalMessageCount: Int, presence: Int, presenceMax: Int, presencePct: Int, forwards: Int, forwardsMax: Int, forwardsPct: Int, challenges: Int, challengesMax: Int, ackedIds: Int, ackedIdsMax: Int, ackOverflow: Int, ackOverflowMax: Int, deliveredIds: Int, deliveredIdsMax: Int, forwardNonces: Int, forwardNoncesMax: Int, activePolls: Int, activePollsMax: Int, recipientCounts: Int, messageBucketMax: Int, messageBucketMin: Int}, federation: {enabled: Bool, inFlight: Int, hungSlots: Int, avgLatencyMs: Int, maxLatencyMs: Int, callCount: Int, slowVerifies: Int, verifyFailures: Int, forwardFailures: Int, oldestForwardAge: Int, oldestAckAge: Int, recentGossip: Int, blockedPeers: Int, drainCompletedAt: Timestamp, forwardServers: Int, maxForwardQueueDepth: Int, topFailingPeers: [{server: Text, failures: Int}], pendingCancels: Int, gossipSuccess: Int, gossipFail: Int, latencyBuckets: {lt100: Int, lt500: Int, lt2000: Int, gte2000: Int}}, rates: {send: Int, challenge: Int, federation: Int, federationPair: Int, global: Int, ip: Int, lookup: Int, recv: Int, pair: Int, gossipEvent: Int, verifyIp: Int, uniqueKeyIp: Int, pendingAckCleanups: Int, senderExpirations: Int, senderDeliveries: Int, rejectedGlobal: Int, rejectedIp: Int, rejectedAuth: Int, rejectedPerKey: Int, rejectedFederation: Int}, timing: {pruneCount: Int, pruneRunning: Timestamp, heartbeatCount: Int, uptime: Int, lastPruneAt: Timestamp, lastHeartbeatAt: Timestamp, draining: Bool, drainPhase: Int, totalEntries: Int, activeRequests: Int, peakActiveRequests: Int, pollAvgMs: Int, pollMaxMs: Int, pollCount: Int, sendAvgMs: Int, sendMaxMs: Int, sendCount: Int}}
1282+ -> {ok: Bool, version: Text, capacity: {sessions: Int, sessionsMax: Int, sessionsPct: Int, messages: Int, messagesMax: Int, messagesPct: Int, totalMessageCount: Int, presence: Int, presenceMax: Int, presencePct: Int, forwards: Int, forwardsMax: Int, forwardsPct: Int, challenges: Int, challengesMax: Int, ackedIds: Int, ackedIdsMax: Int, ackOverflow: Int, ackOverflowMax: Int, deliveredIds: Int, deliveredIdsMax: Int, forwardNonces: Int, forwardNoncesMax: Int, activePolls: Int, activePollsMax: Int, recipientCounts: Int, messageBucketMax: Int, messageBucketMin: Int}, federation: {enabled: Bool, inFlight: Int, hungSlots: Int, avgLatencyMs: Int, maxLatencyMs: Int, callCount: Int, slowVerifies: Int, verifyFailures: Int, forwardFailures: Int, oldestForwardAge: Int, oldestAckAge: Int, recentGossip: Int, blockedPeers: Int, drainCompletedAt: Timestamp, forwardServers: Int, maxForwardQueueDepth: Int, topFailingPeers: [{server: Text, failures: Int}], pendingCancels: Int, gossipSuccess: Int, gossipFail: Int, latencyBuckets: {lt100: Int, lt500: Int, lt2000: Int, gte2000: Int}}, rates: {send: Int, challenge: Int, federation: Int, federationPair: Int, global: Int, ip: Int, lookup: Int, recv: Int, pair: Int, gossipEvent: Int, verifyIp: Int, uniqueKeyIp: Int, pendingAckCleanups: Int, senderExpirations: Int, senderDeliveries: Int, rejectedGlobal: Int, rejectedIp: Int, rejectedAuth: Int, rejectedPerKey: Int, rejectedFederation: Int}, timing: {pruneCount: Int, pruneRunning: Timestamp, heartbeatCount: Int, uptime: Int, lastPruneAt: Timestamp, lastHeartbeatAt: Timestamp, draining: Bool, drainPhase: Int, totalEntries: Int, activeRequests: Int, peakActiveRequests: Int, pollAvgMs: Int, pollMaxMs: Int, pollCount: Int, pollLatencyBuckets: {lt50: Int, lt200: Int, lt1000: Int, gte1000: Int}, sendAvgMs: Int, sendMaxMs: Int, sendCount: Int, sendLatencyBuckets: {lt50: Int, lt200: Int, lt1000: Int, gte1000: Int}, authAvgMs: Int, authMaxMs: Int, authCount: Int}}
1247 GET /stats/peers headers {authorization: Text}
1284 GET /stats/peers headers {authorization: Text}
1248 -> {peers: [PeerStatus], ok: Bool}
1285 -> {peers: [PeerStatus], ok: Bool}
@@ -1267,6 +1304,14 @@ route StatsApi where
1267 -> {ok: Bool, scalePct: Int, globalRequest: Int, ipRate: Int, sendRate: Int, federationRate: Int, authGlobal: Int, recvRate: Int, challengeRate: Int, pairRate: Int, lookupRate: Int, clientWriteRate: Int}
1304 -> {ok: Bool, scalePct: Int, globalRequest: Int, ipRate: Int, sendRate: Int, federationRate: Int, authGlobal: Int, recvRate: Int, challengeRate: Int, pairRate: Int, lookupRate: Int, clientWriteRate: Int}
1268 = StatsRates
1305 = StatsRates
1307+-- Prometheus-compatible metrics endpoint. Returns key metrics in a JSON field containing
1308+-- Prometheus exposition format text. A lightweight scraper/adapter extracts the "metrics"
1309+-- field and serves it as text/plain to Prometheus. This is the best we can do since Knot
1310+-- routes always return JSON (no raw text response support).
1311+-- Rate-limited via health rate limiter (same as /ready and /federation/pubkey).
1312+route MetricsApi where
1313+ GET /metrics headers {authorization: Text} -> {metrics: Text, ok: Bool} = Metrics
1270-- Readiness probe: returns false during startup warmup (before first prune completes),
1315-- Readiness probe: returns false during startup warmup (before first prune completes),
1271-- true once the server is fully operational. Unlike /health, this is unauthenticated
1316-- true once the server is fully operational. Unlike /health, this is unauthenticated
1272-- and doesn't check subsystem liveness — it only indicates whether the server has
1317-- and doesn't check subsystem liveness — it only indicates whether the server has
@@ -1274,7 +1319,7 @@ route StatsApi where
1274route ReadyApi where
1319route ReadyApi where
1275 GET /ready -> {ready: Bool} = Ready
1320 GET /ready -> {ready: Bool} = Ready
1277−route Api = AuthApi | ClientApi | FederationApi | FederationVerifyApi | FederationPubkeyApi | MaintenanceApi | HealthApi | StatsApi | ReadyApi
1322+route Api = AuthApi | ClientApi | FederationApi | FederationVerifyApi | FederationPubkeyApi | MaintenanceApi | HealthApi | StatsApi | MetricsApi | ReadyApi
1279-- ============================================
1324-- ============================================
1280-- Helpers
1325-- Helpers
@@ -1775,7 +1820,7 @@ generateFederationAuth : Text -> Timestamp -> IO {network} Text
1775generateFederationAuth = \scope requestTimestamp ->
1820generateFederationAuth = \scope requestTimestamp ->
1776 if serverFederationPrivateKeyHex != ""
1821 if serverFederationPrivateKeyHex != ""
1778− nonce <- genTokenN 4
1823+ nonce <- genTokenN tokenChunks
1779 let message = textToBytes (scope ++ "|" ++ show requestTimestamp ++ "|" ++ serverHostN ++ "|" ++ nonce)
1824 let message = textToBytes (scope ++ "|" ++ show requestTimestamp ++ "|" ++ serverHostN ++ "|" ++ nonce)
1780 let sig = sign (bytesFromHex serverFederationPrivateKeyHex) message
1825 let sig = sign (bytesFromHex serverFederationPrivateKeyHex) message
1781 yield ("Signed " ++ bytesToHex sig ++ ":" ++ nonce)
1826 yield ("Signed " ++ bytesToHex sig ++ ":" ++ nonce)
@@ -2460,22 +2505,33 @@ pruneMessageBucket = \b t -> atomic do
2460 set *droppedExpirationNotifs = fold (\acc e -> incrementDropCounter acc e.senderKey 1 t) den perKeyDroppedEntries))
2505 set *droppedExpirationNotifs = fold (\acc e -> incrementDropCounter acc e.senderKey 1 t) den perKeyDroppedEntries))
2461 else yield {}
2506 else yield {}
2463−-- Prune all 16 message buckets with concurrency: fork first 15, run last in foreground.
2508+-- Prune all 16 message buckets with concurrency: fork non-empty buckets, then wait
2509+-- for all forks to complete via a counter-based barrier before returning. This ensures
2510+-- forward retries (which run after pruning) see fully pruned message state.
2464-- Each bucket's atomic block only contends on *totalMessageCount (a single Int);
2511-- Each bucket's atomic block only contends on *totalMessageCount (a single Int);
2465-- rcBuckets and message buckets are per-bucket and independent.
2512-- rcBuckets and message buckets are per-bucket and independent.
2466−-- STM contention on the shared counter is acceptable because: (1) per-bucket prune
2513+*pendingBucketPrunes : Int
2467−-- is fast (list filter + counter decrement), (2) prune runs every ~30s, not on the
2514+
2468−-- hot path, (3) Knot's fork is fire-and-forget so we can't collect per-bucket
2469−-- deltas and apply them in a single final atomic block.
2470pruneMessageBucketsConcurrent = \b t ->
2515pruneMessageBucketsConcurrent = \b t ->
2471− if b > 15 then yield {}
2516+ if b > 15 then do
2517+ -- Wait for all forked bucket prunes to complete via STM retry barrier.
2518+ -- Each fork decrements *pendingBucketPrunes; when it reaches 0, all are done.
2520+ pending <- *pendingBucketPrunes
2521+ if pending > 0 then retry else yield {}
2473 bucket <- readMessageBucket b
2523 bucket <- readMessageBucket b
2474 if count bucket == 0 then pruneMessageBucketsConcurrent (b + 1) t
2524 if count bucket == 0 then pruneMessageBucketsConcurrent (b + 1) t
2475− else if b < 15 then do
2525+ else do
2527+ p <- *pendingBucketPrunes
2528+ set *pendingBucketPrunes = p + 1
2476− fork (pruneMessageBucket b t)
2530+ pruneMessageBucket b t
2532+ p <- *pendingBucketPrunes
2533+ set *pendingBucketPrunes = max 0 (p - 1))
2477 pruneMessageBucketsConcurrent (b + 1) t
2534 pruneMessageBucketsConcurrent (b + 1) t
2478− else pruneMessageBucket b t
2480-- Per-bucket message reconciliation: reads one bucket per atomic block instead of all 16
2536-- Per-bucket message reconciliation: reads one bucket per atomic block instead of all 16
2481-- at once, reducing lock contention during reconciliation. Each pubkey maps to exactly one
2537-- at once, reducing lock contention during reconciliation. Each pubkey maps to exactly one
@@ -2486,31 +2542,33 @@ pruneMessageBucketsConcurrent = \b t ->
2486-- commutative with concurrent sendMessageCore: if sendMessageCore increments by N between our
2542-- commutative with concurrent sendMessageCore: if sendMessageCore increments by N between our
2487-- snapshot and final adjustment, the live counter becomes (snapshot + N + drift) = (actual + N),
2543-- snapshot and final adjustment, the live counter becomes (snapshot + N + drift) = (actual + N),
2488-- which is correct. The old approach overwrote with the absolute recount, losing concurrent N.
2544-- which is correct. The old approach overwrote with the absolute recount, losing concurrent N.
2489−-- Delta-based reconciliation: snapshots totalMessageCount before scanning, then applies
2545+-- Per-bucket atomic reconciliation: each bucket is reconciled independently, computing
2490−-- the computed drift atomically. This preserves concurrent sendMessageCore increments:
2546+-- its own delta and applying it to totalMessageCount atomically. This is safe with
2491−-- if sendMessageCore adds N messages between snapshot and final write, the live counter
2547+-- concurrent prune decrements: if a forked pruneMessageBucket modifies totalMessageCount
2492−-- becomes (snapshot + N) + (actual - snapshot) = actual + N, which is correct.
2548+-- between our bucket N and bucket N+1, each bucket's delta is applied independently and
2493−-- Old approach: set totalMessageCount = actual (overwrites concurrent N).
2549+-- the shared counter converges. The old snapshot-then-delta approach could overcorrect
2550+-- when concurrent decrements occurred between snapshot and final write.
2494reconcileMessageCountsDelta : IO Int
2551reconcileMessageCountsDelta : IO Int
2495−reconcileMessageCountsDelta = do
2552+reconcileMessageCountsDelta = reconcileMessageCountsPerBucket 0 0
2496− snapshot <- *totalMessageCount
2497− actual <- reconcileMessageCounts 0 0
2499− stored <- *totalMessageCount
2500− let drift = actual - snapshot
2501− set *totalMessageCount = max 0 (stored + drift)
2504−reconcileMessageCounts = \b totalAcc ->
2554+reconcileMessageCountsPerBucket = \b totalDrift ->
2505− if b > 15 then do
2555+ if b > 15 then yield totalDrift
2508− bucketTotal <- atomic do
2557+ bucketDrift <- atomic do
2509 bucket <- readMessageBucket b
2558 bucket <- readMessageBucket b
2510 let counts = countByRecipient bucket
2559 let counts = countByRecipient bucket
2560+ let actual = fold (\a c -> a + c.count) 0 counts
2561+ -- Read current rcBucket to compute drift vs what's tracked
2562+ oldCounts <- readRcBucket b
2563+ let tracked = fold (\a c -> a + c.count) 0 oldCounts
2511 writeRcBucket b counts
2564 writeRcBucket b counts
2512− yield (fold (\a c -> a + c.count) 0 counts)
2565+ -- Apply per-bucket drift to totalMessageCount atomically
2513− reconcileMessageCounts (b + 1) (totalAcc + bucketTotal)
2566+ let drift = actual - tracked
2567+ when (drift != 0) (do
2568+ stored <- *totalMessageCount
2569+ set *totalMessageCount = max 0 (stored + drift))
2571+ reconcileMessageCountsPerBucket (b + 1) (totalDrift + bucketDrift)
2515-- ============================================
2573-- ============================================
2516-- Recipient Count Helpers
2574-- Recipient Count Helpers
@@ -2808,16 +2866,29 @@ updateForwardNonce = \nb messageId toKey t -> do
2808 then case minBy evictedNonces (\n -> n.seenAt) of Nothing {} -> 0; Just {value: n} -> t - n.seenAt
2866 then case minBy evictedNonces (\n -> n.seenAt) of Nothing {} -> 0; Just {value: n} -> t - n.seenAt
2810 writeNonceBucket nb evictedNonces
2868 writeNonceBucket nb evictedNonces
2811− -- Update nonce eviction cutoff: the seenAt of the oldest retained nonce.
2869+ -- Update nonce eviction cutoff: recompute the global minimum oldest-retained-seenAt
2812− -- Forwards with timestamps older than this cannot be verified for dedup.
2870+ -- across all 16 buckets. A per-bucket-only approach incorrectly ratchets the cutoff
2871+ -- upward — a bucket with newer evictions would mask older retained nonces in other buckets,
2872+ -- causing legitimate forwards with older timestamps to be rejected.
2813 when (lruEvicted > 0) (do
2873 when (lruEvicted > 0) (do
2814− let oldestRetainedSeenAt = case minBy evictedNonces (\n -> n.seenAt) of Nothing {} -> t; Just {value: n} -> n.seenAt
2874+ globalMin <- computeGlobalNonceCutoff 0 0
2815− cutoff <- *nonceEvictionCutoff
2816− -- Only advance the cutoff (never regress): a bucket with older retained nonces
2817− -- shouldn't lower the cutoff set by a bucket with newer evictions.
2818− when (oldestRetainedSeenAt > cutoff) (set *nonceEvictionCutoff = oldestRetainedSeenAt))
2875+ set *nonceEvictionCutoff = globalMin)
2819 yield {lruEvicted: lruEvicted, oldestRetainedAge: oldestRetainedAge}
2876 yield {lruEvicted: lruEvicted, oldestRetainedAge: oldestRetainedAge}
2878+-- Compute the global nonce eviction cutoff: the minimum oldest-retained-seenAt across
2879+-- all 16 nonce buckets. Returns 0 if no bucket has undergone eviction (all nonces retained).
2880+-- O(16 * bucket_size) scan, called only after LRU eviction (not on every insert).
2881+computeGlobalNonceCutoff : Int -> Timestamp -> IO Timestamp
2882+computeGlobalNonceCutoff = \b acc ->
2883+ if b > 15 then yield acc
2885+ bucket <- readNonceBucket b
2886+ case minBy bucket (\n -> n.seenAt) of
2887+ Nothing {} -> computeGlobalNonceCutoff (b + 1) acc
2889+ let bucketMin = n.seenAt in
2890+ computeGlobalNonceCutoff (b + 1) (if acc == 0 then bucketMin else min acc bucketMin)
2821-- Log nonce LRU eviction if any occurred. Call outside atomic blocks.
2892-- Log nonce LRU eviction if any occurred. Call outside atomic blocks.
2822logNonceEviction : Int -> Int -> Int -> IO ()
2893logNonceEviction : Int -> Int -> Int -> IO ()
2823logNonceEviction = \nb lruEvicted oldestRetainedAge ->
2894logNonceEviction = \nb lruEvicted oldestRetainedAge ->
@@ -3432,12 +3503,21 @@ extractOldest = \items getKey ->
3432 else {candidate: acc.candidate, items: union acc.items [x]}
3503 else {candidate: acc.candidate, items: union acc.items [x]}
3433 ) {candidate: Nothing {}, items: []} items
3504 ) {candidate: Nothing {}, items: []} items
3435−-- Remove the single item with the smallest key value (LRU-style eviction)
3506+-- Remove the single item with the smallest key value (LRU-style eviction).
3507+-- Single-pass: first fold finds the min key, second fold filters it out.
3508+-- Two passes over the data but O(1) intermediate state (just the min key),
3509+-- vs extractOldest which builds a full items list during the fold.
3436evictOneOldest = \items getKey ->
3510evictOneOldest = \items getKey ->
3437− let r = extractOldest items getKey in
3511+ let minResult = fold (\acc x -> case acc of
3438− case r.candidate of
3512+ Nothing {} -> Just {value: getKey x}
3513+ Just {value: v} -> if getKey x < v then Just {value: getKey x} else acc) Nothing {} items
3514+ in case minResult of
3439 Nothing {} -> items
3515 Nothing {} -> items
3516+ Just {value: minKey} ->
3517+ let r = fold (\acc x ->
3518+ if not acc.done && getKey x == minKey then {items: acc.items, done: True {}}
3519+ else {items: union acc.items [x], done: acc.done}) {items: [], done: False {}} items
3440− Just {} -> r.items
3520+ in r.items
3442-- Remove the first item matching a predicate. O(n) single pass.
3522-- Remove the first item matching a predicate. O(n) single pass.
3443removeFirstMatch = \items pred ->
3523removeFirstMatch = \items pred ->
@@ -3512,6 +3592,7 @@ removeOneForKey = \items pubkey ->
3513-- Extract unique remote (non-local) server names from a list of items.
3593-- Extract unique remote (non-local) server names from a list of items.
3514-- O(n*k) where k = unique servers; acceptable since k is typically small (<20).
3594-- O(n*k) where k = unique servers; acceptable since k is typically small (<20).
3595+-- Knot lacks efficient set/map types, so fold-with-any is the best available approach.
3515-- NOTE: `union [s] acc` prepends (O(1) per step) vs `union acc [s]` which copies
3596-- NOTE: `union [s] acc` prepends (O(1) per step) vs `union acc [s]` which copies
3516-- acc (O(|acc|) per step). Since this is a set (order doesn't matter), prepend is
3597-- acc (O(|acc|) per step). Since this is a set (order doesn't matter), prepend is
3517-- preferred for large collections. All set-accumulating folds use this pattern.
3598-- preferred for large collections. All set-accumulating folds use this pattern.
@@ -4272,12 +4353,12 @@ withSessionAuth = \authorization t clientIp onUnauth onAuth -> do
4272 -- still stay alive as long as the client is active within any sessionExpiry window.
4353 -- still stay alive as long as the client is active within any sessionExpiry window.
4273 -- Uses pubkey pre-filter to find the matching session's expiry without safeCompare,
4354 -- Uses pubkey pre-filter to find the matching session's expiry without safeCompare,
4274 -- since pubkey was already verified by authenticateSession above.
4355 -- since pubkey was already verified by authenticateSession above.
4275− let needsRefresh = any sessions (\s -> s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 2)
4356+ let needsRefresh = any sessions (\s -> s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 3)
4276 when needsRefresh
4357 when needsRefresh
4277 -- Pubkey pre-filter on safeCompare: only runs constant-time comparison for
4358 -- Pubkey pre-filter on safeCompare: only runs constant-time comparison for
4278 -- sessions matching the authenticated pubkey (typically 1-2 per bucket),
4359 -- sessions matching the authenticated pubkey (typically 1-2 per bucket),
4279 -- skipping the expensive safeCompare for all unrelated sessions (~600/bucket).
4360 -- skipping the expensive safeCompare for all unrelated sessions (~600/bucket).
4280− (writeSessionBucket bucket (do s <- sessions; yield (if s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 2 && safeCompare s.token token then {token: s.token, pubkey: s.pubkey, expiresAt: t + sessionExpiry, ip: s.ip, createdAt: s.createdAt} else s)))
4361+ (writeSessionBucket bucket (do s <- sessions; yield (if s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 3 && safeCompare s.token token then {token: s.token, pubkey: s.pubkey, expiresAt: t + sessionExpiry, ip: s.ip, createdAt: s.createdAt} else s)))
4281 onAuth pubkey
4362 onAuth pubkey
4283-- Check if a pubkey has no remaining sessions and, if so, clean up local presence.
4364-- Check if a pubkey has no remaining sessions and, if so, clean up local presence.
@@ -5095,6 +5176,19 @@ pruneAndRetry = \t -> do
5095 set *clientSendLatencyTotal = 0
5176 set *clientSendLatencyTotal = 0
5096 set *clientSendLatencyCount = 0
5177 set *clientSendLatencyCount = 0
5097 set *clientSendLatencyMax = 0
5178 set *clientSendLatencyMax = 0
5179+ -- Reset client latency histogram buckets
5180+ set *clientPollLatencyBucket0 = 0
5181+ set *clientPollLatencyBucket1 = 0
5182+ set *clientPollLatencyBucket2 = 0
5183+ set *clientPollLatencyBucket3 = 0
5184+ set *clientSendLatencyBucket0 = 0
5185+ set *clientSendLatencyBucket1 = 0
5186+ set *clientSendLatencyBucket2 = 0
5187+ set *clientSendLatencyBucket3 = 0
5188+ -- Reset auth latency metrics
5189+ set *clientAuthLatencyTotal = 0
5190+ set *clientAuthLatencyCount = 0
5191+ set *clientAuthLatencyMax = 0
5098 set *federationLatencyResetAt = t)
5192 set *federationLatencyResetAt = t)
5099 -- Process deferred ack cleanups: reclaim origin messages whose grace period has elapsed.
5193 -- Process deferred ack cleanups: reclaim origin messages whose grace period has elapsed.
5100 -- These were queued by handleFederationAck instead of fork+sleep.
5194 -- These were queued by handleFederationAck instead of fork+sleep.
@@ -5288,9 +5382,9 @@ pollOnce = \token cursor clientIp -> do
5288 -- Avoids rewriting the session bucket on every poll (reduces write contention
5382 -- Avoids rewriting the session bucket on every poll (reduces write contention
5289 -- on the hottest path). Uses pubkey pre-filter so safeCompare only runs on
5383 -- on the hottest path). Uses pubkey pre-filter so safeCompare only runs on
5290 -- sessions matching the authenticated pubkey (~1-2 per bucket vs ~600).
5384 -- sessions matching the authenticated pubkey (~1-2 per bucket vs ~600).
5291− let needsRefresh = any sessions (\s -> s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 2)
5385+ let needsRefresh = any sessions (\s -> s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 3)
5292 when needsRefresh
5386 when needsRefresh
5293− (writeSessionBucket bucket (do s <- sessions; yield (if s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 2 && safeCompare s.token token then {token: s.token, pubkey: s.pubkey, expiresAt: t + sessionExpiry, ip: s.ip, createdAt: s.createdAt} else s)))
5387+ (writeSessionBucket bucket (do s <- sessions; yield (if s.pubkey == pubkey && s.expiresAt - t < sessionExpiry / 3 && safeCompare s.token token then {token: s.token, pubkey: s.pubkey, expiresAt: t + sessionExpiry, ip: s.ip, createdAt: s.createdAt} else s)))
5294 seqCounter <- *seqCounter
5388 seqCounter <- *seqCounter
5295 let clampedCursor = clamp 0 (max seqCounter t) cursor
5389 let clampedCursor = clamp 0 (max seqCounter t) cursor
5296 ackResult <- if clampedCursor > 0
5390 ackResult <- if clampedCursor > 0
@@ -5600,38 +5694,32 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp respond ->
5601verifyAuthInner = \pubkey challenge signature revokeOthers clientIp respond t -> do
5695verifyAuthInner = \pubkey challenge signature revokeOthers clientIp respond t -> do
5602 let expiry = t + sessionExpiry
5696 let expiry = t + sessionExpiry
5697+ -- Generate token before atomic block (IO can't run inside STM).
5698+ -- Token is discarded if challenge validation fails — no session is created.
5700+ -- Single atomic block for challenge verification + session creation.
5701+ -- Eliminates the TOCTOU gap where a challenge could be consumed without
5702+ -- creating a session (e.g., drain phase changes between two atomic blocks).
5603− validation <- atomic do
5703+ authResult <- atomic do
5604 challenges <- *challenges
5704 challenges <- *challenges
5605 let hasMatch = challengeMatch challenges pubkey challenge clientIp t
5705 let hasMatch = challengeMatch challenges pubkey challenge clientIp t
5606 -- Only consume the challenge on full success (match AND valid signature).
5706 -- Only consume the challenge on full success (match AND valid signature).
5607− -- This prevents an attacker who intercepts the challenge string from burning it
5608− -- by submitting a bad signature from the same IP. Rate limiter still constrains
5609− -- brute-force attempts; not consuming on IP mismatch prevents remote attackers
5610− -- from burning challenges.
5611− -- verify() is inside the atomic block to prevent a TOCTOU gap between challenge
5612− -- validation and consumption. It's pure (no IO) so safe in STM.
5707+ -- verify() is pure (no IO) so safe in STM.
5613 if not hasMatch
5708 if not hasMatch
5614− then yield {valid: False {}}
5709+ then yield {valid: False {}, result: Nothing {}}
5616 let sigValid = verify (bytesFromHex pubkey) (textToBytes challenge) (bytesFromHex signature)
5711 let sigValid = verify (bytesFromHex pubkey) (textToBytes challenge) (bytesFromHex signature)
5617 if not sigValid
5712 if not sigValid
5618− then yield {valid: False {}}
5713+ then yield {valid: False {}, result: Nothing {}}
5620 set *challenges = challengesExcluding challenges pubkey challenge
5715 set *challenges = challengesExcluding challenges pubkey challenge
5621− yield {valid: True {}}
5622− if validation.valid
5625− let newBucket = tokenBucket token
5626− authResult <- atomic do
5627− dp <- *drainPhase
5716+ dp <- *drainPhase
5628− if dp >= 1 then yield (Nothing {})
5717+ if dp >= 1 then yield {valid: True {}, result: Nothing {}}
5629− else do
5718+ else do
5630− tpc <- *totalPresenceCount
5719+ tpc <- *totalPresenceCount
5631− knownPeers <- *knownPeerServers
5720+ knownPeers <- *knownPeerServers
5632− -- Count per-key sessions without materializing all sessions (O(sessions) but no union)
5633− sessionsForKey <- countSessionsForKey pubkey
5721+ sessionsForKey <- countSessionsForKey pubkey
5634− tsc <- *totalSessionCount
5722+ tsc <- *totalSessionCount
5635 let newSession = {token: token, pubkey: pubkey, expiresAt: expiry, ip: clientIp, createdAt: t}
5723 let newSession = {token: token, pubkey: pubkey, expiresAt: expiry, ip: clientIp, createdAt: t}
5636 -- Update presence: replace local entry for this pubkey, evict oldest if over capacity
5724 -- Update presence: replace local entry for this pubkey, evict oldest if over capacity
5637 let pb = pubkeyBucket pubkey
5725 let pb = pubkeyBucket pubkey
@@ -5652,7 +5740,7 @@ verifyAuthInner = \pubkey challenge signature revokeOthers clientIp respond t ->
5652 currentBucket <- readSessionBucket nb
5740 currentBucket <- readSessionBucket nb
5653 writeSessionBucket nb (union currentBucket [newSession])
5741 writeSessionBucket nb (union currentBucket [newSession])
5654 set *totalSessionCount = tsc + 1
5742 set *totalSessionCount = tsc + 1
5655− yield (Just {value: {peerServers: peerServers, revokedCount: 0}})
5743+ yield {valid: True {}, result: Just {value: {peerServers: peerServers, revokedCount: 0}}}
5657 -- Slow path: revocation or capacity pressure.
5745 -- Slow path: revocation or capacity pressure.
5658 -- Bucket-by-bucket approach: revoke sessions for this pubkey across all
5746 -- Bucket-by-bucket approach: revoke sessions for this pubkey across all
@@ -5684,16 +5772,17 @@ verifyAuthInner = \pubkey challenge signature revokeOthers clientIp respond t ->
5684 -- Final count: estimatedTotal + 1 (new session) - globalEviction (0 or 1)
5772 -- Final count: estimatedTotal + 1 (new session) - globalEviction (0 or 1)
5685 let globalEvicted = if estimatedTotal >= maxSessions then 1 else 0
5773 let globalEvicted = if estimatedTotal >= maxSessions then 1 else 0
5686 set *totalSessionCount = estimatedTotal - globalEvicted + 1
5774 set *totalSessionCount = estimatedTotal - globalEvicted + 1
5687− yield (Just {value: {peerServers: peerServers, revokedCount: revokedCount}})
5775+ yield {valid: True {}, result: Just {value: {peerServers: peerServers, revokedCount: revokedCount}}}
5776+ if not authResult.valid
5777+ then yield (respond {token: "", expiresAt: 0, error: "invalid"})
5688− case authResult of
5778+ else case authResult.result of
5689− Nothing {} -> yield (respond {token: "", expiresAt: 0, error: "draining"})
5779+ Nothing {} -> yield (respond {token: "", expiresAt: 0, error: "draining"})
5690− Just {value: result} -> do
5780+ Just {value: result} -> do
5691 when (revokeOthers && result.revokedCount > 0)
5781 when (revokeOthers && result.revokedCount > 0)
5692 (logInfo ("verify: revokeOthers revoked " ++ show result.revokedCount ++ " sessions for " ++ pubkey))
5782 (logInfo ("verify: revokeOthers revoked " ++ show result.revokedCount ++ " sessions for " ++ pubkey))
5693 fork (when federationEnabled
5783 fork (when federationEnabled
5694 (sendBatchedGossip result.peerServers [{eventType: GossipOnline {}, pubkey: pubkey, server: serverHostN, timestamp: t, ttl: onlineGossipTtl, seenBy: [serverHostN]}] gossipOriginHops))
5784 (sendBatchedGossip result.peerServers [{eventType: GossipOnline {}, pubkey: pubkey, server: serverHostN, timestamp: t, ttl: onlineGossipTtl, seenBy: [serverHostN]}] gossipOriginHops))
5695 yield (respond {token: token, expiresAt: expiry, error: ""})
5785 yield (respond {token: token, expiresAt: expiry, error: ""})
5696− else yield (respond {token: "", expiresAt: 0, error: "invalid"})
5698-- POST /poll: Long-poll for new messages. Cursor-based: messages at or before cursor
5787-- POST /poll: Long-poll for new messages. Cursor-based: messages at or before cursor
5699-- are acked (removed from mailbox). Blocks until new messages arrive or heartbeat fires.
5788-- are acked (removed from mailbox). Blocks until new messages arrive or heartbeat fires.
@@ -6199,9 +6288,8 @@ handleRevoke = \targetToken authorization clientIp respond -> do
6199-- This gives faster offline detection than waiting for session expiry (1 hour).
6288-- This gives faster offline detection than waiting for session expiry (1 hour).
6200handleLogout = \authorization clientIp respond -> do
6289handleLogout = \authorization clientIp respond -> do
6201 t <- now
6290 t <- now
6202− dp <- *drainPhase
6291+ -- Logout is allowed during all drain phases (including full drain): it reduces load
6203− if dp >= 2 then yield (respond {ok: False {}, authorized: False {}})
6292+ -- and the offline gossip is skipped during drain by gossipOfflineIfLastSession.
6205 globalRateLimited <- checkGlobalRate t
6293 globalRateLimited <- checkGlobalRate t
6206 if globalRateLimited then yield (respond {ok: False {}, authorized: False {}})
6294 if globalRateLimited then yield (respond {ok: False {}, authorized: False {}})
@@ -6918,6 +7006,74 @@ forwardBucketGroupInner = \entries fromServer b t acc presenceBkt bucket ackedBk
6918 else updatedNonces
7006 else updatedNonces
6919 forwardBucketGroupInner rest fromServer b t (union acc [{messageId: entry.messageId, ok: True {}}]) presenceBkt newBucket ackedBkt delivBkt newNonceBkt newRc (msgStored + 1)
7007 forwardBucketGroupInner rest fromServer b t (union acc [{messageId: entry.messageId, ok: True {}}]) presenceBkt newBucket ackedBkt delivBkt newNonceBkt newRc (msgStored + 1)
7009+-- POST /federation/delivery-receipt: Destination server notifies origin that federated
7010+-- messages were polled/acked by the recipient. The origin server records delivery receipts
7011+-- so the sender can see "delivered" status via their next poll response.
7012+-- Best-effort: uses federation rate limiting but failures are not retried. The federation
7013+-- ack path already confirms protocol-level delivery; receipts are a UX enhancement.
7014+handleFederationDeliveryReceipt = \messageIds toKeys fromServer requestTimestamp correlationId authorization respond -> do
7016+ let corrId = sanitizeCorrelationId correlationId
7017+ when (corrId != "") (logInfo ("[CORR] " ++ corrId ++ " delivery-receipt from " ++ fromServer))
7018+ let receiptRespond = \r -> respond {ok: r.ok, error: r.error, receivedCount: 0, retryAfterMs: r.retryAfterMs}
7019+ withFederationAuth authorization t receiptRespond fromServer "delivery-receipt" requestTimestamp (not (isLocalServer fromServer) && count messageIds <= maxMessageIds) (False {}) (do
7020+ -- Pair messageIds with toKeys (parallel arrays). Reject if lengths differ.
7021+ if count messageIds != count toKeys
7022+ then yield (respond {ok: False {}, error: "mismatched_lengths", receivedCount: 0, retryAfterMs: 0})
7024+ -- Look up senderKey for each message from the local message store.
7025+ -- Only locally-originated messages (originServer == "") have senderKey.
7026+ let capped = takeRelation maxMessageIds messageIds
7027+ receivedCount <- atomic do
7028+ deliveries <- fold (\acc mid ->
7029+ -- Find the matching toKey by position (parallel arrays)
7030+ let idx = count acc.checked in
7031+ let toKey = fold (\tacc tk -> if tacc.i == idx then {i: tacc.i + 1, key: tk} else {i: tacc.i + 1, key: tacc.key}) {i: 0, key: ""} toKeys in
7033+ then {checked: union acc.checked [mid], receipts: acc.receipts}
7035+ let b = pubkeyBucket toKey.key
7036+ -- Check if we have the original message with senderKey
7037+ -- (message may have been pruned, but senderKey was already captured in forward entries by fedDeliveryReceipts)
7038+ {checked: union acc.checked [mid], receipts: union acc.receipts [{senderKey: "", messageId: mid, toKey: toKey.key, deliveredAt: t}]})
7039+ {checked: [], receipts: []} capped
7040+ -- For federation delivery receipts, we need to look up the senderKey from message store
7041+ -- Per-bucket: group by pubkeyBucket and look up senderKey from messages
7042+ let withSender = fold (\acc r ->
7043+ acc) [] deliveries.receipts
7044+ -- Simpler approach: record receipts with empty senderKey — the sender can match by messageId
7045+ -- (they know which messages they sent). This avoids the expensive per-message lookup.
7046+ -- Actually, we need senderKey to route receipts to the right sender's poll response.
7047+ -- Fall back to scanning message buckets for the senderKey.
7048+ yield (count deliveries.receipts)
7049+ -- Look up senderKey per-bucket for the receipt messages
7050+ receiptsByBucket capped toKeys t 0
7051+ yield (respond {ok: True {}, error: "", receivedCount: receivedCount, retryAfterMs: 0}))
7053+-- Look up senderKey from messages and record delivery receipts per-bucket.
7054+receiptsByBucket = \messageIds toKeys t idx ->
7055+ if idx >= count messageIds then yield {}
7057+ -- Extract messageId and toKey at current index
7058+ let mid = fold (\acc m -> if acc.i == idx then {i: acc.i + 1, val: m} else {i: acc.i + 1, val: acc.val}) {i: 0, val: ""} messageIds
7059+ let tk = fold (\acc k -> if acc.i == idx then {i: acc.i + 1, val: k} else {i: acc.i + 1, val: acc.val}) {i: 0, val: ""} toKeys
7060+ when (mid.val != "" && tk.val != "") (do
7061+ let b = pubkeyBucket tk.val
7063+ bucket <- readMessageBucket b
7064+ let msg = headOf (do m <- bucket; where m.messageId == mid.val && m.toKey == tk.val && m.senderKey != "" && m.originServer == ""; yield m)
7066+ Nothing {} -> yield {}
7067+ Just {value: m} -> do
7068+ sd <- *senderDeliveries
7069+ let isDup = any sd (\d -> d.messageId == mid.val && d.toKey == tk.val)
7070+ when (not isDup) (do
7071+ let combined = union sd [{senderKey: m.senderKey, messageId: mid.val, toKey: tk.val, deliveredAt: t}]
7072+ set *senderDeliveries = if count combined > maxSenderDeliveries
7073+ then evictOneOldest combined (\d -> d.deliveredAt)
7075+ receiptsByBucket messageIds toKeys t (idx + 1)
6921-- POST /messages/cancel-batch: Cancel multiple sent messages in one request.
7077-- POST /messages/cancel-batch: Cancel multiple sent messages in one request.
6922-- Auth checked once; each cancel processed independently. Partial success possible.
7078-- Auth checked once; each cancel processed independently. Partial success possible.
6923handleCancelBatch = \cancels authorization clientIp respond -> do
7079handleCancelBatch = \cancels authorization clientIp respond -> do
@@ -6980,7 +7136,7 @@ clientCancelBatchInner = \senderKey entries t resultAcc fedAcc ->
6980 Nothing {} -> yield {cancelled: False {}, cancelServers: []}
7136 Nothing {} -> yield {cancelled: False {}, cancelServers: []}
6981 Just {value: msg} ->
7137 Just {value: msg} ->
6982 if msg.originServer != "" then yield {cancelled: False {}, cancelServers: []}
7138 if msg.originServer != "" then yield {cancelled: False {}, cancelServers: []}
6983− else if msg.senderKey != "" && msg.senderKey != senderKey then yield {cancelled: False {}, cancelServers: []}
7139+ else if msg.senderKey == "" || msg.senderKey != senderKey then yield {cancelled: False {}, cancelServers: []}
6985 writeMessageBucket b (do m <- bucket; where not (m.messageId == entry.messageId && m.toKey == entry.to); yield m)
7141 writeMessageBucket b (do m <- bucket; where not (m.messageId == entry.messageId && m.toKey == entry.to); yield m)
6986 tc <- *totalMessageCount
7142 tc <- *totalMessageCount
@@ -7201,7 +7357,9 @@ computeAndCacheHealth = \t -> do
7201 pruneRun <- *pruneRunning
7357 pruneRun <- *pruneRunning
7202 dp <- *drainPhase
7358 dp <- *drainPhase
7203 let warmup = t - sa < healthPruneMaxAge
7359 let warmup = t - sa < healthPruneMaxAge
7360+ lastDur <- *lastPruneDuration
7361+ let pruneStuckThreshold = if lastDur > 0 then clamp (backgroundPruneInterval * 3) (backgroundPruneInterval * 10) (lastDur * 3) else backgroundPruneInterval * 5
7204− let pruneStuck = pruneRun > 0 && t - pruneRun > backgroundPruneInterval * 5
7362+ let pruneStuck = pruneRun > 0 && t - pruneRun > pruneStuckThreshold
7205 let cancelCount = count pendingCancels
7363 let cancelCount = count pendingCancels
7206 let capacityOk = tmc * 10 <= maxMessages * 9
7364 let capacityOk = tmc * 10 <= maxMessages * 9
7207 && presenceCount * 10 <= maxPresenceEntries * 9
7365 && presenceCount * 10 <= maxPresenceEntries * 9
@@ -7229,6 +7387,9 @@ computeAndCacheHealth = \t -> do
7229 -- take >24 minutes (healthForwardQueueMaxAge) to appear, so they're always caught.
7387 -- take >24 minutes (healthForwardQueueMaxAge) to appear, so they're always caught.
7230 fwdStaleCount <- *cachedForwardStaleCount
7388 fwdStaleCount <- *cachedForwardStaleCount
7231 let fwdQueueOk = fwdStaleCount < healthForwardQueueMinStaleServers
7389 let fwdQueueOk = fwdStaleCount < healthForwardQueueMinStaleServers
7390+ -- Request pressure: healthy when active requests < 90% of max (or backpressure disabled)
7391+ arq <- *activeRequests
7392+ let requestPressureOk = maxActiveRequests <= 0 || arq * 10 < maxActiveRequests * 9
7232 yield {pruneOk: warmup || (t - lastPrune < healthPruneMaxAge && not pruneStuck),
7393 yield {pruneOk: warmup || (t - lastPrune < healthPruneMaxAge && not pruneStuck),
7233 heartbeatOk: warmup || t - lastHb < healthHeartbeatMaxAge,
7394 heartbeatOk: warmup || t - lastHb < healthHeartbeatMaxAge,
7234 capacityOk: capacityOk,
7395 capacityOk: capacityOk,
@@ -7237,9 +7398,10 @@ computeAndCacheHealth = \t -> do
7237 watchdogOk: wdOk,
7398 watchdogOk: wdOk,
7238 nonceOk: nonceOk,
7399 nonceOk: nonceOk,
7239 forwardQueueOk: fwdQueueOk,
7400 forwardQueueOk: fwdQueueOk,
7401+ requestPressureOk: requestPressureOk,
7240 isDraining: dp > 0,
7402 isDraining: dp > 0,
7241 degradedPeers: degradedPeerCount}
7403 degradedPeers: degradedPeerCount}
7242− let allOk = not result.isDraining && result.pruneOk && result.heartbeatOk && result.capacityOk && result.federationOk && result.watchdogOk && result.nonceOk && result.forwardQueueOk
7404+ let allOk = not result.isDraining && result.pruneOk && result.heartbeatOk && result.capacityOk && result.federationOk && result.watchdogOk && result.nonceOk && result.forwardQueueOk && result.requestPressureOk
7243 atomic do
7405 atomic do
7244 set *cachedHealthOk = allOk
7406 set *cachedHealthOk = allOk
7245 set *cachedHealthPrune = result.pruneOk
7407 set *cachedHealthPrune = result.pruneOk
@@ -7250,6 +7412,7 @@ computeAndCacheHealth = \t -> do
7250 set *cachedHealthWatchdog = result.watchdogOk
7412 set *cachedHealthWatchdog = result.watchdogOk
7251 set *cachedHealthNonce = result.nonceOk
7413 set *cachedHealthNonce = result.nonceOk
7252 set *cachedHealthForwardQueue = result.forwardQueueOk
7414 set *cachedHealthForwardQueue = result.forwardQueueOk
7415+ set *cachedHealthRequestPressure = result.requestPressureOk
7253 set *cachedHealthDegradedPeers = result.degradedPeers
7416 set *cachedHealthDegradedPeers = result.degradedPeers
7254 yield result
7417 yield result
@@ -7265,10 +7428,11 @@ readCachedHealth = \respond -> do
7265 cW <- *cachedHealthWatchdog
7428 cW <- *cachedHealthWatchdog
7266 cN <- *cachedHealthNonce
7429 cN <- *cachedHealthNonce
7267 cFQ <- *cachedHealthForwardQueue
7430 cFQ <- *cachedHealthForwardQueue
7431+ cRP <- *cachedHealthRequestPressure
7268 cCW <- *cachedHealthCapacityWarning
7432 cCW <- *cachedHealthCapacityWarning
7269 cDP <- *cachedHealthDegradedPeers
7433 cDP <- *cachedHealthDegradedPeers
7270 dp <- *drainPhase
7434 dp <- *drainPhase
7271− yield {ok: cOk && dp == 0, version: serverBuildVersion, pruneHealthy: cP, heartbeatHealthy: cH, capacityHealthy: cC, capacityWarning: cCW, federationHealthy: cF, watchdogHealthy: cW, nonceHealthy: cN, forwardQueueHealthy: cFQ, draining: dp > 0, degradedPeers: cDP}
7435+ yield {ok: cOk && dp == 0, version: serverBuildVersion, pruneHealthy: cP, heartbeatHealthy: cH, capacityHealthy: cC, capacityWarning: cCW, federationHealthy: cF, watchdogHealthy: cW, nonceHealthy: cN, forwardQueueHealthy: cFQ, requestPressureHealthy: cRP, draining: dp > 0, degradedPeers: cDP}
7272 yield (respond cached)
7436 yield (respond cached)
7274handleHealth = \authorization respond -> do
7438handleHealth = \authorization respond -> do
@@ -7291,7 +7455,7 @@ handleHealth = \authorization respond -> do
7291 then readCachedHealth respond
7455 then readCachedHealth respond
7293 result <- computeAndCacheHealth t
7457 result <- computeAndCacheHealth t
7294− let allOk = not result.isDraining && result.pruneOk && result.heartbeatOk && result.capacityOk && result.federationOk && result.watchdogOk && result.nonceOk && result.forwardQueueOk
7458+ let allOk = not result.isDraining && result.pruneOk && result.heartbeatOk && result.capacityOk && result.federationOk && result.watchdogOk && result.nonceOk && result.forwardQueueOk && result.requestPressureOk
7295 yield (respond {ok: allOk, version: serverBuildVersion, pruneHealthy: result.pruneOk,
7459 yield (respond {ok: allOk, version: serverBuildVersion, pruneHealthy: result.pruneOk,
7296 heartbeatHealthy: result.heartbeatOk,
7460 heartbeatHealthy: result.heartbeatOk,
7297 capacityHealthy: result.capacityOk,
7461 capacityHealthy: result.capacityOk,
@@ -7300,10 +7464,11 @@ handleHealth = \authorization respond -> do
7300 watchdogHealthy: result.watchdogOk,
7464 watchdogHealthy: result.watchdogOk,
7301 nonceHealthy: result.nonceOk,
7465 nonceHealthy: result.nonceOk,
7302 forwardQueueHealthy: result.forwardQueueOk,
7466 forwardQueueHealthy: result.forwardQueueOk,
7467+ requestPressureHealthy: result.requestPressureOk,
7303 draining: result.isDraining,
7468 draining: result.isDraining,
7304 degradedPeers: result.degradedPeers})
7469 degradedPeers: result.degradedPeers})
7306−zeroStats = {ok: False {}, version: serverBuildVersion, capacity: {sessions: 0, sessionsMax: 0, sessionsPct: 0, messages: 0, messagesMax: 0, messagesPct: 0, totalMessageCount: 0, presence: 0, presenceMax: 0, presencePct: 0, forwards: 0, forwardsMax: 0, forwardsPct: 0, challenges: 0, challengesMax: 0, ackedIds: 0, ackedIdsMax: 0, ackOverflow: 0, ackOverflowMax: 0, deliveredIds: 0, deliveredIdsMax: 0, forwardNonces: 0, forwardNoncesMax: 0, activePolls: 0, activePollsMax: 0, recipientCounts: 0, messageBucketMax: 0, messageBucketMin: 0}, federation: {enabled: False {}, inFlight: 0, hungSlots: 0, avgLatencyMs: 0, maxLatencyMs: 0, callCount: 0, slowVerifies: 0, verifyFailures: 0, forwardFailures: 0, oldestForwardAge: 0, oldestAckAge: 0, recentGossip: 0, blockedPeers: 0, drainCompletedAt: 0, forwardServers: 0, maxForwardQueueDepth: 0, topFailingPeers: [], pendingCancels: 0, gossipSuccess: 0, gossipFail: 0, latencyBuckets: {lt100: 0, lt500: 0, lt2000: 0, gte2000: 0}}, rates: {send: 0, challenge: 0, federation: 0, federationPair: 0, global: 0, ip: 0, lookup: 0, recv: 0, pair: 0, gossipEvent: 0, verifyIp: 0, uniqueKeyIp: 0, pendingAckCleanups: 0, senderExpirations: 0, senderDeliveries: 0, rejectedGlobal: 0, rejectedIp: 0, rejectedAuth: 0, rejectedPerKey: 0, rejectedFederation: 0}, timing: {pruneCount: 0, pruneRunning: 0, heartbeatCount: 0, uptime: 0, lastPruneAt: 0, lastHeartbeatAt: 0, draining: False {}, drainPhase: 0, totalEntries: 0, activeRequests: 0, peakActiveRequests: 0, pollAvgMs: 0, pollMaxMs: 0, pollCount: 0, sendAvgMs: 0, sendMaxMs: 0, sendCount: 0}}
7471+zeroStats = {ok: False {}, version: serverBuildVersion, capacity: {sessions: 0, sessionsMax: 0, sessionsPct: 0, messages: 0, messagesMax: 0, messagesPct: 0, totalMessageCount: 0, presence: 0, presenceMax: 0, presencePct: 0, forwards: 0, forwardsMax: 0, forwardsPct: 0, challenges: 0, challengesMax: 0, ackedIds: 0, ackedIdsMax: 0, ackOverflow: 0, ackOverflowMax: 0, deliveredIds: 0, deliveredIdsMax: 0, forwardNonces: 0, forwardNoncesMax: 0, activePolls: 0, activePollsMax: 0, recipientCounts: 0, messageBucketMax: 0, messageBucketMin: 0}, federation: {enabled: False {}, inFlight: 0, hungSlots: 0, avgLatencyMs: 0, maxLatencyMs: 0, callCount: 0, slowVerifies: 0, verifyFailures: 0, forwardFailures: 0, oldestForwardAge: 0, oldestAckAge: 0, recentGossip: 0, blockedPeers: 0, drainCompletedAt: 0, forwardServers: 0, maxForwardQueueDepth: 0, topFailingPeers: [], pendingCancels: 0, gossipSuccess: 0, gossipFail: 0, latencyBuckets: {lt100: 0, lt500: 0, lt2000: 0, gte2000: 0}}, rates: {send: 0, challenge: 0, federation: 0, federationPair: 0, global: 0, ip: 0, lookup: 0, recv: 0, pair: 0, gossipEvent: 0, verifyIp: 0, uniqueKeyIp: 0, pendingAckCleanups: 0, senderExpirations: 0, senderDeliveries: 0, rejectedGlobal: 0, rejectedIp: 0, rejectedAuth: 0, rejectedPerKey: 0, rejectedFederation: 0}, timing: {pruneCount: 0, pruneRunning: 0, heartbeatCount: 0, uptime: 0, lastPruneAt: 0, lastHeartbeatAt: 0, draining: False {}, drainPhase: 0, totalEntries: 0, activeRequests: 0, peakActiveRequests: 0, pollAvgMs: 0, pollMaxMs: 0, pollCount: 0, pollLatencyBuckets: {lt50: 0, lt200: 0, lt1000: 0, gte1000: 0}, sendAvgMs: 0, sendMaxMs: 0, sendCount: 0, sendLatencyBuckets: {lt50: 0, lt200: 0, lt1000: 0, gte1000: 0}, authAvgMs: 0, authMaxMs: 0, authCount: 0}}
7308handleStats = \authorization respond -> do
7473handleStats = \authorization respond -> do
7309 t <- now
7474 t <- now
@@ -7386,7 +7551,18 @@ handleStats = \authorization respond -> do
7386 cslm <- *clientSendLatencyMax
7551 cslm <- *clientSendLatencyMax
7387 gss <- *gossipSendSuccess
7552 gss <- *gossipSendSuccess
7388 gsf <- *gossipSendFail
7553 gsf <- *gossipSendFail
7554+ cplb0 <- *clientPollLatencyBucket0
7555+ cplb1 <- *clientPollLatencyBucket1
7556+ cplb2 <- *clientPollLatencyBucket2
7557+ cplb3 <- *clientPollLatencyBucket3
7558+ cslb0 <- *clientSendLatencyBucket0
7559+ cslb1 <- *clientSendLatencyBucket1
7560+ cslb2 <- *clientSendLatencyBucket2
7561+ cslb3 <- *clientSendLatencyBucket3
7562+ calt <- *clientAuthLatencyTotal
7563+ calc <- *clientAuthLatencyCount
7564+ calm <- *clientAuthLatencyMax
7389− yield {sr: sr, cr: cr, fr: fr, fpr: fpr, grc: grc, ir: ir, lr: lr, rr: rr, prr: prr, pc: pc, pr: pr, hc: hc, sa: sa, lp: lp, lh: lh, dr: dr, flt: flt, flc: flc, flm: flm, flb0: flb0, flb1: flb1, flb2: flb2, flb3: flb3, svc: svc, vfc: vfc, dca: dca, rlg: rlg, rli: rli, rla: rla, rlk: rlk, rlf: rlf, arq: arq, parq: parq, cplt: cplt, cplc: cplc, cplm: cplm, cslt: cslt, cslc: cslc, cslm: cslm, gss: gss, gsf: gsf}
7565+ yield {sr: sr, cr: cr, fr: fr, fpr: fpr, grc: grc, ir: ir, lr: lr, rr: rr, prr: prr, pc: pc, pr: pr, hc: hc, sa: sa, lp: lp, lh: lh, dr: dr, flt: flt, flc: flc, flm: flm, flb0: flb0, flb1: flb1, flb2: flb2, flb3: flb3, svc: svc, vfc: vfc, dca: dca, rlg: rlg, rli: rli, rla: rla, rlk: rlk, rlf: rlf, arq: arq, parq: parq, cplt: cplt, cplc: cplc, cplm: cplm, cslt: cslt, cslc: cslc, cslm: cslm, gss: gss, gsf: gsf, cplb0: cplb0, cplb1: cplb1, cplb2: cplb2, cplb3: cplb3, cslb0: cslb0, cslb1: cslb1, cslb2: cslb2, cslb3: cslb3, calt: calt, calc: calc, calm: calm}
7390 -- Compute derived metrics
7566 -- Compute derived metrics
7391 let sessions = core.sc
7567 let sessions = core.sc
7392 let messages = core.tmc
7568 let messages = core.tmc
@@ -7414,7 +7590,7 @@ handleStats = \authorization respond -> do
7414 capacity: {sessions: sessions, sessionsMax: maxSessions, sessionsPct: sessPct, messages: messages, messagesMax: maxMessages, messagesPct: msgPct, totalMessageCount: messages, presence: presence, presenceMax: maxPresenceEntries, presencePct: presPct, forwards: fwds, forwardsMax: maxForwardsEntries, forwardsPct: fwdPct, challenges: count core.c, challengesMax: maxChallenges, ackedIds: core.aCount, ackedIdsMax: maxAckedIds, ackOverflow: count core.ao, ackOverflowMax: maxAckOverflow, deliveredIds: core.dCount, deliveredIdsMax: maxDeliveredIds, forwardNonces: core.fnCount, forwardNoncesMax: maxForwardNonces, activePolls: count core.ap, activePollsMax: maxActivePolls, recipientCounts: count core.rc, messageBucketMax: bucketStats.bucketMax, messageBucketMin: bucketStats.bucketMin},
7590 capacity: {sessions: sessions, sessionsMax: maxSessions, sessionsPct: sessPct, messages: messages, messagesMax: maxMessages, messagesPct: msgPct, totalMessageCount: messages, presence: presence, presenceMax: maxPresenceEntries, presencePct: presPct, forwards: fwds, forwardsMax: maxForwardsEntries, forwardsPct: fwdPct, challenges: count core.c, challengesMax: maxChallenges, ackedIds: core.aCount, ackedIdsMax: maxAckedIds, ackOverflow: count core.ao, ackOverflowMax: maxAckOverflow, deliveredIds: core.dCount, deliveredIdsMax: maxDeliveredIds, forwardNonces: core.fnCount, forwardNoncesMax: maxForwardNonces, activePolls: count core.ap, activePollsMax: maxActivePolls, recipientCounts: count core.rc, messageBucketMax: bucketStats.bucketMax, messageBucketMin: bucketStats.bucketMin},
7415 federation: {enabled: federationEnabled, inFlight: count core.fi, hungSlots: hungFedSlots, avgLatencyMs: fedAvg, maxLatencyMs: counters.flm, callCount: counters.flc, slowVerifies: counters.svc, verifyFailures: counters.vfc, forwardFailures: count core.ff, oldestForwardAge: oldestFwdAge, oldestAckAge: oldestAckAge, recentGossip: count core.rg, blockedPeers: count core.bp, drainCompletedAt: counters.dca, forwardServers: fwdServerCount, maxForwardQueueDepth: maxFwdDepth, topFailingPeers: topFailingPeers, pendingCancels: count core.pfc, gossipSuccess: counters.gss, gossipFail: counters.gsf, latencyBuckets: {lt100: counters.flb0, lt500: counters.flb1, lt2000: counters.flb2, gte2000: counters.flb3}},
7591 federation: {enabled: federationEnabled, inFlight: count core.fi, hungSlots: hungFedSlots, avgLatencyMs: fedAvg, maxLatencyMs: counters.flm, callCount: counters.flc, slowVerifies: counters.svc, verifyFailures: counters.vfc, forwardFailures: count core.ff, oldestForwardAge: oldestFwdAge, oldestAckAge: oldestAckAge, recentGossip: count core.rg, blockedPeers: count core.bp, drainCompletedAt: counters.dca, forwardServers: fwdServerCount, maxForwardQueueDepth: maxFwdDepth, topFailingPeers: topFailingPeers, pendingCancels: count core.pfc, gossipSuccess: counters.gss, gossipFail: counters.gsf, latencyBuckets: {lt100: counters.flb0, lt500: counters.flb1, lt2000: counters.flb2, gte2000: counters.flb3}},
7416 rates: {send: count counters.sr, challenge: count counters.cr, federation: count counters.fr, federationPair: count counters.fpr, global: counters.grc, ip: count counters.ir, lookup: count counters.lr, recv: count counters.rr, pair: count counters.prr, gossipEvent: count core.ger, verifyIp: count core.vir, uniqueKeyIp: count core.ukir, pendingAckCleanups: count core.pac, senderExpirations: count core.se, senderDeliveries: count core.sdl, rejectedGlobal: counters.rlg, rejectedIp: counters.rli, rejectedAuth: counters.rla, rejectedPerKey: counters.rlk, rejectedFederation: counters.rlf},
7592 rates: {send: count counters.sr, challenge: count counters.cr, federation: count counters.fr, federationPair: count counters.fpr, global: counters.grc, ip: count counters.ir, lookup: count counters.lr, recv: count counters.rr, pair: count counters.prr, gossipEvent: count core.ger, verifyIp: count core.vir, uniqueKeyIp: count core.ukir, pendingAckCleanups: count core.pac, senderExpirations: count core.se, senderDeliveries: count core.sdl, rejectedGlobal: counters.rlg, rejectedIp: counters.rli, rejectedAuth: counters.rla, rejectedPerKey: counters.rlk, rejectedFederation: counters.rlf},
7417− timing: {pruneCount: counters.pc, pruneRunning: counters.pr, heartbeatCount: counters.hc, uptime: t - counters.sa, lastPruneAt: counters.lp, lastHeartbeatAt: counters.lh, draining: counters.dr > 0, drainPhase: counters.dr, totalEntries: sessions + messages + presence + fwds + count core.c + core.aCount + count core.ao + core.dCount + core.fnCount + count core.ap + count core.fi, activeRequests: counters.arq, peakActiveRequests: counters.parq, pollAvgMs: pollAvg, pollMaxMs: counters.cplm, pollCount: counters.cplc, sendAvgMs: sendAvg, sendMaxMs: counters.cslm, sendCount: counters.cslc}})
7593+ timing: {pruneCount: counters.pc, pruneRunning: counters.pr, heartbeatCount: counters.hc, uptime: t - counters.sa, lastPruneAt: counters.lp, lastHeartbeatAt: counters.lh, draining: counters.dr > 0, drainPhase: counters.dr, totalEntries: sessions + messages + presence + fwds + count core.c + core.aCount + count core.ao + core.dCount + core.fnCount + count core.ap + count core.fi, activeRequests: counters.arq, peakActiveRequests: counters.parq, pollAvgMs: pollAvg, pollMaxMs: counters.cplm, pollCount: counters.cplc, pollLatencyBuckets: {lt50: counters.cplb0, lt200: counters.cplb1, lt1000: counters.cplb2, gte1000: counters.cplb3}, sendAvgMs: sendAvg, sendMaxMs: counters.cslm, sendCount: counters.cslc, sendLatencyBuckets: {lt50: counters.cslb0, lt200: counters.cslb1, lt1000: counters.cslb2, gte1000: counters.cslb3}, authAvgMs: if counters.calc > 0 then counters.calt / counters.calc else 0, authMaxMs: counters.calm, authCount: counters.calc}})
7418 else yield (respond zeroStats)
7594 else yield (respond zeroStats)
7420handleStatsPeers = \authorization respond -> do
7596handleStatsPeers = \authorization respond -> do
@@ -7565,6 +7741,52 @@ handleReady = \respond -> do
7565 let isReady = lastPrune > 0 && lastPrune >= sa
7741 let isReady = lastPrune > 0 && lastPrune >= sa
7566 yield (respond {ready: isReady})
7742 yield (respond {ready: isReady})
7744+-- GET /metrics: Prometheus-format metrics in a JSON wrapper.
7745+-- Authenticated callers get full metrics; unauthenticated get basic capacity only.
7746+handleMetrics = \authorization respond -> do
7748+ healthLimited <- checkHealthRate t
7749+ if healthLimited then yield (respond {metrics: "", ok: False {}})
7751+ isAuthed <- verifyMaintenanceAuth authorization
7752+ result <- atomic do
7753+ tmc <- *totalMessageCount
7754+ sc <- *totalSessionCount
7755+ pc <- *totalPresenceCount
7756+ fc <- *totalForwardCount
7758+ arq <- *activeRequests
7759+ parq <- *peakActiveRequests
7762+ yield {tmc: tmc, sc: sc, pc: pc, fc: fc, ap: ap, arq: arq, parq: parq, sa: sa, dp: dp}
7763+ -- Basic capacity metrics (always available)
7764+ let base = "# HELP skrepka_messages_total Current message count\n# TYPE skrepka_messages_total gauge\nskrepka_messages_total " ++ show result.tmc ++ "\n# HELP skrepka_messages_max Maximum message capacity\n# TYPE skrepka_messages_max gauge\nskrepka_messages_max " ++ show maxMessages ++ "\n# HELP skrepka_sessions_total Current session count\n# TYPE skrepka_sessions_total gauge\nskrepka_sessions_total " ++ show result.sc ++ "\n# HELP skrepka_sessions_max Maximum session capacity\n# TYPE skrepka_sessions_max gauge\nskrepka_sessions_max " ++ show maxSessions ++ "\n# HELP skrepka_presence_total Current presence count\n# TYPE skrepka_presence_total gauge\nskrepka_presence_total " ++ show result.pc ++ "\n# HELP skrepka_presence_max Maximum presence capacity\n# TYPE skrepka_presence_max gauge\nskrepka_presence_max " ++ show maxPresenceEntries ++ "\n# HELP skrepka_forwards_total Current forward queue size\n# TYPE skrepka_forwards_total gauge\nskrepka_forwards_total " ++ show result.fc ++ "\n# HELP skrepka_active_polls Current active long-poll connections\n# TYPE skrepka_active_polls gauge\nskrepka_active_polls " ++ show (count result.ap) ++ "\n# HELP skrepka_active_requests Current active HTTP requests\n# TYPE skrepka_active_requests gauge\nskrepka_active_requests " ++ show result.arq ++ "\n# HELP skrepka_peak_active_requests Peak concurrent requests since last reset\n# TYPE skrepka_peak_active_requests gauge\nskrepka_peak_active_requests " ++ show result.parq ++ "\n# HELP skrepka_uptime_seconds Server uptime in seconds\n# TYPE skrepka_uptime_seconds gauge\nskrepka_uptime_seconds " ++ show ((t - result.sa) / 1000) ++ "\n# HELP skrepka_draining Whether the server is draining\n# TYPE skrepka_draining gauge\nskrepka_draining " ++ if result.dp > 0 then "1" else "0" ++ "\n"
7765+ if not isAuthed then yield (respond {metrics: base, ok: True {}})
7767+ -- Extended metrics for authenticated callers
7769+ flc <- *federationLatencyCount
7770+ flt <- *federationLatencyTotal
7771+ flm <- *federationLatencyMax
7772+ cplc <- *clientPollLatencyCount
7773+ cplt <- *clientPollLatencyTotal
7774+ cslc <- *clientSendLatencyCount
7775+ cslt <- *clientSendLatencyTotal
7776+ rlg <- *rateLimitedGlobal
7777+ rli <- *rateLimitedIp
7778+ rla <- *rateLimitedAuth
7779+ rlk <- *rateLimitedPerKey
7780+ rlf <- *rateLimitedFederation
7781+ gss <- *gossipSendSuccess
7782+ gsf <- *gossipSendFail
7783+ yield {flc: flc, flt: flt, flm: flm, cplc: cplc, cplt: cplt, cslc: cslc, cslt: cslt, rlg: rlg, rli: rli, rla: rla, rlk: rlk, rlf: rlf, gss: gss, gsf: gsf}
7784+ let fedAvg = if ext.flc > 0 then ext.flt / ext.flc else 0
7785+ let pollAvg = if ext.cplc > 0 then ext.cplt / ext.cplc else 0
7786+ let sendAvg = if ext.cslc > 0 then ext.cslt / ext.cslc else 0
7787+ let extended = "# HELP skrepka_federation_latency_avg_ms Average federation call latency\n# TYPE skrepka_federation_latency_avg_ms gauge\nskrepka_federation_latency_avg_ms " ++ show fedAvg ++ "\n# HELP skrepka_federation_latency_max_ms Max federation call latency\n# TYPE skrepka_federation_latency_max_ms gauge\nskrepka_federation_latency_max_ms " ++ show ext.flm ++ "\n# HELP skrepka_federation_calls_total Federation calls in current window\n# TYPE skrepka_federation_calls_total gauge\nskrepka_federation_calls_total " ++ show ext.flc ++ "\n# HELP skrepka_poll_latency_avg_ms Average poll latency\n# TYPE skrepka_poll_latency_avg_ms gauge\nskrepka_poll_latency_avg_ms " ++ show pollAvg ++ "\n# HELP skrepka_send_latency_avg_ms Average send latency\n# TYPE skrepka_send_latency_avg_ms gauge\nskrepka_send_latency_avg_ms " ++ show sendAvg ++ "\n# HELP skrepka_rate_limited_total Rate-limited requests by type\n# TYPE skrepka_rate_limited_total gauge\nskrepka_rate_limited_total{type=\"global\"} " ++ show ext.rlg ++ "\nskrepka_rate_limited_total{type=\"ip\"} " ++ show ext.rli ++ "\nskrepka_rate_limited_total{type=\"auth\"} " ++ show ext.rla ++ "\nskrepka_rate_limited_total{type=\"per_key\"} " ++ show ext.rlk ++ "\nskrepka_rate_limited_total{type=\"federation\"} " ++ show ext.rlf ++ "\n# HELP skrepka_gossip_total Gossip send results\n# TYPE skrepka_gossip_total gauge\nskrepka_gossip_total{result=\"success\"} " ++ show ext.gss ++ "\nskrepka_gossip_total{result=\"fail\"} " ++ show ext.gsf ++ "\n"
7788+ yield (respond {metrics: base ++ extended, ok: True {}})
7568-- POST /messages/ack: Explicitly acknowledge received messages by ID.
7790-- POST /messages/ack: Explicitly acknowledge received messages by ID.
7569-- Alternative to the implicit cursor-based ack in poll. Benefits:
7791-- Alternative to the implicit cursor-based ack in poll. Benefits:
7570-- 1. Reduces the window for duplicate delivery (ack immediately without waiting for next poll)
7792-- 1. Reduces the window for duplicate delivery (ack immediately without waiting for next poll)
@@ -7788,10 +8010,13 @@ handleCancelMessage = \messageId to authorization clientIp respond -> do
7788 Nothing {} -> yield {cancelled: False {}, cancelServers: [], nonceLruEvicted: 0, nonceOldestAge: 0}
8010 Nothing {} -> yield {cancelled: False {}, cancelServers: [], nonceLruEvicted: 0, nonceOldestAge: 0}
7789 Just {value: msg} ->
8011 Just {value: msg} ->
7790 -- Only allow cancelling locally-originated messages (originServer == "")
8012 -- Only allow cancelling locally-originated messages (originServer == "")
7791− -- where the caller is the actual sender. Federation forwards have no
8013+ -- where the caller's pubkey matches the stored senderKey. Federation
7792− -- senderKey and should not be cancellable via this endpoint.
8014+ -- forwards have no senderKey and cannot be cancelled via this endpoint.
8015+ -- Strict check: senderKey must be non-empty and match. MessageId knowledge
8016+ -- alone is insufficient — prevents cancel if messageId leaks via logs,
8017+ -- federation peers, or recipient sharing.
7793 if msg.originServer != "" then yield {cancelled: False {}, cancelServers: [], nonceLruEvicted: 0, nonceOldestAge: 0}
8018 if msg.originServer != "" then yield {cancelled: False {}, cancelServers: [], nonceLruEvicted: 0, nonceOldestAge: 0}
7794− else if msg.senderKey != "" && msg.senderKey != senderKey then yield {cancelled: False {}, cancelServers: [], nonceLruEvicted: 0, nonceOldestAge: 0}
8019+ else if msg.senderKey == "" || msg.senderKey != senderKey then yield {cancelled: False {}, cancelServers: [], nonceLruEvicted: 0, nonceOldestAge: 0}
7796 writeMessageBucket b (do m <- bucket; where not (m.messageId == messageId && m.toKey == to); yield m)
8021 writeMessageBucket b (do m <- bucket; where not (m.messageId == messageId && m.toKey == to); yield m)
7797 tc <- *totalMessageCount
8022 tc <- *totalMessageCount
@@ -8350,6 +8575,7 @@ requestPath = \req -> case req of
8350 FederationCancel {} -> "POST /federation/cancel"
8575 FederationCancel {} -> "POST /federation/cancel"
8351 FederationBatchCancel {} -> "POST /federation/cancel-batch"
8576 FederationBatchCancel {} -> "POST /federation/cancel-batch"
8352 FederationBatchForward {} -> "POST /federation/forward-batch"
8577 FederationBatchForward {} -> "POST /federation/forward-batch"
8578+ FederationDeliveryReceipt {} -> "POST /federation/delivery-receipt"
8353 VerifyToken {} -> "POST /federation/verify"
8579 VerifyToken {} -> "POST /federation/verify"
8354 FederationPubkey {} -> "GET /federation/pubkey"
8580 FederationPubkey {} -> "GET /federation/pubkey"
8355 Prune {} -> "POST /maintenance/prune"
8581 Prune {} -> "POST /maintenance/prune"
@@ -8380,6 +8606,7 @@ requestPath = \req -> case req of
8380 TopRecipients {} -> "POST /stats/top-recipients"
8606 TopRecipients {} -> "POST /stats/top-recipients"
8381 StatsForwards {} -> "GET /stats/forwards"
8607 StatsForwards {} -> "GET /stats/forwards"
8382 StatsRates {} -> "GET /stats/rates"
8608 StatsRates {} -> "GET /stats/rates"
8609+ Metrics {} -> "GET /metrics"
8383 Ready {} -> "GET /ready"
8610 Ready {} -> "GET /ready"
8385-- Extract client IP for access logging (empty for federation/maintenance/stats routes).
8612-- Extract client IP for access logging (empty for federation/maintenance/stats routes).
@@ -8436,6 +8663,8 @@ serveInner = \req -> case req of
8436 warnOldProtocol fromServer protocolVersion; handleFederationBatchCancel cancels fromServer requestTimestamp correlationId authorization respond
8663 warnOldProtocol fromServer protocolVersion; handleFederationBatchCancel cancels fromServer requestTimestamp correlationId authorization respond
8437 FederationBatchForward {forwards, fromServer, requestTimestamp, correlationId, protocolVersion, authorization, respond} -> do
8664 FederationBatchForward {forwards, fromServer, requestTimestamp, correlationId, protocolVersion, authorization, respond} -> do
8438 warnOldProtocol fromServer protocolVersion; handleFederationBatchForward forwards fromServer requestTimestamp correlationId authorization respond
8665 warnOldProtocol fromServer protocolVersion; handleFederationBatchForward forwards fromServer requestTimestamp correlationId authorization respond
8666+ FederationDeliveryReceipt {messageIds, toKeys, fromServer, requestTimestamp, correlationId, protocolVersion, authorization, respond} -> do
8667+ warnOldProtocol fromServer protocolVersion; handleFederationDeliveryReceipt messageIds toKeys fromServer requestTimestamp correlationId authorization respond
8439 VerifyToken {token, scope, clientIp, respond} -> handleVerifyToken token scope clientIp respond
8668 VerifyToken {token, scope, clientIp, respond} -> handleVerifyToken token scope clientIp respond
8440 FederationPubkey {respond} -> handleFederationPubkey respond
8669 FederationPubkey {respond} -> handleFederationPubkey respond
8441 Prune {authorization, respond} -> handlePrune authorization respond
8670 Prune {authorization, respond} -> handlePrune authorization respond
@@ -8466,6 +8695,7 @@ serveInner = \req -> case req of
8466 TopRecipients {limit, authorization, respond} -> handleTopRecipients limit authorization respond
8695 TopRecipients {limit, authorization, respond} -> handleTopRecipients limit authorization respond
8467 StatsForwards {authorization, respond} -> handleStatsForwards authorization respond
8696 StatsForwards {authorization, respond} -> handleStatsForwards authorization respond
8468 StatsRates {authorization, respond} -> handleStatsRates authorization respond
8697 StatsRates {authorization, respond} -> handleStatsRates authorization respond
8698+ Metrics {authorization, respond} -> handleMetrics authorization respond
8469 Ready {respond} -> handleReady respond
8699 Ready {respond} -> handleReady respond
8471-- Backpressure: check if the server is overloaded (too many concurrent requests).
8701-- Backpressure: check if the server is overloaded (too many concurrent requests).
@@ -8483,6 +8713,7 @@ isOverloadExempt = \req -> case req of
8483 StatsPeers {} -> True {}
8713 StatsPeers {} -> True {}
8484 StatsKey {} -> True {}
8714 StatsKey {} -> True {}
8485 StatsRates {} -> True {}
8715 StatsRates {} -> True {}
8716+ Metrics {} -> True {}
8486 _ -> False {}
8717 _ -> False {}
8488-- Reject overloaded requests with appropriate empty/error responses per route type.
8719-- Reject overloaded requests with appropriate empty/error responses per route type.
@@ -8515,6 +8746,7 @@ serveOverloaded = \req -> case req of
8515 FederationCancel {respond} -> respond {ok: False {}, error: "overloaded", cancelled: False {}, retryAfterMs: 1000}
8746 FederationCancel {respond} -> respond {ok: False {}, error: "overloaded", cancelled: False {}, retryAfterMs: 1000}
8516 FederationBatchCancel {respond} -> respond {ok: False {}, error: "overloaded", results: [], retryAfterMs: 1000}
8747 FederationBatchCancel {respond} -> respond {ok: False {}, error: "overloaded", results: [], retryAfterMs: 1000}
8517 FederationBatchForward {respond} -> respond {ok: False {}, error: "overloaded", results: [], retryAfterMs: 1000}
8748 FederationBatchForward {respond} -> respond {ok: False {}, error: "overloaded", results: [], retryAfterMs: 1000}
8749+ FederationDeliveryReceipt {respond} -> respond {ok: False {}, error: "overloaded", receivedCount: 0, retryAfterMs: 1000}
8518 VerifyToken {respond} -> respond {ok: False {}}
8750 VerifyToken {respond} -> respond {ok: False {}}
8519 FederationPubkey {respond} -> respond {pubkey: "", ok: False {}}
8751 FederationPubkey {respond} -> respond {pubkey: "", ok: False {}}
8520 -- Stats/admin routes (exempt routes handled by isOverloadExempt; these are fallbacks)
8752 -- Stats/admin routes (exempt routes handled by isOverloadExempt; these are fallbacks)
@@ -8537,6 +8769,7 @@ serveOverloaded = \req -> case req of
8537 UnallowPeer {respond} -> respond {ok: False {}, removed: False {}}
8769 UnallowPeer {respond} -> respond {ok: False {}, removed: False {}}
8538 ListAllowed {respond} -> respond {peers: [], ok: False {}}
8770 ListAllowed {respond} -> respond {peers: [], ok: False {}}
8539 RotateSecret {respond} -> respond {ok: False {}, error: "overloaded"}
8771 RotateSecret {respond} -> respond {ok: False {}, error: "overloaded"}
8772+ Metrics {respond} -> respond {metrics: "", ok: False {}}
8540 -- Exempt routes handled by isOverloadExempt; this branch is unreachable
8773 -- Exempt routes handled by isOverloadExempt; this branch is unreachable
8541 _ -> yield {}
8774 _ -> yield {}
@@ -8579,6 +8812,11 @@ serve = \req -> do
8579 set *clientPollLatencyTotal = total + latency
8812 set *clientPollLatencyTotal = total + latency
8580 set *clientPollLatencyCount = cnt + 1
8813 set *clientPollLatencyCount = cnt + 1
8581 set *clientPollLatencyMax = if latency > mx then latency else mx
8814 set *clientPollLatencyMax = if latency > mx then latency else mx
8815+ -- Histogram bucket increment
8816+ if latency < clientLatencyT1 then do b <- *clientPollLatencyBucket0; set *clientPollLatencyBucket0 = b + 1
8817+ else if latency < clientLatencyT2 then do b <- *clientPollLatencyBucket1; set *clientPollLatencyBucket1 = b + 1
8818+ else if latency < clientLatencyT3 then do b <- *clientPollLatencyBucket2; set *clientPollLatencyBucket2 = b + 1
8819+ else do b <- *clientPollLatencyBucket3; set *clientPollLatencyBucket3 = b + 1
8582 SendMessage {} -> atomic do
8820 SendMessage {} -> atomic do
8583 total <- *clientSendLatencyTotal
8821 total <- *clientSendLatencyTotal
8584 cnt <- *clientSendLatencyCount
8822 cnt <- *clientSendLatencyCount
@@ -8586,6 +8824,10 @@ serve = \req -> do
8586 set *clientSendLatencyTotal = total + latency
8824 set *clientSendLatencyTotal = total + latency
8587 set *clientSendLatencyCount = cnt + 1
8825 set *clientSendLatencyCount = cnt + 1
8588 set *clientSendLatencyMax = if latency > mx then latency else mx
8826 set *clientSendLatencyMax = if latency > mx then latency else mx
8827+ if latency < clientLatencyT1 then do b <- *clientSendLatencyBucket0; set *clientSendLatencyBucket0 = b + 1
8828+ else if latency < clientLatencyT2 then do b <- *clientSendLatencyBucket1; set *clientSendLatencyBucket1 = b + 1
8829+ else if latency < clientLatencyT3 then do b <- *clientSendLatencyBucket2; set *clientSendLatencyBucket2 = b + 1
8830+ else do b <- *clientSendLatencyBucket3; set *clientSendLatencyBucket3 = b + 1
8589 SendBatch {} -> atomic do
8831 SendBatch {} -> atomic do
8590 total <- *clientSendLatencyTotal
8832 total <- *clientSendLatencyTotal
8591 cnt <- *clientSendLatencyCount
8833 cnt <- *clientSendLatencyCount
@@ -8593,6 +8835,24 @@ serve = \req -> do
8593 set *clientSendLatencyTotal = total + latency
8835 set *clientSendLatencyTotal = total + latency
8594 set *clientSendLatencyCount = cnt + 1
8836 set *clientSendLatencyCount = cnt + 1
8595 set *clientSendLatencyMax = if latency > mx then latency else mx
8837 set *clientSendLatencyMax = if latency > mx then latency else mx
8838+ if latency < clientLatencyT1 then do b <- *clientSendLatencyBucket0; set *clientSendLatencyBucket0 = b + 1
8839+ else if latency < clientLatencyT2 then do b <- *clientSendLatencyBucket1; set *clientSendLatencyBucket1 = b + 1
8840+ else if latency < clientLatencyT3 then do b <- *clientSendLatencyBucket2; set *clientSendLatencyBucket2 = b + 1
8841+ else do b <- *clientSendLatencyBucket3; set *clientSendLatencyBucket3 = b + 1
8842+ ReqChallenge {} -> atomic do
8843+ total <- *clientAuthLatencyTotal
8844+ cnt <- *clientAuthLatencyCount
8845+ mx <- *clientAuthLatencyMax
8846+ set *clientAuthLatencyTotal = total + latency
8847+ set *clientAuthLatencyCount = cnt + 1
8848+ set *clientAuthLatencyMax = if latency > mx then latency else mx
8849+ VerifyAuth {} -> atomic do
8850+ total <- *clientAuthLatencyTotal
8851+ cnt <- *clientAuthLatencyCount
8852+ mx <- *clientAuthLatencyMax
8853+ set *clientAuthLatencyTotal = total + latency
8854+ set *clientAuthLatencyCount = cnt + 1
8855+ set *clientAuthLatencyMax = if latency > mx then latency else mx
8596 _ -> yield {}
8856 _ -> yield {}
8597 let path = requestPath req
8857 let path = requestPath req
8598 let ip = requestClientIp req
8858 let ip = requestClientIp req
@@ -8672,10 +8932,12 @@ backgroundPrune = do
8672 set *lastHeartbeatAt = t)
8932 set *lastHeartbeatAt = t)
8673 status <- atomic do
8933 status <- atomic do
8674 running <- *pruneRunning
8934 running <- *pruneRunning
8935+ -- Dynamic stuck threshold: 3x the last known cycle duration, with a floor of 3x prune interval
8675− -- 5x threshold: allows heavy prune cycles (reconciliation after restore, large nonce
8936+ -- and ceiling of 10x prune interval. Adapts to heavy cycles (reconciliation, large nonce pruning)
8676− -- pruning) to complete without triggering false stuck-recovery. Original 2x was too
8937+ -- without the fixed 5x being too tight for fast cycles or too loose for slow ones.
8677− -- tight and could cause spurious fork resets during legitimate long-running prune cycles.
8938+ lastDuration <- *lastPruneDuration
8939+ let dynamicThreshold = if lastDuration > 0 then clamp (backgroundPruneInterval * 3) (backgroundPruneInterval * 10) (lastDuration * 3) else backgroundPruneInterval * 5
8678− if running > 0 && t - running < backgroundPruneInterval * 5
8940+ if running > 0 && t - running < dynamicThreshold
8679 then yield {canRun: False {}, recovered: False {}, gen: 0, stuckSince: 0}
8941 then yield {canRun: False {}, recovered: False {}, gen: 0, stuckSince: 0}
8681 set *pruneRunning = t
8943 set *pruneRunning = t
@@ -8714,9 +8976,12 @@ backgroundPrune = do
8714 pruneAndRetry t
8976 pruneAndRetry t
8715 -- Only clear running flag if this is still the latest generation
8977 -- Only clear running flag if this is still the latest generation
8716 -- (prevents a stuck old fork from clearing the flag after a recovery cycle started)
8978 -- (prevents a stuck old fork from clearing the flag after a recovery cycle started)
8717 atomic do
8980 atomic do
8718 currentGen <- *pruneGeneration
8981 currentGen <- *pruneGeneration
8719− when (currentGen == status.gen) (set *pruneRunning = 0)
8982+ when (currentGen == status.gen) (do
8983+ set *pruneRunning = 0
8984+ set *lastPruneDuration = endT - t)
8720 af <- *activePruneForks
8985 af <- *activePruneForks
8721 set *activePruneForks = max 0 (af - 1))
8986 set *activePruneForks = max 0 (af - 1))
8722 else logWarn "backgroundPrune: skipping (previous cycle still running)"
8987 else logWarn "backgroundPrune: skipping (previous cycle still running)"
@@ -8905,10 +9170,11 @@ main = do
8905 && forwardNonceRetention >= messageMaxAge
9170 && forwardNonceRetention >= messageMaxAge
8906 && maxGlobalFederationConcurrency >= maxFederationConcurrency
9171 && maxGlobalFederationConcurrency >= maxFederationConcurrency
8907 && maxGossipEventsPerServer > maxGossipEvents
9172 && maxGossipEventsPerServer > maxGossipEvents
9173+ && onlineGossipTtl * 1000 > sessionExpiry
8908 if not configOk
9174 if not configOk
8909 then logError "FATAL: invalid configuration — capacity limits and intervals must be > 0, server not started"
9175 then logError "FATAL: invalid configuration — capacity limits and intervals must be > 0, server not started"
8910 else if not configRelationsOk
9176 else if not configRelationsOk
8911− then logError "FATAL: invalid configuration — relationship invariants violated (check: unackedMessageMaxAge <= messageMaxAge, challengeExpiry < sessionExpiry, sessionExpiry <= maxSessionLifetime, retryBackoffBase <= retryBackoffMax, backgroundPruneInterval < healthPruneMaxAge, pollTimeoutMs < sessionExpiry, gossipDedupWindow < presenceRefreshInterval, federationFetchTimeoutMs <= backgroundPruneInterval, maxAckOverflow <= maxAckedIds, gossipOriginHops <= maxGossipHops, maxThirdPartyGossipHops <= maxGossipHops, verificationTokenExpiry <= federationFetchTimeoutMs, forwardNonceRetention >= messageMaxAge, maxGlobalFederationConcurrency >= maxFederationConcurrency, maxGossipEventsPerServer > maxGossipEvents, maxForwardsPerSenderPerKey <= maxForwardsPerKey, per-key/per-server limits <= global limits), server not started"
9177+ then logError "FATAL: invalid configuration — relationship invariants violated (check: unackedMessageMaxAge <= messageMaxAge, challengeExpiry < sessionExpiry, sessionExpiry <= maxSessionLifetime, retryBackoffBase <= retryBackoffMax, backgroundPruneInterval < healthPruneMaxAge, pollTimeoutMs < sessionExpiry, gossipDedupWindow < presenceRefreshInterval, federationFetchTimeoutMs <= backgroundPruneInterval, maxAckOverflow <= maxAckedIds, gossipOriginHops <= maxGossipHops, maxThirdPartyGossipHops <= maxGossipHops, verificationTokenExpiry <= federationFetchTimeoutMs, forwardNonceRetention >= messageMaxAge, maxGlobalFederationConcurrency >= maxFederationConcurrency, maxGossipEventsPerServer > maxGossipEvents, onlineGossipTtl*1000 > sessionExpiry, maxForwardsPerSenderPerKey <= maxForwardsPerKey, per-key/per-server limits <= global limits), server not started"
8913 t <- now
9179 t <- now
8914 -- Mix random bits into generation to prevent sub-millisecond restart collisions.
9180 -- Mix random bits into generation to prevent sub-millisecond restart collisions.