perf(slack): memoize allow-from and mention paths

This commit is contained in:
Peter Steinberger
2026-03-02 20:18:41 +00:00
parent 2f352306fe
commit fb5d8a9cd1
4 changed files with 166 additions and 11 deletions

View File

@@ -7,17 +7,27 @@ vi.mock("../../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readChannelAllowFromStoreMock(...args), readChannelAllowFromStore: (...args: unknown[]) => readChannelAllowFromStoreMock(...args),
})); }));
import { resolveSlackEffectiveAllowFrom } from "./auth.js"; import { clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } from "./auth.js";
function makeSlackCtx(allowFrom: string[]): SlackMonitorContext { function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
return { return {
allowFrom, allowFrom,
accountId: "main",
dmPolicy: "pairing",
} as unknown as SlackMonitorContext; } as unknown as SlackMonitorContext;
} }
describe("resolveSlackEffectiveAllowFrom", () => { describe("resolveSlackEffectiveAllowFrom", () => {
const prevTtl = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
beforeEach(() => { beforeEach(() => {
readChannelAllowFromStoreMock.mockReset(); readChannelAllowFromStoreMock.mockReset();
clearSlackAllowFromCacheForTest();
if (prevTtl === undefined) {
delete process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
} else {
process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS = prevTtl;
}
}); });
it("falls back to channel config allowFrom when pairing store throws", async () => { it("falls back to channel config allowFrom when pairing store throws", async () => {
@@ -37,4 +47,27 @@ describe("resolveSlackEffectiveAllowFrom", () => {
expect(effective.allowFrom).toEqual(["u1"]); expect(effective.allowFrom).toEqual(["u1"]);
expect(effective.allowFromLower).toEqual(["u1"]); expect(effective.allowFromLower).toEqual(["u1"]);
}); });
it("memoizes pairing-store allowFrom reads within TTL", async () => {
readChannelAllowFromStoreMock.mockResolvedValue(["u2"]);
const ctx = makeSlackCtx(["u1"]);
const first = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
const second = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
expect(first.allowFrom).toEqual(["u1", "u2"]);
expect(second.allowFrom).toEqual(["u1", "u2"]);
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(1);
});
it("refreshes pairing-store allowFrom when cache TTL is zero", async () => {
process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS = "0";
readChannelAllowFromStoreMock.mockResolvedValue(["u2"]);
const ctx = makeSlackCtx(["u1"]);
await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(2);
});
}); });

View File

@@ -8,13 +8,89 @@ import {
import { resolveSlackChannelConfig } from "./channel-config.js"; import { resolveSlackChannelConfig } from "./channel-config.js";
import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js"; import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js";
type ResolvedAllowFromLists = {
allowFrom: string[];
allowFromLower: string[];
};
type SlackAllowFromCacheState = {
baseSignature?: string;
base?: ResolvedAllowFromLists;
pairingKey?: string;
pairing?: ResolvedAllowFromLists;
pairingExpiresAtMs?: number;
pairingPending?: Promise<ResolvedAllowFromLists>;
};
let slackAllowFromCache = new WeakMap<SlackMonitorContext, SlackAllowFromCacheState>();
const DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS = 5000;
function getPairingAllowFromCacheTtlMs(): number {
const raw = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS?.trim();
if (!raw) {
return DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return DEFAULT_PAIRING_ALLOW_FROM_CACHE_TTL_MS;
}
return Math.max(0, Math.floor(parsed));
}
function getAllowFromCacheState(ctx: SlackMonitorContext): SlackAllowFromCacheState {
const existing = slackAllowFromCache.get(ctx);
if (existing) {
return existing;
}
const next: SlackAllowFromCacheState = {};
slackAllowFromCache.set(ctx, next);
return next;
}
function buildBaseAllowFrom(ctx: SlackMonitorContext): ResolvedAllowFromLists {
const allowFrom = normalizeAllowList(ctx.allowFrom);
return {
allowFrom,
allowFromLower: normalizeAllowListLower(allowFrom),
};
}
export async function resolveSlackEffectiveAllowFrom( export async function resolveSlackEffectiveAllowFrom(
ctx: SlackMonitorContext, ctx: SlackMonitorContext,
options?: { includePairingStore?: boolean }, options?: { includePairingStore?: boolean },
) { ) {
const includePairingStore = options?.includePairingStore === true; const includePairingStore = options?.includePairingStore === true;
let storeAllowFrom: string[] = []; const cache = getAllowFromCacheState(ctx);
if (includePairingStore) { const baseSignature = JSON.stringify(ctx.allowFrom);
if (cache.baseSignature !== baseSignature || !cache.base) {
cache.baseSignature = baseSignature;
cache.base = buildBaseAllowFrom(ctx);
cache.pairing = undefined;
cache.pairingKey = undefined;
cache.pairingExpiresAtMs = undefined;
cache.pairingPending = undefined;
}
if (!includePairingStore) {
return cache.base;
}
const ttlMs = getPairingAllowFromCacheTtlMs();
const nowMs = Date.now();
const pairingKey = `${ctx.accountId}:${ctx.dmPolicy}`;
if (
ttlMs > 0 &&
cache.pairing &&
cache.pairingKey === pairingKey &&
(cache.pairingExpiresAtMs ?? 0) >= nowMs
) {
return cache.pairing;
}
if (cache.pairingPending && cache.pairingKey === pairingKey) {
return await cache.pairingPending;
}
const pairingPending = (async (): Promise<ResolvedAllowFromLists> => {
let storeAllowFrom: string[] = [];
try { try {
const resolved = await readStoreAllowFromForDmPolicy({ const resolved = await readStoreAllowFromForDmPolicy({
provider: "slack", provider: "slack",
@@ -25,10 +101,34 @@ export async function resolveSlackEffectiveAllowFrom(
} catch { } catch {
storeAllowFrom = []; storeAllowFrom = [];
} }
const allowFrom = normalizeAllowList([...(cache.base?.allowFrom ?? []), ...storeAllowFrom]);
return {
allowFrom,
allowFromLower: normalizeAllowListLower(allowFrom),
};
})();
cache.pairingKey = pairingKey;
cache.pairingPending = pairingPending;
try {
const resolved = await pairingPending;
if (ttlMs > 0) {
cache.pairing = resolved;
cache.pairingExpiresAtMs = nowMs + ttlMs;
} else {
cache.pairing = undefined;
cache.pairingExpiresAtMs = undefined;
}
return resolved;
} finally {
if (cache.pairingPending === pairingPending) {
cache.pairingPending = undefined;
}
} }
const allowFrom = normalizeAllowList([...ctx.allowFrom, ...storeAllowFrom]); }
const allowFromLower = normalizeAllowListLower(allowFrom);
return { allowFrom, allowFromLower }; export function clearSlackAllowFromCacheForTest(): void {
slackAllowFromCache = new WeakMap<SlackMonitorContext, SlackAllowFromCacheState>();
} }
export function isSlackSenderAllowListed(params: { export function isSlackSenderAllowListed(params: {

View File

@@ -170,7 +170,9 @@ export function createSlackMonitorContext(params: {
const allowFrom = normalizeAllowList(params.allowFrom); const allowFrom = normalizeAllowList(params.allowFrom);
const groupDmChannels = normalizeAllowList(params.groupDmChannels); const groupDmChannels = normalizeAllowList(params.groupDmChannels);
const groupDmChannelsLower = normalizeAllowListLower(groupDmChannels);
const defaultRequireMention = params.defaultRequireMention ?? true; const defaultRequireMention = params.defaultRequireMention ?? true;
const hasChannelAllowlistConfig = Object.keys(params.channelsConfig ?? {}).length > 0;
const markMessageSeen = (channelId: string | undefined, ts?: string) => { const markMessageSeen = (channelId: string | undefined, ts?: string) => {
if (!channelId || !ts) { if (!channelId || !ts) {
@@ -308,7 +310,6 @@ export function createSlackMonitorContext(params: {
} }
if (isGroupDm && groupDmChannels.length > 0) { if (isGroupDm && groupDmChannels.length > 0) {
const allowList = normalizeAllowListLower(groupDmChannels);
const candidates = [ const candidates = [
p.channelId, p.channelId,
p.channelName ? `#${p.channelName}` : undefined, p.channelName ? `#${p.channelName}` : undefined,
@@ -318,7 +319,8 @@ export function createSlackMonitorContext(params: {
.filter((value): value is string => Boolean(value)) .filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase()); .map((value) => value.toLowerCase());
const permitted = const permitted =
allowList.includes("*") || candidates.some((candidate) => allowList.includes(candidate)); groupDmChannelsLower.includes("*") ||
candidates.some((candidate) => groupDmChannelsLower.includes(candidate));
if (!permitted) { if (!permitted) {
return false; return false;
} }
@@ -333,8 +335,7 @@ export function createSlackMonitorContext(params: {
}); });
const channelMatchMeta = formatAllowlistMatchMeta(channelConfig); const channelMatchMeta = formatAllowlistMatchMeta(channelConfig);
const channelAllowed = channelConfig?.allowed !== false; const channelAllowed = channelConfig?.allowed !== false;
const channelAllowlistConfigured = const channelAllowlistConfigured = hasChannelAllowlistConfig;
Boolean(params.channelsConfig) && Object.keys(params.channelsConfig ?? {}).length > 0;
if ( if (
!isSlackChannelAllowedByPolicy({ !isSlackChannelAllowedByPolicy({
groupPolicy: params.groupPolicy, groupPolicy: params.groupPolicy,

View File

@@ -51,6 +51,27 @@ import {
import { resolveSlackRoomContextHints } from "../room-context.js"; import { resolveSlackRoomContextHints } from "../room-context.js";
import type { PreparedSlackMessage } from "./types.js"; import type { PreparedSlackMessage } from "./types.js";
const mentionRegexCache = new WeakMap<SlackMonitorContext, Map<string, RegExp[]>>();
function resolveCachedMentionRegexes(
ctx: SlackMonitorContext,
agentId: string | undefined,
): RegExp[] {
const key = agentId?.trim() || "__default__";
let byAgent = mentionRegexCache.get(ctx);
if (!byAgent) {
byAgent = new Map<string, RegExp[]>();
mentionRegexCache.set(ctx, byAgent);
}
const cached = byAgent.get(key);
if (cached) {
return cached;
}
const built = buildMentionRegexes(ctx.cfg, agentId);
byAgent.set(key, built);
return built;
}
export async function prepareSlackMessage(params: { export async function prepareSlackMessage(params: {
ctx: SlackMonitorContext; ctx: SlackMonitorContext;
account: ResolvedSlackAccount; account: ResolvedSlackAccount;
@@ -205,7 +226,7 @@ export async function prepareSlackMessage(params: {
const historyKey = const historyKey =
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel; isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId); const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId);
const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const explicitlyMentioned = Boolean( const explicitlyMentioned = Boolean(
ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`), ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`),