Skip to content

Commit 5afbd24

Browse files
committed
fix(cache): skip cache queries for subscriptions with only ephemeral kinds
Ephemeral events (kinds 20000-29999) are never stored in cache, so querying the cache for them is wasteful. This adds proper filtering to skip cache queries when all filters contain only ephemeral kinds. Also exports filterForCache utilities for cache adapters to use.
1 parent 55381d4 commit 5afbd24

File tree

5 files changed

+226
-4
lines changed

5 files changed

+226
-4
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/src/cache/filter.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { describe, expect, it } from "vitest";
2+
import { filterEphemeralKindsFromFilter, filterForCache, isEphemeralKind } from "./filter";
3+
import type { NDKFilter, NDKSubscription } from "../subscription/index.js";
4+
5+
describe("isEphemeralKind", () => {
6+
it("should return true for kinds in the 20000-29999 range", () => {
7+
expect(isEphemeralKind(20000)).toBe(true);
8+
expect(isEphemeralKind(20001)).toBe(true);
9+
expect(isEphemeralKind(25000)).toBe(true);
10+
expect(isEphemeralKind(29999)).toBe(true);
11+
});
12+
13+
it("should return false for kinds outside the ephemeral range", () => {
14+
expect(isEphemeralKind(0)).toBe(false);
15+
expect(isEphemeralKind(1)).toBe(false);
16+
expect(isEphemeralKind(19999)).toBe(false);
17+
expect(isEphemeralKind(30000)).toBe(false);
18+
expect(isEphemeralKind(10002)).toBe(false);
19+
});
20+
});
21+
22+
describe("filterEphemeralKindsFromFilter", () => {
23+
it("should return the original filter if no kinds are specified", () => {
24+
const filter: NDKFilter = { authors: ["pubkey1"] };
25+
const result = filterEphemeralKindsFromFilter(filter);
26+
expect(result).toEqual(filter);
27+
});
28+
29+
it("should return the original filter if no ephemeral kinds are present", () => {
30+
const filter: NDKFilter = { kinds: [1, 3, 10002], authors: ["pubkey1"] };
31+
const result = filterEphemeralKindsFromFilter(filter);
32+
expect(result).toEqual(filter);
33+
});
34+
35+
it("should filter out ephemeral kinds while keeping non-ephemeral ones", () => {
36+
const filter: NDKFilter = { kinds: [1, 20001, 3, 25000], authors: ["pubkey1"] };
37+
const result = filterEphemeralKindsFromFilter(filter);
38+
expect(result).toEqual({ kinds: [1, 3], authors: ["pubkey1"] });
39+
});
40+
41+
it("should return null if all kinds are ephemeral", () => {
42+
const filter: NDKFilter = { kinds: [20000, 20001, 25000], authors: ["pubkey1"] };
43+
const result = filterEphemeralKindsFromFilter(filter);
44+
expect(result).toBeNull();
45+
});
46+
47+
it("should return null for filters with only ephemeral kinds (edge case: exactly 20000)", () => {
48+
const filter: NDKFilter = { kinds: [20000] };
49+
const result = filterEphemeralKindsFromFilter(filter);
50+
expect(result).toBeNull();
51+
});
52+
53+
it("should return null for filters with only ephemeral kinds (edge case: exactly 29999)", () => {
54+
const filter: NDKFilter = { kinds: [29999] };
55+
const result = filterEphemeralKindsFromFilter(filter);
56+
expect(result).toBeNull();
57+
});
58+
});
59+
60+
describe("filterForCache", () => {
61+
// Create a minimal mock subscription
62+
const createMockSubscription = (filters: NDKFilter[], cacheUnconstrainFilter?: string[]): NDKSubscription => ({
63+
filters,
64+
cacheUnconstrainFilter,
65+
} as unknown as NDKSubscription);
66+
67+
it("should remove ephemeral kinds from filters", () => {
68+
const subscription = createMockSubscription([
69+
{ kinds: [1, 20001], authors: ["pubkey1"] },
70+
]);
71+
const result = filterForCache(subscription);
72+
expect(result).toEqual([{ kinds: [1], authors: ["pubkey1"] }]);
73+
});
74+
75+
it("should remove filters where all kinds are ephemeral", () => {
76+
const subscription = createMockSubscription([
77+
{ kinds: [20001, 25000], authors: ["pubkey1"] },
78+
{ kinds: [1, 3], authors: ["pubkey2"] },
79+
]);
80+
const result = filterForCache(subscription);
81+
expect(result).toEqual([{ kinds: [1, 3], authors: ["pubkey2"] }]);
82+
});
83+
84+
it("should return empty array if all filters only have ephemeral kinds", () => {
85+
const subscription = createMockSubscription([
86+
{ kinds: [20001], authors: ["pubkey1"] },
87+
{ kinds: [25000], authors: ["pubkey2"] },
88+
]);
89+
const result = filterForCache(subscription);
90+
expect(result).toEqual([]);
91+
});
92+
93+
it("should handle cacheUnconstrainFilter by removing specified keys", () => {
94+
const subscription = createMockSubscription(
95+
[{ kinds: [1], authors: ["pubkey1"], limit: 10, since: 1234567890 }],
96+
["limit", "since"]
97+
);
98+
const result = filterForCache(subscription);
99+
expect(result).toEqual([{ kinds: [1], authors: ["pubkey1"] }]);
100+
});
101+
102+
it("should handle both cacheUnconstrainFilter and ephemeral filtering", () => {
103+
const subscription = createMockSubscription(
104+
[{ kinds: [1, 20001], authors: ["pubkey1"], limit: 10 }],
105+
["limit"]
106+
);
107+
const result = filterForCache(subscription);
108+
expect(result).toEqual([{ kinds: [1], authors: ["pubkey1"] }]);
109+
});
110+
111+
it("should not break filters without kinds specified", () => {
112+
const subscription = createMockSubscription([
113+
{ authors: ["pubkey1"] },
114+
{ ids: ["eventid1"] },
115+
]);
116+
const result = filterForCache(subscription);
117+
expect(result).toEqual([
118+
{ authors: ["pubkey1"] },
119+
{ ids: ["eventid1"] },
120+
]);
121+
});
122+
123+
it("should handle empty kinds array", () => {
124+
const subscription = createMockSubscription([
125+
{ kinds: [], authors: ["pubkey1"] },
126+
]);
127+
const result = filterForCache(subscription);
128+
expect(result).toEqual([{ kinds: [], authors: ["pubkey1"] }]);
129+
});
130+
131+
it("should filter out filters that become empty after cacheUnconstrainFilter", () => {
132+
const subscription = createMockSubscription(
133+
[{ limit: 10 }],
134+
["limit"]
135+
);
136+
const result = filterForCache(subscription);
137+
expect(result).toEqual([]);
138+
});
139+
});

core/src/cache/filter.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { NDKFilter, NDKSubscription } from "../subscription/index.js";
2+
3+
/**
4+
* Checks if a kind is ephemeral (20000-29999 range).
5+
* Ephemeral events are not stored in cache, so there's no point querying for them.
6+
*/
7+
export function isEphemeralKind(kind: number): boolean {
8+
return kind >= 20000 && kind < 30000;
9+
}
10+
11+
/**
12+
* Filters out ephemeral kinds from a filter's kinds array.
13+
* Returns null if the filter had kinds but all were ephemeral (meaning the filter should be skipped entirely).
14+
* Returns the original filter if no kinds were specified (don't break REQs without kind filters).
15+
* Returns a new filter with non-ephemeral kinds if some kinds were valid.
16+
*/
17+
export function filterEphemeralKindsFromFilter(filter: NDKFilter): NDKFilter | null {
18+
// If no kinds are specified, return the filter as-is
19+
// (we don't want to break REQs that don't specify kinds)
20+
if (!filter.kinds || filter.kinds.length === 0) {
21+
return filter;
22+
}
23+
24+
// Filter out ephemeral kinds
25+
const nonEphemeralKinds = filter.kinds.filter((kind) => !isEphemeralKind(kind));
26+
27+
// If all kinds were ephemeral, return null to skip this filter entirely
28+
if (nonEphemeralKinds.length === 0) {
29+
return null;
30+
}
31+
32+
// If no kinds were filtered out, return the original filter
33+
if (nonEphemeralKinds.length === filter.kinds.length) {
34+
return filter;
35+
}
36+
37+
// Return a new filter with only non-ephemeral kinds
38+
return {
39+
...filter,
40+
kinds: nonEphemeralKinds,
41+
};
42+
}
43+
44+
/**
45+
* Processes filters for cache queries by:
46+
* 1. Removing constrained fields (limit, since, until) if cacheUnconstrainFilter is set
47+
* 2. Filtering out ephemeral kinds (20000-29999) since they're never cached
48+
*
49+
* Returns an empty array if all filters should be skipped (all kinds were ephemeral).
50+
*/
51+
export function filterForCache(subscription: NDKSubscription): NDKFilter[] {
52+
let filters = subscription.filters;
53+
54+
// First, handle cacheUnconstrainFilter if set
55+
if (subscription.cacheUnconstrainFilter) {
56+
filters = filters.map((filter) => {
57+
const filterCopy = { ...filter };
58+
for (const key of subscription.cacheUnconstrainFilter!) {
59+
delete (filterCopy as Record<string, unknown>)[key];
60+
}
61+
return filterCopy;
62+
});
63+
// Remove filters that became empty after removing constrained keys
64+
filters = filters.filter((filter) => Object.keys(filter).length > 0);
65+
}
66+
67+
// Then, filter out ephemeral kinds
68+
const processedFilters: NDKFilter[] = [];
69+
70+
for (const filter of filters) {
71+
const processed = filterEphemeralKindsFromFilter(filter);
72+
if (processed !== null) {
73+
processedFilters.push(processed);
74+
}
75+
}
76+
77+
return processedFilters;
78+
}

core/src/cache/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import type { Hexpubkey, ProfilePointer } from "../user/index.js";
88
import type { NDKUserProfile } from "../user/profile.js";
99
import type { NDKLnUrlData } from "../zapper/ln.js";
1010

11+
// Export filter utilities for cache adapters
12+
export { filterForCache, filterEphemeralKindsFromFilter, isEphemeralKind } from "./filter.js";
13+
1114
export type NDKCacheEntry<T> = T & {
1215
cachedAt?: number;
1316
};

core/src/subscription/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,13 +517,14 @@ export class NDKSubscription extends EventEmitter<{
517517
}
518518

519519
private shouldQueryCache(): boolean {
520-
if (this.opts.addSinceFromCache) return true;
521-
522520
// explicitly told to not query the cache
523521
if (this.opts?.cacheUsage === NDKSubscriptionCacheUsage.ONLY_RELAY) return false;
524522

525-
const hasNonEphemeralKind = this.filters.some((f) => f.kinds?.some((k) => kindIsEphemeral(k)));
526-
if (hasNonEphemeralKind) return true;
523+
// If all filters contain only ephemeral kinds, don't query cache
524+
const allFiltersEphemeralOnly = this.filters.every((f) =>
525+
f.kinds && f.kinds.length > 0 && f.kinds.every((k) => kindIsEphemeral(k))
526+
);
527+
if (allFiltersEphemeralOnly) return false;
527528

528529
return true;
529530
}

0 commit comments

Comments
 (0)