libp2r2p 0.10.21 → 0.10.22
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 +46 -0
- package/package.json +1 -1
- package/relay/services/relay-pool.js +82 -24
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ never decorated with `meta`; relay provenance belongs to its enclosing item:
|
|
|
21
21
|
{ type: 'event', event, relay }
|
|
22
22
|
{ type: 'error', relay, error }
|
|
23
23
|
{ type: 'eose', relays: [{ relay, status, error }] } // error is optional
|
|
24
|
+
{ type: 'live-progress', relay, epoch, since, until } // live and live-enabled feeds only
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
`eose` means the **initial read attempt is complete**, including empty reads and
|
|
@@ -101,6 +102,51 @@ content-key, private-channel and NIP-46 helpers unwrap pool results internally
|
|
|
101
102
|
and retain their higher-level event contracts. Count, publication and disconnect
|
|
102
103
|
return formats are unchanged.
|
|
103
104
|
|
|
105
|
+
### Live overlap and progress (0.10.22)
|
|
106
|
+
|
|
107
|
+
Every live subscription opens with `since = max(filter.since ?? 0, now - 600, 0)`,
|
|
108
|
+
including its first connection. This accepts events that arrive after opening
|
|
109
|
+
but carry a timestamp up to ten minutes earlier. Explicit filter boundaries still
|
|
110
|
+
apply, and retained events before EOSE remain excluded from strictly-live output.
|
|
111
|
+
|
|
112
|
+
Reconnect recovery overlaps each relay's last received timestamp by 600 seconds,
|
|
113
|
+
without going below the caller's `since` or zero. Empty connections use their
|
|
114
|
+
opening time instead. Cursors advance independently even for events deduplicated
|
|
115
|
+
across relays; future timestamps are capped at their local receipt time. Failed
|
|
116
|
+
or interrupted recoveries retain their earlier baseline for the next reconnect.
|
|
117
|
+
|
|
118
|
+
Live readers automatically enqueue a `live-progress` envelope every 60 seconds
|
|
119
|
+
while ready, including when no events arrive. There is no enabling option.
|
|
120
|
+
`epoch` is a numeric connection-attempt identifier scoped to this iterator;
|
|
121
|
+
compare it together with `relay`, not across separate iterators. `since` is the
|
|
122
|
+
local EOSE time (or the explicit filter lower bound if later), and `until` is the
|
|
123
|
+
local time at the tick, both inclusive Unix seconds. Repeated markers describe
|
|
124
|
+
the same continuous epoch with an increasing `until`.
|
|
125
|
+
|
|
126
|
+
Progress starts only after that relay's EOSE and successful completion of any
|
|
127
|
+
pending recovery. It pauses on disconnection, does not span connection epochs,
|
|
128
|
+
and stops on cancellation, draining or `until`. History-only and empty-relay
|
|
129
|
+
reads do not emit it. Timers never issue extra network queries.
|
|
130
|
+
|
|
131
|
+
Markers share the bounded queue with events and follow all earlier received
|
|
132
|
+
items. A feed preserves that ordering behind its initial history and queued live
|
|
133
|
+
events. Consumers must finish persisting preceding events before persisting
|
|
134
|
+
progress, and must not treat a marker as proof of historical completeness or as
|
|
135
|
+
coverage of a disconnected interval. This is client-observed continuity, not a
|
|
136
|
+
relay acknowledgement; delayed relay delivery or clock skew beyond the overlap
|
|
137
|
+
can still leave gaps. A backward local clock cannot regress an emitted boundary.
|
|
138
|
+
|
|
139
|
+
Every involuntary live close emits an error and still permits automatic recovery.
|
|
140
|
+
A close without an explicit cause uses `error.code === 'RELAY_LIVE_INTERRUPTED'`
|
|
141
|
+
and `category === 'transport'`; a pre-EOSE close retains status `closed` in the
|
|
142
|
+
initial report. Explicit causes are preserved. Caller cancellation, draining and
|
|
143
|
+
expiry of `filter.until` do not manufacture interruption errors.
|
|
144
|
+
|
|
145
|
+
Process Nostr events only under `item.type === 'event'`. Handle controls you need
|
|
146
|
+
and ignore unknown types; a new control is neither an event nor stream completion.
|
|
147
|
+
The internal query, private-channel and NIP-46 consumers retain their existing
|
|
148
|
+
higher-level event-only contracts.
|
|
149
|
+
|
|
104
150
|
### Read admission and bounded snapshots (0.10.21)
|
|
105
151
|
|
|
106
152
|
Each pool coordinates event reads by normalized connection URL. Constructor
|
package/package.json
CHANGED
|
@@ -13,6 +13,8 @@ const COUNT_TIMEOUT_MS = 5000
|
|
|
13
13
|
const COUNT_TIMEOUT_AFTER_FIRST_COUNT_MS = 500
|
|
14
14
|
const SEND_TIMEOUT_UNTIL_FIRST_FULFILLMENT_MS = 3000
|
|
15
15
|
const SEND_TIMEOUT_MS = 30000
|
|
16
|
+
const LIVE_OVERLAP_SECONDS = 600
|
|
17
|
+
const LIVE_PROGRESS_INTERVAL_MS = 60000
|
|
16
18
|
|
|
17
19
|
// Returns a function that should be called for each received event (valid or invalid).
|
|
18
20
|
// Calls onSatisfied() and stops counting once the filter is fully satisfied per relay:
|
|
@@ -607,8 +609,34 @@ export class RelayPool {
|
|
|
607
609
|
// Preserve until for forwarding to the live sub filter and the teardown timer
|
|
608
610
|
const filterUntil = filter.until > 0 ? filter.until : null
|
|
609
611
|
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
+
// Recovery uses each relay's received timestamps, or its opening time when empty.
|
|
613
|
+
const lastSeenAt = new Map()
|
|
614
|
+
const openedAt = new Map()
|
|
615
|
+
const pendingGaps = new Map()
|
|
616
|
+
const attempts = new Map()
|
|
617
|
+
let nextEpoch = 0
|
|
618
|
+
const sinceFloor = Math.max(0, filter.since ?? 0)
|
|
619
|
+
const overlappingSince = timestamp => Math.max(sinceFloor, timestamp - LIVE_OVERLAP_SECONDS)
|
|
620
|
+
const observeEvent = (event, url) => {
|
|
621
|
+
// Every relay advances independently, including cross-relay duplicates.
|
|
622
|
+
// Future-dated events cannot hide an offline interval during recovery.
|
|
623
|
+
const timestamp = Math.min(event.created_at, Math.floor(Date.now() / 1000))
|
|
624
|
+
if (Number.isFinite(timestamp) && timestamp > (lastSeenAt.get(url) ?? -Infinity)) lastSeenAt.set(url, timestamp)
|
|
625
|
+
}
|
|
626
|
+
const scheduleProgress = attempt => {
|
|
627
|
+
if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt ||
|
|
628
|
+
attempt.since === null || !attempt.recovered || attempt.timer) return
|
|
629
|
+
attempt.timer = maybeUnref(setTimeout(() => {
|
|
630
|
+
attempt.timer = null
|
|
631
|
+
if (isDone || attempt.closed || attempts.get(attempt.url) !== attempt) return
|
|
632
|
+
const until = Math.min(filterUntil ?? Infinity, Math.floor(Date.now() / 1000))
|
|
633
|
+
if (until > attempt.lastProgress && until >= attempt.since) {
|
|
634
|
+
enqueue({ type: 'live-progress', relay: attempt.url, epoch: attempt.epoch, since: attempt.since, until })
|
|
635
|
+
attempt.lastProgress = until
|
|
636
|
+
}
|
|
637
|
+
scheduleProgress(attempt)
|
|
638
|
+
}, LIVE_PROGRESS_INTERVAL_MS))
|
|
639
|
+
}
|
|
612
640
|
|
|
613
641
|
// Bounded dedup set to handle overlap between reconnect gap fill and live sub.
|
|
614
642
|
const seenIds = new Set()
|
|
@@ -661,6 +689,10 @@ export class RelayPool {
|
|
|
661
689
|
clearTimeout(untilTimer)
|
|
662
690
|
finishReady('closed', false)
|
|
663
691
|
gapAc.abort()
|
|
692
|
+
for (const attempt of attempts.values()) {
|
|
693
|
+
clearTimeout(attempt.timer)
|
|
694
|
+
attempt.stop.abort()
|
|
695
|
+
}
|
|
664
696
|
for (const timer of retryTimers.values()) clearTimeout(timer)
|
|
665
697
|
retryTimers.clear()
|
|
666
698
|
liveSubs.forEach(sub => sub.close())
|
|
@@ -677,7 +709,6 @@ export class RelayPool {
|
|
|
677
709
|
if (seenIds.size >= 500) seenIds.delete(seenIds.values().next().value) // evict oldest
|
|
678
710
|
seenIds.add(event.id)
|
|
679
711
|
}
|
|
680
|
-
if (event.created_at > (lastSeenAt ?? 0)) lastSeenAt = event.created_at
|
|
681
712
|
enqueue({ type: 'event', event, relay: url })
|
|
682
713
|
}
|
|
683
714
|
|
|
@@ -714,10 +745,10 @@ export class RelayPool {
|
|
|
714
745
|
maybeFinishInitialReady()
|
|
715
746
|
}
|
|
716
747
|
}
|
|
717
|
-
const reportClosed = url => {
|
|
748
|
+
const reportClosed = (url, error) => {
|
|
718
749
|
if (!isReady && initialPending.delete(url)) {
|
|
719
750
|
clearTimeout(readyTimeouts.get(url))
|
|
720
|
-
initialOutcomes.set(url, { relay: url, status: 'closed' })
|
|
751
|
+
initialOutcomes.set(url, { relay: url, status: 'closed', error })
|
|
721
752
|
maybeFinishInitialReady()
|
|
722
753
|
}
|
|
723
754
|
}
|
|
@@ -727,7 +758,7 @@ export class RelayPool {
|
|
|
727
758
|
const nextDelay = Math.min(reconnectDelay * 2, 5 * 60_000)
|
|
728
759
|
const timer = maybeUnref(setTimeout(() => {
|
|
729
760
|
retryTimers.delete(url)
|
|
730
|
-
subscribeToRelay(url, lastSeenAt, nextDelay)
|
|
761
|
+
subscribeToRelay(url, pendingGaps.get(url) ?? lastSeenAt.get(url) ?? openedAt.get(url) ?? null, nextDelay)
|
|
731
762
|
}, reconnectDelay))
|
|
732
763
|
retryTimers.set(url, timer)
|
|
733
764
|
}
|
|
@@ -740,24 +771,35 @@ export class RelayPool {
|
|
|
740
771
|
|
|
741
772
|
// Runs a reconnect gap fill for a single relay and returns a promise that resolves
|
|
742
773
|
// when it completes. now is shared with the live sub so both use the same boundary.
|
|
743
|
-
const runReconnectGapFill = (url, gapSince, now, slots) => {
|
|
774
|
+
const runReconnectGapFill = (url, gapSince, now, slots, attempt) => {
|
|
744
775
|
const gapUntil = filterUntil !== null ? Math.min(now, filterUntil) : now
|
|
745
|
-
const gapFilter = { ...baseFilter, since: gapSince, until: gapUntil }
|
|
776
|
+
const gapFilter = { ...baseFilter, since: overlappingSince(gapSince), until: gapUntil }
|
|
746
777
|
const gapGen = _gapEventsGenerator(gapFilter, [url], {
|
|
747
778
|
_admissions: new Map([[url, Promise.resolve(slots)]]),
|
|
748
779
|
queueTimeout,
|
|
749
780
|
timeout: timeoutForReconnectGap,
|
|
750
781
|
timeoutAfterFirstEose: timeoutAfterFirstReconnectGapEose,
|
|
751
782
|
signal,
|
|
752
|
-
_stopSignal: gapAc.signal
|
|
783
|
+
_stopSignal: AbortSignal.any([gapAc.signal, attempt.stop.signal])
|
|
753
784
|
})
|
|
754
785
|
return (async () => {
|
|
786
|
+
let completed = false
|
|
787
|
+
let failed = false
|
|
755
788
|
for await (const item of gapGen) {
|
|
756
|
-
if (item?.type === 'event')
|
|
757
|
-
|
|
789
|
+
if (item?.type === 'event') {
|
|
790
|
+
observeEvent(item.event, url)
|
|
791
|
+
pushEvent(item.event, url, true)
|
|
792
|
+
} else if (item?.type === 'error') {
|
|
793
|
+
failed = true
|
|
794
|
+
if (!isDone) enqueue(item)
|
|
795
|
+
} else if (item?.type === 'eose') {
|
|
796
|
+
completed = item.relays?.length === 1 && ['eose', 'satisfied'].includes(item.relays[0].status)
|
|
797
|
+
}
|
|
758
798
|
}
|
|
799
|
+
return completed && !failed
|
|
759
800
|
})().catch(err => {
|
|
760
801
|
reportFailure(url, asError(err))
|
|
802
|
+
return false
|
|
761
803
|
}).finally(() => slots.history.release())
|
|
762
804
|
}
|
|
763
805
|
|
|
@@ -766,7 +808,10 @@ export class RelayPool {
|
|
|
766
808
|
// Don't reconnect if we're past the until boundary
|
|
767
809
|
if (filterUntil !== null && now >= filterUntil) return
|
|
768
810
|
const reservation = !initialAdmissions.has(url) && _admissions?.get(url)
|
|
769
|
-
const recovering = gapSince !== null
|
|
811
|
+
const recovering = gapSince !== null
|
|
812
|
+
if (recovering) pendingGaps.set(url, gapSince)
|
|
813
|
+
const attempt = { url, epoch: ++nextEpoch, since: null, lastProgress: -Infinity, recovered: !recovering, timer: null, closed: false, stop: new AbortController() }
|
|
814
|
+
attempts.set(url, attempt)
|
|
770
815
|
let recoveryLease
|
|
771
816
|
initialAdmissions.add(url)
|
|
772
817
|
Promise.resolve(reservation || this.#admission.acquire(normalizeRelayUrl(url), { live: true, history: recovering, signal: gapAc.signal, queueTimeout })).then(async slots => {
|
|
@@ -790,20 +835,22 @@ export class RelayPool {
|
|
|
790
835
|
|
|
791
836
|
// Buffer post-EOSE live events while a reconnect gap fill is running so
|
|
792
837
|
// the historical gap is yielded before newer socket events.
|
|
793
|
-
let liveBuffer =
|
|
838
|
+
let liveBuffer = recovering ? [] : null
|
|
794
839
|
let liveEose = false
|
|
795
840
|
let bufferedBytes = 0
|
|
796
841
|
|
|
797
842
|
// Open the live sub first so the relay starts buffering incoming events
|
|
798
843
|
// before we scan its database for the reconnect gap fill.
|
|
799
844
|
// Forward until to the relay so it can enforce the boundary server-side.
|
|
800
|
-
|
|
845
|
+
if (!openedAt.has(url)) openedAt.set(url, now)
|
|
846
|
+
const liveFilter = { ...baseFilter, since: overlappingSince(now), limit: 0 }
|
|
801
847
|
if (filterUntil !== null) liveFilter.until = filterUntil
|
|
802
848
|
const liveSub = relay.subscribe([liveFilter], {
|
|
803
849
|
onevent: (event) => {
|
|
804
850
|
// A limit:0 relay may still send retained events before EOSE. Do not
|
|
805
851
|
// expose them from a strictly-live stream.
|
|
806
|
-
if (isDone || liveSubs.get(url) !== liveSub || !liveEose) return
|
|
852
|
+
if (isDone || liveSubs.get(url) !== liveSub || attempt.closed || !liveEose) return
|
|
853
|
+
observeEvent(event, url)
|
|
807
854
|
if (liveBuffer) {
|
|
808
855
|
const bytes = encoder.encode(JSON.stringify(event)).byteLength
|
|
809
856
|
if (liveBuffer.length >= maxBufferedLiveEvents || bufferedBytes + bytes > maxBufferedLiveBytes) {
|
|
@@ -815,33 +862,44 @@ export class RelayPool {
|
|
|
815
862
|
} else pushEvent(event, url)
|
|
816
863
|
},
|
|
817
864
|
onclose: error => {
|
|
865
|
+
if (attempt.closed || attempts.get(url) !== attempt) return
|
|
866
|
+
attempt.closed = true
|
|
867
|
+
clearTimeout(attempt.timer)
|
|
868
|
+
attempt.stop.abort()
|
|
818
869
|
if (liveSubs.get(url) === liveSub) liveSubs.delete(url)
|
|
819
|
-
else if (liveSubs.has(url)) return
|
|
820
870
|
lease.release()
|
|
821
871
|
if (liveLeases.get(url) === lease) liveLeases.delete(url)
|
|
822
872
|
readyRelays.delete(url)
|
|
823
|
-
if (!isDone) {
|
|
824
|
-
if (error !== undefined) reportFailure(url, asError(error))
|
|
825
|
-
else if (!liveEose) reportClosed(url)
|
|
826
|
-
}
|
|
827
873
|
if (isDone) return
|
|
874
|
+
if (error !== undefined) reportFailure(url, asError(error))
|
|
875
|
+
else {
|
|
876
|
+
const interruption = Object.assign(categorizeRelayError(new Error('RELAY_LIVE_INTERRUPTED'), 'transport'), { code: 'RELAY_LIVE_INTERRUPTED' })
|
|
877
|
+
enqueue({ type: 'error', relay: url, error: interruption })
|
|
878
|
+
reportClosed(url, interruption)
|
|
879
|
+
}
|
|
828
880
|
scheduleReconnect(url, reconnectDelay)
|
|
829
881
|
},
|
|
830
882
|
oneose: () => {
|
|
831
|
-
if (isDone ||
|
|
883
|
+
if (isDone || attempt.closed || attempts.get(url) !== attempt || liveEose) return
|
|
832
884
|
liveEose = true
|
|
885
|
+
attempt.since = Math.max(sinceFloor, Math.floor(Date.now() / 1000))
|
|
833
886
|
markInitialEose(url)
|
|
887
|
+
scheduleProgress(attempt)
|
|
834
888
|
}
|
|
835
889
|
})
|
|
836
890
|
if (isDone) { liveSub.close(); recoveryLease?.release(); return }
|
|
837
891
|
liveSubs.set(url, liveSub)
|
|
838
892
|
|
|
839
|
-
if (
|
|
840
|
-
const task = runReconnectGapFill(url, gapSince, now, slots).
|
|
893
|
+
if (recovering) {
|
|
894
|
+
const task = runReconnectGapFill(url, gapSince, now, slots, attempt).then(recovered => {
|
|
895
|
+
attempt.recovered = recovered
|
|
896
|
+
if (recovered && !attempt.closed && attempts.get(url) === attempt) pendingGaps.delete(url)
|
|
897
|
+
}).finally(() => {
|
|
841
898
|
const buf = liveBuffer
|
|
842
899
|
liveBuffer = null
|
|
843
900
|
for (const event of buf) pushEvent(event, url, true)
|
|
844
901
|
gapTasks.delete(task)
|
|
902
|
+
scheduleProgress(attempt)
|
|
845
903
|
p.resolve()
|
|
846
904
|
})
|
|
847
905
|
gapTasks.add(task)
|
|
@@ -939,7 +997,7 @@ export class RelayPool {
|
|
|
939
997
|
|
|
940
998
|
// limit:0 is a live-only stream and reports that stream's initial window.
|
|
941
999
|
if (filter.limit === 0) {
|
|
942
|
-
yield * _liveGenerator(filter, relays, options)
|
|
1000
|
+
yield * _liveGenerator(filter, relays, { ...options, maxBufferedLiveEvents, maxBufferedLiveBytes })
|
|
943
1001
|
return
|
|
944
1002
|
}
|
|
945
1003
|
|