perf: gateway + DB + frontend optimizations (P8-003)

- DB client: configure connection pool (max=20, idle_timeout=30s, connect_timeout=5s)
- DB schema: add missing indexes for auth sessions, accounts, conversations, agent_logs
- DB schema: promote preferences(user_id,key) to UNIQUE index for ON CONFLICT upsert
- Drizzle migration: 0003_p8003_perf_indexes.sql
- preferences.service: replace 2-query SELECT+INSERT/UPDATE with single-round-trip upsert
- conversations repo: add ORDER BY + LIMIT to findAll (200) and findMessages (500)
- session-gc.service: make onModuleInit fire-and-forget (removes cold-start TTFB block)
- next.config.ts: enable compress, productionBrowserSourceMaps:false, image avif/webp
- docs/PERFORMANCE.md: full profiling report and change impact notes
This commit is contained in:
2026-03-18 21:26:45 -05:00
parent cbfd6fb996
commit 3b81bc9f3d
11 changed files with 2823 additions and 88 deletions

View File

@@ -36,16 +36,24 @@ export class SessionGCService implements OnModuleInit {
@Inject(LOG_SERVICE) private readonly logService: LogService,
) {}
async onModuleInit(): Promise<void> {
this.logger.log('Running full GC on cold start...');
const result = await this.fullCollect();
this.logger.log(
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
`${result.logsDemoted} logs demoted, ` +
`${result.jobsPurged} jobs purged, ` +
`${result.tempFilesRemoved} temp dirs removed ` +
`(${result.duration}ms)`,
);
onModuleInit(): void {
// Fire-and-forget: run full GC asynchronously so it does not block the
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms
// depending on Valkey key count; deferring it removes that latency from
// the TTFB of the first HTTP request.
this.fullCollect()
.then((result) => {
this.logger.log(
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
`${result.logsDemoted} logs demoted, ` +
`${result.jobsPurged} jobs purged, ` +
`${result.tempFilesRemoved} temp dirs removed ` +
`(${result.duration}ms)`,
);
})
.catch((err: unknown) => {
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
});
}
/**

View File

@@ -5,34 +5,28 @@ import type { Db } from '@mosaic/db';
/**
* Build a mock Drizzle DB where the select chain supports:
* db.select().from().where() → resolves to `listRows`
* db.select().from().where().limit(n) → resolves to `singleRow`
* db.insert().values().onConflictDoUpdate() → resolves to []
*/
function makeMockDb(
listRows: Array<{ key: string; value: unknown }> = [],
singleRow: Array<{ id: string }> = [],
): Db {
function makeMockDb(listRows: Array<{ key: string; value: unknown }> = []): Db {
const chainWithLimit = {
limit: vi.fn().mockResolvedValue(singleRow),
limit: vi.fn().mockResolvedValue([]),
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
};
const selectFrom = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnValue(chainWithLimit),
};
const updateResult = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
const deleteResult = {
where: vi.fn().mockResolvedValue([]),
};
// Single-round-trip upsert chain: insert().values().onConflictDoUpdate()
const insertResult = {
values: vi.fn().mockResolvedValue([]),
values: vi.fn().mockReturnThis(),
onConflictDoUpdate: vi.fn().mockResolvedValue([]),
};
return {
select: vi.fn().mockReturnValue(selectFrom),
update: vi.fn().mockReturnValue(updateResult),
delete: vi.fn().mockReturnValue(deleteResult),
insert: vi.fn().mockReturnValue(insertResult),
} as unknown as Db;
@@ -98,23 +92,14 @@ describe('PreferencesService', () => {
expect(result.message).toContain('platform enforcement');
});
it('upserts a mutable preference and returns success — insert path', async () => {
// singleRow=[] → no existing row → insert path
const db = makeMockDb([], []);
it('upserts a mutable preference and returns success', async () => {
// Single-round-trip INSERT … ON CONFLICT DO UPDATE path.
const db = makeMockDb([]);
const service = new PreferencesService(db);
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
expect(result.success).toBe(true);
expect(result.message).toContain('"agent.thinkingLevel"');
});
it('upserts a mutable preference and returns success — update path', async () => {
// singleRow has an id → existing row → update path
const db = makeMockDb([], [{ id: 'existing-id' }]);
const service = new PreferencesService(db);
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
expect(result.success).toBe(true);
expect(result.message).toContain('"agent.thinkingLevel"');
});
});
describe('reset', () => {

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db';
import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaic/db';
import { DB } from '../database/database.module.js';
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
@@ -88,25 +88,24 @@ export class PreferencesService {
}
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
const existing = await this.db
.select({ id: preferencesTable.id })
.from(preferencesTable)
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
.limit(1);
if (existing.length > 0) {
await this.db
.update(preferencesTable)
.set({ value: value as never, updatedAt: new Date() })
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
} else {
await this.db.insert(preferencesTable).values({
// Single-round-trip upsert using INSERT … ON CONFLICT DO UPDATE.
// Previously this was two queries (SELECT + INSERT/UPDATE), which doubled
// the DB round-trips and introduced a TOCTOU window under concurrent writes.
await this.db
.insert(preferencesTable)
.values({
userId,
key,
value: value as never,
mutable: true,
})
.onConflictDoUpdate({
target: [preferencesTable.userId, preferencesTable.key],
set: {
value: sql`excluded.value`,
updatedAt: sql`now()`,
},
});
}
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
}

View File

@@ -3,6 +3,30 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@mosaic/design-tokens'],
// Enable gzip/brotli compression for all responses.
compress: true,
// Reduce bundle size: disable source maps in production builds.
productionBrowserSourceMaps: false,
// Image optimisation: allow the gateway origin as an external image source.
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
// Experimental: enable React compiler for automatic memoisation (Next 15+).
// Falls back gracefully if the compiler plugin is not installed.
experimental: {
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
// turbo: {},
},
};
export default nextConfig;