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 +8 -0
- package/package.json +1 -1
- package/private-messenger/event-store/README.md +5 -4
- package/private-messenger/event-store/index.js +1 -1
- package/private-messenger/helpers/fallback-race.js +2 -1
- package/private-messenger/helpers/publication-relay-health.js +96 -0
- package/private-messenger/helpers/publication.js +5 -1
- package/private-messenger/helpers/send-routing.js +45 -17
- package/private-messenger/index.js +10 -14
- package/private-messenger/session/README.md +19 -9
- package/private-messenger/session/helpers/publication-journal.js +15 -1
- package/private-messenger/session/index.js +25 -4
- package/relay/helpers/failure.js +3 -2
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
|
@@ -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
|
|
28
|
-
`undefined`
|
|
29
|
-
|
|
30
|
-
and
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const available = [...primaryByPeer[pubkey]
|
|
56
|
-
return available.length && available.every(
|
|
57
|
-
})
|
|
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,
|
|
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 => {
|
|
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 => !
|
|
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())
|
|
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)) || !
|
|
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 {
|
|
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.
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
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.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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.
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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()
|
package/relay/helpers/failure.js
CHANGED
|
@@ -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
|
-
|
|
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)
|