Batch single-#a query filters in NostrBatcher
The /feed page was firing one REQ per visible CampaignCard for
`{ kinds: [8333], '#a': [aTag], limit: 500 }` (kind 8333 donations
keyed to that card's campaign coordinate). With 25 Agora entities
per page that was ~25 parallel single-aTag REQs hitting the relay
in the same render tick — enough for relay.ditto.pub to drop the
socket with a 1005 close.
Mirror the existing single-#e pattern: detect single-#a filters,
collect them per (kinds, limit) shape over a microtask, then issue
one combined REQ with the merged `#a` array and group the result
events back to their callers by which addressable coordinate they
reference. Same approach `useAllCampaigns` already uses by hand for
its campaigns grid, now applied transparently to every per-card
hook (useCampaignDonations, useEventRSVPs, useMyRSVP, the badge and
livestream-chat single-coord lookups).
This commit is contained in:
@@ -357,6 +357,35 @@ function getETagValue(filter: NostrFilter): string {
|
||||
return ((filter as Record<string, unknown>)['#e'] as string[])[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter that queries by a single `#a` (addressable coordinate) tag with
|
||||
* kinds and limit. e.g. `{ kinds: [8333], '#a': [aTag], limit: 500 }`.
|
||||
* Must NOT have `authors` — that's a different pattern.
|
||||
*
|
||||
* Used by per-card hooks like `useCampaignDonations` (one card → one REQ),
|
||||
* which fan out to N REQs when N cards mount in the same render. Batching
|
||||
* collapses them into a single `'#a': [aTag1, aTag2, …]` REQ.
|
||||
*/
|
||||
function isATagFilter(filter: NostrFilter): boolean {
|
||||
const keys = Object.keys(filter);
|
||||
return (
|
||||
keys.every((k) => k === 'kinds' || k === '#a' || k === 'limit') &&
|
||||
Array.isArray(filter.kinds) &&
|
||||
filter.kinds.length > 0 &&
|
||||
!filter.authors &&
|
||||
(filter as Record<string, unknown>)['#a'] !== undefined &&
|
||||
Array.isArray((filter as Record<string, unknown>)['#a']) &&
|
||||
((filter as Record<string, unknown>)['#a'] as string[]).length === 1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the single `#a` value from a filter known to have one.
|
||||
*/
|
||||
function getATagValue(filter: NostrFilter): string {
|
||||
return ((filter as Record<string, unknown>)['#a'] as string[])[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a multi-filter array can be batched: every filter must be an
|
||||
* e-tag or q-tag filter referencing the same single event ID.
|
||||
@@ -434,6 +463,8 @@ export class NostrBatcher {
|
||||
private dTagCollectors = new Map<string, BatchCollector<NostrEvent | undefined>>();
|
||||
/** Keyed by sorted kinds string for #e-tag batching. Returns arrays. */
|
||||
private eTagCollectors = new Map<string, BatchCollector<NostrEvent[]>>();
|
||||
/** Keyed by sorted kinds string for #a-tag batching. Returns arrays. */
|
||||
private aTagCollectors = new Map<string, BatchCollector<NostrEvent[]>>();
|
||||
/** Keyed by serialized filter shapes for multi-filter #e/#q batching. */
|
||||
private multiFilterCollectors = new Map<string, BatchCollector<NostrEvent[]>>();
|
||||
|
||||
@@ -516,6 +547,26 @@ export class NostrBatcher {
|
||||
return collector.request(eventId, opts?.signal);
|
||||
}
|
||||
|
||||
// { kinds: [...], '#a': [aTag] } (no authors)
|
||||
// The dominant feed-page leak: each CampaignCard's `useCampaignDonations`
|
||||
// fires `{ kinds: [8333], '#a': [aTag], limit: 500 }` independently,
|
||||
// so 25 cards = 25 REQs. Batching collapses them per (kinds, limit)
|
||||
// shape into one REQ.
|
||||
if (isATagFilter(filter)) {
|
||||
const aTag = getATagValue(filter);
|
||||
const kindsKey = [...filter.kinds!].sort().join(',');
|
||||
const limit = filter.limit ?? 50;
|
||||
const collectorKey = `${kindsKey}:${limit}`;
|
||||
let collector = this.aTagCollectors.get(collectorKey);
|
||||
if (!collector) {
|
||||
collector = new BatchCollector((aTags, signal) =>
|
||||
this.executeATagBatch(filter.kinds!, aTags, limit, signal),
|
||||
);
|
||||
this.aTagCollectors.set(collectorKey, collector);
|
||||
}
|
||||
return collector.request(aTag, opts?.signal);
|
||||
}
|
||||
|
||||
// { kinds: [k], authors: [a], '#d': [d] }
|
||||
if (isDTagFilter(filter)) {
|
||||
const kind = filter.kinds![0];
|
||||
@@ -742,6 +793,46 @@ export class NostrBatcher {
|
||||
return results;
|
||||
}
|
||||
|
||||
private async executeATagBatch(
|
||||
kinds: number[],
|
||||
aTags: string[],
|
||||
perItemLimit: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<Map<string, NostrEvent[]>> {
|
||||
const results = new Map<string, NostrEvent[]>();
|
||||
try {
|
||||
const events = await this.pool.query(
|
||||
[{ kinds, '#a': aTags, limit: aTags.length * perItemLimit }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Group results by which addressable coordinate they reference via a-tag.
|
||||
// A single event may reference multiple coordinates (e.g. a zap receipt
|
||||
// tagging both a campaign and a pledge); attribute it to each matching
|
||||
// coord so every caller waiting on those aTags receives it.
|
||||
const byATag = new Map<string, NostrEvent[]>();
|
||||
const aTagSet = new Set(aTags);
|
||||
for (const event of events) {
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'a' && aTagSet.has(tag[1])) {
|
||||
const existing = byATag.get(tag[1]) ?? [];
|
||||
existing.push(event);
|
||||
byATag.set(tag[1], existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const aTag of aTags) {
|
||||
results.set(aTag, byATag.get(aTag) ?? []);
|
||||
}
|
||||
} catch {
|
||||
for (const aTag of aTags) {
|
||||
results.set(aTag, []);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async executeMultiFilterBatch(
|
||||
templateFilters: NostrFilter[],
|
||||
eventIds: string[],
|
||||
|
||||
Reference in New Issue
Block a user