libp2r2p 0.11.11 → 0.11.12
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
|
@@ -541,6 +541,22 @@ and the uncovered subranges plus the pre-oldest left edge are requested from
|
|
|
541
541
|
seeders. Offline attempts never consume the budget, mark coverage or contact
|
|
542
542
|
seeders; they wait for connectivity. The per-relay tracker is process-local, so a
|
|
543
543
|
restart falls back to the persisted whole interval.
|
|
544
|
+
|
|
545
|
+
`prioritizeRange(channelPubkey, { since, until, type })` registers an
|
|
546
|
+
application-directed priority window for the next recovery pass. `unread-page`
|
|
547
|
+
requires `since` (a read anchor); `tail` derives a bounded window from the
|
|
548
|
+
newest persisted range using `priorityTailSeconds` (six hours by default).
|
|
549
|
+
Priority subranges are fetched before older history; relays are ordered by
|
|
550
|
+
recent EOSE, consecutive failures and latency; and the first priority page may
|
|
551
|
+
hedge up to `priorityHedgeRelays` (two) relays with `priorityHedgeDelayMs`
|
|
552
|
+
(500 ms), aborting the loser. When relays do not cover the window, seeders with
|
|
553
|
+
recent presence (`announcedAt`/`lastActiveAt` within `seederOnlineSeconds`) are
|
|
554
|
+
asked only for that subrange; a successful ask counts as coverage. Priority
|
|
555
|
+
probes never consume the relay tier budget, and priorities/statistics are
|
|
556
|
+
process-local: they expire after `priorityTtlMs` (ten minutes) or once covered,
|
|
557
|
+
and clear with channel/recovery state. Presence payloads and persisted ranges
|
|
558
|
+
are unchanged.
|
|
559
|
+
|
|
544
560
|
`private-channel.fetchHistory(options)` (0.11.6) is the complete-interval API
|
|
545
561
|
used by messenger recovery. It accepts the signers, processor/fragment options,
|
|
546
562
|
callbacks and abort signal of `fetch`, plus inclusive `since`/`until` bounds
|
package/helpers/ranges.js
CHANGED
|
@@ -29,3 +29,19 @@ export function subtractRanges (range, covered = []) {
|
|
|
29
29
|
if (cursor <= range.end) out.push({ start: cursor, end: range.end })
|
|
30
30
|
return out
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
export function intersectRanges (left = [], right = []) {
|
|
34
|
+
const a = mergeRanges(left)
|
|
35
|
+
const b = mergeRanges(right)
|
|
36
|
+
const out = []
|
|
37
|
+
let i = 0
|
|
38
|
+
let j = 0
|
|
39
|
+
while (i < a.length && j < b.length) {
|
|
40
|
+
const start = Math.max(a[i].start, b[j].start)
|
|
41
|
+
const end = Math.min(a[i].end, b[j].end)
|
|
42
|
+
if (start <= end) out.push({ start, end })
|
|
43
|
+
if (a[i].end < b[j].end) i++
|
|
44
|
+
else j++
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -46,6 +46,7 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
46
46
|
}
|
|
47
47
|
for (const relay of urls) {
|
|
48
48
|
const relayStartCount = receivedEventCount
|
|
49
|
+
let relayElapsedMs = 0
|
|
49
50
|
const intervals = resumeIntervalsFor({ resume, relay, since, until })
|
|
50
51
|
.map(interval => ({ since: interval.start, until: interval.end, limit: PAGE_SIZE }))
|
|
51
52
|
const covered = []
|
|
@@ -83,7 +84,11 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
83
84
|
outcome = { relay, status: 'error', error }
|
|
84
85
|
intervals.push(interval)
|
|
85
86
|
break
|
|
86
|
-
} finally {
|
|
87
|
+
} finally {
|
|
88
|
+
const took = performance.now() - started
|
|
89
|
+
elapsedMs += took
|
|
90
|
+
relayElapsedMs += took
|
|
91
|
+
}
|
|
87
92
|
signal?.throwIfAborted()
|
|
88
93
|
const status = page.relays?.find(item => item.relay === relay)
|
|
89
94
|
if (page.errors?.length || !status || !['eose', 'satisfied'].includes(status.status)) {
|
|
@@ -123,7 +128,8 @@ export async function readHistory ({ filter, relays, receiverPubkey, signal, get
|
|
|
123
128
|
? {
|
|
124
129
|
covered: mergeRanges(covered),
|
|
125
130
|
pending: mergeRanges(intervals.map(interval => ({ start: interval.since, end: interval.until }))),
|
|
126
|
-
events: receivedEventCount - relayStartCount
|
|
131
|
+
events: receivedEventCount - relayStartCount,
|
|
132
|
+
elapsedMs: Math.round(relayElapsedMs)
|
|
127
133
|
}
|
|
128
134
|
: {})
|
|
129
135
|
})
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
// - For other event-list replies, use createEventReplyPacker({ messenger, question, code }).update(event).
|
|
44
44
|
|
|
45
45
|
import { createAbortableSemaphore } from '../helpers/abortable-semaphore.js'
|
|
46
|
-
import { mergeRanges, subtractRanges } from '../helpers/ranges.js'
|
|
46
|
+
import { mergeRanges, subtractRanges, intersectRanges } from '../helpers/ranges.js'
|
|
47
47
|
import * as privateMessage from '../private-message/index.js'
|
|
48
48
|
import { isOnline } from '../network/index.js'
|
|
49
49
|
import { createSendRelayRouting, normalizeFallbackRelays } from './helpers/send-routing.js'
|
|
@@ -130,6 +130,10 @@ export const RECOVERY_RELAY_RETRY_LIMITS = Object.freeze({
|
|
|
130
130
|
allFailed: 10
|
|
131
131
|
})
|
|
132
132
|
const RECOVERY_ONLINE_CACHE_MS = 5000
|
|
133
|
+
const DEFAULT_PRIORITY_TAIL_SECONDS = 6 * 3600
|
|
134
|
+
const DEFAULT_PRIORITY_TTL_MS = 10 * 60 * 1000
|
|
135
|
+
const DEFAULT_PRIORITY_HEDGE_DELAY_MS = 500
|
|
136
|
+
const DEFAULT_PRIORITY_HEDGE_RELAYS = 2
|
|
133
137
|
|
|
134
138
|
function textToBase64 (text) {
|
|
135
139
|
return bytesToBase64(encoder.encode(text))
|
|
@@ -213,6 +217,10 @@ export class PrivateMessenger {
|
|
|
213
217
|
reloadGapDelayMs = DEFAULT_RELOAD_GAP_DELAY_MS,
|
|
214
218
|
seederPresenceIntervalMs = DEFAULT_SEEDER_PRESENCE_INTERVAL_MS,
|
|
215
219
|
seederOnlineSeconds = DEFAULT_SEEDER_ONLINE_SECONDS,
|
|
220
|
+
priorityTailSeconds = DEFAULT_PRIORITY_TAIL_SECONDS,
|
|
221
|
+
priorityTtlMs = DEFAULT_PRIORITY_TTL_MS,
|
|
222
|
+
priorityHedgeDelayMs = DEFAULT_PRIORITY_HEDGE_DELAY_MS,
|
|
223
|
+
priorityHedgeRelays = DEFAULT_PRIORITY_HEDGE_RELAYS,
|
|
216
224
|
maxDynamicRecoverySeeders = DEFAULT_MAX_DYNAMIC_RECOVERY_SEEDERS,
|
|
217
225
|
messageQueueMaxBytes = DEFAULT_MESSAGE_QUEUE_MAX_BYTES,
|
|
218
226
|
seedQueueMaxBytes = DEFAULT_SEED_QUEUE_MAX_BYTES,
|
|
@@ -247,6 +255,19 @@ export class PrivateMessenger {
|
|
|
247
255
|
this.reloadGapDelayMs = reloadGapDelayMs
|
|
248
256
|
this.seederPresenceIntervalMs = seederPresenceIntervalMs
|
|
249
257
|
this.seederOnlineSeconds = seederOnlineSeconds
|
|
258
|
+
this.priorityTailSeconds = Number.isFinite(priorityTailSeconds) && priorityTailSeconds >= 0
|
|
259
|
+
? Math.floor(priorityTailSeconds)
|
|
260
|
+
: DEFAULT_PRIORITY_TAIL_SECONDS
|
|
261
|
+
this.priorityTtlMs = Number.isFinite(priorityTtlMs) && priorityTtlMs > 0
|
|
262
|
+
? Math.floor(priorityTtlMs)
|
|
263
|
+
: DEFAULT_PRIORITY_TTL_MS
|
|
264
|
+
this.priorityHedgeDelayMs = Number.isFinite(priorityHedgeDelayMs) && priorityHedgeDelayMs >= 0
|
|
265
|
+
? Math.floor(priorityHedgeDelayMs)
|
|
266
|
+
: DEFAULT_PRIORITY_HEDGE_DELAY_MS
|
|
267
|
+
const hedgeRelays = Math.floor(Number(priorityHedgeRelays))
|
|
268
|
+
this.priorityHedgeRelays = Number.isSafeInteger(hedgeRelays) && hedgeRelays >= 1
|
|
269
|
+
? Math.min(hedgeRelays, 3)
|
|
270
|
+
: DEFAULT_PRIORITY_HEDGE_RELAYS
|
|
250
271
|
this.maxDynamicRecoverySeeders = maxDynamicRecoverySeeders
|
|
251
272
|
this.messageQueueMaxBytes = messageQueueMaxBytes
|
|
252
273
|
this.seedQueueMaxBytes = seedQueueMaxBytes
|
|
@@ -299,6 +320,7 @@ export class PrivateMessenger {
|
|
|
299
320
|
this.recoveryAdmission = createAbortableSemaphore(2)
|
|
300
321
|
this.recoveryControllers = new Set()
|
|
301
322
|
this.recoveryRanges = new Map()
|
|
323
|
+
this.recoveryPriority = new Map()
|
|
302
324
|
this.recoveryOnline = { at: 0, value: true }
|
|
303
325
|
this.resumeWork = null
|
|
304
326
|
this.capacityTimer = null
|
|
@@ -675,7 +697,7 @@ export class PrivateMessenger {
|
|
|
675
697
|
|
|
676
698
|
if (removedPubkeys.length) {
|
|
677
699
|
await this.stampChannelActivity(removedPubkeys)
|
|
678
|
-
for (const pubkey of removedPubkeys) this.
|
|
700
|
+
for (const pubkey of removedPubkeys) this.dropRecoveryState(pubkey)
|
|
679
701
|
}
|
|
680
702
|
|
|
681
703
|
const storageSnapshot = await activatePrivateMessengerStorage({
|
|
@@ -1214,6 +1236,7 @@ export class PrivateMessenger {
|
|
|
1214
1236
|
start: range.start,
|
|
1215
1237
|
end: range.end,
|
|
1216
1238
|
covered: [],
|
|
1239
|
+
claimed: [],
|
|
1217
1240
|
oldestCreatedAt: null,
|
|
1218
1241
|
relays: new Map(),
|
|
1219
1242
|
tier: '',
|
|
@@ -1241,20 +1264,34 @@ export class PrivateMessenger {
|
|
|
1241
1264
|
dead: false,
|
|
1242
1265
|
eose: false,
|
|
1243
1266
|
events: 0,
|
|
1244
|
-
error: null
|
|
1267
|
+
error: null,
|
|
1268
|
+
consecutiveFailures: 0,
|
|
1269
|
+
latencyMs: null,
|
|
1270
|
+
lastEoseAt: 0,
|
|
1271
|
+
lastErrorAt: 0
|
|
1245
1272
|
})
|
|
1246
1273
|
}
|
|
1247
1274
|
}
|
|
1248
1275
|
|
|
1276
|
+
orderedRecoveryRelays (record) {
|
|
1277
|
+
return [...record.relays.entries()]
|
|
1278
|
+
.filter(([, state]) => !state.dead && !state.eose && state.pending?.length)
|
|
1279
|
+
.sort((a, b) => {
|
|
1280
|
+
const failures = (a[1].consecutiveFailures || 0) - (b[1].consecutiveFailures || 0)
|
|
1281
|
+
if (failures) return failures
|
|
1282
|
+
const latency = (a[1].latencyMs ?? Infinity) - (b[1].latencyMs ?? Infinity)
|
|
1283
|
+
if (latency) return latency
|
|
1284
|
+
return (b[1].lastEoseAt || 0) - (a[1].lastEoseAt || 0)
|
|
1285
|
+
})
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1249
1288
|
recoveryRelayPlan (record) {
|
|
1250
1289
|
const plan = {}
|
|
1251
|
-
|
|
1252
|
-
for (const [relay, state] of
|
|
1253
|
-
if (state.dead || state.eose || !state.pending?.length) continue
|
|
1290
|
+
const ordered = this.orderedRecoveryRelays(record)
|
|
1291
|
+
for (const [relay, state] of ordered) {
|
|
1254
1292
|
plan[relay] = state.pending
|
|
1255
|
-
hasWork = true
|
|
1256
1293
|
}
|
|
1257
|
-
return
|
|
1294
|
+
return ordered.length ? plan : null
|
|
1258
1295
|
}
|
|
1259
1296
|
|
|
1260
1297
|
hasActiveRecoveryRelays (channelPubkey) {
|
|
@@ -1289,7 +1326,65 @@ export class PrivateMessenger {
|
|
|
1289
1326
|
}
|
|
1290
1327
|
}
|
|
1291
1328
|
|
|
1292
|
-
|
|
1329
|
+
dropRecoveryState (channelPubkey) {
|
|
1330
|
+
this.dropRecoveryRanges(channelPubkey)
|
|
1331
|
+
this.recoveryPriority.delete(channelPubkey)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
recoveryPriorities (channelPubkey) {
|
|
1335
|
+
const entries = this.recoveryPriority.get(channelPubkey) || []
|
|
1336
|
+
if (!entries.length) return []
|
|
1337
|
+
const now = Date.now()
|
|
1338
|
+
const active = entries.filter(entry => entry.expiresAt > now)
|
|
1339
|
+
if (active.length !== entries.length) {
|
|
1340
|
+
if (active.length) this.recoveryPriority.set(channelPubkey, active)
|
|
1341
|
+
else this.recoveryPriority.delete(channelPubkey)
|
|
1342
|
+
}
|
|
1343
|
+
return active
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async prioritizeRange (channelPubkey, { since, until, type = 'unread-page', ttlMs } = {}) {
|
|
1347
|
+
if (typeof channelPubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(channelPubkey)) throw new ValidationError('INVALID_CHANNEL_PUBKEY')
|
|
1348
|
+
if (type !== 'unread-page' && type !== 'tail') throw new ValidationError('INVALID_PRIORITY_TYPE')
|
|
1349
|
+
if (since !== undefined && !Number.isSafeInteger(since)) throw new ValidationError('INVALID_PRIORITY_RANGE')
|
|
1350
|
+
if (until !== undefined && !Number.isSafeInteger(until)) throw new ValidationError('INVALID_PRIORITY_RANGE')
|
|
1351
|
+
if (type === 'unread-page' && since === undefined) throw new ValidationError('PRIORITY_SINCE_REQUIRED')
|
|
1352
|
+
|
|
1353
|
+
const now = nowSeconds()
|
|
1354
|
+
const ranges = (this.readState().channels[channelPubkey]?.offlineRanges || []).slice()
|
|
1355
|
+
if (!ranges.length) return false
|
|
1356
|
+
|
|
1357
|
+
let start = since
|
|
1358
|
+
let end = until
|
|
1359
|
+
if (type === 'unread-page') {
|
|
1360
|
+
const range = ranges.filter(candidate => candidate.end >= start).sort((a, b) => a.start - b.start)[0]
|
|
1361
|
+
if (!range) return false
|
|
1362
|
+
start = Math.max(start, range.start)
|
|
1363
|
+
if (end === undefined) end = Math.min(range.end, now)
|
|
1364
|
+
} else {
|
|
1365
|
+
const range = ranges.sort((a, b) => b.end - a.end)[0]
|
|
1366
|
+
if (end === undefined) end = Math.min(range.end, now)
|
|
1367
|
+
if (start === undefined) start = Math.max(range.start, end - this.priorityTailSeconds)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const recoverySeconds = this.offlineRecoverySecondsFor(channelPubkey)
|
|
1371
|
+
if (recoverySeconds) {
|
|
1372
|
+
start = Math.max(start, now - recoverySeconds)
|
|
1373
|
+
end = Math.min(end, now)
|
|
1374
|
+
}
|
|
1375
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) return false
|
|
1376
|
+
|
|
1377
|
+
const ttl = Number.isSafeInteger(ttlMs) && ttlMs > 0 ? ttlMs : this.priorityTtlMs
|
|
1378
|
+
const entries = this.recoveryPriority.get(channelPubkey) || []
|
|
1379
|
+
const createdAt = Date.now()
|
|
1380
|
+
if (!entries.some(entry => entry.start === start && entry.end === end && entry.type === type)) {
|
|
1381
|
+
entries.push({ start, end, type, createdAt, expiresAt: createdAt + ttl })
|
|
1382
|
+
this.recoveryPriority.set(channelPubkey, entries)
|
|
1383
|
+
}
|
|
1384
|
+
return true
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
updateRecoveryRecord (record, history, { completeRelays = new Set((history.relays || []).map(entry => entry.relay)) } = {}) {
|
|
1293
1388
|
record.covered = mergeRanges([
|
|
1294
1389
|
...record.covered,
|
|
1295
1390
|
...(history.relays || []).flatMap(entry => entry.covered || [])
|
|
@@ -1306,13 +1401,30 @@ export class PrivateMessenger {
|
|
|
1306
1401
|
const relayErrors = [...(entry.errors || [])]
|
|
1307
1402
|
if (entry.error) relayErrors.push(entry.error)
|
|
1308
1403
|
state.error = relayErrors[0] || null
|
|
1309
|
-
const done = entry.status === 'eose' && !(entry.pending?.length)
|
|
1404
|
+
const done = entry.status === 'eose' && !(entry.pending?.length) && completeRelays.has(relay)
|
|
1310
1405
|
state.covered = mergeRanges([...(state.covered || []), ...(entry.covered || [])])
|
|
1311
1406
|
state.eose = done
|
|
1312
|
-
state.pending = done
|
|
1407
|
+
state.pending = done
|
|
1408
|
+
? []
|
|
1409
|
+
: subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
|
|
1313
1410
|
state.events += entry.events || 0
|
|
1411
|
+
if (Number.isFinite(entry.elapsedMs)) {
|
|
1412
|
+
state.latencyMs = state.latencyMs == null
|
|
1413
|
+
? entry.elapsedMs
|
|
1414
|
+
: Math.round(state.latencyMs * 0.7 + entry.elapsedMs * 0.3)
|
|
1415
|
+
}
|
|
1314
1416
|
if (done) {
|
|
1315
1417
|
state.attempts = 0
|
|
1418
|
+
state.consecutiveFailures = 0
|
|
1419
|
+
state.lastEoseAt = nowSeconds()
|
|
1420
|
+
} else if (entry.status === 'eose') {
|
|
1421
|
+
state.consecutiveFailures = 0
|
|
1422
|
+
state.lastEoseAt = nowSeconds()
|
|
1423
|
+
} else {
|
|
1424
|
+
state.consecutiveFailures = (state.consecutiveFailures || 0) + 1
|
|
1425
|
+
state.lastErrorAt = nowSeconds()
|
|
1426
|
+
}
|
|
1427
|
+
if (entry.status === 'eose') {
|
|
1316
1428
|
record.everEose = true
|
|
1317
1429
|
if (state.events > 0) record.everEoseWithEvents = true
|
|
1318
1430
|
}
|
|
@@ -1321,6 +1433,52 @@ export class PrivateMessenger {
|
|
|
1321
1433
|
record.lastErrors = (history.relays || []).flatMap(entry => [...(entry.errors || []), ...(entry.error ? [entry.error] : [])])
|
|
1322
1434
|
}
|
|
1323
1435
|
|
|
1436
|
+
applyRecoveryCoverage (record, ranges) {
|
|
1437
|
+
if (!ranges?.length) return
|
|
1438
|
+
record.claimed = mergeRanges([...record.claimed, ...ranges])
|
|
1439
|
+
record.covered = mergeRanges([...record.covered, ...ranges])
|
|
1440
|
+
for (const state of record.relays.values()) {
|
|
1441
|
+
if (state.eose) continue
|
|
1442
|
+
state.pending = subtractRanges({ start: record.start, end: record.end }, mergeRanges([...state.covered, ...record.claimed]))
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
activeRecoverySeeders (channelPubkey) {
|
|
1447
|
+
if (!this.offlineRecoverySecondsFor(channelPubkey)) return []
|
|
1448
|
+
const channel = this.channels.get(channelPubkey)
|
|
1449
|
+
if (!channel?.signer) return []
|
|
1450
|
+
const activity = this.readState().channels[channelPubkey]?.seederActivity || {}
|
|
1451
|
+
const cutoff = nowSeconds() - this.seederOnlineSeconds
|
|
1452
|
+
const candidates = new Set([...(channel.seeders || []), ...Object.keys(activity)])
|
|
1453
|
+
return [...candidates].filter(seeder => {
|
|
1454
|
+
if (!seeder || seeder === this.userPubkey) return false
|
|
1455
|
+
const entry = activity[seeder]
|
|
1456
|
+
const recent = Math.max(entry?.announcedAt || 0, entry?.lastActiveAt || 0)
|
|
1457
|
+
return recent >= cutoff
|
|
1458
|
+
})
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
prunePriorityEntries (channelPubkey) {
|
|
1462
|
+
const entries = this.recoveryPriorities(channelPubkey)
|
|
1463
|
+
if (!entries.length) return
|
|
1464
|
+
const records = [...this.recoveryRanges.values()].filter(record => record.channelPubkey === channelPubkey)
|
|
1465
|
+
const next = entries.filter(entry => {
|
|
1466
|
+
let intersects = false
|
|
1467
|
+
for (const record of records) {
|
|
1468
|
+
const parts = intersectRanges(
|
|
1469
|
+
[{ start: entry.start, end: entry.end }],
|
|
1470
|
+
[{ start: record.start, end: record.end }]
|
|
1471
|
+
)
|
|
1472
|
+
if (!parts.length) continue
|
|
1473
|
+
intersects = true
|
|
1474
|
+
if (subtractRanges(parts[0], record.covered).length) return true
|
|
1475
|
+
}
|
|
1476
|
+
return !intersects
|
|
1477
|
+
})
|
|
1478
|
+
if (next.length) this.recoveryPriority.set(channelPubkey, next)
|
|
1479
|
+
else this.recoveryPriority.delete(channelPubkey)
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1324
1482
|
applyRecoveryRetryBudget (record) {
|
|
1325
1483
|
const limit = RECOVERY_RELAY_RETRY_LIMITS[record.tier] ?? RECOVERY_RELAY_RETRY_LIMITS.allFailed
|
|
1326
1484
|
for (const state of record.relays.values()) {
|
|
@@ -1462,7 +1620,7 @@ export class PrivateMessenger {
|
|
|
1462
1620
|
const pubkeys = channels ? uniq(Array.isArray(channels) ? channels : [channels]) : [...this.desiredChannels]
|
|
1463
1621
|
for (const extension of this.extensions) extension.unwatch?.(pubkeys)
|
|
1464
1622
|
for (const pubkey of pubkeys) this.desiredChannels.delete(pubkey)
|
|
1465
|
-
for (const pubkey of pubkeys) this.
|
|
1623
|
+
for (const pubkey of pubkeys) this.dropRecoveryState(pubkey)
|
|
1466
1624
|
this.recordInterruption(pubkeys)
|
|
1467
1625
|
for (const controller of this.recoveryControllers) {
|
|
1468
1626
|
if (pubkeys.includes(controller.channelPubkey)) controller.abort()
|
|
@@ -2156,7 +2314,7 @@ export class PrivateMessenger {
|
|
|
2156
2314
|
this.writeState(state)
|
|
2157
2315
|
await this.flushStateWrites()
|
|
2158
2316
|
for (const channel of channels) {
|
|
2159
|
-
if (!this.offlineRecoverySecondsFor(channel)) this.
|
|
2317
|
+
if (!this.offlineRecoverySecondsFor(channel)) this.dropRecoveryState(channel.pubkey)
|
|
2160
2318
|
}
|
|
2161
2319
|
for (const channel of channels) await this.pruneStoredSeeds(channel.pubkey)
|
|
2162
2320
|
})
|
|
@@ -2231,15 +2389,15 @@ export class PrivateMessenger {
|
|
|
2231
2389
|
return asks
|
|
2232
2390
|
}
|
|
2233
2391
|
|
|
2234
|
-
async #askSeedersForMissingRangeAttempt (channelPubkey, since, until) {
|
|
2392
|
+
async #askSeedersForMissingRangeAttempt (channelPubkey, since, until, { seeders } = {}) {
|
|
2235
2393
|
if (!this.offlineRecoverySecondsFor(channelPubkey)) return { asks: [], failures: [] }
|
|
2236
2394
|
if (!this.channels.get(channelPubkey)?.signer) return { asks: [], failures: [] }
|
|
2237
|
-
const
|
|
2238
|
-
if (!
|
|
2395
|
+
const candidates = seeders ?? this.recoverySeeders(channelPubkey)
|
|
2396
|
+
if (!candidates.length || until < since) return { asks: [], failures: [] }
|
|
2239
2397
|
|
|
2240
2398
|
const asks = []
|
|
2241
2399
|
const failures = []
|
|
2242
|
-
for (const seeder of
|
|
2400
|
+
for (const seeder of candidates) {
|
|
2243
2401
|
try {
|
|
2244
2402
|
const ask = await this.ask({
|
|
2245
2403
|
channelPubkey,
|
|
@@ -2385,6 +2543,146 @@ export class PrivateMessenger {
|
|
|
2385
2543
|
return failures
|
|
2386
2544
|
}
|
|
2387
2545
|
|
|
2546
|
+
recoveryFetchOptions ({ channelPubkey, channel, relays, since, until, signal, partial = false, resume = null }) {
|
|
2547
|
+
return {
|
|
2548
|
+
_acquirePage: pageSignal => this.recoveryAdmission.acquire(pageSignal),
|
|
2549
|
+
signal,
|
|
2550
|
+
receivedChunkScope: this.storageLeaseId,
|
|
2551
|
+
receiverSigner: this.userSigner,
|
|
2552
|
+
iykcSigner: this.contentKeySigner,
|
|
2553
|
+
privateChannelSigner: channel.signer,
|
|
2554
|
+
privateChannelReaderSigner: channel.readerSigner,
|
|
2555
|
+
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2556
|
+
privateChannelPubkeys: [channelPubkey],
|
|
2557
|
+
receiverPubkey: this.userPubkey,
|
|
2558
|
+
relays,
|
|
2559
|
+
since: Math.max(0, since),
|
|
2560
|
+
until,
|
|
2561
|
+
partial,
|
|
2562
|
+
resume,
|
|
2563
|
+
mode: channel.mode,
|
|
2564
|
+
modeByPubkey: { [channelPubkey]: channel.mode },
|
|
2565
|
+
receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
|
|
2566
|
+
receivedChunkIndexedDB: this._indexedDB,
|
|
2567
|
+
onEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor(eventType(event), channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2568
|
+
onNymEvent: (event, outer, meta) => this.receive(channelPubkey, () => this.enqueueRumor('nym', channelPubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2569
|
+
onSeedEvent: seed => this.receive(channelPubkey, () => this.enqueueSeed(channelPubkey, seed), seed),
|
|
2570
|
+
onContentKeyUsage: usage => this.handleContentKeyUsage(channelPubkey, usage),
|
|
2571
|
+
onError: err => { throw err }
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
async hedgedPriorityFetch ({ channelPubkey, channel, candidates, since, until }) {
|
|
2576
|
+
const launched = []
|
|
2577
|
+
let hedgeTimer = null
|
|
2578
|
+
let secondStarted = false
|
|
2579
|
+
const launch = candidate => {
|
|
2580
|
+
const controller = new AbortController()
|
|
2581
|
+
const promise = this._privateChannel
|
|
2582
|
+
.fetchHistory(this.recoveryFetchOptions({
|
|
2583
|
+
channelPubkey,
|
|
2584
|
+
channel,
|
|
2585
|
+
relays: [candidate.relay],
|
|
2586
|
+
since,
|
|
2587
|
+
until,
|
|
2588
|
+
signal: controller.signal,
|
|
2589
|
+
partial: true,
|
|
2590
|
+
resume: { [candidate.relay]: candidate.pending }
|
|
2591
|
+
}))
|
|
2592
|
+
.then(
|
|
2593
|
+
history => ({ ok: true, relay: candidate.relay, controller, history }),
|
|
2594
|
+
error => ({ ok: false, relay: candidate.relay, controller, error })
|
|
2595
|
+
)
|
|
2596
|
+
promise.catch(() => {})
|
|
2597
|
+
launched.push({ promise, controller })
|
|
2598
|
+
return promise
|
|
2599
|
+
}
|
|
2600
|
+
try {
|
|
2601
|
+
const first = launch(candidates[0])
|
|
2602
|
+
const hedge = candidates.length > 1
|
|
2603
|
+
? new Promise(resolve => {
|
|
2604
|
+
hedgeTimer = this._setTimeout(() => {
|
|
2605
|
+
secondStarted = true
|
|
2606
|
+
resolve(launch(candidates[1]))
|
|
2607
|
+
}, this.priorityHedgeDelayMs)
|
|
2608
|
+
})
|
|
2609
|
+
: null
|
|
2610
|
+
let settled = hedge ? await Promise.race([first, hedge]) : await first
|
|
2611
|
+
if (!settled.ok) {
|
|
2612
|
+
if (secondStarted && launched.length > 1) settled = await launched[launched.length - 1].promise
|
|
2613
|
+
else if (candidates.length > 1) settled = await launch(candidates[1])
|
|
2614
|
+
}
|
|
2615
|
+
if (!settled.ok) return null
|
|
2616
|
+
for (const entry of launched) {
|
|
2617
|
+
if (entry.controller === settled.controller) continue
|
|
2618
|
+
try { entry.controller.abort() } catch { /* aborted */ }
|
|
2619
|
+
}
|
|
2620
|
+
return settled
|
|
2621
|
+
} finally {
|
|
2622
|
+
if (hedgeTimer) this._clearTimeout(hedgeTimer)
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
async askPrioritySeeders (channelPubkey, intervals) {
|
|
2627
|
+
if (!intervals.length) return []
|
|
2628
|
+
if (!await this.checkRecoveryOnline()) return []
|
|
2629
|
+
const seeders = this.activeRecoverySeeders(channelPubkey)
|
|
2630
|
+
if (!seeders.length) return []
|
|
2631
|
+
const failures = []
|
|
2632
|
+
const claimed = []
|
|
2633
|
+
for (const interval of mergeRanges(intervals)) {
|
|
2634
|
+
const attempt = await this.#askSeedersForMissingRangeAttempt(channelPubkey, interval.start, interval.end, { seeders })
|
|
2635
|
+
failures.push(...attempt.failures.map(failure => failure.error))
|
|
2636
|
+
if (!attempt.failures.length) claimed.push(interval)
|
|
2637
|
+
}
|
|
2638
|
+
if (claimed.length) {
|
|
2639
|
+
for (const record of this.recoveryRanges.values()) {
|
|
2640
|
+
if (record.channelPubkey !== channelPubkey) continue
|
|
2641
|
+
const applied = intersectRanges(claimed, [{ start: record.start, end: record.end }])
|
|
2642
|
+
if (applied.length) this.applyRecoveryCoverage(record, applied)
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
return failures
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
async runPriorityLane (channelPubkey, channel, ranges) {
|
|
2649
|
+
const priorities = this.recoveryPriorities(channelPubkey)
|
|
2650
|
+
if (!priorities.length) return
|
|
2651
|
+
const fetchRelays = await this.resolveWatchRelays(channel)
|
|
2652
|
+
for (const range of ranges) {
|
|
2653
|
+
const record = this.recoveryRangeRecord(channelPubkey, range)
|
|
2654
|
+
this.reconcileRecoveryRelays(record, fetchRelays)
|
|
2655
|
+
const priorityIntervals = intersectRanges(
|
|
2656
|
+
priorities.map(entry => ({ start: entry.start, end: entry.end })),
|
|
2657
|
+
[{ start: range.start, end: range.end }]
|
|
2658
|
+
)
|
|
2659
|
+
if (!priorityIntervals.length) continue
|
|
2660
|
+
const pending = subtractRanges({ start: range.start, end: range.end }, record.covered)
|
|
2661
|
+
const wanted = intersectRanges(priorityIntervals, pending)
|
|
2662
|
+
if (!wanted.length) continue
|
|
2663
|
+
const candidates = this.orderedRecoveryRelays(record)
|
|
2664
|
+
.map(([relay, state]) => ({ relay, pending: intersectRanges(state.pending, wanted) }))
|
|
2665
|
+
.filter(candidate => candidate.pending.length)
|
|
2666
|
+
.slice(0, this.priorityHedgeRelays)
|
|
2667
|
+
if (candidates.length) {
|
|
2668
|
+
const winner = await this.hedgedPriorityFetch({
|
|
2669
|
+
channelPubkey,
|
|
2670
|
+
channel,
|
|
2671
|
+
candidates,
|
|
2672
|
+
since: wanted[0].start,
|
|
2673
|
+
until: wanted[wanted.length - 1].end
|
|
2674
|
+
})
|
|
2675
|
+
if (winner?.history) {
|
|
2676
|
+
if (this.isPartialRecoveryHistory(winner.history)) this.updateRecoveryRecord(record, winner.history, { completeRelays: new Set() })
|
|
2677
|
+
else this.applyRecoveryCoverage(record, wanted)
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
const uncovered = intersectRanges(wanted, subtractRanges({ start: range.start, end: range.end }, record.covered))
|
|
2681
|
+
if (uncovered.length) await this.askPrioritySeeders(channelPubkey, uncovered)
|
|
2682
|
+
}
|
|
2683
|
+
this.prunePriorityEntries(channelPubkey)
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2388
2686
|
async recoverChannelRanges (channels) {
|
|
2389
2687
|
const failures = []
|
|
2390
2688
|
const state = this.readState()
|
|
@@ -2395,17 +2693,21 @@ export class PrivateMessenger {
|
|
|
2395
2693
|
const channel = this.channels.get(pubkey)
|
|
2396
2694
|
const current = state.channels[pubkey]
|
|
2397
2695
|
if (!channel || !current?.offlineRanges?.length) {
|
|
2398
|
-
this.
|
|
2696
|
+
this.dropRecoveryState(pubkey)
|
|
2399
2697
|
continue
|
|
2400
2698
|
}
|
|
2401
2699
|
const recoverySeconds = this.offlineRecoverySecondsFor(channel)
|
|
2402
2700
|
if (!recoverySeconds) {
|
|
2403
|
-
this.
|
|
2701
|
+
this.dropRecoveryState(pubkey)
|
|
2404
2702
|
continue
|
|
2405
2703
|
}
|
|
2406
2704
|
const minStart = now - recoverySeconds
|
|
2407
2705
|
const processedRanges = new Set(current.offlineRanges.map(range => `${range.start}:${range.end}`))
|
|
2408
2706
|
|
|
2707
|
+
if (online) {
|
|
2708
|
+
try { await this.runPriorityLane(pubkey, channel, current.offlineRanges) } catch (error) { this.onError?.(error) }
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2409
2711
|
const remaining = []
|
|
2410
2712
|
let recoveredThrough = current.recoveredThrough || 0
|
|
2411
2713
|
for (const range of current.offlineRanges) {
|
|
@@ -2423,32 +2725,16 @@ export class PrivateMessenger {
|
|
|
2423
2725
|
this.reconcileRecoveryRelays(record, fetchRelays)
|
|
2424
2726
|
const plan = this.recoveryRelayPlan(record)
|
|
2425
2727
|
if (plan) {
|
|
2426
|
-
const history = await this._privateChannel.fetchHistory({
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
receivedChunkScope: this.storageLeaseId,
|
|
2430
|
-
receiverSigner: this.userSigner,
|
|
2431
|
-
iykcSigner: this.contentKeySigner,
|
|
2432
|
-
privateChannelSigner: channel.signer,
|
|
2433
|
-
privateChannelReaderSigner: channel.readerSigner,
|
|
2434
|
-
privateChannelReaderPubkey: channel.readerPubkey,
|
|
2435
|
-
privateChannelPubkeys: [pubkey],
|
|
2436
|
-
receiverPubkey: this.userPubkey,
|
|
2728
|
+
const history = await this._privateChannel.fetchHistory(this.recoveryFetchOptions({
|
|
2729
|
+
channelPubkey: pubkey,
|
|
2730
|
+
channel,
|
|
2437
2731
|
relays: Object.keys(plan),
|
|
2438
|
-
since:
|
|
2732
|
+
since: range.start,
|
|
2439
2733
|
until: range.end,
|
|
2734
|
+
signal: controller.signal,
|
|
2440
2735
|
partial: true,
|
|
2441
|
-
resume: plan
|
|
2442
|
-
|
|
2443
|
-
modeByPubkey: { [pubkey]: channel.mode },
|
|
2444
|
-
receivedChunkTtlMs: this.receivedChunkTtlMsFor(channel),
|
|
2445
|
-
receivedChunkIndexedDB: this._indexedDB,
|
|
2446
|
-
onEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor(eventType(event), pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2447
|
-
onNymEvent: (event, outer, meta) => this.receive(pubkey, () => this.enqueueRumor('nym', pubkey, { event, outer, meta, payload: parseEventContent(event) }), { event, outer }),
|
|
2448
|
-
onSeedEvent: seed => this.receive(pubkey, () => this.enqueueSeed(pubkey, seed), seed),
|
|
2449
|
-
onContentKeyUsage: usage => this.handleContentKeyUsage(pubkey, usage),
|
|
2450
|
-
onError: err => { throw err }
|
|
2451
|
-
})
|
|
2736
|
+
resume: plan
|
|
2737
|
+
}))
|
|
2452
2738
|
controller.signal.throwIfAborted()
|
|
2453
2739
|
if (this.isPartialRecoveryHistory(history)) {
|
|
2454
2740
|
this.updateRecoveryRecord(record, history)
|
|
@@ -2517,7 +2803,7 @@ export class PrivateMessenger {
|
|
|
2517
2803
|
async clearChannel (pubkey) {
|
|
2518
2804
|
await this.unwatch(pubkey)
|
|
2519
2805
|
await this._privateMessage.clearChannelState?.(pubkey)
|
|
2520
|
-
this.
|
|
2806
|
+
this.dropRecoveryState(pubkey)
|
|
2521
2807
|
return this.runQueueOperation(async () => {
|
|
2522
2808
|
this.channels.delete(pubkey)
|
|
2523
2809
|
this.removeChannelState(pubkey)
|
|
@@ -2551,7 +2837,7 @@ export class PrivateMessenger {
|
|
|
2551
2837
|
await this.queue?.removeBy('byChannel', pubkey)
|
|
2552
2838
|
await (this.seedStorage ? this.seedStorage.removeLocal({ channelPubkey: pubkey }) : this.seedQueue?.removeBy('byChannel', pubkey))
|
|
2553
2839
|
}
|
|
2554
|
-
for (const pubkey of stalePubkeys) this.
|
|
2840
|
+
for (const pubkey of stalePubkeys) this.dropRecoveryState(pubkey)
|
|
2555
2841
|
this.state = state
|
|
2556
2842
|
if (stalePubkeys.length) await this.removeChannelStates(stalePubkeys)
|
|
2557
2843
|
})
|
|
@@ -2643,6 +2929,7 @@ export class PrivateMessenger {
|
|
|
2643
2929
|
})
|
|
2644
2930
|
}
|
|
2645
2931
|
this.recoveryRanges.clear()
|
|
2932
|
+
this.recoveryPriority.clear()
|
|
2646
2933
|
if (unwatchError) throw unwatchError
|
|
2647
2934
|
})()
|
|
2648
2935
|
return this.closePromise
|
|
@@ -17,9 +17,17 @@ key published after an earlier miss. Set it to `false` when the signer owns that
|
|
|
17
17
|
`createMessengerSigner` adapts this byte signer to the transport's Base64 API.
|
|
18
18
|
|
|
19
19
|
Methods: `setPeers`, `setAvailable(boolean)`, `prefetchContentKeys(peers?)`,
|
|
20
|
-
`enqueue`, `retry`, `cancel`, `download`,
|
|
20
|
+
`prioritizeRange(peer, options)`, `enqueue`, `retry`, `cancel`, `download`,
|
|
21
|
+
`cancelDownload`, and `close`.
|
|
21
22
|
`prefetchContentKeys` warms the content-key lookup cache for the owner and the
|
|
22
23
|
given peers (default: current peers); it is best-effort and never rejects.
|
|
24
|
+
`prioritizeRange` registers an in-memory priority window for a conversation:
|
|
25
|
+
`type: 'unread-page'` requires `since` (the read anchor), while `type: 'tail'`
|
|
26
|
+
defaults to the newest persisted range minus `priorityTailSeconds` (6 hours).
|
|
27
|
+
The coordinator resolves the peer's channel and queues the request until the
|
|
28
|
+
channel exists. Priorities expire after `priorityTtlMs` (10 minutes) or once
|
|
29
|
+
covered, and are dropped with recovery state. They are process-local and reset
|
|
30
|
+
on reload.
|
|
23
31
|
Callbacks are `onOutbox`, `onMedia`,
|
|
24
32
|
`onError`, and optional `onSendError(error, { id, peer })`. The caller maps its
|
|
25
33
|
account permissions/connectivity/lock state to availability and owns view subscriptions. There are no DOM or UI imports.
|
|
@@ -46,6 +46,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
46
46
|
const downloadIntents = new Map()
|
|
47
47
|
const downloadEpochs = new Map()
|
|
48
48
|
const activeDownloads = new Map()
|
|
49
|
+
const pendingPriorityRanges = new Map()
|
|
49
50
|
let downloadWriteTail = Promise.resolve()
|
|
50
51
|
const writeDownloadIntent = operation => {
|
|
51
52
|
const work = downloadWriteTail.catch(() => {}).then(operation)
|
|
@@ -89,6 +90,16 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
89
90
|
if (!messenger) messenger = await Messenger({ fallbackRelays, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, onMessageQueued: () => drain(), onError: reportError })
|
|
90
91
|
if (closed || !available || version !== lifecycle) { await messenger.pause('signer'); return }
|
|
91
92
|
await messenger.update({ channels: values })
|
|
93
|
+
if (pendingPriorityRanges.size && typeof messenger.prioritizeRange === 'function') {
|
|
94
|
+
for (const [peer, list] of [...pendingPriorityRanges]) {
|
|
95
|
+
const channel = channels.get(peer)
|
|
96
|
+
if (!channel) continue
|
|
97
|
+
for (const request of list) {
|
|
98
|
+
try { await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error) }
|
|
99
|
+
}
|
|
100
|
+
pendingPriorityRanges.delete(peer)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
92
103
|
await messenger.resume('signer')
|
|
93
104
|
if (!fileTransfers) {
|
|
94
105
|
fileTransfers = FileTransfer({
|
|
@@ -322,6 +333,23 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
322
333
|
return {}
|
|
323
334
|
}
|
|
324
335
|
},
|
|
336
|
+
async prioritizeRange (peer, options = {}) {
|
|
337
|
+
if (!/^[0-9a-f]{64}$/.test(peer || '')) throw new TypeError('INVALID_PRIORITY_PEER')
|
|
338
|
+
const { since, until, type = 'unread-page' } = options || {}
|
|
339
|
+
if (type !== 'unread-page' && type !== 'tail') throw new TypeError('INVALID_PRIORITY_TYPE')
|
|
340
|
+
if (since !== undefined && !Number.isSafeInteger(since)) throw new TypeError('INVALID_PRIORITY_RANGE')
|
|
341
|
+
if (until !== undefined && !Number.isSafeInteger(until)) throw new TypeError('INVALID_PRIORITY_RANGE')
|
|
342
|
+
if (type === 'unread-page' && since === undefined) throw new TypeError('PRIORITY_SINCE_REQUIRED')
|
|
343
|
+
const request = { since, until, type }
|
|
344
|
+
const channel = channels.get(peer)
|
|
345
|
+
if (channel && typeof messenger?.prioritizeRange === 'function') {
|
|
346
|
+
try { return await messenger.prioritizeRange(channel.pubkey, request) } catch (error) { reportError(error); return false }
|
|
347
|
+
}
|
|
348
|
+
const list = pendingPriorityRanges.get(peer) || []
|
|
349
|
+
list.push(request)
|
|
350
|
+
pendingPriorityRanges.set(peer, list)
|
|
351
|
+
return true
|
|
352
|
+
},
|
|
325
353
|
async setPeers (values) {
|
|
326
354
|
peers.clear(); for (const peer of values) if (peer !== owner) peers.add(peer)
|
|
327
355
|
for (const peer of deniedPeers) if (!peers.has(peer)) deniedPeers.delete(peer)
|
|
@@ -367,7 +395,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
|
|
|
367
395
|
},
|
|
368
396
|
async close () {
|
|
369
397
|
closed = true; available = false; lifecycle++
|
|
370
|
-
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear()
|
|
398
|
+
stopOnlineRetry(); offlineSends.clear(); activeDownloads.clear(); pendingPriorityRanges.clear()
|
|
371
399
|
for (const controller of sendControllers.values()) controller.abort()
|
|
372
400
|
await Promise.allSettled([configuring, initialized])
|
|
373
401
|
await messenger?.pause('closed')
|