Surface router drops as failed status; bound relay pending subscriptions; jitter backoff

MessageRouter outbox drops (attempt cap, TTL expiry in flush and
cleanup, per-peer overflow eviction) now invoke onMessageDropped, wired
to mark the message .failed in the ConversationStore - guarded so a
late failure never downgrades an already delivered/read status.

NostrRelayManager pending subscriptions gain a per-relay cap (64,
oldest-by-sequence eviction; durable intent still replays from
subscriptionRequestState) and a 10-minute age sweep on the existing
connect path. Reconnect backoff gets injectable +/-20% jitter so
recovering relays don't thundering-herd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 15:05:44 +02:00
co-authored by Claude Fable 5
parent e74d9a7937
commit ed86ed1065
7 changed files with 405 additions and 17 deletions
+73 -10
View File
@@ -66,6 +66,9 @@ struct NostrRelayManagerDependencies {
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
/// Uniform random value in [0, 1) used to jitter reconnect backoff.
/// Injectable so tests can pin or sweep the jitter deterministically.
var jitterUnit: () -> Double
}
private extension NostrRelayManagerDependencies {
@@ -93,7 +96,8 @@ private extension NostrRelayManagerDependencies {
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
now: Date.init,
jitterUnit: { Double.random(in: 0..<1) }
)
}
}
@@ -140,7 +144,16 @@ final class NostrRelayManager: ObservableObject {
private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
// Not-yet-flushed REQs per relay, bounded by a per-relay cap (oldest by
// insertion order evicted) and an age sweep on connect attempts. Dicts are
// unordered, so each entry carries an insertion sequence and queue time.
private struct PendingSubscription {
let messageString: String // encoded REQ JSON
let queuedAt: Date
let sequence: UInt64
}
private var pendingSubscriptions: [String: [String: PendingSubscription]] = [:] // relay URL -> (subscription id -> pending REQ)
private var pendingSubscriptionSequence: UInt64 = 0
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
private struct InboundEventKey: Hashable {
let subscriptionID: String
@@ -432,9 +445,7 @@ final class NostrRelayManager: ObservableObject {
existingSet.insert(url)
}
for url in urls {
var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString
self.pendingSubscriptions[url] = map
queuePendingSubscription(id: id, messageString: messageString, for: url)
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
@@ -550,6 +561,7 @@ final class NostrRelayManager: ObservableObject {
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
guard dependencies.activationAllowed() else { return }
sweepStalePendingSubscriptions()
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
@@ -627,11 +639,53 @@ final class NostrRelayManager: ObservableObject {
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true }
return requestState.relayURLs.allSatisfy { url in
pendingSubscriptions[url]?[id] == requestState.messageString ||
pendingSubscriptions[url]?[id]?.messageString == requestState.messageString ||
subscriptions[url]?.contains(id) == true
}
}
private func queuePendingSubscription(id: String, messageString: String, for url: String) {
var map = pendingSubscriptions[url] ?? [:]
pendingSubscriptionSequence &+= 1
map[id] = PendingSubscription(
messageString: messageString,
queuedAt: dependencies.now(),
sequence: pendingSubscriptionSequence
)
// Bound per-relay pending REQs; evict oldest by insertion order. The
// durable intent stays in subscriptionRequestState, so an evicted REQ
// is still replayed if its subscription is active when the relay
// (re)connects.
while map.count > TransportConfig.nostrPendingSubscriptionsPerRelayCap,
let oldest = map.min(by: { $0.value.sequence < $1.value.sequence }) {
map.removeValue(forKey: oldest.key)
SecureLogger.warning(
"📋 Pending subscription overflow for \(url) - evicted oldest id=\(oldest.key)",
category: .session
)
}
pendingSubscriptions[url] = map
}
/// Drop pending REQs older than the TTL. Runs on connect attempts (the
/// natural maintenance path: connect/ensureConnections/reconnects all
/// funnel through connectToRelays) so stale entries for relays that never
/// come up cannot accumulate without bound.
private func sweepStalePendingSubscriptions() {
let now = dependencies.now()
for (url, map) in pendingSubscriptions {
let fresh = map.filter {
now.timeIntervalSince($0.value.queuedAt) <= TransportConfig.nostrPendingSubscriptionTTLSeconds
}
guard fresh.count != map.count else { continue }
SecureLogger.debug(
"📋 Swept \(map.count - fresh.count) stale pending subscription(s) for \(url)",
category: .session
)
pendingSubscriptions[url] = fresh.isEmpty ? nil : fresh
}
}
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackerEpoch += 1
let epoch = eoseTrackerEpoch
@@ -770,7 +824,7 @@ final class NostrRelayManager: ObservableObject {
/// active subscription targeting this relay must be re-sent.
private func flushPendingSubscriptions(for relayUrl: String) {
guard let connection = connections[relayUrl] else { return }
var toSend = pendingSubscriptions[relayUrl] ?? [:]
var toSend = (pendingSubscriptions[relayUrl] ?? [:]).mapValues(\.messageString)
for (id, state) in subscriptionRequestState where state.relayURLs.contains(relayUrl) && toSend[id] == nil {
toSend[id] = state.messageString
}
@@ -987,12 +1041,16 @@ final class NostrRelayManager: ObservableObject {
return
}
// Calculate backoff interval
let backoffInterval = min(
// Calculate backoff interval with ±jitterRatio random jitter so relays
// that dropped together don't all reconnect at the same instant.
let baseBackoffInterval = min(
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
maxBackoffInterval
)
let jitterRatio = TransportConfig.nostrRelayBackoffJitterRatio
let jitterFactor = 1.0 + (dependencies.jitterUnit() * 2.0 - 1.0) * jitterRatio
let backoffInterval = baseBackoffInterval * jitterFactor
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime
@@ -1054,6 +1112,11 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugPendingSubscriptionIDs(for relayUrl: String) -> Set<String> {
guard let map = pendingSubscriptions[relayUrl] else { return [] }
return Set(map.keys)
}
var debugDuplicateInboundEventDropCount: Int {
duplicateInboundEventDropCount
}