Skip to content

Commit eb9db35

Browse files
committed
Cached importJwk and data host data to improve cpu usage
ref https://linear.app/ghost/issue/BER-3241
1 parent 049047a commit eb9db35

File tree

2 files changed

+112
-23
lines changed

2 files changed

+112
-23
lines changed

src/dispatchers.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,22 @@ export const actorDispatcher = (hostDataContextLoader: HostDataContextLoader) =>
104104
export const keypairDispatcher = (
105105
accountService: AccountService,
106106
hostDataContextLoader: HostDataContextLoader,
107-
) =>
108-
async function keypairDispatcher(ctx: FedifyContext, identifier: string) {
107+
) => {
108+
const MAX_CACHE_SIZE = 1000;
109+
const KEY_TTL_MS = 30 * 60 * 1000; // 30 minutes
110+
const cryptoKeyCache = new Map<
111+
number,
112+
{
113+
publicKey: CryptoKey;
114+
privateKey: CryptoKey;
115+
createdAt: number;
116+
}
117+
>();
118+
119+
return async function keypairDispatcher(
120+
ctx: FedifyContext,
121+
identifier: string,
122+
) {
109123
const hostData = await hostDataContextLoader.loadDataForHost(ctx.host);
110124

111125
if (isError(hostData)) {
@@ -145,6 +159,14 @@ export const keypairDispatcher = (
145159

146160
const { account } = getValue(hostData);
147161

162+
const cached = cryptoKeyCache.get(account.id);
163+
if (cached) {
164+
if (Date.now() - cached.createdAt < KEY_TTL_MS) {
165+
return [cached];
166+
}
167+
cryptoKeyCache.delete(account.id);
168+
}
169+
148170
const keyPair = await accountService.getKeyPair(account.id);
149171

150172
if (isError(keyPair)) {
@@ -176,18 +198,47 @@ export const keypairDispatcher = (
176198
const { publicKey, privateKey } = getValue(keyPair);
177199

178200
try {
179-
return [
180-
{
181-
publicKey: await importJwk(
182-
JSON.parse(publicKey) as JsonWebKey,
183-
'public',
184-
),
185-
privateKey: await importJwk(
186-
JSON.parse(privateKey) as JsonWebKey,
187-
'private',
188-
),
189-
},
190-
];
201+
const imported = {
202+
publicKey: await importJwk(
203+
JSON.parse(publicKey) as JsonWebKey,
204+
'public',
205+
),
206+
privateKey: await importJwk(
207+
JSON.parse(privateKey) as JsonWebKey,
208+
'private',
209+
),
210+
};
211+
212+
const now = Date.now();
213+
214+
// Evict expired entries
215+
for (const [key, entry] of cryptoKeyCache) {
216+
if (now - entry.createdAt >= KEY_TTL_MS) {
217+
cryptoKeyCache.delete(key);
218+
}
219+
}
220+
221+
// If still full, evict the oldest entry
222+
if (cryptoKeyCache.size >= MAX_CACHE_SIZE) {
223+
let oldestKey: number | undefined;
224+
let oldestTime = Infinity;
225+
for (const [key, entry] of cryptoKeyCache) {
226+
if (entry.createdAt < oldestTime) {
227+
oldestTime = entry.createdAt;
228+
oldestKey = key;
229+
}
230+
}
231+
if (oldestKey !== undefined) {
232+
cryptoKeyCache.delete(oldestKey);
233+
}
234+
}
235+
236+
cryptoKeyCache.set(account.id, {
237+
...imported,
238+
createdAt: now,
239+
});
240+
241+
return [imported];
191242
} catch (error) {
192243
ctx.data.logger.error(
193244
'Could not parse keypair for {host} (identifier: {identifier}): {error}',
@@ -200,6 +251,7 @@ export const keypairDispatcher = (
200251
return [];
201252
}
202253
};
254+
};
203255

204256
export function createAcceptHandler(accountService: AccountService) {
205257
return async function handleAccept(ctx: FedifyContext, accept: Accept) {

src/http/host-data-context-loader.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,60 @@ import type { Knex } from 'knex';
33
import type { Account } from '@/account/account.entity';
44
import type { KnexAccountRepository } from '@/account/account.repository.knex';
55
import type { Result } from '@/core/result';
6-
import { error, ok } from '@/core/result';
6+
import { error, isError, ok } from '@/core/result';
77
import type { Site } from '@/site/site.service';
88

9+
type HostDataResult = Result<
10+
{ site: Site; account: Account },
11+
'site-not-found' | 'account-not-found' | 'multiple-users-for-site'
12+
>;
13+
14+
interface CacheEntry {
15+
result: HostDataResult;
16+
expiry: number;
17+
}
18+
919
export class HostDataContextLoader {
20+
private static readonly CACHE_TTL_MS = 60_000;
21+
private static readonly MAX_CACHE_SIZE = 1000;
22+
private static readonly CACHE_ENABLED =
23+
process.env.NODE_ENV === 'production';
24+
private readonly cache = new Map<string, CacheEntry>();
25+
1026
constructor(
1127
readonly db: Knex,
1228
readonly accountRepository: KnexAccountRepository,
1329
) {}
1430

15-
async loadDataForHost(
16-
host: string,
17-
): Promise<
18-
Result<
19-
{ site: Site; account: Account },
20-
'site-not-found' | 'account-not-found' | 'multiple-users-for-site'
21-
>
22-
> {
31+
async loadDataForHost(host: string): Promise<HostDataResult> {
32+
if (!HostDataContextLoader.CACHE_ENABLED) {
33+
return this.queryDataForHost(host);
34+
}
35+
36+
const now = Date.now();
37+
const cached = this.cache.get(host);
38+
39+
if (cached && cached.expiry > now) {
40+
return cached.result;
41+
}
42+
43+
const result = await this.queryDataForHost(host);
44+
45+
if (!isError(result)) {
46+
if (this.cache.size >= HostDataContextLoader.MAX_CACHE_SIZE) {
47+
this.cache.delete(this.cache.keys().next().value!);
48+
}
49+
50+
this.cache.set(host, {
51+
result,
52+
expiry: now + HostDataContextLoader.CACHE_TTL_MS,
53+
});
54+
}
55+
56+
return result;
57+
}
58+
59+
private async queryDataForHost(host: string): Promise<HostDataResult> {
2360
const results = await this.db('sites')
2461
.leftJoin('users', 'users.site_id', 'sites.id')
2562
.leftJoin('accounts', 'accounts.id', 'users.account_id')

0 commit comments

Comments
 (0)