libp2r2p 0.11.29 → 0.11.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1270,3 +1270,11 @@ and writes in groups of at most 100. Batched checks combine one recovery-type
1270
1270
  AND filter with coordinate OR filters, preserve input order and continue to
1271
1271
  decode and validate every returned snapshot. Custom adapters without batched
1272
1272
  methods retain the sequential fallback.
1273
+
1274
+ The 0.11.30 session archives each publication's recovery rows through one
1275
+ `putMany` call and treats pending journal rows as present in batched `hasMany`
1276
+ checks. Publication routing also keeps a runtime-only, per-messenger relay health
1277
+ circuit: explicit bans/refusals open immediately, repeated transport failures
1278
+ open after three attempts, and a fully quarantined route reports
1279
+ `RELAY_CIRCUIT_OPEN` with `retryAt` before another envelope is prepared. Reads and
1280
+ receive routing are unaffected. See [session relay fallback](private-messenger/session/README.md#relay-fallback).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libp2r2p",
3
- "version": "0.11.29",
3
+ "version": "0.11.30",
4
4
  "description": "Peer-to-relay-to-peer",
5
5
  "keywords": [
6
6
  "p2r2p",
@@ -24,10 +24,11 @@ without decrypting the archive; the store may also purge expired wrappers itself
24
24
  - Authorizations: the same operations plus `find(query)`, choosing the newest
25
25
  valid share, then the shorter expiry and lexicographically smaller record ID.
26
26
  - Batched methods preserve input order and process at most 100 coordinates per
27
- query. Identical inputs share one immutable result; expired inputs return
28
- `undefined` without a query or write. Batched checks use the recovery type
29
- as an AND filter and the record coordinates as an OR filter, then still decode
30
- and validate every returned snapshot.
27
+ query. Identical inputs share one immutable result. `putMany` returns
28
+ `undefined` for an expired input without querying or writing; `hasMany` returns
29
+ `false` for expired snapshots. Batched checks use the recovery type as an AND
30
+ filter and the record coordinates as an OR filter, then still decode and
31
+ validate every returned snapshot.
31
32
  - `iterate` is an async generator. Queries can name `recordId`, `channelPubkey`,
32
33
  `receiverPubkey`, `since`/`until`, or the file tuple `controlChannelPubkey`,
33
34
  `fileChannelPubkey`, `receiverPubkey`, and `indices`. `signal` cancels reads.
@@ -132,7 +132,7 @@ export function createPersonalCopyRecoveryStorage ({ eventStore, signer, now = n
132
132
  const recordIds = rows.map(row => row.recordId || encodeRecord(type, row).tags[0][1])
133
133
  const present = new Set()
134
134
  for (const batch of chunks([...new Set(recordIds)])) {
135
- for await (const { row } of records(type, { recordIds: batch, includeExpired: true })) present.add(row.recordId)
135
+ for await (const { row } of records(type, { recordIds: batch })) present.add(row.recordId)
136
136
  }
137
137
  return recordIds.map(recordId => present.has(recordId))
138
138
  }
@@ -2,7 +2,7 @@ import { isReplaceableRelayFailure } from '../../relay/helpers/failure.js'
2
2
 
3
3
  // Each invocation owns one already signed outer event and its encrypted subset.
4
4
  // Lanes share coverage and the tried set; only started reports outlive the race.
5
- export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
5
+ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tried, nextPrimary, nextFallback, sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, rememberSuccess = () => {}, delay, attemptStartedAt, monotonicNow = () => performance.now(), now, setTimer, clearTimer }) {
6
6
  const pending = new Set(receivers)
7
7
  const summaries = []
8
8
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
@@ -65,6 +65,7 @@ export function publishWithEarlyFallback ({ event, receivers, first, mirrors, tr
65
65
  const settled = Promise.resolve(result.promise).catch(reason => ({ fulfilled: 0, errors: [{ reason }] }))
66
66
  slot.resolve(settled)
67
67
  settled.then(async report => {
68
+ rememberSuccess(report.succeededRelays || [])
68
69
  const failed = (report.errors || []).filter(item => isReplaceableRelayFailure(item.reason))
69
70
  // Late redundant failures can inform later sends after this race wins.
70
71
  if (failed.length && (result.success || (done && !pending.size)) && !transportSignal?.aborted && isCurrent()) {
@@ -0,0 +1,96 @@
1
+ import { normalizeRelayUrl } from '../../url/index.js'
2
+
3
+ const PERMANENT_PREFIXES = new Set(['banned', 'blocked', 'restricted', 'auth-required', 'pow'])
4
+ const TRANSIENT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
5
+ const RELAY_LIMIT = 256
6
+ const INITIAL_COOLDOWN_MS = 5 * 60 * 1000
7
+ const MAX_COOLDOWN_MS = 30 * 60 * 1000
8
+ const TIMEOUT_THRESHOLD = 3
9
+
10
+ const prefix = error => {
11
+ for (const value of [error?.message, error?.code]) {
12
+ const match = typeof value === 'string' && /^([a-z-]+):/.exec(value)
13
+ if (match) return match[1]
14
+ }
15
+ return ''
16
+ }
17
+ const relayUrl = value => {
18
+ try { return normalizeRelayUrl(value) } catch { return '' }
19
+ }
20
+ const cooldown = level => Math.min(MAX_COOLDOWN_MS, INITIAL_COOLDOWN_MS * 2 ** Math.max(0, level - 1))
21
+
22
+ // Publication-only, runtime relay health shared by one PrivateMessenger.
23
+ // Permanent refusals open immediately; transport failures open after three
24
+ // consecutive attempts. Expired entries become one half-open probe.
25
+ export function createPublicationRelayHealth ({ now = Date.now, limit = RELAY_LIMIT } = {}) {
26
+ const relays = new Map()
27
+
28
+ function save (relay, state) {
29
+ relays.delete(relay)
30
+ relays.set(relay, state)
31
+ while (relays.size > limit) relays.delete(relays.keys().next().value)
32
+ }
33
+
34
+ function isExcluded (relay, at = now()) {
35
+ relay = relayUrl(relay)
36
+ const state = relay ? relays.get(relay) : undefined
37
+ return Boolean(state?.until && state.until > at)
38
+ }
39
+
40
+ function retryAt (candidates, at = now()) {
41
+ let earliest = 0
42
+ for (const candidate of candidates || []) {
43
+ const relay = relayUrl(candidate)
44
+ const until = relay ? relays.get(relay)?.until : 0
45
+ if (until > at && (!earliest || until < earliest)) earliest = until
46
+ }
47
+ return earliest
48
+ }
49
+
50
+ function recordFailure (relay, reason, at = now()) {
51
+ relay = relayUrl(relay)
52
+ if (!relay) return 0
53
+ const previous = relays.get(relay) || { failures: 0, level: 0, until: 0 }
54
+ const halfOpen = previous.until > 0 && previous.until <= at
55
+ const permanent = PERMANENT_PREFIXES.has(prefix(reason))
56
+ const transient = TRANSIENT_CATEGORIES.has(reason?.category)
57
+ const retryAfter = Number.isSafeInteger(reason?.retryAt)
58
+ ? reason.retryAt
59
+ : Number.isSafeInteger(reason?.retryAfterMs)
60
+ ? at + Math.max(0, reason.retryAfterMs)
61
+ : 0
62
+ let next = previous
63
+ if (permanent || retryAfter > at) {
64
+ const level = halfOpen ? Math.min(previous.level + 1, 5) : Math.max(1, previous.level)
65
+ next = { failures: 0, level, until: Math.min(at + cooldown(level), retryAfter > at ? retryAfter : Infinity) }
66
+ } else if (transient || prefix(reason) === 'error') {
67
+ const failures = previous.failures + 1
68
+ if (halfOpen) {
69
+ const level = Math.min(previous.level + 1, 5)
70
+ next = { failures: 0, level, until: at + cooldown(level) }
71
+ } else if (failures >= TIMEOUT_THRESHOLD) {
72
+ next = { failures: 0, level: Math.max(1, previous.level), until: at + cooldown(Math.max(1, previous.level)) }
73
+ } else next = { failures, level: previous.level, until: 0 }
74
+ }
75
+ save(relay, next)
76
+ return next.until > at ? next.until : 0
77
+ }
78
+
79
+ function recordSuccess (relay) {
80
+ relay = relayUrl(relay)
81
+ if (relay) relays.delete(relay)
82
+ }
83
+
84
+ function clear () {
85
+ relays.clear()
86
+ }
87
+
88
+ return { isExcluded, retryAt, recordFailure, recordSuccess, clear }
89
+ }
90
+
91
+ export {
92
+ INITIAL_COOLDOWN_MS as PUBLICATION_RELAY_INITIAL_COOLDOWN_MS,
93
+ MAX_COOLDOWN_MS as PUBLICATION_RELAY_MAX_COOLDOWN_MS,
94
+ RELAY_LIMIT as PUBLICATION_RELAY_HEALTH_LIMIT,
95
+ TIMEOUT_THRESHOLD as PUBLICATION_TIMEOUT_THRESHOLD
96
+ }
@@ -68,6 +68,9 @@ export function sanitizePublicationError (error, context = {}) {
68
68
  ...(safeToken(source.reason) ? { reason: source.reason } : {}),
69
69
  ...(safeToken(source.eventId) ? { eventId: source.eventId } : {}),
70
70
  ...(Number.isSafeInteger(source.eventKind) ? { eventKind: source.eventKind } : {}),
71
+ ...(safeToken(source.category) ? { category: source.category } : {}),
72
+ ...(Number.isSafeInteger(source.retryAt) ? { retryAt: source.retryAt } : {}),
73
+ ...(Number.isSafeInteger(source.retryAfterMs) ? { retryAfterMs: source.retryAfterMs } : {}),
71
74
  ...(source.retryWhenAvailable === true ? { retryWhenAvailable: true } : {}),
72
75
  ...(source.retryWhenOnline === true ? { retryWhenOnline: true } : {}),
73
76
  reports: reports.map(sanitizedReport)
@@ -85,7 +88,8 @@ export function publicationFailureDiagnostic (error) {
85
88
  if (safeToken(value) && contextFields.includes(key)) result[key] = value
86
89
  else if (Number.isSafeInteger(value) && contextNumbers.includes(key)) result[key] = value
87
90
  }
88
- for (const key of ['code', 'reason', 'eventId']) if (safeToken(error?.[key])) result[key] = error[key]
91
+ for (const key of ['code', 'reason', 'eventId', 'category']) if (safeToken(error?.[key])) result[key] = error[key]
92
+ for (const key of ['retryAt', 'retryAfterMs']) if (Number.isSafeInteger(error?.[key])) result[key] = error[key]
89
93
  for (const key of ['retryWhenAvailable', 'retryWhenOnline']) if (error?.[key] === true) result[key] = true
90
94
  result.reports = (Array.isArray(error?.reports) ? error.reports : []).map(sanitizedReport)
91
95
  return result
@@ -8,6 +8,15 @@ const normalized = relay => { try { return normalizeRelayUrl(relay) } catch { re
8
8
 
9
9
  export { isReplaceableRelayFailure }
10
10
 
11
+ export function relayCircuitError (retryAt) {
12
+ return Object.assign(new Error('RELAY_CIRCUIT_OPEN'), {
13
+ code: 'RELAY_CIRCUIT_OPEN',
14
+ category: 'timeout',
15
+ retryAt,
16
+ retryWhenAvailable: true
17
+ })
18
+ }
19
+
11
20
  export function normalizeFallbackRelays (value = []) {
12
21
  if (!Array.isArray(value)) throw new ValidationError('INVALID_FALLBACK_RELAYS')
13
22
  return [...new Set(Array.from(value, normalizeRelayUrl))]
@@ -32,11 +41,13 @@ function routeGroups (routes) {
32
41
  return [...groups.values()]
33
42
  }
34
43
 
35
- export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
44
+ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey, primaryRelays, primaryRelayToReceivers, fallbackRelays = [], fallbackDelayMs = null, exclusions, relayHealth, pickRelays, recoveryRelays, publish, publishNymEvent, sendEvent, isOnline, isCurrent, signal, pauseSignal, attemptStartedAt, monotonicNow = () => performance.now(), now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
36
45
  // Keep discovery's defaults when NIP-65 is unavailable. Explicit maps/lists
37
46
  // retain their fanout; configured fallbacks are a separate last-resort stage.
38
47
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
39
48
  if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
49
+ exclusions ??= new Map()
50
+ relayHealth ??= { isExcluded: () => false, retryAt: () => 0, recordFailure: () => 0, recordSuccess: () => {} }
40
51
  const transportSignal = signal && pauseSignal ? AbortSignal.any([signal, pauseSignal]) : signal || pauseSignal
41
52
  const explicit = Boolean(primaryRelays || primaryRelayToReceivers)
42
53
  const primaryRoutes = primaryRelayToReceivers || (primaryRelays
@@ -49,12 +60,17 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
49
60
  const candidates = [...new Set([...Object.values(primaryByPeer).flatMap(value => value.read), ...fallbackRelays])]
50
61
  const candidateSet = new Set(candidates)
51
62
  for (const [relay, expires] of exclusions) if (expires <= now() || !candidateSet.has(relay)) exclusions.delete(relay)
52
- // A fresh attempt can recheck an exhausted list; each outer event still owns
53
- // a tried set so concurrent sends/fragments cannot create a retry loop.
54
- if (peers.some(pubkey => {
55
- const available = [...primaryByPeer[pubkey].read, ...fallbackRelays]
56
- return available.length && available.every(relay => exclusions.has(relay))
57
- })) exclusions.clear()
63
+ const unavailable = relay => exclusions.has(relay) || relayHealth.isExcluded(relay, now())
64
+ const initialExcluded = new Set(candidates.filter(unavailable))
65
+ const blockedPeers = peers.filter(pubkey => {
66
+ const available = [...primaryByPeer[pubkey]?.read || [], ...fallbackRelays]
67
+ return available.length > 0 && available.every(unavailable)
68
+ })
69
+ const circuitCandidates = blockedPeers.length
70
+ ? [...new Set(blockedPeers.flatMap(pubkey => [...primaryByPeer[pubkey]?.read || [], ...fallbackRelays]))]
71
+ : candidates
72
+ const circuitOpen = (candidates.length > 0 && candidates.every(relay => initialExcluded.has(relay))) || blockedPeers.length > 0
73
+ const circuitRetryAt = circuitOpen ? relayHealth.retryAt(circuitCandidates, now()) : 0
58
74
  const select = (receivers, excluded, staged = false) => {
59
75
  const excludedRelays = [...excluded]
60
76
  const excludeRelaysByPubkey = new Map(receivers.map(pubkey => [pubkey, excludedRelays]))
@@ -69,10 +85,20 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
69
85
  for (const [relay, pubkeys] of fallback) routes.set(relay, [...new Set([...(routes.get(relay) || []), ...pubkeys])])
70
86
  return routes
71
87
  }
72
- const relayToReceivers = primaryRelayToReceivers || select(peers, exclusions.keys())
88
+ const relayToReceivers = primaryRelayToReceivers || select(peers, initialExcluded)
73
89
  const current = () => !transportSignal?.aborted && isCurrent()
74
90
  const rejected = report => (report?.errors || []).filter(item => candidateSet.has(normalized(item.relay)) && isReplaceableRelayFailure(item.reason))
75
- const remember = errors => { for (const item of errors) if (candidateSet.has(normalized(item.relay))) exclusions.set(normalized(item.relay), now() + EXCLUSION_MS) }
91
+ const remember = errors => {
92
+ for (const item of errors) {
93
+ const relay = normalized(item.relay)
94
+ if (!candidateSet.has(relay)) continue
95
+ exclusions.set(relay, now() + EXCLUSION_MS)
96
+ relayHealth.recordFailure(relay, item.reason, now())
97
+ }
98
+ }
99
+ const rememberSuccess = relays => {
100
+ for (const relay of relays || []) relayHealth.recordSuccess(relay)
101
+ }
76
102
  const online = async () => {
77
103
  if (!current()) return false
78
104
  try { return await isOnline({ signal: transportSignal }) && current() } catch { return false }
@@ -85,11 +111,11 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
85
111
  const pending = new Set(receivers)
86
112
  const mirrors = initialRelays.filter(relay => !candidateSet.has(normalized(relay)))
87
113
  const initialPrimary = context.primaryRelays || (primaryRelays || peers.length === 1 ? routeEntries(relayToReceivers).map(([relay]) => relay) : [])
88
- const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !exclusions.has(relay))
114
+ const first = [...new Set(initialPrimary.map(normalizeRelayUrl))].filter(relay => !unavailable(relay))
89
115
  if (!explicit && fallbackDelayMs !== null && fallbackRelays.length) {
90
116
  const tried = new Set()
91
117
  const pick = (pending, fallback) => {
92
- const excluded = [...new Set([...exclusions.keys(), ...tried])]
118
+ const excluded = [...new Set([...candidates.filter(unavailable), ...exclusions.keys(), ...tried])]
93
119
  const routes = pickRelays([...pending], fallback
94
120
  ? Object.fromEntries([...pending].map(pubkey => [pubkey, { read: fallbackRelays }]))
95
121
  : primaryByPeer, { relayType: 'read', maxPerPubkey: 2, excludeRelaysByPubkey: new Map([...pending].map(pubkey => [pubkey, excluded])), emptyRelaysFallback: [] })
@@ -98,17 +124,16 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
98
124
  const result = await publishWithEarlyFallback({
99
125
  event, receivers, first: first.length ? { receivers, relays: first } : null, mirrors, tried,
100
126
  nextPrimary: pending => pick(pending, false), nextFallback: pending => pick(pending, true),
101
- sendEvent, isOnline, isCurrent, signal, pauseSignal, remember,
127
+ sendEvent, isOnline, isCurrent, signal, pauseSignal, remember, rememberSuccess,
102
128
  delay: fallbackDelayMs, attemptStartedAt, monotonicNow, now, setTimer, clearTimer
103
129
  })
104
- if (!result.success && current()) exclusions.clear()
105
130
  return result
106
131
  }
107
132
  let batch = first.length ? { receivers, relays: first } : null
108
133
  const tried = new Set()
109
134
  const errors = []
110
135
  const summaries = []
111
- const next = () => routeGroups(select([...pending], new Set([...exclusions.keys(), ...tried]), true))[0]
136
+ const next = () => routeGroups(select([...pending], new Set([...candidates.filter(unavailable), ...exclusions.keys(), ...tried]), true))[0]
112
137
  const finish = result => ({
113
138
  ...result,
114
139
  success: summaries.length > 0 && pending.size === 0,
@@ -136,6 +161,7 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
136
161
  result = await sendEvent(event, relays, { signal: transportSignal })
137
162
  const settled = Promise.resolve(result.promise)
138
163
  summaries.push(settled)
164
+ settled.then(report => rememberSuccess(report?.succeededRelays || [])).catch(() => {})
139
165
  if (result.success) {
140
166
  for (const pubkey of batch.receivers) pending.delete(pubkey)
141
167
  // Keep the first ACK immediate; learn about redundant failures later.
@@ -148,10 +174,10 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
148
174
  const report = await settled
149
175
  const failed = rejected(report)
150
176
  errors.push(...(report?.errors || []))
177
+ if (failed.length) remember(failed)
151
178
  if (!failed.length || errors.some(item => !isReplaceableRelayFailure(item.reason))) return finished(result)
152
- if (!next()) { exclusions.clear(); return finished(result) }
179
+ if (!next()) return finished(result)
153
180
  if (!await online()) return { ...finish(result), retryWhenAvailable: !signal?.aborted, retryWhenOnline: !signal?.aborted }
154
- remember(failed)
155
181
  }
156
182
  if (!current()) return finished(result)
157
183
  batch = null
@@ -161,8 +187,10 @@ export function createSendRelayRouting ({ peer, peers = [peer], relaysByPubkey,
161
187
 
162
188
  return {
163
189
  relayToReceivers,
190
+ circuitOpen,
191
+ retryAt: circuitRetryAt,
164
192
  // A recovery mirror must not reintroduce refused relays or promote a fallback.
165
- recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(normalized(relay)) || !exclusions.has(normalized(relay))),
193
+ recoveryRelays: recoveryRelays.filter(relay => !candidateSet.has(normalized(relay)) || !unavailable(normalized(relay))),
166
194
  _publish: options => (options.nymSigner ? publishNymEvent : publish)({ ...options, _publish: send })
167
195
  }
168
196
  }
@@ -49,7 +49,8 @@ import { isOnline, onOnline } from '../network/index.js'
49
49
  import { isRetryableRelayFailure, isReplaceableRelayFailure } from '../relay/index.js'
50
50
  import { createPauseRecovery, isRecoverableStorageFailure } from './helpers/pause-recovery.js'
51
51
  import { backgroundSigner } from './helpers/background-signer.js'
52
- import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay } from './helpers/send-routing.js'
52
+ import { createPublicationRelayHealth } from './helpers/publication-relay-health.js'
53
+ import { createSendRelayRouting, normalizeFallbackRelays, normalizeFallbackDelay, relayCircuitError } from './helpers/send-routing.js'
53
54
  import { incompleteFetchError } from '../private-channel/helpers/fetch-error.js'
54
55
  import { recoveryRetryDelay, isPermanentRecoveryError } from './helpers/recovery-retry.js'
55
56
  import { assertMessagePublished, publicationFailureDiagnostic, sanitizePublicationError } from './helpers/publication.js'
@@ -319,9 +320,10 @@ export class PrivateMessenger {
319
320
  this._subscribeRelayListUpdates = _subscribeRelayListUpdates
320
321
  this._isOnline = _isOnline
321
322
  this._waitForForeground = _waitForForeground
322
- this.sendRelayExclusions = new Map()
323
+ this.publicationRelayHealth = createPublicationRelayHealth({ now: _now })
323
324
  this.sendRoutingLifetime = new AbortController()
324
325
  this.sendRoutingPause = new AbortController()
326
+ this._now = _now
325
327
  this._setTimeout = _setTimeout
326
328
  this._clearTimeout = _clearTimeout
327
329
  this._setInterval = _setInterval
@@ -800,7 +802,6 @@ export class PrivateMessenger {
800
802
  this.stopPresencePublisher(pubkey)
801
803
  }
802
804
  for (const pubkey of removedPubkeys) this.channels.delete(pubkey)
803
- for (const [key, value] of this.sendRelayExclusions) if (removedPubkeys.includes(value.channelPubkey)) this.sendRelayExclusions.delete(key)
804
805
  for (const channel of changed) this.channels.set(channel.pubkey, channel)
805
806
 
806
807
  // Incremental registration must not prune persisted channels whose signers
@@ -894,22 +895,16 @@ export class PrivateMessenger {
894
895
  async resolveSendRouting ({ channel, receiverPubkeys, relays, relayToReceivers, signal, attemptStartedAt, onDebug }) {
895
896
  if (attemptStartedAt !== undefined && (!Number.isFinite(attemptStartedAt) || attemptStartedAt < 0)) throw new ValidationError('INVALID_ATTEMPT_STARTED_AT')
896
897
  const recoveryRelays = await this.recoveryMirrorRelays(channel.pubkey)
897
- if (relayToReceivers && !this.fallbackRelays.length) return { relayToReceivers, recoveryRelays }
898
898
  const fixed = relayToReceivers ? null : relays?.length ? uniq(relays) : channel.sendRelays.length ? channel.sendRelays : channel.relays.length ? channel.relays : null
899
- if (fixed && !this.fallbackRelays.length) return { relays: fixed, recoveryRelays }
900
899
  const recipients = uniq(receiverPubkeys?.length ? receiverPubkeys : relayMapReceivers(relayToReceivers))
901
900
  const relaysByPubkey = fixed || relayToReceivers ? undefined : await this._getRelaysByPubkey(recipients)
902
- const key = `${channel.pubkey}:${[...recipients].sort().join(',')}`
903
- if (!this.sendRelayExclusions.has(key)) {
904
- if (this.sendRelayExclusions.size >= 256) this.sendRelayExclusions.delete(this.sendRelayExclusions.keys().next().value)
905
- this.sendRelayExclusions.set(key, { channelPubkey: channel.pubkey, exclusions: new Map() })
906
- }
907
901
  const routing = createSendRelayRouting({
908
902
  peers: fixed && !recipients.length ? [this.userPubkey] : recipients, relaysByPubkey, recoveryRelays, primaryRelays: fixed?.map(normalizeRelayUrl), primaryRelayToReceivers: relayToReceivers, fallbackRelays: this.fallbackRelays,
909
903
  signal: signal ? AbortSignal.any([this.sendRoutingLifetime.signal, signal]) : this.sendRoutingLifetime.signal,
910
- exclusions: this.sendRelayExclusions.get(key).exclusions,
904
+ exclusions: new Map(),
905
+ relayHealth: this.publicationRelayHealth,
911
906
  fallbackDelayMs: this.fallbackDelayMs, attemptStartedAt, pauseSignal: this.sendRoutingPause.signal,
912
- setTimer: this._setTimeout, clearTimer: this._clearTimeout,
907
+ setTimer: this._setTimeout, clearTimer: this._clearTimeout, now: this._now,
913
908
  pickRelays: this._pickRelaysForPubkeys,
914
909
  publish: this._privateChannel.publish || privateChannel.publish,
915
910
  publishNymEvent: this._privateChannel.publishNymEvent || privateChannel.publishNymEvent,
@@ -922,10 +917,11 @@ export class PrivateMessenger {
922
917
  isOnline: this._isOnline,
923
918
  isCurrent: () => !this.closePromise && !this.pauseReasons.size && this.channels.has(channel.pubkey)
924
919
  })
920
+ if (routing.circuitOpen) throw relayCircuitError(routing.retryAt)
925
921
  if (!relayMapRelays(routing.relayToReceivers).length) throw new ValidationError('NO_RELAYS')
926
922
  // Fixed lists address the original receiver set as a whole, preserving the
927
923
  // existing encryption grouping instead of inventing per-recipient routes.
928
- if (fixed) return { relays: fixed, recoveryRelays: routing.recoveryRelays, _publish: routing._publish }
924
+ if (fixed) return { relays: relayMapRelays(routing.relayToReceivers), recoveryRelays: routing.recoveryRelays, _publish: routing._publish }
929
925
  return routing
930
926
  }
931
927
 
@@ -3384,7 +3380,7 @@ export class PrivateMessenger {
3384
3380
  if (this.closePromise) return this.closePromise
3385
3381
  const initSettledPromise = this.initSettledPromise
3386
3382
  this.sendRoutingLifetime.abort()
3387
- this.sendRelayExclusions.clear()
3383
+ this.publicationRelayHealth.clear()
3388
3384
  this.pendingStorageWrites.clear()
3389
3385
  for (const task of this.pauseRecoveries.values()) task.stop()
3390
3386
  for (const task of this.watchRecoveries.values()) task.stop()
@@ -183,8 +183,8 @@ tag or deletion capability is recreated. Confirmed connectivity, exclusions and
183
183
  connection cooldowns remain authoritative; a waiting primary is not penalized
184
184
  merely because fallback started. Late native failures may inform later sends.
185
185
 
186
- On `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or `error`
187
- rejections, or connection/transport/timeout failures, replacement requires
186
+ On `banned`, `blocked`, `restricted`, `auth-required`, `pow`, `rate-limited`, or
187
+ `error` rejections, or connection/transport/timeout failures, replacement requires
188
188
  `isOnline` to confirm connectivity. Invalid messages, local signer/authentication
189
189
  failures and unknown error text retain their original diagnosis.
190
190
 
@@ -204,10 +204,18 @@ members, every pending member must be covered before the outer event succeeds;
204
204
  a partial ACK cannot hide another member's failure. Remaining primary routes are
205
205
  exhausted before configured fallbacks with the default null delay or explicit
206
206
  routes. Redundant acknowledgements may update
207
- future routing preferences without delaying a successfully covered send. Preferences are channel/recipient-scoped, in-memory,
208
- expire after five minutes, and never alter receive subscriptions. A fresh attempt
209
- can recheck an exhausted list. Additional fallback relays come only from the
210
- caller configuration; absent NIP-65 lists retain the existing discovery defaults.
207
+ future routing preferences without delaying a successfully covered send.
208
+ Publication health is shared by the messenger instance and never alters receive
209
+ subscriptions. Explicit bans/refusals open that relay immediately; connection,
210
+ transport and timeout failures open it after three consecutive attempts.
211
+ Cooldown starts at five minutes, doubles after failed half-open probes and caps
212
+ at 30 minutes. A success resets only that relay. When every route available to a
213
+ pending recipient is cooling down, publication returns
214
+ `RELAY_CIRCUIT_OPEN` with `retryAt` before preparing another encrypted envelope;
215
+ the range or outbox item remains pending and is retried no earlier than that
216
+ deadline. Health is bounded, runtime-only and cleared on close. Additional
217
+ fallback relays come only from the caller configuration; absent NIP-65 lists
218
+ retain the existing discovery defaults.
211
219
 
212
220
  When offline/interrupted with alternatives remaining, reports carry
213
221
  `retryWhenAvailable: true`. The outbox remains pending and emits no `onSendError`.
@@ -268,9 +276,11 @@ The existing `onPreparedSeed` callback now also supplies `publication`. The
268
276
  session commits that minimal journal before transmitting; direct messengers
269
277
  retain their local seed-store durability. Only after that commit can relay
270
278
  publication proceed. One independent serial worker converts recovery seeds into
271
- their configured archive. Pending durable rows participate in recovery reads
272
- immediately, including during a blocked archive write. No new seeds are enabled
273
- for modes which did not previously retain them.
279
+ their configured archive. Event-store adapters receive all rows for a publication
280
+ through one `putMany` call; serial `put` remains the external-adapter fallback.
281
+ Pending durable rows participate in recovery reads and batched `hasMany` checks
282
+ immediately, including during a blocked archive write. Expired rows are absent.
283
+ No new seeds are enabled for modes which did not previously retain them.
274
284
 
275
285
  First acceptance of every required envelope emits saved independently of archive
276
286
  or cleanup. ACK progress checkpoints serialize with other entry writes. A crash
@@ -130,6 +130,19 @@ export function pendingSeedMatches (row, query) {
130
130
  }
131
131
 
132
132
  export function withPendingSeeds (storage, pending) {
133
+ async function hasMany (rows) {
134
+ const pendingIds = new Set(pending().map(row => row.recordId).filter(Boolean))
135
+ const unresolved = rows.filter(row => !row.recordId || !pendingIds.has(row.recordId))
136
+ let persisted
137
+ if (typeof storage.hasMany === 'function') persisted = await storage.hasMany(unresolved)
138
+ else {
139
+ persisted = []
140
+ for (const row of unresolved) persisted.push(await storage.has(row))
141
+ }
142
+ if (!Array.isArray(persisted) || persisted.length !== unresolved.length) throw new Error('INVALID_RECOVERY_STORAGE_RESULT')
143
+ let index = 0
144
+ return rows.map(row => Boolean(row.recordId && pendingIds.has(row.recordId)) || Boolean(persisted[index++]))
145
+ }
133
146
  return {
134
147
  ...storage,
135
148
  async * iterate (query = {}) {
@@ -138,6 +151,7 @@ export function withPendingSeeds (storage, pending) {
138
151
  for (const row of pending()) if (pendingSeedMatches(row, query)) { seen.add(row.recordId); yield row }
139
152
  for await (const row of storage.iterate(query)) if (!seen.has(row.recordId)) yield row
140
153
  },
141
- has: row => pending().some(value => value.recordId === row.recordId) || storage.has(row)
154
+ hasMany,
155
+ async has (row) { return (await hasMany([row]))[0] }
142
156
  }
143
157
  }
@@ -153,6 +153,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
153
153
  let repump
154
154
  let draining
155
155
  let redrain = false
156
+ const sendRetryAt = new Map()
156
157
  const idleWaiters = new Set()
157
158
  const settled = () => { if (!sending && !localSaving && !enqueuing && !draining && !cleanups.size) { for (const resolve of idleWaiters) resolve(); idleWaiters.clear() } }
158
159
  const localComplete = entry => entry.localSaved.every(Boolean)
@@ -164,7 +165,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
164
165
  const journal = createPublicationJournal({
165
166
  entries, store: () => storage, onError: reportError, debug, setTimer: _setTimeout, clearTimer: _clearTimeout, random: _random,
166
167
  archive: async (entry, rows) => {
167
- if (recoveryStorage?.seeds) { for (const row of rows) await recoveryStorage.seeds.put(row) } else if (entry.prepared.target.file) {
168
+ if (recoveryStorage?.seeds) {
169
+ if (typeof recoveryStorage.seeds.putMany === 'function') await recoveryStorage.seeds.putMany(rows)
170
+ else for (const row of rows) await recoveryStorage.seeds.put(row)
171
+ } else if (entry.prepared.target.file) {
168
172
  for (const seed of entry.prepared.publication.seeds) await fileTransfers.archiveSeed(entry.prepared.target.file, seed)
169
173
  } else {
170
174
  for (const seed of entry.prepared.publication.seeds) await messenger.enqueueSeed(seed.channelPubkey, seed)
@@ -450,7 +454,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
450
454
  if (cleanups.has(entry.id)) return cleanups.get(entry.id)
451
455
  const startedAt = Date.now()
452
456
  const work = Promise.resolve().then(async () => { await journal.flush(entry); await storage.remove(entry.id) }).then(() => {
453
- entries.delete(entry.id); localRetries.delete(entry.id); attemptStarts.delete(entry.id)
457
+ entries.delete(entry.id); localRetries.delete(entry.id); attemptStarts.delete(entry.id); sendRetryAt.delete(entry.id)
454
458
  debug(entry, 'outbox-cleanup', startedAt)
455
459
  emit()
456
460
  }).catch(error => {
@@ -594,6 +598,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
594
598
  // The failed wire event may be a quote or file chunk. Report the
595
599
  // owning outbox item separately, without mutating the native error.
596
600
  if (error.retryWhenOnline) offlineSends.add(entry.id)
601
+ if (Number.isSafeInteger(error.retryAt)) sendRetryAt.set(entry.id, error.retryAt)
602
+ else sendRetryAt.delete(entry.id)
597
603
  if (entry.failed && (error.retryWhenAvailable || (entry.deliveryConfirmed && entry.retryable)) && !pausedSends.has(entry.id)) scheduleSendRetry()
598
604
  if (!closed && !entry.deletion && !cancelled.has(entry.id) && !entry.deliveryConfirmed && !error.retryWhenAvailable) onSendError(error, { id: entry.id, peer: entry.peer })
599
605
  }
@@ -614,14 +620,28 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
614
620
  const pendingRetryable = entry => Boolean(!cancelled.has(entry.id) && ((entry.localFailed && entry.localRetryable) || (entry.failed && (entry.retryable || entry.status === 'pending'))))
615
621
  function scheduleSendRetry () {
616
622
  if (closed || !available || sendRetryTimer) return
617
- const delay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * (0.8 + _random() * 0.4))
623
+ let nextRetryAt = 0
624
+ let unconstrained = false
625
+ for (const entry of entries.values()) {
626
+ if (!pendingRetryable(entry)) continue
627
+ const retryAt = sendRetryAt.get(entry.id)
628
+ if (!Number.isSafeInteger(retryAt)) { unconstrained = true; continue }
629
+ if (!nextRetryAt || retryAt < nextRetryAt) nextRetryAt = retryAt
630
+ }
631
+ if (unconstrained) nextRetryAt = 0
632
+ const backoff = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * (0.8 + _random() * 0.4))
633
+ const delay = Math.max(backoff, nextRetryAt ? nextRetryAt - Date.now() : 0)
618
634
  sendRetryDelay = Math.min(SEND_RETRY_MAX_MS, sendRetryDelay * 2)
619
635
  sendRetryTimer = _setTimeout(() => {
620
636
  sendRetryTimer = undefined
621
637
  if (closed || !available) return
638
+ const now = Date.now()
622
639
  let retried = false
623
640
  for (const entry of entries.values()) {
624
641
  if (!pendingRetryable(entry)) continue
642
+ const retryAt = sendRetryAt.get(entry.id)
643
+ if (Number.isSafeInteger(retryAt) && retryAt > now) continue
644
+ sendRetryAt.delete(entry.id)
625
645
  if (entry.localFailed && entry.localRetryable) { localRetries.add(entry.id); retried = true }
626
646
  if (pausedSends.has(entry.id) || !entry.failed || !(entry.retryable || entry.status === 'pending')) continue
627
647
  entry.failed = false
@@ -630,6 +650,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
630
650
  retried = true
631
651
  }
632
652
  if (retried) { emit(); pump() }
653
+ if ([...entries.values()].some(entry => pendingRetryable(entry) && Number.isSafeInteger(sendRetryAt.get(entry.id)))) scheduleSendRetry()
633
654
  }, delay)
634
655
  sendRetryTimer.unref?.()
635
656
  }
@@ -921,7 +942,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
921
942
  },
922
943
  close () {
923
944
  return (closeWork ??= (async () => {
924
- closed = true; available = false; lifecycle++; localRetries.clear()
945
+ closed = true; available = false; lifecycle++; localRetries.clear(); sendRetryAt.clear()
925
946
  stopOnlineRetry(); offlineSends.clear(); networkUrgency.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
926
947
  clearSendRetry(); inboxRecovery.stop(); preparation.close(); foreground.close(); pausedSends.clear()
927
948
  for (const controller of sendControllers.values()) controller.abort()
@@ -1,6 +1,6 @@
1
1
  const TRANSPORT_CATEGORIES = new Set(['connection', 'transport', 'timeout'])
2
2
  const RETRY_PREFIXES = new Set(['rate-limited', 'error'])
3
- const REPLACEMENT_PREFIXES = new Set(['blocked', 'restricted', 'auth-required', 'pow', ...RETRY_PREFIXES])
3
+ const REPLACEMENT_PREFIXES = new Set(['banned', 'blocked', 'restricted', 'auth-required', 'pow', ...RETRY_PREFIXES])
4
4
 
5
5
  // Retry and replacement are different policies: a refusal can justify another
6
6
  // relay without making the same operation retryable on the refusing relay.
@@ -8,7 +8,8 @@ function matches (error, prefixes) {
8
8
  if (error?.name === 'Nip42AuthenticationError') return false
9
9
  if (TRANSPORT_CATEGORIES.has(error?.category)) return true
10
10
  if (error?.category && error.category !== 'relay') return false
11
- return prefixes.has(/^([a-z-]+):/.exec(error?.message || '')?.[1])
11
+ const name = /^([a-z-]+):/.exec(error?.message || '')?.[1] || /^([a-z-]+):/.exec(error?.code || '')?.[1]
12
+ return prefixes.has(name)
12
13
  }
13
14
 
14
15
  export const isRetryableRelayFailure = error => matches(error, RETRY_PREFIXES)