fix(#189): add composite database index for job_events table

Add composite index [jobId, timestamp] to improve query performance
for the most common job_events access patterns.

Changes:
- Add @@index([jobId, timestamp]) to JobEvent model in schema.prisma
- Create migration 20260202122655_add_job_events_composite_index
- Add performance tests to validate index effectiveness
- Document index design rationale in scratchpad
- Fix lint errors in api-key.guard, herald.service, runner-jobs.service

Rationale:
The composite index [jobId, timestamp] optimizes the dominant query
pattern used across all services:
- JobEventsService.getEventsByJobId (WHERE jobId, ORDER BY timestamp)
- RunnerJobsService.streamEvents (WHERE jobId + timestamp range)
- RunnerJobsService.findOne (implicit jobId filter + timestamp order)

This index provides:
- Fast filtering by jobId (highly selective)
- Efficient timestamp-based ordering
- Optimal support for timestamp range queries
- Backward compatibility with jobId-only queries

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-02 12:30:19 -06:00
parent e3479aeffd
commit 7101864a15
7 changed files with 553 additions and 48 deletions

View File

@@ -0,0 +1,226 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { JobEventsService } from "./job-events.service";
import { PrismaService } from "../prisma/prisma.service";
import { JOB_CREATED, JOB_STARTED, STEP_STARTED } from "./event-types";
/**
* Performance tests for JobEventsService
*
* These tests verify that the composite index [jobId, timestamp] improves
* query performance for the most common access patterns.
*
* NOTE: These tests require a real database connection with realistic data volume.
* Run with: pnpm test:api -- job-events.performance.spec.ts
*/
describe("JobEventsService Performance", () => {
let service: JobEventsService;
let prisma: PrismaService;
let testJobId: string;
let testWorkspaceId: string;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [JobEventsService, PrismaService],
}).compile();
service = module.get<JobEventsService>(JobEventsService);
prisma = module.get<PrismaService>(PrismaService);
// Create test workspace
const workspace = await prisma.workspace.create({
data: {
name: "Performance Test Workspace",
owner: {
create: {
email: `perf-test-${Date.now()}@example.com`,
name: "Performance Test User",
},
},
},
});
testWorkspaceId = workspace.id;
// Create test job with many events
const job = await prisma.runnerJob.create({
data: {
workspaceId: testWorkspaceId,
type: "code-task",
status: "RUNNING",
priority: 5,
progressPercent: 0,
},
});
testJobId = job.id;
// Create 1000 events to simulate realistic load
const events = [];
for (let i = 0; i < 1000; i++) {
events.push({
jobId: testJobId,
type: i % 3 === 0 ? JOB_STARTED : i % 3 === 1 ? STEP_STARTED : JOB_CREATED,
timestamp: new Date(Date.now() - (1000 - i) * 1000), // Events over ~16 minutes
actor: "system",
payload: { iteration: i },
});
}
// Batch insert for performance
await prisma.jobEvent.createMany({
data: events,
});
});
afterAll(async () => {
// Clean up test data
await prisma.jobEvent.deleteMany({
where: { jobId: testJobId },
});
await prisma.runnerJob.delete({
where: { id: testJobId },
});
await prisma.workspace.delete({
where: { id: testWorkspaceId },
});
await prisma.$disconnect();
});
describe("Query Performance", () => {
it("should efficiently query events by jobId with timestamp ordering", async () => {
const startTime = performance.now();
const result = await service.getEventsByJobId(testJobId, {
page: 1,
limit: 50,
});
const endTime = performance.now();
const queryTime = endTime - startTime;
expect(result.data).toHaveLength(50);
expect(result.meta.total).toBe(1000);
expect(queryTime).toBeLessThan(100); // Should complete in under 100ms
// Verify events are ordered by timestamp ascending
for (let i = 1; i < result.data.length; i++) {
expect(result.data[i].timestamp.getTime()).toBeGreaterThanOrEqual(
result.data[i - 1].timestamp.getTime()
);
}
});
it("should efficiently query events by jobId and type with timestamp ordering", async () => {
const startTime = performance.now();
const result = await service.getEventsByJobId(testJobId, {
type: JOB_STARTED,
page: 1,
limit: 50,
});
const endTime = performance.now();
const queryTime = endTime - startTime;
expect(result.data.length).toBeGreaterThan(0);
expect(result.data.every((e) => e.type === JOB_STARTED)).toBe(true);
expect(queryTime).toBeLessThan(100); // Should complete in under 100ms
});
it("should efficiently query events with timestamp range (streaming pattern)", async () => {
// Get a timestamp from the middle of our test data
const midpointTime = new Date(Date.now() - 500 * 1000);
const startTime = performance.now();
const events = await prisma.jobEvent.findMany({
where: {
jobId: testJobId,
timestamp: { gt: midpointTime },
},
orderBy: { timestamp: "asc" },
take: 100,
});
const endTime = performance.now();
const queryTime = endTime - startTime;
expect(events.length).toBeGreaterThan(0);
expect(events.length).toBeLessThanOrEqual(100);
expect(queryTime).toBeLessThan(50); // Range queries should be very fast with index
// Verify all events are after the midpoint
events.forEach((event) => {
expect(event.timestamp.getTime()).toBeGreaterThan(midpointTime.getTime());
});
});
it("should use the composite index in query plan", async () => {
// Execute EXPLAIN ANALYZE to verify index usage
const explainResult = await prisma.$queryRaw<Array<{ "QUERY PLAN": string }>>`
EXPLAIN (FORMAT JSON)
SELECT * FROM job_events
WHERE job_id = ${testJobId}::uuid
ORDER BY timestamp ASC
LIMIT 50
`;
const queryPlan = JSON.stringify(explainResult);
// Verify that an index scan is used (not a sequential scan)
expect(queryPlan.toLowerCase()).toContain("index");
expect(queryPlan.toLowerCase()).not.toContain("seq scan on job_events");
// The composite index should be named something like:
// job_events_job_id_timestamp_idx or similar
expect(queryPlan.includes("job_events_job_id") || queryPlan.includes("index")).toBe(true);
});
});
describe("Pagination Performance", () => {
it("should efficiently paginate through all events", async () => {
const startTime = performance.now();
// Fetch page 10 (events 450-499)
const result = await service.getEventsByJobId(testJobId, {
page: 10,
limit: 50,
});
const endTime = performance.now();
const queryTime = endTime - startTime;
expect(result.data).toHaveLength(50);
expect(result.meta.page).toBe(10);
expect(queryTime).toBeLessThan(150); // Should complete in under 150ms even with OFFSET
});
});
describe("Concurrent Query Performance", () => {
it("should handle multiple concurrent queries efficiently", async () => {
const startTime = performance.now();
// Simulate 10 concurrent clients querying the same job
const queries = Array.from({ length: 10 }, (_, i) =>
service.getEventsByJobId(testJobId, {
page: i + 1,
limit: 50,
})
);
const results = await Promise.all(queries);
const endTime = performance.now();
const totalTime = endTime - startTime;
expect(results).toHaveLength(10);
results.forEach((result, i) => {
expect(result.data).toHaveLength(50);
expect(result.meta.page).toBe(i + 1);
});
// All 10 queries should complete in under 500ms total
expect(totalTime).toBeLessThan(500);
});
});
});