Apply RLS context at task service boundaries, harden orchestrator/web integration and session startup behavior, re-enable targeted frontend tests, and lock vulnerable transitive dependencies so QA and security gates pass cleanly.
256 lines
8.0 KiB
TypeScript
256 lines
8.0 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { JobEventsService } from "./job-events.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { VaultService } from "../vault/vault.service";
|
|
import { CryptoService } from "../federation/crypto.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
|
|
*/
|
|
const shouldRunDbIntegrationTests =
|
|
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
|
|
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
|
|
|
|
describeFn("JobEventsService Performance", () => {
|
|
let service: JobEventsService;
|
|
let prisma: PrismaService;
|
|
let testJobId: string;
|
|
let testWorkspaceId: string;
|
|
|
|
beforeAll(async () => {
|
|
// Mock ConfigService for VaultService/CryptoService
|
|
const mockConfigService = {
|
|
get: vi.fn((key: string) => {
|
|
const config: Record<string, string> = {
|
|
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
OPENBAO_ADDR: "http://localhost:8200",
|
|
OPENBAO_ROLE_ID: "test-role-id",
|
|
OPENBAO_SECRET_ID: "test-secret-id",
|
|
};
|
|
return config[key] || null;
|
|
}),
|
|
};
|
|
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
JobEventsService,
|
|
PrismaService,
|
|
{
|
|
provide: ConfigService,
|
|
useValue: mockConfigService,
|
|
},
|
|
VaultService,
|
|
CryptoService,
|
|
],
|
|
}).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);
|
|
});
|
|
});
|
|
});
|