Skip to content

Commit b73c7b0

Browse files
authored
perf: reduce idle CPU/memory with lazy loading (#3)
## Changed - Make insight polling non-overlapping and disable when poll interval is <= 0 - Lazy-load search backend client and chat adapters to avoid loading unused SDKs - Prune expired in-memory conversation entries on save (rate-limited)
1 parent 6a8c796 commit b73c7b0

7 files changed

Lines changed: 69 additions & 25 deletions

File tree

src/agent/tools/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export async function createToolRegistry(opts: {
4848
policy: AgentPolicy;
4949
}): Promise<ToolRegistry> {
5050
const log = getLogger({ component: 'agent.tools' });
51-
const client = createSearchClient(opts.config.search);
51+
const client = await createSearchClient(opts.config.search);
5252
const fields = await waitForOpenSearchFieldMapping({
5353
client,
5454
indexPattern: opts.config.search.indexPattern,

src/insights/engine.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@ export async function startInsightEngine(opts: { config: KaytooConfig; insightSi
3737
const { config } = opts;
3838
const log = getLogger({ component: 'insights' });
3939
const controller = new AbortController();
40-
const client = createSearchClient(config.search);
40+
41+
if (config.behavior.pollIntervalSeconds <= 0) {
42+
log.info({ pollSeconds: config.behavior.pollIntervalSeconds }, 'insight polling disabled');
43+
return { stop: () => controller.abort() };
44+
}
45+
46+
const client = await createSearchClient(config.search);
4147
const fields = await waitForOpenSearchFieldMapping({
4248
client,
4349
indexPattern: config.search.indexPattern,
@@ -55,9 +61,17 @@ export async function startInsightEngine(opts: { config: KaytooConfig; insightSi
5561
});
5662
const dedupe = new DedupeStore(config.behavior.dedupeTtlSeconds * 1000);
5763
const warnAt = new Map<string, number>();
58-
const timer = setInterval(() => void pollOnce(), config.behavior.pollIntervalSeconds * 1000);
64+
let timer: NodeJS.Timeout | undefined;
65+
let inFlight = false;
66+
67+
const scheduleNext = (): void => {
68+
if (controller.signal.aborted) return;
69+
timer = setTimeout(() => void pollOnce(), config.behavior.pollIntervalSeconds * 1000);
70+
};
5971

6072
async function pollOnce(): Promise<void> {
73+
if (inFlight) return;
74+
inFlight = true;
6175
return runWithLogContextAsync({ pollId: randomUUID() }, async () => {
6276
try {
6377
if (controller.signal.aborted) return;
@@ -167,6 +181,9 @@ export async function startInsightEngine(opts: { config: KaytooConfig; insightSi
167181
await postFindings(novel);
168182
} catch (e) {
169183
log.error({ ...logErr(e) }, 'poll failed');
184+
} finally {
185+
inFlight = false;
186+
scheduleNext();
170187
}
171188
});
172189
}
@@ -195,7 +212,7 @@ export async function startInsightEngine(opts: { config: KaytooConfig; insightSi
195212
return {
196213
stop: () => {
197214
controller.abort();
198-
clearInterval(timer);
215+
if (timer) clearTimeout(timer);
199216
},
200217
};
201218
}

src/main.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,10 @@
11
import { parseKaytooArgv } from './cli/argv.js';
22
import { getConfig } from './config.js';
3-
import { createSlackNotifierWithRetry } from './notify/slack.js';
43
import { createConsoleInsightSink } from './notify/consoleInsightSink.js';
54
import { startInsightEngine } from './insights/engine.js';
6-
import { ChatRouter } from './chat/router.js';
7-
import { startSlackSocketAdapter } from './chat/adapters/slackSocket.js';
8-
import { startMatrixAdapter } from './chat/adapters/matrix.js';
9-
import { startMattermostAdapter } from './chat/adapters/mattermost.js';
10-
import { createAgentRuntime } from './agent/runtime.js';
11-
import { createSlackChatNotifier } from './notify/slackNotifier.js';
12-
import { createMatrixNotifier } from './notify/matrixNotifier.js';
13-
import { createMattermostNotifier } from './notify/mattermostNotifier.js';
14-
import { createMultiNotifier, createPromiseBackedNotifier } from './notify/multiNotifier.js';
155
import { getLogger, initLogging } from './logging/logger.js';
166
import { startE2eHttpChatServer } from './e2eHttpChatServer.js';
7+
import type { ChatRouter as ChatRouterT } from './chat/router.js';
178

189
const argv = parseKaytooArgv();
1910
const config = getConfig(process.env, argv);
@@ -28,7 +19,13 @@ log.info(
2819
const insightSink =
2920
config.output === 'console'
3021
? createConsoleInsightSink(getLogger({ component: 'insights.out' }))
31-
: createSlackNotifierWithRetry({ botToken: config.slack!.botToken!, log: getLogger({ component: 'notify.slack' }) });
22+
: await (async () => {
23+
const { createSlackNotifierWithRetry } = await import('./notify/slack.js');
24+
return createSlackNotifierWithRetry({
25+
botToken: config.slack!.botToken!,
26+
log: getLogger({ component: 'notify.slack' }),
27+
});
28+
})();
3229

3330
// Bind e2e HTTP chat before the insight engine: the engine awaits the first poll, which can take a long time
3431
// (OpenSearch + LLM) and would otherwise block /health and /chat until that poll finishes.
@@ -49,14 +46,33 @@ if (config.output === 'console') {
4946
'console output mode: insight polling (Slack/Matrix/Mattermost adapters off unless KAYTOO_HTTP_CHAT_BIND set)',
5047
);
5148
} else {
49+
const [{ ChatRouter }, { createAgentRuntime }, notify, slackSocket, matrixAdapter, mattermostAdapter] =
50+
await Promise.all([
51+
import('./chat/router.js'),
52+
import('./agent/runtime.js'),
53+
import('./notify/multiNotifier.js'),
54+
import('./chat/adapters/slackSocket.js'),
55+
import('./chat/adapters/matrix.js'),
56+
import('./chat/adapters/mattermost.js'),
57+
]);
58+
59+
const { createMultiNotifier, createPromiseBackedNotifier } = notify;
60+
const { startSlackSocketAdapter } = slackSocket;
61+
const { startMatrixAdapter } = matrixAdapter;
62+
const { startMattermostAdapter } = mattermostAdapter;
63+
64+
const { createSlackChatNotifier } = await import('./notify/slackNotifier.js');
65+
const { createMatrixNotifier } = await import('./notify/matrixNotifier.js');
66+
const { createMattermostNotifier } = await import('./notify/mattermostNotifier.js');
67+
5268
const slackCfg = config.slack!;
5369
const slack = insightSink;
5470
const slackNotifier = createSlackChatNotifier({ slack });
5571
// Adapters call `onEvent` as soon as they connect; the router is created later, so we
5672
// resolve the router through a promise until `ChatRouter` is constructed below.
57-
const routerCtl = Promise.withResolvers<ChatRouter>();
73+
const routerCtl = Promise.withResolvers<ChatRouterT>();
5874

59-
const onEvent = async (evt: Parameters<ChatRouter['handleEvent']>[0]) => {
75+
const onEvent = async (evt: Parameters<ChatRouterT['handleEvent']>[0]) => {
6076
await (await routerCtl.promise).handleEvent(evt);
6177
};
6278

src/search/client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { Client as ElasticClient } from '@elastic/elasticsearch';
2-
import { Client as OpenSearchClient } from '@opensearch-project/opensearch';
31
import type { KaytooConfig } from '../config.js';
42
import type { SearchClient } from './types.js';
53

6-
export function createSearchClient(config: KaytooConfig['search']): SearchClient {
4+
export async function createSearchClient(config: KaytooConfig['search']): Promise<SearchClient> {
75
if (config.backend === 'elasticsearch') {
6+
const { Client: ElasticClient } = await import('@elastic/elasticsearch');
87
const client = new ElasticClient({
98
node: config.url,
109
auth: { username: config.username, password: config.password },
@@ -17,6 +16,7 @@ export function createSearchClient(config: KaytooConfig['search']): SearchClient
1716
return adapted as unknown as SearchClient;
1817
}
1918

19+
const { Client: OpenSearchClient } = await import('@opensearch-project/opensearch');
2020
const client = new OpenSearchClient({
2121
node: config.url,
2222
auth: { username: config.username, password: config.password },

src/storage/conversationStore.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ export function createFileConversationStore(opts: {
118118
/** Non-persistent in-memory store (single process). */
119119
export function createMemoryConversationStore(opts: { ttlMs: number }): ConversationStore {
120120
const map = new Map<string, StoredConversation>();
121+
const gc = { nextAtMs: 0 };
122+
const maybePrune = (): void => {
123+
const now = Date.now();
124+
if (now < gc.nextAtMs) return;
125+
gc.nextAtMs = now + 60_000;
126+
const cutoff = now - opts.ttlMs;
127+
for (const [k, e] of map) {
128+
if (!isStoredConversation(e) || e.updatedAtMs < cutoff) map.delete(k);
129+
}
130+
};
121131
return {
122132
async load(key) {
123133
const e = map.get(key);
@@ -133,6 +143,7 @@ export function createMemoryConversationStore(opts: { ttlMs: number }): Conversa
133143
return e;
134144
},
135145
async save(key, data) {
146+
maybePrune();
136147
const merged: StoredConversation = { ...data, updatedAtMs: Date.now() };
137148
if (!isStoredConversation(merged)) throw new Error('invalid conversation payload');
138149
map.set(key, merged);

test/opensearch/queries.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('opensearch helpers', () => {
1313
const { createSearchClient } = await import('../../src/search/client.js');
1414
const { Client } = (await import('@opensearch-project/opensearch')) as unknown as { Client: ReturnType<typeof vi.fn> };
1515

16-
createSearchClient({
16+
await createSearchClient({
1717
backend: 'opensearch',
1818
url: 'https://os.example.com',
1919
username: 'u',
@@ -27,7 +27,7 @@ describe('opensearch helpers', () => {
2727
});
2828

2929
Client.mockClear();
30-
createSearchClient({
30+
await createSearchClient({
3131
backend: 'opensearch',
3232
url: 'https://os.example.com',
3333
username: 'u',

test/searchClient.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('createSearchClient', () => {
2828
};
2929
});
3030
const { createSearchClient } = await import('../src/search/client.js');
31-
const sc = createSearchClient({
31+
const sc = await createSearchClient({
3232
backend: 'elasticsearch',
3333
url: 'https://es.example:9200',
3434
username: 'u',
@@ -52,7 +52,7 @@ describe('createSearchClient', () => {
5252
return { search: vi.fn(), fieldCaps: vi.fn() };
5353
});
5454
const { createSearchClient } = await import('../src/search/client.js');
55-
createSearchClient({
55+
await createSearchClient({
5656
backend: 'opensearch',
5757
url: 'https://os.example:9200',
5858
username: 'u',
@@ -74,7 +74,7 @@ describe('createSearchClient', () => {
7474
return { search: vi.fn(), fieldCaps: vi.fn() };
7575
});
7676
const { createSearchClient } = await import('../src/search/client.js');
77-
createSearchClient({
77+
await createSearchClient({
7878
backend: 'opensearch',
7979
url: 'https://os.example',
8080
username: 'u',

0 commit comments

Comments
 (0)