mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-03 21:36:17 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdc68e801a | ||
|
|
30742ab352 | ||
|
|
1622bd7109 | ||
|
|
64073f8fa9 | ||
|
|
3ef61bafcb | ||
|
|
ba35a87a7c | ||
|
|
ac6034f764 | ||
|
|
53be8d1755 | ||
|
|
431ad153bc | ||
|
|
bf41e75b78 | ||
|
|
a76a1911ea | ||
|
|
e10f76665f | ||
|
|
6cd91bb058 | ||
|
|
7922f9a4d1 |
@@ -971,7 +971,7 @@ class AccountConcordActions(
|
||||
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey))
|
||||
// Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give
|
||||
// the fetch a generous window to drain every relay before we pick the newest copy.
|
||||
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L)
|
||||
val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L)
|
||||
val newest = events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
|
||||
val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0
|
||||
Log.d(
|
||||
@@ -1015,7 +1015,7 @@ class AccountConcordActions(
|
||||
}
|
||||
if (filters.isEmpty()) return
|
||||
val byRelay = filters.groupBy { it.relay }.mapValues { (_, group) -> group.map { it.filter } }
|
||||
account.client.fetchAll(filters = byRelay, timeoutMs = 20_000L)
|
||||
account.client.fetchAll(filters = byRelay, idleTimeoutMs = 20_000L)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,7 +133,7 @@ class AccountRelayGroupActions(
|
||||
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
|
||||
timeoutMs = 8_000,
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||
|
||||
+1
-1
@@ -255,7 +255,7 @@ class AccountNappletGateways(
|
||||
emptyList()
|
||||
} else {
|
||||
runCatching {
|
||||
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
|
||||
account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } }
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ class NappletResourceFetcher(
|
||||
val relays = account.homeRelays.flow.value
|
||||
if (relays.isEmpty()) return null
|
||||
return runCatching {
|
||||
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS)
|
||||
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS)
|
||||
}.getOrDefault(emptyList())
|
||||
.maxByOrNull { it.createdAt }
|
||||
}
|
||||
|
||||
+8
-3
@@ -84,9 +84,14 @@ class UserCardsSubAssembler(
|
||||
add(it, account.userProfile().pubkeyHex)
|
||||
}
|
||||
}
|
||||
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account ->
|
||||
if (account != null) {
|
||||
add(account.relayUrl, account.pubkey)
|
||||
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider ->
|
||||
if (provider != null) {
|
||||
add(provider.relayUrl, provider.pubkey)
|
||||
}
|
||||
}
|
||||
accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider ->
|
||||
if (provider != null) {
|
||||
add(provider.relayUrl, provider.pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -153,7 +153,7 @@ class AgentConsoleViewModel : ViewModel() {
|
||||
// (pendingOnAuthRequired) so it authenticates on the `auth-required` CLOSED and retries.
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = relays.associateWith { filters },
|
||||
timeoutMs = 8_000,
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
}
|
||||
|
||||
+2
-2
@@ -97,7 +97,7 @@ private suspend fun runBuzzDmDiscovery(
|
||||
// rather than returning empty.
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = relays.associateWith { discoveryFilters },
|
||||
timeoutMs = 8_000,
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { relay, event ->
|
||||
(event as? MemberAddedNotificationEvent)?.let { recordDiscovery(me, it, relay) }
|
||||
@@ -135,7 +135,7 @@ private suspend fun fetchDmMetadata(
|
||||
.groupBy({ it.value }, { it.key })
|
||||
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
|
||||
if (byRelay.isEmpty()) return
|
||||
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -200,7 +200,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
)
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = relays.associateWith { filters },
|
||||
timeoutMs = 8_000,
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { relay, event ->
|
||||
(event as? MemberAddedNotificationEvent)?.channel()?.let { memberChannels[it] = relay }
|
||||
@@ -215,7 +215,7 @@ class BuzzDmListViewModel : ViewModel() {
|
||||
.groupBy({ it.value }, { it.key })
|
||||
.mapValues { (_, ids) -> listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to ids))) }
|
||||
if (byRelay.isEmpty()) return
|
||||
account.client.fetchAllWithHooks(filters = byRelay, timeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
account.client.fetchAllWithHooks(filters = byRelay, idleTimeoutMs = 8_000, pendingOnAuthRequired = true) { _, _ -> false }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -149,7 +149,7 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
),
|
||||
),
|
||||
),
|
||||
timeoutMs = 8_000,
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, event ->
|
||||
(event as? MemberAddedNotificationEvent)?.channel()?.let { channelIds.add(it) }
|
||||
@@ -172,7 +172,7 @@ class BuzzRelayImportViewModel : ViewModel() {
|
||||
Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())),
|
||||
),
|
||||
),
|
||||
timeoutMs = 8_000,
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
}
|
||||
|
||||
+1
-1
@@ -502,7 +502,7 @@ class EventSync(
|
||||
try {
|
||||
client.fetchAllPagesFromPool(
|
||||
filters = perRelayFilters,
|
||||
timeoutMs = RELAY_TIMEOUT_MS,
|
||||
idleTimeoutMs = RELAY_TIMEOUT_MS,
|
||||
maxConcurrentRelays = MAX_CONCURRENT_RELAYS,
|
||||
onNewPage = { until, sourceRelay ->
|
||||
_liveActivity.value.runningRelays[sourceRelay]
|
||||
|
||||
+1
-1
@@ -199,7 +199,7 @@ class CashuWalletDiscovery(
|
||||
fetchAllPages(
|
||||
relay = relay,
|
||||
filters = filters,
|
||||
timeoutMs = RELAY_TIMEOUT_MS,
|
||||
idleTimeoutMs = RELAY_TIMEOUT_MS,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
}.onFailure {
|
||||
|
||||
@@ -1942,14 +1942,95 @@
|
||||
</plurals>
|
||||
<!-- Expanded-only breakdown of the always-on notification. Counts overlap: one relay commonly
|
||||
serves several jobs at once, so these deliberately sum to more than the relay count. -->
|
||||
<plurals name="relay_purpose_line">
|
||||
<item quantity="one">%1$s \u00b7 %2$d पुनःप्रसारक</item>
|
||||
<item quantity="other">%1$s \u00b7 %2$d पुनःप्रसारक</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_browsing">जालभ्रमण</string>
|
||||
<string name="relay_purpose_media">ध्वनिचित्राभिलेख</string>
|
||||
<string name="relay_purpose_tags">विषयसूचक</string>
|
||||
<string name="relay_purpose_topics">विषय सूची</string>
|
||||
<string name="relay_purpose_thread">वार्तालाप</string>
|
||||
<string name="relay_purpose_search">खोज</string>
|
||||
<string name="relay_purpose_referenced">लुप्त घटनाओं को ढूँढें</string>
|
||||
<string name="relay_purpose_engagement">घटना अवलोकन</string>
|
||||
<string name="relay_explain_referenced">घटनाओं को विभेदक अनुसार ले आता है जिसका उल्लेख आपके पटल पर अमुक करता है पर जिसकी प्राप्ती अभी नहीं हुई। एक उद्धरण अथवा एक प्रत्युत्तर का पूर्वपत्र अथवा एक सूत्र का मूल।</string>
|
||||
<string name="relay_explain_engagement">घटनाओं का अवलोकन करता है जो वर्तमान में प्रदर्शित हो रहे हैं नए प्रत्युत्तर प्रतिक्रियाएँ उद्धरण ज्साप तथा वृत्तान्तों के लिए जिससे गिनतियों का नवीकरण होता है जब आप पढ रहे हैं।</string>
|
||||
<string name="relay_purpose_add_ons">संलग्न</string>
|
||||
<string name="relay_purpose_relay_info">पुनःप्रसारक जानकारी</string>
|
||||
<string name="relay_purpose_other">अन्य</string>
|
||||
<!-- Activity labels, used where the app's own noun for the data type already means something
|
||||
else to the user: "Outbox Relays" is their own relay list in settings, and "Profile" is
|
||||
their profile screen. Naming the job avoids an active misreading. -->
|
||||
<string name="relay_purpose_relay_list_finder">पुनःप्रसारक सूची खोजकर्ता</string>
|
||||
<string name="relay_purpose_observing_profiles">परिचय अवलोकन</string>
|
||||
<string name="relay_purpose_your_account">लेखा जानकारी</string>
|
||||
<string name="relay_purpose_home_feed">मुख्य सूचनावली</string>
|
||||
<string name="relay_purpose_relay_groups">पुनःप्रसारक समूह</string>
|
||||
<plurals name="active_subs_groups">
|
||||
<item quantity="one">%1$d समूह</item>
|
||||
<item quantity="other">%1$d समूह</item>
|
||||
</plurals>
|
||||
<string name="relay_purpose_ephemeral_chats">अस्थायी चर्चाएँ</string>
|
||||
<string name="relay_purpose_geohash_chats">स्थानीय चर्चाएँ</string>
|
||||
<string name="relay_purpose_live_chat">वर्तमानप्रवाह चर्चा</string>
|
||||
<string name="relay_explain_relay_groups">निप॰२९ समूह जिनसे आप जुडे हैं। प्रत्येक समूह एक जालावास पुनःप्रसारक में रहता है। इसलिए क्रमक प्रत्येक पुनःप्रसारक से संयोजन करता है जो आपके किसी समूह का जालावास है।</string>
|
||||
<string name="relay_explain_ephemeral_chats">चर्चाशालाएँ जो कोई इतिहास नहीं रखते। सन्देश केवल तब तक रहते हैं जब तक आप संयोजित हैं। इसलिए ये ग्राहकता बनाए रखते हैं कुछ भी प्राप्त होने के लिए।</string>
|
||||
<string name="relay_explain_geohash_chats">स्थान आधारित शालाएँ उन क्षेत्रों के लिए जिनका आप अनुगमन करते हैं। पृष्ट उन पुनःप्रसारको से जो इनके जालावास हैं।</string>
|
||||
<string name="relay_explain_live_chat">चर्चा तथा ज्साप उद्देश्य जो वर्तमानप्रवाहों से संलग्न हैं जिन्हें आप खोले हुए हैं अथवा अनुगमन करते हैं।</string>
|
||||
<string name="relay_purpose_dm_inbox">सीधासन्देश आगतपेटिका</string>
|
||||
<string name="relay_purpose_your_wallet">धनकोष</string>
|
||||
<string name="relay_purpose_nutzap_inbox">नटज्साप आगतपेटिका</string>
|
||||
<string name="relay_purpose_mint_directory">टकसाल निर्देशिका</string>
|
||||
<string name="relay_purpose_nwc">धनकोष संयोजन</string>
|
||||
<string name="relay_purpose_community_chats">समुदाय चर्चाएँ</string>
|
||||
<string name="relay_purpose_community_feeds">समुदाय सूचनावलियाँ</string>
|
||||
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
|
||||
Describe the real strategy, not the intent — these are read by people trying to explain
|
||||
a relay count they think is too high. -->
|
||||
<string name="relay_explain_notifications">आपके आगतपेटिका पुनःप्रसारक तथा कुछ अल्पमात्रा परिभ्रमणवर्ती दृष्टान्त पुनःप्रसारक जिनपर आपके अनुचरित पत्र प्रकाशित करते हैं। यदि कोई उल्लेख अन्यत्र भेजा गया।</string>
|
||||
<string name="relay_explain_direct_messages">आपके सीधासन्देश पुनःप्रसारक। जहाँ उपहारकोषयुक्त सन्देश भेजे जाते हैं।</string>
|
||||
<string name="relay_explain_public_chats">मुख्य पुनःप्रसारक प्रत्येक चर्चा का जिन्हें आप खोल रखे हैं अथवा जिनसे आप जुड चुके हैं।</string>
|
||||
<string name="relay_explain_community_chats">पुनःप्रसारक जिनपर प्रत्येक समुदाय अपने पत्र प्रकाशित करते हैं।</string>
|
||||
<string name="relay_explain_encrypted_groups">समूह सन्देश तथा कुंचिकापेटलियाँ। प्रत्येक समूह के पुनःप्रसारकों पर।</string>
|
||||
<string name="relay_explain_live_rooms">शाला के पुनःप्रसारक। जब वह खुला हो।</string>
|
||||
<string name="relay_explain_account_data">आपके अपने परिचय तथा स्थापना विकल्प तथा पाण्डुलिपियाँ। आपके मुख्य पुनःप्रसारकों पर।</string>
|
||||
<string name="relay_explain_profiles">वर्तमानतः पटल पर लोगों के परिचय।</string>
|
||||
<string name="relay_explain_relay_lists">खोजता है किन पुनःप्रसारकों पर प्रत्येक व्यक्ति प्रकाशन करता है। जिससे कि उनके पत्र सम्यक स्थल से प्राप्प हो।</string>
|
||||
<string name="relay_explain_follows">अनुचरण सूचियाँ। आपकी सूचनावली तथा आपका विश्वासजाल का निर्माण के लिए उपयुक्त।</string>
|
||||
<string name="relay_explain_moderation">वृत्तान्त जो आपके अनुचरितों ने लिखा वर्तमानतः आपके पटल पर दिखनेवाले परिचयों के विषय में। पृष्ट प्रत्येक पुनःप्रसारक से जिनपर वे पत्र प्रकाशन करते हैं।</string>
|
||||
<string name="relay_purpose_reports_from_follows">अनुचरित से वृत्तान्त</string>
|
||||
<string name="relay_explain_wallet">आपके अपने धनकोष घटनाएँ। पुनःपठित उन पुनःप्रसारकों से जिनपर आपने उनके प्रकाशन किए।</string>
|
||||
<string name="relay_explain_nutzap_inbox">सुनता है आपके नटज्साप पुनःप्रसारकों पर तथा आपके आगतपेटिका तथा सीधासन्देश पुनःप्रसारकों पर। जिससे कि कोई भी भुगतान छूट ना जाए।</string>
|
||||
<string name="relay_explain_mint_directory">पुनःप्रसारकों का वीक्षण करता है यह देखने के लिए कि कौनसे टकसाल हैं तथा लोग किनकी अनुशम्सा करते हैं।</string>
|
||||
<string name="relay_explain_nwc">आपके संयोजित धनकोष से सूचनाएँ।</string>
|
||||
<string name="active_subs_title">सक्रिय पुनःप्रसारक ग्राहकताएँ</string>
|
||||
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
|
||||
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
|
||||
<plurals name="active_subs_filters">
|
||||
<item quantity="one">%1$d छलनी</item>
|
||||
<item quantity="other">%1$d छलनियाँ</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_relays">
|
||||
<item quantity="one">%1$d पुनःप्रसारक</item>
|
||||
<item quantity="other">%1$d पुनःप्रसारक</item>
|
||||
</plurals>
|
||||
<plurals name="active_subs_untagged">
|
||||
<item quantity="one">%1$d छलनी आरोपित नहीं अब तक</item>
|
||||
<item quantity="other">%1$d छलनियाँ आरोपित नहीं अब तक</item>
|
||||
</plurals>
|
||||
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
|
||||
<string name="active_subs_unattributed">किसी लेखा प्रति आरोपित नहीं</string>
|
||||
<string name="active_subs_no_entity">सभी</string>
|
||||
<string name="active_subs_scope_global">सभी</string>
|
||||
<string name="active_subs_scope_follows">आपके द्वारा अनुचरित लोग</string>
|
||||
<string name="active_subs_scope_authors">चयनित लोगों की सूची</string>
|
||||
<string name="active_subs_scope_muted">मौनकृत लोग</string>
|
||||
<string name="active_subs_scope_all_communities">आपके समुदाय</string>
|
||||
<string name="active_subs_scope_algo">एक प्रिय कलनविधि सूचनावली</string>
|
||||
<string name="active_subs_share">%1$dप्रतिशतप्रतिशत सब में से</string>
|
||||
<string name="active_subs_search_keywords">ग्राहकताएँ छलनियाँ पुनःप्रसारक अनुरोध अनु॰ संयोजन क्यों निदानतन्त्र</string>
|
||||
<string name="relay_explain_home">आपके अनुचरितों के पत्र। पठित उन पुनःप्रसारकों से जिनपर उनमें से प्रत्येक प्रकाशन करते हैं।</string>
|
||||
<string name="always_on_notif_connecting">आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026</string>
|
||||
<string name="always_on_notif_setting_title">सदैव सक्रिय सूचना सेवा</string>
|
||||
<string name="always_on_notif_setting_description">अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे।</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -153,12 +153,12 @@ class AmethystAppFunctions {
|
||||
|
||||
// Quartz's INostrClient.fetchAll handles subscribe → drain on
|
||||
// EOSE/closed/cannot-connect → unsubscribe → dedup by id → sort
|
||||
// newest-first. Wraps everything in a withTimeoutOrNull(timeoutMs)
|
||||
// newest-first. Wraps everything in a withTimeoutOrNull(idleTimeoutMs)
|
||||
// so a slow relay can't stall the dispatch.
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val candidates =
|
||||
@@ -397,7 +397,7 @@ class AmethystAppFunctions {
|
||||
return Amethyst.instance.client
|
||||
.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
).mapNotNull { it as? TextNoteEvent }
|
||||
.take(limit)
|
||||
}
|
||||
@@ -449,7 +449,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val hits =
|
||||
@@ -521,7 +521,7 @@ class AmethystAppFunctions {
|
||||
client
|
||||
.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
).mapNotNull { it as? MetadataEvent }
|
||||
.filter { it.pubKey == pubkey }
|
||||
.maxByOrNull { it.createdAt }
|
||||
@@ -569,7 +569,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val hits =
|
||||
@@ -643,7 +643,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val hits =
|
||||
@@ -686,7 +686,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val hits =
|
||||
@@ -733,7 +733,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val hits =
|
||||
@@ -785,7 +785,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val receipts = events.mapNotNull { it as? LnZapEvent }
|
||||
@@ -880,7 +880,7 @@ class AmethystAppFunctions {
|
||||
client
|
||||
.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
).mapNotNull { it as? GiftWrapEvent }
|
||||
|
||||
val seen = HashSet<HexKey>()
|
||||
@@ -947,7 +947,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val hits =
|
||||
@@ -991,7 +991,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val streams =
|
||||
@@ -1691,7 +1691,7 @@ class AmethystAppFunctions {
|
||||
return client
|
||||
.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
).mapNotNull { it as? MetadataEvent }
|
||||
.maxByOrNull { it.createdAt }
|
||||
?.contactMetaData()
|
||||
@@ -2027,7 +2027,7 @@ class AmethystAppFunctions {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
idleTimeoutMs = GEMINI_FETCH_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val hits =
|
||||
|
||||
@@ -547,7 +547,7 @@ class Context(
|
||||
if (needAuth.isEmpty()) break
|
||||
// A cheap REQ whose only purpose is to force the AUTH handshake to completion.
|
||||
val warmFilter = listOf(Filter(kinds = listOf(event.kind), limit = 1))
|
||||
drain(needAuth.associateWith { warmFilter }, timeoutMs = 8_000, pendingOnAuthRequired = true)
|
||||
drain(needAuth.associateWith { warmFilter }, idleTimeoutMs = 8_000, pendingOnAuthRequired = true)
|
||||
results = results + client.publishAndCollectResults(event, needAuth, timeoutSecs)
|
||||
attempt++
|
||||
}
|
||||
@@ -565,7 +565,7 @@ class Context(
|
||||
* When [deadOut] is provided, every relay that reported it could not be
|
||||
* connected to (`onCannotConnect`) is added to it, so callers can prune
|
||||
* proven-dead relays from future routing instead of paying the full
|
||||
* [timeoutMs] on them again. Slow-but-connected relays are NOT reported —
|
||||
* [idleTimeoutMs] on them again. Slow-but-connected relays are NOT reported —
|
||||
* only hard connect failures, so a temporarily-busy relay isn't discarded.
|
||||
*
|
||||
* With [pendingOnAuthRequired], a relay that refuses the REQ with an
|
||||
@@ -573,24 +573,24 @@ class Context(
|
||||
* NIP-42 responder answers the challenge and the client re-fires this same
|
||||
* subscription (`syncFilters`), so the post-auth events are collected instead of
|
||||
* returning empty. If auth never satisfies it, the relay simply falls through to
|
||||
* the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
|
||||
* the [idleTimeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served
|
||||
* only to a connection authenticated as the derived stream key.
|
||||
*/
|
||||
suspend fun drain(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 8_000,
|
||||
idleTimeoutMs: Long = 8_000,
|
||||
diagnoseSlow: Boolean = false,
|
||||
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
|
||||
pendingOnAuthRequired: Boolean = false,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> =
|
||||
client.fetchAllWithHooks(
|
||||
filters = filters,
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
pendingOnAuthRequired = pendingOnAuthRequired,
|
||||
deadOut = deadOut,
|
||||
onTimeout =
|
||||
if (diagnoseSlow) {
|
||||
{ stalled, doneReasons, collected -> logSlowDrain(timeoutMs, stalled, doneReasons, collected) }
|
||||
{ stalled, doneReasons, collected -> logSlowDrain(idleTimeoutMs, stalled, doneReasons, collected) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -604,7 +604,7 @@ class Context(
|
||||
* "relay is slow" and "we never connected" are easy to tell apart.
|
||||
*/
|
||||
private fun logSlowDrain(
|
||||
timeoutMs: Long,
|
||||
idleTimeoutMs: Long,
|
||||
stalled: Set<NormalizedRelayUrl>,
|
||||
doneReasons: Map<NormalizedRelayUrl, String>,
|
||||
collected: List<Pair<NormalizedRelayUrl, Event>>,
|
||||
@@ -615,7 +615,7 @@ class Context(
|
||||
val slowDetail = stalled.take(12).joinToString(", ") { "${it.url}(${eventsPer[it] ?: 0}ev)" }
|
||||
val cannotDetail = cannot.entries.take(8).joinToString(", ") { "${it.key.url}=${it.value.removePrefix("cannot:").take(40)}" }
|
||||
System.err.println(
|
||||
"[drain] timeout ${timeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
|
||||
"[drain] timeout ${idleTimeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" +
|
||||
(if (slowDetail.isNotEmpty()) " | slow: $slowDetail" else "") +
|
||||
(if (cannotDetail.isNotEmpty()) " | cannot: $cannotDetail" else ""),
|
||||
)
|
||||
@@ -641,12 +641,12 @@ class Context(
|
||||
*/
|
||||
suspend fun drainAllPages(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 30_000,
|
||||
idleTimeoutMs: Long = 30_000,
|
||||
maxConcurrentRelays: Int = 8,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> =
|
||||
client.fetchAllPagesFromPoolWithHooks(
|
||||
filters = filters,
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
maxConcurrentRelays = maxConcurrentRelays,
|
||||
) { _, event -> verifyAndStore(event) }
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ object AwaitCommands {
|
||||
val event =
|
||||
ctx.client.fetchFirst(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = 3_000,
|
||||
idleTimeoutMs = 3_000,
|
||||
)
|
||||
if (event is KeyPackageEvent) {
|
||||
Output.emit(
|
||||
|
||||
@@ -357,7 +357,7 @@ object DmCommands {
|
||||
.groupBy { it.relay }
|
||||
.mapValues { (_, v) -> v.map { it.filter } }
|
||||
|
||||
val raw = ctx.drain(filters, timeoutMs = timeoutSecs * 1000)
|
||||
val raw = ctx.drain(filters, idleTimeoutMs = timeoutSecs * 1000)
|
||||
|
||||
val messages = decryptDms(ctx, raw, peerHex)
|
||||
val out =
|
||||
@@ -415,7 +415,7 @@ object DmCommands {
|
||||
.groupBy { it.relay }
|
||||
.mapValues { (_, v) -> v.map { it.filter } }
|
||||
|
||||
val raw = ctx.drain(filters, timeoutMs = 3_000)
|
||||
val raw = ctx.drain(filters, idleTimeoutMs = 3_000)
|
||||
val messages = decryptDms(ctx, raw, peerHex)
|
||||
// Match against the text body for kind:14 and against the URL
|
||||
// for kind:15 — both are exposed as `searchText` so callers
|
||||
|
||||
@@ -103,7 +103,7 @@ object GitReadCommands {
|
||||
ctx
|
||||
.drainAllPages(
|
||||
relays.associateWith { listOf(Filter(kinds = listOf(itemKind), tags = mapOf("a" to listOf(repoAddress)), limit = limit)) },
|
||||
timeoutMs = READ_TIMEOUT_MS,
|
||||
idleTimeoutMs = READ_TIMEOUT_MS,
|
||||
).asSequence()
|
||||
.map { it.second }
|
||||
.filter { it.kind == itemKind }
|
||||
@@ -160,7 +160,7 @@ object GitReadCommands {
|
||||
relays.associateWith {
|
||||
listOf(Filter(kinds = STATUS_KINDS + listOf(CommentEvent.KIND, GitReplyEvent.KIND), tags = mapOf("e" to listOf(id))))
|
||||
},
|
||||
timeoutMs = READ_TIMEOUT_MS,
|
||||
idleTimeoutMs = READ_TIMEOUT_MS,
|
||||
).map { it.second }
|
||||
.distinctBy { it.id }
|
||||
|
||||
@@ -208,7 +208,7 @@ object GitReadCommands {
|
||||
ctx
|
||||
.drainAllPages(
|
||||
relays.associateWith { listOf(Filter(kinds = STATUS_KINDS, tags = mapOf("e" to chunk))) },
|
||||
timeoutMs = READ_TIMEOUT_MS,
|
||||
idleTimeoutMs = READ_TIMEOUT_MS,
|
||||
).map { it.second }
|
||||
}.filterIsInstance<GitStatusEvent>()
|
||||
.distinctBy { it.id }
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ object GroupAddMemberCommand {
|
||||
client = ctx.client,
|
||||
targetPubKey = pub,
|
||||
relays = kpRelays,
|
||||
timeoutMs = 10_000,
|
||||
idleTimeoutMs = 10_000,
|
||||
)
|
||||
if (kpEvent == null) {
|
||||
report.add(mapOf("pubkey" to pub, "status" to "no_key_package"))
|
||||
|
||||
@@ -99,7 +99,7 @@ object KeyPackageCommands {
|
||||
client = ctx.client,
|
||||
targetPubKey = targetHex,
|
||||
relays = relays,
|
||||
timeoutMs = 10_000,
|
||||
idleTimeoutMs = 10_000,
|
||||
)
|
||||
if (event == null) {
|
||||
return Output.error("not_found", "no KeyPackage for $targetHex on ${relays.size} relay(s)")
|
||||
|
||||
+1
-1
@@ -287,7 +287,7 @@ object GrapeRankCrawl {
|
||||
// Default null → pull EVERY follower each relay holds; --max
|
||||
// N caps the total per relay for a quick spot check.
|
||||
maxPerRelay = args.flag("max")?.toIntOrNull(),
|
||||
timeoutMs = args.timeoutMs(15),
|
||||
idleTimeoutMs = args.timeoutMs(15),
|
||||
maxConcurrentRelays = relayConcurrency,
|
||||
insertBatchSize = args.intFlag(FLAG_INSERT_BATCH, INSERT_BATCH_DEFAULT),
|
||||
),
|
||||
|
||||
@@ -1,17 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Login & Auth -->
|
||||
<string name="login_title">Üdvözöljük az Amethystben</string>
|
||||
<string name="login_subtitle">Jelentkezzen be Nostr a-fiókjába</string>
|
||||
<string name="login_subtitle_desktop">Asztali Nostr-kliens</string>
|
||||
<string name="login_card_title">Jelentkezzen be Nostr-a kulcsával</string>
|
||||
<string name="login_card_subtitle">nsec a teljes hozzáféréshez, bunker:// a távoli aláíróhoz, vagy npub a csak olvasható módhoz</string>
|
||||
<string name="login_with_key">Bejelentkezés kulccsal</string>
|
||||
<string name="login_button">Bejelentkezés</string>
|
||||
<string name="login_generate_new">Új kulcs előállítása</string>
|
||||
<string name="login_generate_button">Új előállítása</string>
|
||||
<string name="login_key_hint">Adja meg a privát kulcsát (nsec) vagy a nyilvános kulcsát (npub)</string>
|
||||
<string name="login_key_label">nsec, bunker:// vagy npub</string>
|
||||
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
|
||||
<string name="login_show_key">Kulcs megjelenítése</string>
|
||||
<string name="login_hide_key">Kulcs elrejtése</string>
|
||||
<!-- New Key Warning -->
|
||||
<string name="new_key_warning_title">FONTOS: Mentse el a kulcsait!</string>
|
||||
<string name="new_key_warning_message">A titkos kulcsa (nsec) az EGYETLEN módja annak, hogy hozzáférjen a fiókjához. Ha elveszíti, akkor a fiókja végleg elvész. Mentse el egy biztonságos helyre!</string>
|
||||
<string name="new_key_public_label">Nyilvános kulcs (megosztható):</string>
|
||||
<string name="new_key_secret_label">Titkos kulcs (SOHA ne ossza meg!):</string>
|
||||
<string name="new_key_continue_button">Elmentettem a kulcsaimat, folytatás</string>
|
||||
<!-- Common Actions -->
|
||||
<string name="action_copy">Másolás</string>
|
||||
<string name="action_paste">Beillesztés</string>
|
||||
<string name="action_cancel">Mégse</string>
|
||||
<string name="action_ok">OK</string>
|
||||
<string name="action_save">Mentés</string>
|
||||
<string name="action_delete">Törlés</string>
|
||||
<string name="action_share">Megosztás</string>
|
||||
<!-- Errors -->
|
||||
<string name="error_invalid_key">Érvénytelen kulcsformátum. Ellenőrizze, és próbálja újra.</string>
|
||||
<string name="error_network">Hálózati hiba. Ellenőrizze a kapcsolatot.</string>
|
||||
<string name="error_generic">Hiba történt. Próbálja újra.</string>
|
||||
<!-- Loading & Empty States -->
|
||||
<string name="action_refresh">Frissítés</string>
|
||||
<string name="action_try_again">Próbálja újra</string>
|
||||
<string name="feed_empty">A hírfolyam üres</string>
|
||||
<string name="error_loading_feed">Hiba történt a hírfolyam betöltésekor: %s</string>
|
||||
<!-- Placeholder Screens -->
|
||||
<string name="screen_search_title">Keresés</string>
|
||||
<string name="screen_search_description">Keressen felhasználókat, bejegyzéseket és kulcsszavakat.</string>
|
||||
<string name="screen_messages_title">Üzenetek</string>
|
||||
<string name="screen_messages_description">Az Ön titkosított közvetlen üzenetei itt fognak megjelenni.</string>
|
||||
<string name="screen_notifications_title">Értesítések</string>
|
||||
<string name="screen_notifications_description">Az említések, válaszok és reakciók itt fognak megjelenni.</string>
|
||||
<!-- Accessibility -->
|
||||
<string name="accessibility_user_avatar">Felhasználó profilképe</string>
|
||||
<string name="accessibility_navigate">Navigáció</string>
|
||||
<!-- Relay history paging (shared feed markers + status card) -->
|
||||
<string name="chats_history_loading_label">Betöltés:</string>
|
||||
<string name="chats_history_fully_loaded_label">Teljesen betöltve:</string>
|
||||
<string name="chats_history_fully_loaded">(teljesen betöltve)</string>
|
||||
<string name="chats_history_by_relay">Előzmények átjátszónként</string>
|
||||
<string name="chats_history_stalled_retry">Újra megpróbálja, amint újra megnyitja ezt a képernyőt</string>
|
||||
<string name="chats_history_older">%1$s korábbi üzenet</string>
|
||||
<string name="chats_history_all_caught_up">Naprakész</string>
|
||||
<string name="chats_history_reached_start">Elérte a(z) %1$s üzeneteinek elejét</string>
|
||||
<string name="chats_history_subtitle">%1$s · %2$s · betöltve ekkortól: %3$s</string>
|
||||
<string name="chats_history_subtitle_no_date">%1$s · %2$s</string>
|
||||
<string name="chats_history_waiting">várakozás erre: %1$s</string>
|
||||
<string name="chats_history_incomplete">Néhány átjátszó nem válaszolt</string>
|
||||
<string name="chats_history_incomplete_sub">%1$s nem érhető el · koppintson a részletekért</string>
|
||||
<string name="chats_history_relays_title">%1$s · előzmények átjátszónként</string>
|
||||
<string name="chats_history_relay_since">ekkortól: %1$s</string>
|
||||
<string name="action_dismiss">Eltüntetés</string>
|
||||
<plurals name="chats_history_relays">
|
||||
<item quantity="one">%1$d átjátszó</item>
|
||||
<item quantity="other">%1$d relé</item>
|
||||
</plurals>
|
||||
<!-- Notes & Replies -->
|
||||
<string name="replying_to">válasz neki: </string>
|
||||
<!-- Static sites (NIP-5A) & napplets (NIP-5D) feed card -->
|
||||
<string name="nsite_title">nOldal: %1$s</string>
|
||||
<string name="napplet_card_title">nKisalkalmazás: %1$s</string>
|
||||
<string name="napplet_card_kind">nKisalkalmazás</string>
|
||||
<string name="nsite_website_kind">nOldal</string>
|
||||
<string name="napplet_card_permissions">Amihez hozzáférhet</string>
|
||||
<string name="nsite_root_site">Gyökéroldal</string>
|
||||
<string name="nsite_source">Forrás:</string>
|
||||
<string name="nsite_servers">Kiszolgálók:</string>
|
||||
<string name="nsite_open">Megnyitás</string>
|
||||
<!-- Custom emoji suggestions (NIP-30) -->
|
||||
<string name="use_direct_url">Közvetlen webcím használata</string>
|
||||
<!-- Nicknames (NIP-85 contact cards) -->
|
||||
<string name="nickname_dialog_title">Becenév</string>
|
||||
<string name="nickname_dialog_explainer">Ez jelenik meg Önnek ezen felhasználó neve helyett az alkalmazásban bárhol. Titkosítva tárolódik el a kapcsolatkártyájára: csak Ön olvashatja. Írjon be kettőspontot (:) az egyéni emodzsik használatához.</string>
|
||||
<string name="nickname_label">Becenév</string>
|
||||
<string name="nickname_summary_label">Privát megjegyzés erről a felhasználóról</string>
|
||||
<string name="nickname_save">Mentés</string>
|
||||
<string name="nickname_cancel">Mégse</string>
|
||||
<string name="git_status_open">Nyitva</string>
|
||||
<string name="git_status_merged">Beolvasztva</string>
|
||||
<string name="git_status_closed">Lezárva</string>
|
||||
@@ -53,6 +131,7 @@
|
||||
<string name="road_event_traffic_jam">Forgalmi dugó</string>
|
||||
<string name="road_event_unknown">Útesemény</string>
|
||||
<string name="podcast_value_zap_split_hint">Az erre küldött Zapek megoszlanak a következők között:</string>
|
||||
<string name="podcast_value_split_percent">%1$d%%</string>
|
||||
<string name="podcast_value_for_value">Értéket az értékért</string>
|
||||
<string name="relay_monitor_rtt_open">Megnyitás</string>
|
||||
<string name="relay_monitor_rtt_read">Olvasás </string>
|
||||
@@ -61,6 +140,7 @@
|
||||
<string name="relay_monitor_relay_type">Típus</string>
|
||||
<string name="relay_monitor_requirements">Követelmények</string>
|
||||
<string name="relay_monitor_supported_nips">Támogatott NIP-ek</string>
|
||||
<string name="relay_monitor_ms">%1$d ms</string>
|
||||
<string name="relay_discovery_accepted_kinds">Elfogadott típusok</string>
|
||||
<string name="relay_discovery_geohash">Helyszín</string>
|
||||
<string name="calendar_rsvp_going">Ott leszek</string>
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@ class DmInboxRelayResolver(
|
||||
if (writeRelays.isNotEmpty()) {
|
||||
relays =
|
||||
RecipientRelayFetcher
|
||||
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, timeoutMs = 5_000L)
|
||||
.fetchRelayLists(unauthenticatedClient, pubkey, writeRelays, idleTimeoutMs = 5_000L)
|
||||
.dmInbox
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -221,7 +221,7 @@ class DesktopRelaySubscriptionsCoordinator(
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = indexRelays.associateWith { listOf(filter) },
|
||||
timeoutMs = 8.seconds.inWholeMilliseconds,
|
||||
idleTimeoutMs = 8.seconds.inWholeMilliseconds,
|
||||
)
|
||||
events.forEach { consumeEvent(it, null) }
|
||||
}
|
||||
|
||||
@@ -84,27 +84,34 @@
|
||||
"Dutch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "summoner001",
|
||||
"languages": [
|
||||
"Hungarian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "maxblake2015",
|
||||
"languages": [
|
||||
"Polish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "rajs19420616",
|
||||
"languages": [
|
||||
"Hindi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "vitorpamplona",
|
||||
"languages": [
|
||||
"Czech",
|
||||
"German",
|
||||
"Polish",
|
||||
"Portuguese, Brazilian",
|
||||
"Swedish"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "rajs19420616",
|
||||
"languages": [
|
||||
"Hindi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"user": "greenart7c3",
|
||||
"languages": []
|
||||
@@ -265,10 +272,6 @@
|
||||
"user": "D4rkFIow",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "summoner001",
|
||||
"languages": []
|
||||
},
|
||||
{
|
||||
"user": "fiddleway",
|
||||
"languages": []
|
||||
|
||||
+3
-3
@@ -75,14 +75,14 @@ class FollowerCrawler(
|
||||
* stops paging once it's reached, so a non-null value cuts the crawl short at
|
||||
* that many per relay. Leave it null for completeness; set it only to bound a
|
||||
* spot check. The per-page size is the relay's own default either way.
|
||||
* @param timeoutMs per-page EOSE timeout for a relay before its next page fires.
|
||||
* @param idleTimeoutMs per-page EOSE timeout for a relay before its next page fires.
|
||||
* @param maxConcurrentRelays how many relays page at once (a global fan-out cap).
|
||||
* @param insertBatchSize verified events group-committed per [IEventStore.batchInsert].
|
||||
*/
|
||||
class Config(
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
val maxPerRelay: Int? = null,
|
||||
val timeoutMs: Long = 15_000,
|
||||
val idleTimeoutMs: Long = 15_000,
|
||||
val maxConcurrentRelays: Int = 16,
|
||||
val insertBatchSize: Int = 500,
|
||||
)
|
||||
@@ -162,7 +162,7 @@ class FollowerCrawler(
|
||||
|
||||
client.fetchAllPagesFromPool(
|
||||
filters = perRelay,
|
||||
timeoutMs = config.timeoutMs,
|
||||
idleTimeoutMs = config.idleTimeoutMs,
|
||||
maxConcurrentRelays = config.maxConcurrentRelays,
|
||||
onRelayComplete = { relay, total ->
|
||||
if (total > 0) log("[followers] ${relay.url}: $total kind:3 pages drained")
|
||||
|
||||
+2
-2
@@ -81,7 +81,7 @@ object RecipientRelayFetcher {
|
||||
client: INostrClient,
|
||||
pubKey: HexKey,
|
||||
seedRelays: Set<NormalizedRelayUrl>,
|
||||
timeoutMs: Long = 8_000L,
|
||||
idleTimeoutMs: Long = 8_000L,
|
||||
): Lists {
|
||||
if (seedRelays.isEmpty()) return Lists(emptyList(), emptyList(), null)
|
||||
|
||||
@@ -99,7 +99,7 @@ object RecipientRelayFetcher {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = seedRelays.associateWith { listOf(filter) },
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
)
|
||||
|
||||
var dm: ChatMessageRelayListEvent? = null
|
||||
|
||||
+2
-2
@@ -75,11 +75,11 @@ object KeyPackageFetcher {
|
||||
client: INostrClient,
|
||||
targetPubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
timeoutMs: Long = 30_000,
|
||||
idleTimeoutMs: Long = 30_000,
|
||||
): KeyPackageEvent? {
|
||||
if (relays.isEmpty()) return null
|
||||
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
|
||||
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
|
||||
val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = idleTimeoutMs)
|
||||
// fetchAll returns events sorted by created_at DESC, so the first
|
||||
// KeyPackageEvent is the most recent one any relay had.
|
||||
return events.firstNotNullOfOrNull { it as? KeyPackageEvent }
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* Monotonic "last activity" marker for the idle watchdogs shared by the accessory
|
||||
* fetch/sync loops. [bump] on every sign of life from the relay; [elapsedMs] reports
|
||||
* the silence since the last bump (or since construction, before the first bump).
|
||||
*
|
||||
* This is the timeout convention for every accessory in this package: an
|
||||
* `idleTimeoutMs` is an **idle window measured from the relay's most recent
|
||||
* progress**, not a wall-clock deadline — an actively streaming relay is never cut
|
||||
* off mid-delivery, only one that goes silent. The name is the contract: a
|
||||
* parameter here is called `idleTimeoutMs` precisely because it is not a deadline.
|
||||
*
|
||||
* [bump] is on the per-event hot path (a connection listener may bump for every
|
||||
* message the relay sends — millions during a large download), so it must not
|
||||
* allocate: a single [start] mark is taken once (unboxed field) and each bump only
|
||||
* writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads
|
||||
* write, the driver coroutine reads — visibility is all we need, so a plain volatile
|
||||
* Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event.
|
||||
*/
|
||||
internal class IdleClock {
|
||||
private val start = TimeSource.Monotonic.markNow()
|
||||
|
||||
@Volatile
|
||||
private var lastNanos = 0L
|
||||
|
||||
fun bump() {
|
||||
lastNanos = start.elapsedNow().inWholeNanoseconds
|
||||
}
|
||||
|
||||
fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives the next item, giving up (returning `null`) only after [idleMs] elapse with
|
||||
* no activity on [clock]. Because [clock] can be bumped by *any* relay message — not
|
||||
* just items on this channel — unrelated progress (e.g. download events arriving during
|
||||
* a reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the
|
||||
* watchdog: it waits until an item arrives (a disconnect is delivered as an item, so
|
||||
* a dead socket still unblocks it).
|
||||
*/
|
||||
internal suspend fun <T> Channel<T>.receiveWithinIdle(
|
||||
clock: IdleClock,
|
||||
idleMs: Long,
|
||||
): T? {
|
||||
if (idleMs <= 0) return receive()
|
||||
while (true) {
|
||||
val remaining = idleMs - clock.elapsedMs()
|
||||
if (remaining <= 0) return null
|
||||
val item = withTimeoutOrNull(remaining) { receive() }
|
||||
if (item != null) return item
|
||||
// Timed out with nothing on this channel. If other activity bumped the clock
|
||||
// meanwhile, the next `remaining` is positive and we wait again; otherwise it
|
||||
// is <= 0 on the next iteration and we give up.
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -298,7 +298,11 @@ class NegentropyStoreSync(
|
||||
}
|
||||
}
|
||||
try {
|
||||
client.fetchAllPages(relay, listOf(filter), config.idleTimeoutMs) { event -> events.trySend(event) }
|
||||
// Like fetchByIds, a download keeps a finite idle bound even when the
|
||||
// whole-sync watchdog is disabled (idleTimeoutMs = 0) — a page that
|
||||
// never EOSEs must not hang the sync forever.
|
||||
val pageIdleMs = if (config.idleTimeoutMs > 0) config.idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS
|
||||
client.fetchAllPages(relay, listOf(filter), pageIdleMs) { event -> events.trySend(event) }
|
||||
} finally {
|
||||
events.close()
|
||||
}
|
||||
|
||||
+74
-35
@@ -32,21 +32,27 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip45Count.HyperLogLog
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Sends a NIP-45 COUNT query to a single relay and suspends until
|
||||
* the result arrives or the timeout expires.
|
||||
*
|
||||
* A COUNT exchange is a single response message, so [idleTimeoutMs] here is
|
||||
* trivially the package-wide idle-window convention (time since the most
|
||||
* recent message): no message can arrive before the one that completes it.
|
||||
*
|
||||
* @param relay Target relay to query.
|
||||
* @param filter The filter to count against.
|
||||
* @param timeoutMs How long to wait for a response (default 15 s).
|
||||
* @param idleTimeoutMs How long to wait for the response (default 15 s).
|
||||
* @return The [CountResult], or `null` on timeout.
|
||||
*/
|
||||
suspend fun INostrClient.count(
|
||||
relay: NormalizedRelayUrl,
|
||||
filter: Filter,
|
||||
timeoutMs: Long = 15_000,
|
||||
idleTimeoutMs: Long = 15_000,
|
||||
): CountResult? {
|
||||
val subId = newSubId()
|
||||
val resultChannel = Channel<CountResult>(UNLIMITED)
|
||||
@@ -64,23 +70,22 @@ suspend fun INostrClient.count(
|
||||
}
|
||||
}
|
||||
|
||||
addConnectionListener(listener)
|
||||
return try {
|
||||
addConnectionListener(listener)
|
||||
|
||||
val result =
|
||||
try {
|
||||
count(subId = subId, filters = mapOf(relay to listOf(filter)))
|
||||
count(subId = subId, filters = mapOf(relay to listOf(filter)))
|
||||
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
resultChannel.receive()
|
||||
}
|
||||
} finally {
|
||||
unsubscribe(subId)
|
||||
removeConnectionListener(listener)
|
||||
withTimeoutOrNull(idleTimeoutMs) {
|
||||
resultChannel.receive()
|
||||
}
|
||||
|
||||
resultChannel.close()
|
||||
|
||||
return result
|
||||
} finally {
|
||||
// Every cleanup step belongs in the finally: closing the channel used to
|
||||
// sit after it, so a throw (or cancellation) mid-wait skipped it while the
|
||||
// sibling accessories all cleaned up fully.
|
||||
unsubscribe(subId)
|
||||
removeConnectionListener(listener)
|
||||
resultChannel.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,13 +93,22 @@ suspend fun INostrClient.count(
|
||||
* (one filter per relay) and suspends until all results arrive
|
||||
* or the timeout expires.
|
||||
*
|
||||
* [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a
|
||||
* wall-clock deadline for the whole batch — the package-wide accessory
|
||||
* convention: each *new* relay's COUNT result restarts it, so a large fan-out
|
||||
* where results keep trickling in is never cut short. A relay re-sending a result
|
||||
* it already gave is not progress and does not restart the window, which makes
|
||||
* the call self-bounding (at most one window per relay). A caller wanting a hard
|
||||
* wall-clock bound has `withTimeoutOrNull(ms) { count(...) }` — at the cost of
|
||||
* discarding the partial map, which is why this returns whatever arrived instead.
|
||||
*
|
||||
* @param filters Map of relay -> filter to count.
|
||||
* @param timeoutMs How long to wait for all responses (default 15 s).
|
||||
* @param idleTimeoutMs Idle window between new responses (default 15 s).
|
||||
* @return Map of relay -> [CountResult] for every relay that responded in time.
|
||||
*/
|
||||
suspend fun INostrClient.count(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 15_000,
|
||||
idleTimeoutMs: Long = 15_000,
|
||||
): Map<NormalizedRelayUrl, CountResult> {
|
||||
if (filters.isEmpty()) return emptyMap()
|
||||
|
||||
@@ -115,27 +129,52 @@ suspend fun INostrClient.count(
|
||||
}
|
||||
}
|
||||
|
||||
addConnectionListener(listener)
|
||||
|
||||
filters.forEach { (relay, filterList) ->
|
||||
val subId = newSubId()
|
||||
subIdToRelay[subId] = relay
|
||||
count(subId = subId, filters = mapOf(relay to filterList))
|
||||
}
|
||||
|
||||
val results = mutableMapOf<NormalizedRelayUrl, CountResult>()
|
||||
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
try {
|
||||
addConnectionListener(listener)
|
||||
|
||||
filters.forEach { (relay, filterList) ->
|
||||
val subId = newSubId()
|
||||
subIdToRelay[subId] = relay
|
||||
count(subId = subId, filters = mapOf(relay to filterList))
|
||||
}
|
||||
|
||||
// One idle window per new relay result. The inner loop absorbs repeats
|
||||
// (a relay answering twice) inside the SAME window, so only genuinely
|
||||
// new information pushes the deadline out — bounding the call at one
|
||||
// window per relay without needing a wall-clock ceiling.
|
||||
while (results.size < filters.size) {
|
||||
val (relay, result) = resultChannel.receive()
|
||||
val progressed =
|
||||
withTimeoutOrNull(idleTimeoutMs) {
|
||||
while (true) {
|
||||
// Cancellation (this window expiring, or the caller giving up)
|
||||
// only lands at a suspension point, and receive() does not
|
||||
// suspend while the channel has buffered results — so check
|
||||
// explicitly rather than draining a backlog uninterruptibly.
|
||||
coroutineContext.ensureActive()
|
||||
val (relay, result) = resultChannel.receive()
|
||||
// put() returns the previous value: null means this relay
|
||||
// had not answered yet, i.e. real progress.
|
||||
if (results.put(relay, result) == null) break
|
||||
}
|
||||
true
|
||||
}
|
||||
if (progressed == null) break
|
||||
}
|
||||
|
||||
// A result can land after the last window closed but before we unsubscribe;
|
||||
// it costs nothing to keep, and dropping it would understate the count.
|
||||
while (true) {
|
||||
val (relay, result) = resultChannel.tryReceive().getOrNull() ?: break
|
||||
results[relay] = result
|
||||
}
|
||||
} finally {
|
||||
subIdToRelay.keys.forEach { unsubscribe(it) }
|
||||
removeConnectionListener(listener)
|
||||
resultChannel.close()
|
||||
}
|
||||
|
||||
subIdToRelay.keys.forEach { unsubscribe(it) }
|
||||
removeConnectionListener(listener)
|
||||
resultChannel.close()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -152,20 +191,20 @@ suspend fun INostrClient.count(
|
||||
*
|
||||
* @param relays List of relays to query.
|
||||
* @param filter The filter to count against.
|
||||
* @param timeoutMs How long to wait for all responses (default 15 s).
|
||||
* @param idleTimeoutMs Idle window between responses (default 15 s) — see [count].
|
||||
* @return A merged [CountResult], or `null` if no relay responded.
|
||||
*/
|
||||
suspend fun INostrClient.countMerged(
|
||||
relays: List<NormalizedRelayUrl>,
|
||||
filter: Filter,
|
||||
timeoutMs: Long = 15_000,
|
||||
idleTimeoutMs: Long = 15_000,
|
||||
): CountResult? {
|
||||
if (relays.isEmpty()) return null
|
||||
|
||||
val results =
|
||||
count(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
)
|
||||
|
||||
if (results.isEmpty()) return null
|
||||
|
||||
+17
-17
@@ -31,47 +31,47 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
suspend fun INostrClient.fetchAll(
|
||||
relay: String,
|
||||
filter: Filter,
|
||||
timeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), timeoutMs)
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to listOf(filter)), idleTimeoutMs)
|
||||
|
||||
suspend fun INostrClient.fetchAll(
|
||||
relay: String,
|
||||
filters: List<Filter>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs)
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs)
|
||||
|
||||
suspend fun INostrClient.fetchAll(
|
||||
subscriptionId: String = newSubId(),
|
||||
relay: String,
|
||||
filters: List<Filter>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), timeoutMs)
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
) = fetchAll(subscriptionId, mapOf(RelayUrlNormalizer.normalize(relay) to filters), idleTimeoutMs)
|
||||
|
||||
suspend fun INostrClient.fetchAll(
|
||||
relay: NormalizedRelayUrl,
|
||||
filter: Filter,
|
||||
timeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), timeoutMs)
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(relay to listOf(filter)), idleTimeoutMs)
|
||||
|
||||
suspend fun INostrClient.fetchAll(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(relay to filters), timeoutMs)
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
) = fetchAll(newSubId(), mapOf(relay to filters), idleTimeoutMs)
|
||||
|
||||
suspend fun INostrClient.fetchAll(
|
||||
subscriptionId: String = newSubId(),
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
) = fetchAll(subscriptionId, mapOf(relay to filters), timeoutMs)
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
) = fetchAll(subscriptionId, mapOf(relay to filters), idleTimeoutMs)
|
||||
|
||||
/**
|
||||
* Subscribe [filters], collect every (deduped) event, and return once every
|
||||
* relay reached a terminal state (EOSE, CLOSED, or cannot-connect) or the
|
||||
* line went quiet for [timeoutMs].
|
||||
* line went quiet for [idleTimeoutMs].
|
||||
*
|
||||
* [timeoutMs] is an **idle window, not a hard cap**: every arriving event or
|
||||
* [idleTimeoutMs] is an **idle window, not a hard cap**: every arriving event or
|
||||
* terminal signal resets it, so a slow relay actively streaming a large
|
||||
* backlog is never cropped mid-delivery. The fetch only gives up after a full
|
||||
* window of silence — or at the [maxTotalMs] wall-clock ceiling (default 10x
|
||||
@@ -85,13 +85,13 @@ suspend fun INostrClient.fetchAll(
|
||||
suspend fun INostrClient.fetchAll(
|
||||
subscriptionId: String = newSubId(),
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
maxTotalMs: Long = timeoutMs * 10,
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
maxTotalMs: Long = idleTimeoutMs * 10,
|
||||
): List<Event> {
|
||||
val seenIds = mutableSetOf<HexKey>()
|
||||
return fetchAllWithHooks(
|
||||
filters = filters,
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
subscriptionId = subscriptionId,
|
||||
maxTotalMs = maxTotalMs,
|
||||
) { _, event -> seenIds.add(event.id) }
|
||||
|
||||
+80
-38
@@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
@@ -72,14 +71,28 @@ import kotlin.coroutines.coroutineContext
|
||||
*
|
||||
* @param relay The relay to query.
|
||||
* @param filters Filters to apply on every page (the `until` field is overwritten per page).
|
||||
* @param timeoutMs Maximum time to wait for a single page's EOSE before giving up.
|
||||
* @param idleTimeoutMs Idle window per page — like every accessory timeout, it is measured
|
||||
* from the relay's **most recent message**, not from the page's start: every arriving
|
||||
* event resets it, so a slow relay actively streaming a large page is never cropped
|
||||
* mid-delivery. A page only gives up after this much silence without an EOSE.
|
||||
*
|
||||
* Deliberately no wall-clock ceiling here, unlike [fetchAll]'s `maxTotalMs`. A ceiling
|
||||
* would bound one *page*, not this call: the loop below reacts to a page ending by
|
||||
* advancing the cursor and issuing the next REQ, so a relay trickling events forever
|
||||
* against an unbounded filter would just be re-paged forever — measurably so (see
|
||||
* NostrClientFetchAllPagesIdleTimeoutTest). Worse, cutting a page mid-stream advances
|
||||
* `until` to the oldest event received *so far*, which only preserves the set if the
|
||||
* relay streams strictly newest-first (NIP-01 recommends but does not require it) —
|
||||
* otherwise the not-yet-sent events above that cursor are skipped. What actually
|
||||
* bounds this walk is a [Filter.limit] (the documented way to cap a download) or
|
||||
* cancelling the caller, which the [ensureActive] at the top of each page honors.
|
||||
* @param onEvent Called once for every distinct event delivered, in page order.
|
||||
* @return Total number of distinct events delivered across all pages.
|
||||
*/
|
||||
suspend fun INostrClient.fetchAllPages(
|
||||
relay: NormalizedRelayUrl,
|
||||
filters: List<Filter>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
onNewPage: ((Long) -> Unit)? = null,
|
||||
onEvent: (Event) -> Unit,
|
||||
): Int {
|
||||
@@ -144,6 +157,11 @@ suspend fun INostrClient.fetchAllPages(
|
||||
|
||||
val doneChannel = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
// Idle watchdog for this page: every arriving event bumps it, so the page's
|
||||
// timeout measures silence since the relay's most recent message (the same
|
||||
// convention as fetchAll and the negentropy sync), never total page time.
|
||||
val clock = IdleClock()
|
||||
|
||||
// Captured for the listener: the boundary second we re-fetch this page.
|
||||
val boundary = until
|
||||
var received = 0
|
||||
@@ -160,39 +178,62 @@ suspend fun INostrClient.fetchAllPages(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
received++
|
||||
// Drop a boundary-second event we already delivered on an
|
||||
// earlier page (the inclusive re-fetch returns it again).
|
||||
if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return
|
||||
// The bump is in a finally so it runs for EVERY event —
|
||||
// including the duplicate that returns early below, which is
|
||||
// still a sign of life — and, being a volatile write, runs
|
||||
// AFTER the counters below. That ordering matters: these
|
||||
// counters are written on the relay's reader thread and read
|
||||
// by the driver coroutine once the wait ends. The EOSE path
|
||||
// gets its happens-before from the channel, but the idle
|
||||
// path has no such edge, so without the release write the
|
||||
// driver could read a stale `pageMinTs` (ending the walk
|
||||
// early) or an unsafely published `idsAtPageMin`.
|
||||
try {
|
||||
received++
|
||||
// Drop a boundary-second event we already delivered on an
|
||||
// earlier page (the inclusive re-fetch returns it again).
|
||||
if (boundary != null && event.createdAt == boundary && event.id in seenAtBoundary) return
|
||||
|
||||
// Count this event against every active filter it satisfies
|
||||
// (one event can match more than one). Only a non-search filter
|
||||
// may advance the `until` cursor: a search hit — possibly old,
|
||||
// relevance-ranked — must not drag the cursor back and make the
|
||||
// next page skip events a co-resident normal filter still needs.
|
||||
var atLeastOne = false
|
||||
var advancesCursor = false
|
||||
for ((index, filter) in activeFilters) {
|
||||
if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) {
|
||||
matchCountPerFilter[index]++
|
||||
atLeastOne = true
|
||||
if (filter.search == null) advancesCursor = true
|
||||
}
|
||||
}
|
||||
if (atLeastOne) {
|
||||
onEvent(event)
|
||||
delivered++
|
||||
// Track the oldest advancing second and the ids delivered
|
||||
// in it — that becomes the next boundary and its dedup set.
|
||||
if (advancesCursor) {
|
||||
if (event.createdAt < pageMinTs) {
|
||||
pageMinTs = event.createdAt
|
||||
idsAtPageMin.clear()
|
||||
idsAtPageMin.add(event.id)
|
||||
} else if (event.createdAt == pageMinTs) {
|
||||
idsAtPageMin.add(event.id)
|
||||
// Count this event against every active filter it satisfies
|
||||
// (one event can match more than one). Only a non-search filter
|
||||
// may advance the `until` cursor: a search hit — possibly old,
|
||||
// relevance-ranked — must not drag the cursor back and make the
|
||||
// next page skip events a co-resident normal filter still needs.
|
||||
var atLeastOne = false
|
||||
var advancesCursor = false
|
||||
// Indexed loop, not `for ((i, f) in activeFilters)`: this runs for
|
||||
// EVERY event on the relay's reader thread (millions in a bulk
|
||||
// download) and the destructuring form allocates an Iterator per
|
||||
// event. Same reason quartz uses the `fast*` operators elsewhere
|
||||
// in hot event paths — those only cover Array, so a List needs
|
||||
// the index form.
|
||||
for (i in activeFilters.indices) {
|
||||
val active = activeFilters[i]
|
||||
val index = active.index
|
||||
val filter = active.value
|
||||
if (matchCountPerFilter[index] < (filter.limit ?: Int.MAX_VALUE) && filter.match(event)) {
|
||||
matchCountPerFilter[index]++
|
||||
atLeastOne = true
|
||||
if (filter.search == null) advancesCursor = true
|
||||
}
|
||||
}
|
||||
if (atLeastOne) {
|
||||
onEvent(event)
|
||||
delivered++
|
||||
// Track the oldest advancing second and the ids delivered
|
||||
// in it — that becomes the next boundary and its dedup set.
|
||||
if (advancesCursor) {
|
||||
if (event.createdAt < pageMinTs) {
|
||||
pageMinTs = event.createdAt
|
||||
idsAtPageMin.clear()
|
||||
idsAtPageMin.add(event.id)
|
||||
} else if (event.createdAt == pageMinTs) {
|
||||
idsAtPageMin.add(event.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clock.bump()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,9 +263,10 @@ suspend fun INostrClient.fetchAllPages(
|
||||
|
||||
subscribe(subId, mapOf(relay to activeFilters.map { it.value }), listener)
|
||||
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
doneChannel.receive()
|
||||
}
|
||||
// Wait for the page's terminal signal (EOSE / CLOSED / cannot-connect),
|
||||
// giving up only after [idleTimeoutMs] of silence — the wait resets on every
|
||||
// arriving event, so an actively streaming page is never cut mid-delivery.
|
||||
doneChannel.receiveWithinIdle(clock, idleTimeoutMs)
|
||||
|
||||
unsubscribe(subId)
|
||||
doneChannel.close()
|
||||
@@ -276,14 +318,14 @@ suspend fun INostrClient.fetchAllPages(
|
||||
suspend fun INostrClient.fetchAllPages(
|
||||
relay: String,
|
||||
filters: List<Filter>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
onNewPage: ((Long) -> Unit)? = null,
|
||||
onEvent: (Event) -> Unit,
|
||||
): Int =
|
||||
fetchAllPages(
|
||||
relay = RelayUrlNormalizer.normalize(relay),
|
||||
filters = filters,
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
onNewPage = onNewPage,
|
||||
onEvent = onEvent,
|
||||
)
|
||||
|
||||
+6
-3
@@ -50,7 +50,10 @@ import kotlinx.coroutines.sync.Semaphore
|
||||
* @param filters per-relay filter lists; the key set is the relays queried, in
|
||||
* iteration order (pass a [LinkedHashMap]/`associateWith` result to control it).
|
||||
* A `search` filter is fetched as a single relevance page — see [fetchAllPages].
|
||||
* @param timeoutMs per-page EOSE timeout handed to each relay's [fetchAllPages].
|
||||
* @param idleTimeoutMs per-page idle window handed to each relay's [fetchAllPages] —
|
||||
* measured from that relay's most recent message (every event resets it), not
|
||||
* from the page's start. As in [fetchAllPages] there is no wall-clock ceiling;
|
||||
* bound a relay's walk with a [Filter.limit], or cancel the caller.
|
||||
* @param maxConcurrentRelays upper bound on relays paginating at once (≥ 1).
|
||||
* @param onNewPage optional `(until, relay)` tick before each non-first page.
|
||||
* @param onRelayStart optional hook fired as each relay's download begins.
|
||||
@@ -60,7 +63,7 @@ import kotlinx.coroutines.sync.Semaphore
|
||||
*/
|
||||
suspend fun INostrClient.fetchAllPagesFromPool(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
maxConcurrentRelays: Int = 8,
|
||||
onNewPage: ((until: Long, relay: NormalizedRelayUrl) -> Unit)? = null,
|
||||
onRelayStart: ((relay: NormalizedRelayUrl) -> Unit)? = null,
|
||||
@@ -80,7 +83,7 @@ suspend fun INostrClient.fetchAllPagesFromPool(
|
||||
fetchAllPages(
|
||||
relay = relay,
|
||||
filters = filtersForRelay,
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
onNewPage = onNewPage?.let { cb -> { until -> cb(until, relay) } },
|
||||
) { event -> onEvent(event, relay) }
|
||||
onRelayComplete?.invoke(relay, total)
|
||||
|
||||
+14
-12
@@ -41,13 +41,13 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* funnel every arriving event through the suspending [onEvent] hook (verify /
|
||||
* persist / filter — return `true` to keep it in the result), and return the
|
||||
* accepted `(relay, event)` pairs once every relay reached a terminal state
|
||||
* (EOSE, CLOSED, or cannot-connect) or the line went quiet for [timeoutMs].
|
||||
* (EOSE, CLOSED, or cannot-connect) or the line went quiet for [idleTimeoutMs].
|
||||
*
|
||||
* [timeoutMs] is an **idle window, not a hard cap**: the clock only runs while
|
||||
* [idleTimeoutMs] is an **idle window, not a hard cap**: the clock only runs while
|
||||
* the relays are silent, and every arriving event or terminal signal resets
|
||||
* it. A slow relay actively streaming a large backlog is therefore never
|
||||
* cropped mid-delivery — the fetch ends when the work is done or when nothing
|
||||
* has arrived for [timeoutMs] (a stall). The terminal conditions (EOSE /
|
||||
* has arrived for [idleTimeoutMs] (a stall). The terminal conditions (EOSE /
|
||||
* CLOSED / cannot-connect per relay) are what bound the fetch; the timeout's
|
||||
* only job is detecting relays that will never reach one. [maxTotalMs]
|
||||
* (default 10x the idle window) is the wall-clock ceiling that keeps a
|
||||
@@ -62,20 +62,20 @@ import kotlinx.coroutines.withTimeoutOrNull
|
||||
* as a hard failure via [classifyDrainFailure] (connect refused / DNS / TLS /
|
||||
* dead HTTP upgrade — NOT slow relays or 429s) is recorded, so callers can
|
||||
* prune proven-dead relays from future routing instead of paying the full
|
||||
* [timeoutMs] on them again.
|
||||
* [idleTimeoutMs] on them again.
|
||||
* - **[pendingOnAuthRequired]** — a relay that refuses the REQ with an
|
||||
* `auth-required:` CLOSED is kept pending rather than treated as terminal:
|
||||
* the caller's NIP-42 responder answers the challenge and the client re-fires
|
||||
* this same subscription, so the post-auth events are collected instead of
|
||||
* returning empty. If auth never satisfies it, the relay simply falls through
|
||||
* to the [timeoutMs].
|
||||
* to the [idleTimeoutMs].
|
||||
* - **[onTimeout]** — diagnostic hook fired when the idle window elapsed with
|
||||
* relays still pending: receives the stalled set, the terminal reasons seen so
|
||||
* far (`"eose"` / `"closed:<msg>"` / `"cannot:<msg>"`), and what was collected.
|
||||
*/
|
||||
suspend fun INostrClient.fetchAllWithHooks(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 8_000L,
|
||||
idleTimeoutMs: Long = 8_000L,
|
||||
subscriptionId: String = newSubId(),
|
||||
pendingOnAuthRequired: Boolean = false,
|
||||
deadOut: MutableMap<NormalizedRelayUrl, DrainFailure>? = null,
|
||||
@@ -86,9 +86,11 @@ suspend fun INostrClient.fetchAllWithHooks(
|
||||
* adversarial or misbehaving relay could pin the caller forever. The cap
|
||||
* restores an upper bound while staying far above the idle window, so a
|
||||
* legitimately streaming relay still finishes its backlog. Pass
|
||||
* [Long.MAX_VALUE] for a deliberately uncapped drain.
|
||||
* [Long.MAX_VALUE] for a deliberately uncapped drain; a non-positive value
|
||||
* also uncaps (absorbing an `idleTimeoutMs * 10` overflow from an
|
||||
* effectively-infinite idle window).
|
||||
*/
|
||||
maxTotalMs: Long = timeoutMs * 10,
|
||||
maxTotalMs: Long = idleTimeoutMs * 10,
|
||||
onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
@@ -144,7 +146,7 @@ suspend fun INostrClient.fetchAllWithHooks(
|
||||
coroutineScope {
|
||||
subscribe(subscriptionId, filters, listener)
|
||||
val watchdog =
|
||||
if (maxTotalMs == Long.MAX_VALUE) {
|
||||
if (maxTotalMs <= 0 || maxTotalMs == Long.MAX_VALUE) {
|
||||
null
|
||||
} else {
|
||||
launch {
|
||||
@@ -188,7 +190,7 @@ suspend fun INostrClient.fetchAllWithHooks(
|
||||
// Slow path: both dry — arm one idle wait for the next signal.
|
||||
if (pending == null) {
|
||||
val progressed =
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
withTimeoutOrNull(idleTimeoutMs) {
|
||||
select<Unit> {
|
||||
eventChannel.onReceive { pending = it }
|
||||
doneChannel.onReceive { (relay, reason) ->
|
||||
@@ -254,7 +256,7 @@ suspend fun INostrClient.fetchAllWithHooks(
|
||||
*/
|
||||
suspend fun INostrClient.fetchAllPagesFromPoolWithHooks(
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
maxConcurrentRelays: Int = 8,
|
||||
onEvent: suspend (relay: NormalizedRelayUrl, event: Event) -> Boolean,
|
||||
): List<Pair<NormalizedRelayUrl, Event>> {
|
||||
@@ -285,7 +287,7 @@ suspend fun INostrClient.fetchAllPagesFromPoolWithHooks(
|
||||
try {
|
||||
fetchAllPagesFromPool(
|
||||
filters = filters,
|
||||
timeoutMs = timeoutMs,
|
||||
idleTimeoutMs = idleTimeoutMs,
|
||||
maxConcurrentRelays = maxConcurrentRelays,
|
||||
) { event, relay -> eventChannel.trySend(relay to event) }
|
||||
} finally {
|
||||
|
||||
+68
-21
@@ -29,8 +29,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
suspend fun INostrClient.fetchFirst(
|
||||
relay: String,
|
||||
@@ -64,10 +66,29 @@ suspend fun INostrClient.fetchFirst(
|
||||
filters: List<Filter>,
|
||||
) = fetchFirst(subscriptionId, mapOf(relay to filters))
|
||||
|
||||
/**
|
||||
* Subscribe [filters], return the first event any relay delivers (or `null` when
|
||||
* every relay reached a terminal state — EOSE, CLOSED, or cannot-connect — with
|
||||
* nothing matching, or the line went quiet).
|
||||
*
|
||||
* [idleTimeoutMs] is an **idle window measured from the most recent progress**, not a
|
||||
* wall-clock deadline — the package-wide accessory convention. Progress means a
|
||||
* signal that actually advances the fetch: an event, or the first terminal state
|
||||
* from a relay still being waited on. Repeat chatter from a relay already
|
||||
* accounted for (a CLOSED/reconnect loop) is *not* progress and does not restart
|
||||
* the window — the same rule the negentropy watchdog applies to NOTICE/CLOSED
|
||||
* error chatter, and what keeps a flapping relay from holding this open forever.
|
||||
*
|
||||
* That makes the call self-bounding: at most one progress signal per relay, each
|
||||
* granting a fresh window. There is deliberately no ceiling parameter — a caller
|
||||
* who wants a hard wall-clock bound already has one in
|
||||
* `withTimeoutOrNull(ms) { fetchFirst(...) }`, which costs nothing here since a
|
||||
* timed-out fetch returns `null` either way.
|
||||
*/
|
||||
suspend fun INostrClient.fetchFirst(
|
||||
subscriptionId: String = newSubId(),
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
timeoutMs: Long = 30_000L,
|
||||
idleTimeoutMs: Long = 30_000L,
|
||||
): Event? {
|
||||
val eventChannel = Channel<Event>(UNLIMITED)
|
||||
val doneChannel = Channel<NormalizedRelayUrl>(UNLIMITED)
|
||||
@@ -112,29 +133,55 @@ suspend fun INostrClient.fetchFirst(
|
||||
try {
|
||||
subscribe(subscriptionId, filters, listener)
|
||||
|
||||
withTimeoutOrNull(timeoutMs) {
|
||||
while (remaining.isNotEmpty()) {
|
||||
select {
|
||||
eventChannel.onReceive { event ->
|
||||
result = event
|
||||
remaining.clear()
|
||||
}
|
||||
doneChannel.onReceive { relay ->
|
||||
// A relay sends its matching events before its EOSE, so an event may
|
||||
// already be buffered when this completion fires. select() picks a ready
|
||||
// clause at random, so without this drain we could treat the relay as done
|
||||
// and exit while its event still sits unread in the channel.
|
||||
val buffered = eventChannel.tryReceive().getOrNull()
|
||||
if (buffered != null) {
|
||||
result = buffered
|
||||
remaining.clear()
|
||||
} else {
|
||||
remaining.remove(relay)
|
||||
}
|
||||
// One idle window per unit of progress. The inner loop keeps consuming
|
||||
// non-progress signals INSIDE the same window, so repeat chatter from an
|
||||
// already-accounted-for relay cannot push the deadline out; only a real
|
||||
// advance escapes to the outer loop and earns a fresh window.
|
||||
while (remaining.isNotEmpty()) {
|
||||
val progressed =
|
||||
withTimeoutOrNull(idleTimeoutMs) {
|
||||
while (true) {
|
||||
// Cancellation (this window expiring, or the caller giving up)
|
||||
// only lands at a suspension point, and select() completes
|
||||
// without suspending while either channel has something
|
||||
// buffered — so check explicitly rather than draining a
|
||||
// backlog of chatter uninterruptibly.
|
||||
coroutineContext.ensureActive()
|
||||
val advanced =
|
||||
select<Boolean> {
|
||||
eventChannel.onReceive { event ->
|
||||
result = event
|
||||
remaining.clear()
|
||||
true
|
||||
}
|
||||
doneChannel.onReceive { relay ->
|
||||
// A relay sends its matching events before its EOSE, so an event may
|
||||
// already be buffered when this completion fires. select() picks a ready
|
||||
// clause at random, so without this drain we could treat the relay as done
|
||||
// and exit while its event still sits unread in the channel.
|
||||
val buffered = eventChannel.tryReceive().getOrNull()
|
||||
if (buffered != null) {
|
||||
result = buffered
|
||||
remaining.clear()
|
||||
true
|
||||
} else {
|
||||
// Only the FIRST terminal signal from a relay we are still
|
||||
// waiting on advances the fetch; a repeat is chatter.
|
||||
remaining.remove(relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (advanced) break
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
if (progressed == null) break
|
||||
}
|
||||
|
||||
// An event can land after the last terminal signal but before we
|
||||
// unsubscribe; without this drain it would be dropped and the fetch
|
||||
// would report "nothing found" while holding a match.
|
||||
if (result == null) result = eventChannel.tryReceive().getOrNull()
|
||||
} finally {
|
||||
unsubscribe(subscriptionId)
|
||||
eventChannel.close()
|
||||
|
||||
+1
-51
@@ -47,14 +47,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.concurrent.atomics.AtomicInt
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.concurrent.atomics.decrementAndFetch
|
||||
import kotlin.concurrent.atomics.incrementAndFetch
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.math.min
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* Outcome of a successful [negentropySync] run.
|
||||
@@ -1180,52 +1178,4 @@ internal val KEEP_ALIVE_ID = "f".repeat(64)
|
||||
* still honor "run until the socket drops".
|
||||
*/
|
||||
private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
|
||||
private const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Monotonic "last activity" marker for the idle watchdog. [bump] on every sign of
|
||||
* life from the relay; [elapsedMs] reports the silence since the last bump.
|
||||
*
|
||||
* [bump] is on the per-event hot path (the connection listener bumps for every
|
||||
* message the relay sends — millions during a large download), so it must not
|
||||
* allocate: a single [start] mark is taken once (unboxed field) and each bump only
|
||||
* writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads
|
||||
* write, the driver coroutine reads — visibility is all we need, so a plain volatile
|
||||
* Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event.
|
||||
*/
|
||||
private class IdleClock {
|
||||
private val start = TimeSource.Monotonic.markNow()
|
||||
|
||||
@Volatile
|
||||
private var lastNanos = 0L
|
||||
|
||||
fun bump() {
|
||||
lastNanos = start.elapsedNow().inWholeNanoseconds
|
||||
}
|
||||
|
||||
fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives the next item, giving up (returning `null`) only after [idleMs] elapse with
|
||||
* no activity on [clock]. Because [clock] is bumped by *any* relay message — not just
|
||||
* items on this channel — unrelated progress (e.g. download events arriving during a
|
||||
* reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the
|
||||
* watchdog: it waits until an item arrives (a disconnect is delivered as an item, so
|
||||
* a dead socket still unblocks it).
|
||||
*/
|
||||
private suspend fun <T> Channel<T>.receiveWithinIdle(
|
||||
clock: IdleClock,
|
||||
idleMs: Long,
|
||||
): T? {
|
||||
if (idleMs <= 0) return receive()
|
||||
while (true) {
|
||||
val remaining = idleMs - clock.elapsedMs()
|
||||
if (remaining <= 0) return null
|
||||
val item = withTimeoutOrNull(remaining) { receive() }
|
||||
if (item != null) return item
|
||||
// Timed out with nothing on this channel. If other activity bumped the clock
|
||||
// meanwhile, the next `remaining` is positive and we wait again; otherwise it
|
||||
// is <= 0 on the next iteration and we give up.
|
||||
}
|
||||
}
|
||||
internal const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L
|
||||
|
||||
+47
-5
@@ -12,14 +12,56 @@ count, negentropy sync/reconcile) already exists.
|
||||
Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.<name>` (or
|
||||
`...client.reqs.<name>` for the flow/subscribe helpers).
|
||||
|
||||
## Timeout convention
|
||||
|
||||
Every wait in this package is an **idle window measured from the relay's most recent
|
||||
progress**, never a wall-clock deadline: real progress resets it, so an actively
|
||||
streaming relay is never cut off mid-delivery — the operation only gives up after a
|
||||
full window of silence. The shared primitives are in `IdleWatchdog.kt` (`IdleClock` +
|
||||
`receiveWithinIdle`); use them in a new accessory.
|
||||
|
||||
**The parameter is named `idleTimeoutMs`, never `timeoutMs`** — the name is the
|
||||
contract, so a caller can't mistake it for a deadline. The sole exception is
|
||||
`publishAndConfirm`'s `timeoutInSeconds`, which genuinely *is* a fixed window (see
|
||||
below); the differing name is the tell.
|
||||
|
||||
**Progress, not merely traffic.** A message that tells us nothing new — a relay
|
||||
re-CLOSEing after we already recorded it as done, a duplicate COUNT — must not
|
||||
restart the window, or a flapping relay keeps the call alive indefinitely. This is
|
||||
the rule the negentropy watchdog already applies to `NOTICE`/`CLOSED` chatter, and
|
||||
it is what makes `fetchFirst` and multi-relay `count` self-bounding: at most one
|
||||
window per relay.
|
||||
|
||||
**No accessory takes a wall-clock ceiling parameter.** A hard bound composes at the
|
||||
call site — `withTimeoutOrNull(ms) { fetchFirst(...) }` — so duplicating it in every
|
||||
signature buys nothing. Prefer the idle window inside (the caller cannot implement
|
||||
it; it needs the message stream) and the wall clock outside. Two consequences worth
|
||||
knowing:
|
||||
|
||||
- `fetchAllPages` has no ceiling and could not usefully have one. A per-page cap
|
||||
bounds a *page*, not the call: the loop reacts to a page ending by advancing the
|
||||
cursor and issuing the next `REQ`, so an endless trickle is just re-paged (a
|
||||
400 ms cap measured 8 `REQ`s and no return). It also makes truncation unsafe —
|
||||
cutting a page mid-stream advances `until` to the oldest event received *so far*,
|
||||
which only preserves the set if the relay streams strictly newest-first, which
|
||||
NIP-01 recommends but does not require. Bound a paged download with the filter's
|
||||
`limit`, or by cancelling.
|
||||
- `fetchAll` / `fetchAllWithHooks` keep a pre-existing `maxTotalMs`, and it earns
|
||||
its place: an endless *event* trickle there is genuine progress, so the call never
|
||||
self-terminates, and the internal cap returns the events collected so far where an
|
||||
external `withTimeoutOrNull` would discard them.
|
||||
|
||||
The write side is its own case: `publishAndConfirm`'s `timeoutInSeconds` is a fixed
|
||||
window to collect the `OK`s — a bounded confirmation round-trip, not a stream.
|
||||
|
||||
## One-shot reads (subscribe → collect → return)
|
||||
|
||||
| Function | File | Use when |
|
||||
| --- | --- | --- |
|
||||
| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or timeout. **No verify, no store** — just the events. |
|
||||
| `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). |
|
||||
| `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. |
|
||||
| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once, deduped across them. |
|
||||
| `fetchAll(relay, filter, idleTimeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or a full idle window of silence. **No verify, no store** — just the events. |
|
||||
| `fetchFirst(relay, filter, idleTimeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). |
|
||||
| `fetchAllPages(relay, filters, idleTimeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. |
|
||||
| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once. No cross-relay dedup — the `WithHooks` variant below dedups. |
|
||||
| `fetchAllWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAll` with a suspending per-`(relay, event)` accept hook (verify+store as events arrive), per-relay terminal-reason tracking, optional dead-relay collection (`deadOut` + `classifyDrainFailure`), keep-pending-on-`auth-required` CLOSED (NIP-42 re-fire), and a timeout diagnostic hook. |
|
||||
| `fetchAllPagesFromPoolWithHooks(filters, ...)` | `NostrClientFetchAllWithHooksExt` | `fetchAllPagesFromPool` with the same suspending accept hook, run single-threaded in one consumer; deduped across relays by `SeenIds` before the hook. |
|
||||
|
||||
@@ -42,7 +84,7 @@ Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.<name>` (
|
||||
|
||||
| Function | File | Use when |
|
||||
| --- | --- | --- |
|
||||
| `count(relay, filter, timeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). |
|
||||
| `count(relay, filter, idleTimeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). |
|
||||
| `countMerged(relays, filter, ...)` | `NostrClientCountExt` | Merged count across relays. |
|
||||
|
||||
## Negentropy (NIP-77)
|
||||
|
||||
+27
-1
@@ -72,9 +72,13 @@ class LimitsPolicy(
|
||||
return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else ReqCmd(cmd.subId, clamped))
|
||||
}
|
||||
|
||||
/**
|
||||
* A COUNT is clamped by [RelayLimits.maxLimit] but NEVER given
|
||||
* [RelayLimits.defaultLimit] — see [capLimits].
|
||||
*/
|
||||
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> {
|
||||
subscriptionRejection(cmd.queryId, cmd.filters)?.let { return PolicyResult.Rejected(it) }
|
||||
val clamped = clampLimits(cmd.filters)
|
||||
val clamped = capLimits(cmd.filters)
|
||||
return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else CountCmd(cmd.queryId, clamped))
|
||||
}
|
||||
|
||||
@@ -106,6 +110,28 @@ class LimitsPolicy(
|
||||
return filters.map { it.copy(limit = targetLimit(it.limit)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap what a COUNT asks for, without inventing a page size for it.
|
||||
*
|
||||
* `defaultLimit` answers "how many events should a REQ return when the
|
||||
* client names no limit". A COUNT returns no events, so that question has
|
||||
* no meaning for it — and applying the answer anyway turns every unbounded
|
||||
* COUNT into `min(matches, defaultLimit)`.
|
||||
*
|
||||
* Silently: a relay holding 12,289,614 profiles replied `{"count":500}`,
|
||||
* which is a plausible-looking number, so a client cannot tell it from the
|
||||
* truth. The kinds that happened to fall under the default were correct,
|
||||
* which is what made it survive.
|
||||
*
|
||||
* `maxLimit` still applies, because a client that explicitly asks to count
|
||||
* at most N is asking a question this relay may bound.
|
||||
*/
|
||||
private fun capLimits(filters: List<Filter>): List<Filter> {
|
||||
val max = limits.maxLimit ?: return filters
|
||||
if (filters.none { it.limit != null && it.limit!! > max }) return filters
|
||||
return filters.map { if (it.limit != null && it.limit!! > max) it.copy(limit = max) else it }
|
||||
}
|
||||
|
||||
private fun targetLimit(current: Int?): Int? =
|
||||
when {
|
||||
current != null && limits.maxLimit != null && current > limits.maxLimit -> limits.maxLimit
|
||||
|
||||
+5
-5
@@ -89,7 +89,7 @@ class FetchAllIdleTimeoutTest {
|
||||
val collected =
|
||||
client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
|
||||
timeoutMs = 300,
|
||||
idleTimeoutMs = 300,
|
||||
) { _, _ -> true }
|
||||
feeder.join()
|
||||
assertEquals(10, collected.size, "an actively streaming relay must never be cropped")
|
||||
@@ -111,7 +111,7 @@ class FetchAllIdleTimeoutTest {
|
||||
val collected =
|
||||
client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
|
||||
timeoutMs = 300,
|
||||
idleTimeoutMs = 300,
|
||||
onTimeout = { stalled, _, _ -> stalledRelays = stalled },
|
||||
) { _, _ -> true }
|
||||
assertEquals(2, collected.size, "events before the stall are kept")
|
||||
@@ -136,7 +136,7 @@ class FetchAllIdleTimeoutTest {
|
||||
val events =
|
||||
client.fetchAll(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
|
||||
timeoutMs = 300,
|
||||
idleTimeoutMs = 300,
|
||||
)
|
||||
feeder.join()
|
||||
assertEquals(10, events.size, "fetchAll shares the idle-window semantics")
|
||||
@@ -162,7 +162,7 @@ class FetchAllIdleTimeoutTest {
|
||||
val collected =
|
||||
client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
|
||||
timeoutMs = 300,
|
||||
idleTimeoutMs = 300,
|
||||
maxTotalMs = 1_000,
|
||||
onTimeout = { stalled, _, _ -> stalledRelays = stalled },
|
||||
) { _, _ -> true }
|
||||
@@ -186,7 +186,7 @@ class FetchAllIdleTimeoutTest {
|
||||
val collected =
|
||||
client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(1)))),
|
||||
timeoutMs = 300,
|
||||
idleTimeoutMs = 300,
|
||||
) { _, _ -> true }
|
||||
assertEquals(1, collected.size)
|
||||
assertTrue(currentTime - start < 300, "a terminal EOSE must not wait out the window")
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.client.accessories
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.currentTime
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Pins [fetchFirst]'s timeout to the package-wide convention: `idleTimeoutMs` is
|
||||
* silence measured from the most recent *progress*, not an absolute deadline
|
||||
* across the whole multi-relay wait. Repeat chatter from a relay already
|
||||
* accounted for is not progress, which is what makes the call self-bounding
|
||||
* without a ceiling parameter — a hard bound is the caller's `withTimeoutOrNull`.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class FetchFirstIdleTimeoutTest {
|
||||
/** Captures the subscription listener so the test can play the relays. */
|
||||
private class ScriptedClient : INostrClient by EmptyNostrClient() {
|
||||
var listener: SubscriptionListener? = null
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
this.listener = listener
|
||||
}
|
||||
}
|
||||
|
||||
private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com")
|
||||
private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com")
|
||||
private val relayC = RelayUrlNormalizer.normalize("wss://c.example.com")
|
||||
private val relayD = RelayUrlNormalizer.normalize("wss://d.example.com")
|
||||
|
||||
private fun event(i: Int) =
|
||||
Event(
|
||||
id = i.toString(16).padStart(64, '0'),
|
||||
pubKey = "f".repeat(64),
|
||||
createdAt = i.toLong(),
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "e$i",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
private fun filters(vararg relays: NormalizedRelayUrl) = relays.associateWith { listOf(Filter(kinds = listOf(1))) }
|
||||
|
||||
@Test
|
||||
fun genuineProgressRestartsTheIdleWindow() =
|
||||
runTest {
|
||||
val client = ScriptedClient()
|
||||
launch {
|
||||
// Each relay's FIRST terminal signal is real progress and buys a
|
||||
// fresh window, carrying the fetch well past a single 300ms window
|
||||
// so the slow relay's event at 900ms still lands. An absolute
|
||||
// deadline would have returned null at 300ms.
|
||||
delay(250)
|
||||
client.listener!!.onClosed("rate limited", relayA, null)
|
||||
delay(250)
|
||||
client.listener!!.onClosed("rate limited", relayB, null)
|
||||
delay(250)
|
||||
client.listener!!.onClosed("rate limited", relayC, null)
|
||||
delay(150)
|
||||
client.listener!!.onEvent(event(1), false, relayD, null)
|
||||
}
|
||||
val result =
|
||||
client.fetchFirst(
|
||||
filters = filters(relayA, relayB, relayC, relayD),
|
||||
idleTimeoutMs = 300,
|
||||
)
|
||||
assertEquals(event(1).id, result?.id, "progress must restart the window; the slow relay's event still lands")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun totalSilenceReturnsNullAfterOneIdleWindow() =
|
||||
runTest {
|
||||
val client = ScriptedClient()
|
||||
val start = currentTime
|
||||
val result =
|
||||
client.fetchFirst(
|
||||
filters = filters(relayA),
|
||||
idleTimeoutMs = 300,
|
||||
)
|
||||
assertNull(result)
|
||||
assertEquals(300L, currentTime - start, "a silent relay costs exactly one idle window")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatTerminalChatterDoesNotRestartTheIdleWindow() =
|
||||
runTest {
|
||||
val client = ScriptedClient()
|
||||
val chatter =
|
||||
launch {
|
||||
// relayA re-CLOSEs forever (a reconnect loop); relayB never
|
||||
// answers. Only relayA's FIRST CLOSED is progress — it removes
|
||||
// relayA from `remaining`. The repeats say nothing new, so they
|
||||
// must not push the deadline out (the rule the negentropy
|
||||
// watchdog already uses for NOTICE/CLOSED chatter).
|
||||
while (true) {
|
||||
delay(200)
|
||||
client.listener!!.onClosed("auth-required: again", relayA, null)
|
||||
}
|
||||
}
|
||||
val start = currentTime
|
||||
val result =
|
||||
client.fetchFirst(
|
||||
filters = filters(relayA, relayB),
|
||||
idleTimeoutMs = 300,
|
||||
)
|
||||
chatter.cancel()
|
||||
assertNull(result)
|
||||
// First CLOSED at 200ms is the only progress; the window then expires
|
||||
// 300ms later despite chatter at 400/600/800…
|
||||
assertEquals(500L, currentTime - start, "repeat chatter must not keep the wait alive")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEventArrivingAfterTheLastTerminalSignalIsStillReturned() =
|
||||
runTest {
|
||||
val client = ScriptedClient()
|
||||
launch {
|
||||
delay(100)
|
||||
// The only relay EOSEs, emptying `remaining` and ending the loop —
|
||||
// then its matching event lands before we unsubscribe. Without the
|
||||
// post-loop drain this returns null while holding a match.
|
||||
client.listener!!.onEose(relayA, null)
|
||||
client.listener!!.onEvent(event(7), false, relayA, null)
|
||||
}
|
||||
val result =
|
||||
client.fetchFirst(
|
||||
filters = filters(relayA),
|
||||
idleTimeoutMs = 300,
|
||||
)
|
||||
assertEquals(event(7).id, result?.id, "an event racing the final EOSE must not be dropped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aHardWallClockBoundIsTheCallersToApply() =
|
||||
runTest {
|
||||
val client = ScriptedClient()
|
||||
val chatter =
|
||||
launch {
|
||||
while (true) {
|
||||
delay(50)
|
||||
client.listener!!.onClosed("flapping", relayA, null)
|
||||
}
|
||||
}
|
||||
// No ceiling parameter: composing withTimeoutOrNull at the call site
|
||||
// is the wall-clock bound, and costs nothing because a timed-out
|
||||
// fetchFirst yields null either way.
|
||||
val start = currentTime
|
||||
val result = withTimeoutOrNull(120) { client.fetchFirst(filters = filters(relayA, relayB), idleTimeoutMs = 10_000) }
|
||||
chatter.cancel()
|
||||
assertNull(result)
|
||||
assertEquals(120L, currentTime - start, "the caller's timeout bounds the call")
|
||||
}
|
||||
}
|
||||
+34
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RelayLimitsTest {
|
||||
@@ -157,6 +158,39 @@ class RelayLimitsTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countNeverGetsTheDefaultLimit() {
|
||||
// A COUNT returns no events, so "how many should a REQ return by
|
||||
// default" is not a question it asked. Applying the answer anyway turns
|
||||
// every unbounded COUNT into min(matches, defaultLimit) — a relay
|
||||
// holding 12,289,614 profiles replied {"count":500}, which is plausible
|
||||
// enough that no client can tell it from the truth.
|
||||
val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100))
|
||||
|
||||
val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1))))) as PolicyResult.Accepted
|
||||
|
||||
assertNull(
|
||||
result.cmd.filters
|
||||
.single()
|
||||
.limit,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun countStillHonoursAnExplicitMaxLimit() {
|
||||
// Asking to count at most N is a question the relay may bound.
|
||||
val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100))
|
||||
|
||||
val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1), limit = 900)))) as PolicyResult.Accepted
|
||||
|
||||
assertEquals(
|
||||
100,
|
||||
result.cmd.filters
|
||||
.single()
|
||||
.limit,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesAcceptableRequestUnchanged() {
|
||||
val policy = LimitsPolicy(RelayLimits(maxLimit = 100))
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.relay
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* Pins [fetchAllPages]'s timeout to the package-wide idle-window convention:
|
||||
* `idleTimeoutMs` is silence measured from the relay's MOST RECENT message, not a
|
||||
* wall-clock deadline for the page. A relay that keeps streaming — however
|
||||
* slowly — must never have a page cropped mid-delivery.
|
||||
*
|
||||
* Real-clock ([runBlocking]) on purpose: the page watchdog is a monotonic
|
||||
* `IdleClock` bumped from the socket reader thread, which virtual time can't
|
||||
* exercise.
|
||||
*/
|
||||
class NostrClientFetchAllPagesIdleTimeoutTest {
|
||||
/** Captures the subscription listener so the test can play a relay. */
|
||||
private class ScriptedClient : INostrClient by EmptyNostrClient() {
|
||||
@Volatile
|
||||
var listener: SubscriptionListener? = null
|
||||
|
||||
@Volatile
|
||||
var subscribeCount = 0
|
||||
|
||||
override fun subscribe(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
) {
|
||||
subscribeCount++
|
||||
this.listener = listener
|
||||
}
|
||||
}
|
||||
|
||||
private val relay = RelayUrlNormalizer.normalize("wss://slow.example.com")
|
||||
|
||||
private fun event(
|
||||
i: Int,
|
||||
createdAt: Long = i.toLong(),
|
||||
) = Event(
|
||||
id = i.toString(16).padStart(64, '0'),
|
||||
pubKey = "f".repeat(64),
|
||||
createdAt = createdAt,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "e$i",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun streamingPageOutlivesTheIdleWindow() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val feeder =
|
||||
launch {
|
||||
// 6 events, each arriving 150ms apart — every gap is under the
|
||||
// 500ms idle window, but the whole page (~950ms) far exceeds it.
|
||||
// The old hard per-page deadline truncated this mid-stream and
|
||||
// re-subscribed for another page; the idle window must not.
|
||||
repeat(6) { i ->
|
||||
delay(150)
|
||||
client.listener!!.onEvent(event(i + 1), false, relay, null)
|
||||
}
|
||||
delay(50)
|
||||
client.listener!!.onEose(relay, null)
|
||||
}
|
||||
|
||||
var pages = 0
|
||||
val got = mutableListOf<Event>()
|
||||
val total =
|
||||
client.fetchAllPages(
|
||||
relay = relay,
|
||||
filters = listOf(Filter(kinds = listOf(1), limit = 6)),
|
||||
idleTimeoutMs = 500,
|
||||
onNewPage = { pages++ },
|
||||
) { got.add(it) }
|
||||
feeder.join()
|
||||
|
||||
assertEquals(6, total, "a slowly-but-actively streaming page must never be cropped")
|
||||
assertEquals(6, got.size)
|
||||
assertEquals(1, client.subscribeCount, "the whole stream must arrive in ONE page — a hard deadline would truncate and re-subscribe")
|
||||
assertEquals(0, pages, "no pagination should be needed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentRelayGivesUpOneIdleWindowAfterItsLastMessage() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
val feeder =
|
||||
launch {
|
||||
// Two quick events, then the relay goes quiet without EOSE: the
|
||||
// page must end ~one idle window after the LAST message.
|
||||
delay(50)
|
||||
client.listener!!.onEvent(event(1), false, relay, null)
|
||||
delay(50)
|
||||
client.listener!!.onEvent(event(2), false, relay, null)
|
||||
}
|
||||
|
||||
val start = TimeSource.Monotonic.markNow()
|
||||
val got = mutableListOf<Event>()
|
||||
// limit = 3 keeps the filter unfulfilled, so only the stall can end the
|
||||
// page; the events already delivered are kept and, since nothing older
|
||||
// followed, the next page comes back empty and the walk terminates.
|
||||
val total =
|
||||
client.fetchAllPages(
|
||||
relay = relay,
|
||||
filters = listOf(Filter(kinds = listOf(1), limit = 3)),
|
||||
idleTimeoutMs = 300,
|
||||
) { got.add(it) }
|
||||
feeder.join()
|
||||
val elapsedMs = start.elapsedNow().inWholeMilliseconds
|
||||
|
||||
assertEquals(2, total, "events delivered before the stall are kept")
|
||||
assertTrue(elapsedMs >= 300, "must wait out at least one idle window, took ${elapsedMs}ms")
|
||||
assertTrue(elapsedMs < 5_000, "a stalled page must end promptly after the idle window, took ${elapsedMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents why [fetchAllPages] has no wall-clock ceiling — nor should any
|
||||
* accessory: a hard bound composes at the call site as `withTimeoutOrNull`.
|
||||
*
|
||||
* A per-page ceiling cannot bound this walk: when a page ends, the loop advances
|
||||
* the cursor and fires the NEXT `REQ`, so an endless trickle against an unbounded
|
||||
* filter is merely re-paged. This pins that reality — the walk runs until the
|
||||
* caller cancels — so nobody re-adds a `maxPageMs` believing it caps anything.
|
||||
* What actually bounds a download is the filter's `limit`.
|
||||
*/
|
||||
@Test
|
||||
fun anEndlessTrickleIsBoundedByCancellationNotByAWallClock() =
|
||||
runBlocking {
|
||||
val client = ScriptedClient()
|
||||
var ts = 10_000_000L
|
||||
var i = 1
|
||||
val feeder =
|
||||
launch {
|
||||
// Trickles forever, strictly decreasing created_at, never EOSE.
|
||||
while (true) {
|
||||
delay(40)
|
||||
client.listener?.onEvent(event(i++, ts--), false, relay, null)
|
||||
}
|
||||
}
|
||||
|
||||
val returned =
|
||||
withTimeoutOrNull(1_500) {
|
||||
client.fetchAllPages(
|
||||
relay = relay,
|
||||
filters = listOf(Filter(kinds = listOf(1))), // unbounded: no limit
|
||||
idleTimeoutMs = 200,
|
||||
) { }
|
||||
}
|
||||
feeder.cancel()
|
||||
|
||||
assertNull(returned, "an unbounded filter against an endless trickle ends only by cancellation")
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -141,7 +141,7 @@ class BulkDownloadBenchmark {
|
||||
client.fetchAllPages(
|
||||
relay = relayUrl,
|
||||
filters = listOf(Filter(kinds = listOf(1), since = lo, until = hi)),
|
||||
timeoutMs = LOCAL_PAGE_TIMEOUT_MS,
|
||||
idleTimeoutMs = LOCAL_PAGE_TIMEOUT_MS,
|
||||
) { event ->
|
||||
count.incrementAndGet()
|
||||
bytes.addAndGet(event.content.length.toLong())
|
||||
@@ -376,7 +376,7 @@ class BulkDownloadBenchmark {
|
||||
client.fetchAllPages(
|
||||
relay = relay,
|
||||
filters = listOf(Filter(kinds = listOf(PROD_KIND), limit = PROD_MAX_EVENTS)),
|
||||
timeoutMs = 30_000L,
|
||||
idleTimeoutMs = 30_000L,
|
||||
onNewPage = { pages++ },
|
||||
) { event ->
|
||||
count.incrementAndGet()
|
||||
@@ -672,7 +672,7 @@ class BulkDownloadBenchmark {
|
||||
client.fetchAllPages(
|
||||
relay = relay,
|
||||
filters = listOf(Filter(kinds = listOf(PROD_KIND), limit = PROD_MAX_EVENTS)),
|
||||
timeoutMs = 30_000L,
|
||||
idleTimeoutMs = 30_000L,
|
||||
onNewPage = { pages++ },
|
||||
) { event ->
|
||||
count.incrementAndGet()
|
||||
|
||||
+1
-1
@@ -280,7 +280,7 @@ class ByIdFetchBenchmark {
|
||||
client.fetchAllPages(
|
||||
relay = relay,
|
||||
filters = listOf(Filter(kinds = listOf(KIND))),
|
||||
timeoutMs = ENUM_PAGE_TIMEOUT_MS,
|
||||
idleTimeoutMs = ENUM_PAGE_TIMEOUT_MS,
|
||||
) { event -> ids.add(event.id) }
|
||||
println(" enumerated ${ids.size} ids in %.1fs (paged, untimed baseline for the matrix)".format((System.nanoTime() - t) / 1e9))
|
||||
ids.toList()
|
||||
|
||||
Reference in New Issue
Block a user