perf(main): cache team inbox reads
This commit is contained in:
parent
d50d81632a
commit
4457c6f61b
2 changed files with 178 additions and 1 deletions
|
|
@ -9,6 +9,44 @@ import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files
|
const MAX_INBOX_FILE_BYTES = 10 * 1024 * 1024; // 10MB — skip corrupt/oversized inbox files
|
||||||
const INBOX_READ_CONCURRENCY = process.platform === 'win32' ? 4 : 12;
|
const INBOX_READ_CONCURRENCY = process.platform === 'win32' ? 4 : 12;
|
||||||
|
const INBOX_FILE_CACHE_MAX_ENTRIES = 1_024;
|
||||||
|
|
||||||
|
interface InboxFileSignature {
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
ctimeMs: number;
|
||||||
|
dev: number;
|
||||||
|
ino: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedInboxFile {
|
||||||
|
signature: InboxFileSignature;
|
||||||
|
messages: InboxMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInboxFileSignature(stat: fs.Stats): InboxFileSignature {
|
||||||
|
return {
|
||||||
|
size: stat.size,
|
||||||
|
mtimeMs: stat.mtimeMs,
|
||||||
|
ctimeMs: stat.ctimeMs,
|
||||||
|
dev: stat.dev,
|
||||||
|
ino: stat.ino,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inboxFileSignaturesEqual(a: InboxFileSignature, b: InboxFileSignature): boolean {
|
||||||
|
return (
|
||||||
|
a.size === b.size &&
|
||||||
|
a.mtimeMs === b.mtimeMs &&
|
||||||
|
a.ctimeMs === b.ctimeMs &&
|
||||||
|
a.dev === b.dev &&
|
||||||
|
a.ino === b.ino
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneInboxMessages(messages: readonly InboxMessage[]): InboxMessage[] {
|
||||||
|
return structuredClone([...messages]);
|
||||||
|
}
|
||||||
|
|
||||||
async function mapLimit<T, R>(
|
async function mapLimit<T, R>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
|
|
@ -30,6 +68,43 @@ async function mapLimit<T, R>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamInboxReader {
|
export class TeamInboxReader {
|
||||||
|
private readonly inboxFileCache = new Map<string, CachedInboxFile>();
|
||||||
|
|
||||||
|
private getCachedMessages(
|
||||||
|
inboxPath: string,
|
||||||
|
signature: InboxFileSignature
|
||||||
|
): InboxMessage[] | undefined {
|
||||||
|
const cached = this.inboxFileCache.get(inboxPath);
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!inboxFileSignaturesEqual(cached.signature, signature)) {
|
||||||
|
this.inboxFileCache.delete(inboxPath);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cloneInboxMessages(cached.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCachedMessages(
|
||||||
|
inboxPath: string,
|
||||||
|
signature: InboxFileSignature,
|
||||||
|
messages: readonly InboxMessage[]
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!this.inboxFileCache.has(inboxPath) &&
|
||||||
|
this.inboxFileCache.size >= INBOX_FILE_CACHE_MAX_ENTRIES
|
||||||
|
) {
|
||||||
|
const oldestKey = this.inboxFileCache.keys().next().value;
|
||||||
|
if (oldestKey) {
|
||||||
|
this.inboxFileCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.inboxFileCache.set(inboxPath, {
|
||||||
|
signature,
|
||||||
|
messages: cloneInboxMessages(messages),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async listInboxNames(teamName: string): Promise<string[]> {
|
async listInboxNames(teamName: string): Promise<string[]> {
|
||||||
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
|
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
|
||||||
|
|
||||||
|
|
@ -53,15 +128,23 @@ export class TeamInboxReader {
|
||||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
|
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
|
||||||
|
|
||||||
let raw: string;
|
let raw: string;
|
||||||
|
let signature: InboxFileSignature;
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(inboxPath);
|
const stat = await fs.promises.stat(inboxPath);
|
||||||
// Avoid hangs on non-regular files (FIFO, sockets) and unbounded memory usage on huge files.
|
// Avoid hangs on non-regular files (FIFO, sockets) and unbounded memory usage on huge files.
|
||||||
if (!stat.isFile() || stat.size > MAX_INBOX_FILE_BYTES) {
|
if (!stat.isFile() || stat.size > MAX_INBOX_FILE_BYTES) {
|
||||||
|
this.inboxFileCache.delete(inboxPath);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
signature = buildInboxFileSignature(stat);
|
||||||
|
const cached = this.getCachedMessages(inboxPath, signature);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
raw = await readFileUtf8WithTimeout(inboxPath, 5_000);
|
raw = await readFileUtf8WithTimeout(inboxPath, 5_000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
this.inboxFileCache.delete(inboxPath);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (error instanceof FileReadTimeoutError) {
|
if (error instanceof FileReadTimeoutError) {
|
||||||
|
|
@ -74,9 +157,11 @@ export class TeamInboxReader {
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw) as unknown;
|
parsed = JSON.parse(raw) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
|
this.setCachedMessages(inboxPath, signature, []);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
|
this.setCachedMessages(inboxPath, signature, []);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,7 +284,8 @@ export class TeamInboxReader {
|
||||||
return bt - at;
|
return bt - at;
|
||||||
});
|
});
|
||||||
|
|
||||||
return messages;
|
this.setCachedMessages(inboxPath, signature, messages);
|
||||||
|
return cloneInboxMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessages(teamName: string): Promise<InboxMessage[]> {
|
async getMessages(teamName: string): Promise<InboxMessage[]> {
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,14 @@ const hoisted = vi.hoisted(() => {
|
||||||
error.code = 'ENOENT';
|
error.code = 'ENOENT';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const signatureValue = Array.from(data).reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
||||||
return {
|
return {
|
||||||
isFile: () => true,
|
isFile: () => true,
|
||||||
size: Buffer.byteLength(data, 'utf8'),
|
size: Buffer.byteLength(data, 'utf8'),
|
||||||
|
mtimeMs: signatureValue,
|
||||||
|
ctimeMs: signatureValue,
|
||||||
|
dev: 1,
|
||||||
|
ino: signatureValue,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -69,6 +74,7 @@ describe('TeamInboxReader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hoisted.files.clear();
|
hoisted.files.clear();
|
||||||
hoisted.dirs.clear();
|
hoisted.dirs.clear();
|
||||||
|
hoisted.stat.mockClear();
|
||||||
hoisted.readdir.mockClear();
|
hoisted.readdir.mockClear();
|
||||||
hoisted.readFile.mockClear();
|
hoisted.readFile.mockClear();
|
||||||
});
|
});
|
||||||
|
|
@ -119,6 +125,91 @@ describe('TeamInboxReader', () => {
|
||||||
expect(merged[1].text).toBe('older');
|
expect(merged[1].text).toBe('older');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caches getMessagesFor results while the inbox file signature is unchanged', async () => {
|
||||||
|
hoisted.files.set(
|
||||||
|
'/mock/teams/my-team/inboxes/alice.json',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'cached',
|
||||||
|
timestamp: '2026-01-01T00:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-1',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await reader.getMessagesFor('my-team', 'alice');
|
||||||
|
first[0]!.to = 'mutated';
|
||||||
|
const second = await reader.getMessagesFor('my-team', 'alice');
|
||||||
|
|
||||||
|
expect(hoisted.stat).toHaveBeenCalledTimes(2);
|
||||||
|
expect(hoisted.readFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
messageId: 'm-1',
|
||||||
|
text: 'cached',
|
||||||
|
to: undefined,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-reads getMessagesFor results when the inbox file signature changes', async () => {
|
||||||
|
const inboxPath = '/mock/teams/my-team/inboxes/alice.json';
|
||||||
|
hoisted.files.set(
|
||||||
|
inboxPath,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'first',
|
||||||
|
timestamp: '2026-01-01T00:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-1',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect((await reader.getMessagesFor('my-team', 'alice'))[0]?.text).toBe('first');
|
||||||
|
|
||||||
|
hoisted.files.set(
|
||||||
|
inboxPath,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'second',
|
||||||
|
timestamp: '2026-01-01T00:00:01.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-2',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await reader.getMessagesFor('my-team', 'alice'))[0]?.text).toBe('second');
|
||||||
|
expect(hoisted.readFile).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let getMessages recipient backfill mutate cached member inbox rows', async () => {
|
||||||
|
hoisted.dirs.set(inboxDir, ['alice.json']);
|
||||||
|
hoisted.files.set(
|
||||||
|
'/mock/teams/my-team/inboxes/alice.json',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'legacy recipient',
|
||||||
|
timestamp: '2026-01-01T00:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-1',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged = await reader.getMessages('my-team');
|
||||||
|
const direct = await reader.getMessagesFor('my-team', 'alice');
|
||||||
|
|
||||||
|
expect(merged[0]?.to).toBe('alice');
|
||||||
|
expect(direct[0]?.to).toBeUndefined();
|
||||||
|
expect(hoisted.readFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('generates deterministic messageId for legacy inbox rows without messageId', async () => {
|
it('generates deterministic messageId for legacy inbox rows without messageId', async () => {
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
'/mock/teams/my-team/inboxes/alice.json',
|
'/mock/teams/my-team/inboxes/alice.json',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue