Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { RunnerJobsService } from "./runner-jobs.service";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { BullMqService } from "../bullmq/bullmq.service";
|
|
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
|
import { RunnerJobStatus } from "@prisma/client";
|
|
import { ConflictException, BadRequestException } from "@nestjs/common";
|
|
|
|
/**
|
|
* Concurrency tests for RunnerJobsService
|
|
* These tests verify that race conditions in job status updates are properly handled
|
|
*/
|
|
describe("RunnerJobsService - Concurrency", () => {
|
|
let service: RunnerJobsService;
|
|
let prisma: PrismaService;
|
|
|
|
const mockBullMqService = {
|
|
addJob: vi.fn(),
|
|
getQueue: vi.fn(),
|
|
};
|
|
|
|
const mockWebSocketGateway = {
|
|
emitJobCreated: vi.fn(),
|
|
emitJobStatusChanged: vi.fn(),
|
|
emitJobProgress: vi.fn(),
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
RunnerJobsService,
|
|
{
|
|
provide: PrismaService,
|
|
useValue: {
|
|
runnerJob: {
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
updateMany: vi.fn(),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
provide: BullMqService,
|
|
useValue: mockBullMqService,
|
|
},
|
|
{
|
|
provide: WebSocketGateway,
|
|
useValue: mockWebSocketGateway,
|
|
},
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<RunnerJobsService>(RunnerJobsService);
|
|
prisma = module.get<PrismaService>(PrismaService);
|
|
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("concurrent status updates", () => {
|
|
it("should detect concurrent status update conflict using version field", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
// Mock job with version 1
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 1,
|
|
startedAt: new Date(),
|
|
};
|
|
|
|
// First findUnique returns job with version 1
|
|
vi.mocked(prisma.runnerJob.findUnique).mockResolvedValue(mockJob as any);
|
|
|
|
// updateMany returns 0 (no rows updated - version mismatch)
|
|
vi.mocked(prisma.runnerJob.updateMany).mockResolvedValue({ count: 0 });
|
|
|
|
// Should throw ConflictException when concurrent update detected
|
|
await expect(
|
|
service.updateStatus(jobId, workspaceId, RunnerJobStatus.COMPLETED)
|
|
).rejects.toThrow(ConflictException);
|
|
|
|
// Verify updateMany was called with version check
|
|
expect(prisma.runnerJob.updateMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
id: jobId,
|
|
workspaceId,
|
|
version: 1,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should successfully update when no concurrent conflict exists", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 1,
|
|
startedAt: new Date(),
|
|
};
|
|
|
|
const updatedJob = {
|
|
...mockJob,
|
|
status: RunnerJobStatus.COMPLETED,
|
|
version: 2,
|
|
completedAt: new Date(),
|
|
};
|
|
|
|
// First call for initial read
|
|
vi.mocked(prisma.runnerJob.findUnique)
|
|
.mockResolvedValueOnce(mockJob as any)
|
|
// Second call after updateMany succeeds
|
|
.mockResolvedValueOnce(updatedJob as any);
|
|
|
|
vi.mocked(prisma.runnerJob.updateMany).mockResolvedValue({ count: 1 });
|
|
|
|
const result = await service.updateStatus(jobId, workspaceId, RunnerJobStatus.COMPLETED);
|
|
|
|
expect(result.status).toBe(RunnerJobStatus.COMPLETED);
|
|
expect(result.version).toBe(2);
|
|
});
|
|
|
|
it("should retry on conflict and succeed on second attempt", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockJobV1 = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 1,
|
|
};
|
|
|
|
const mockJobV2 = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 2,
|
|
};
|
|
|
|
const updatedJob = {
|
|
...mockJobV2,
|
|
status: RunnerJobStatus.COMPLETED,
|
|
version: 3,
|
|
completedAt: new Date(),
|
|
};
|
|
|
|
// First attempt: version 1, updateMany returns 0 (conflict)
|
|
vi.mocked(prisma.runnerJob.findUnique)
|
|
.mockResolvedValueOnce(mockJobV1 as any) // Initial read
|
|
.mockResolvedValueOnce(mockJobV2 as any) // Retry read
|
|
.mockResolvedValueOnce(updatedJob as any); // Final read after update
|
|
|
|
vi.mocked(prisma.runnerJob.updateMany)
|
|
.mockResolvedValueOnce({ count: 0 }) // First attempt fails
|
|
.mockResolvedValueOnce({ count: 1 }); // Retry succeeds
|
|
|
|
const result = await service.updateStatus(jobId, workspaceId, RunnerJobStatus.COMPLETED);
|
|
|
|
expect(result.status).toBe(RunnerJobStatus.COMPLETED);
|
|
expect(prisma.runnerJob.updateMany).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("concurrent progress updates", () => {
|
|
it("should detect concurrent progress update conflict", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
progressPercent: 50,
|
|
version: 5,
|
|
};
|
|
|
|
vi.mocked(prisma.runnerJob.findUnique).mockResolvedValue(mockJob as any);
|
|
vi.mocked(prisma.runnerJob.updateMany).mockResolvedValue({ count: 0 });
|
|
|
|
await expect(service.updateProgress(jobId, workspaceId, 75)).rejects.toThrow(
|
|
ConflictException
|
|
);
|
|
});
|
|
|
|
it("should handle rapid sequential progress updates", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
// Simulate 5 rapid progress updates
|
|
const progressValues = [20, 40, 60, 80, 100];
|
|
let version = 1;
|
|
|
|
for (const progress of progressValues) {
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
progressPercent: progress - 20,
|
|
version,
|
|
};
|
|
|
|
const updatedJob = {
|
|
...mockJob,
|
|
progressPercent: progress,
|
|
version: version + 1,
|
|
};
|
|
|
|
vi.mocked(prisma.runnerJob.findUnique)
|
|
.mockResolvedValueOnce(mockJob as any)
|
|
.mockResolvedValueOnce(updatedJob as any);
|
|
|
|
vi.mocked(prisma.runnerJob.updateMany).mockResolvedValueOnce({ count: 1 });
|
|
|
|
const result = await service.updateProgress(jobId, workspaceId, progress);
|
|
|
|
expect(result.progressPercent).toBe(progress);
|
|
expect(result.version).toBe(version + 1);
|
|
|
|
version++;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("concurrent completion", () => {
|
|
it("should prevent double completion with different results", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 1,
|
|
startedAt: new Date(),
|
|
};
|
|
|
|
const updatedJob = {
|
|
...mockJob,
|
|
status: RunnerJobStatus.COMPLETED,
|
|
version: 2,
|
|
result: { outcome: "success-A" },
|
|
completedAt: new Date(),
|
|
};
|
|
|
|
// Test first completion (succeeds)
|
|
vi.mocked(prisma.runnerJob.findUnique)
|
|
.mockResolvedValueOnce(mockJob as any) // First completion - initial read
|
|
.mockResolvedValueOnce(updatedJob as any); // First completion - after update
|
|
|
|
vi.mocked(prisma.runnerJob.updateMany).mockResolvedValueOnce({ count: 1 });
|
|
|
|
const result1 = await service.updateStatus(jobId, workspaceId, RunnerJobStatus.COMPLETED, {
|
|
result: { outcome: "success-A" },
|
|
});
|
|
|
|
expect(result1.status).toBe(RunnerJobStatus.COMPLETED);
|
|
|
|
// Test second completion (fails due to version mismatch - will retry 3 times)
|
|
vi.mocked(prisma.runnerJob.findUnique)
|
|
.mockResolvedValueOnce(mockJob as any) // Attempt 1: Reads stale version
|
|
.mockResolvedValueOnce(mockJob as any) // Attempt 2: Retry reads stale version
|
|
.mockResolvedValueOnce(mockJob as any); // Attempt 3: Final retry reads stale version
|
|
|
|
vi.mocked(prisma.runnerJob.updateMany)
|
|
.mockResolvedValueOnce({ count: 0 }) // Attempt 1: Version conflict
|
|
.mockResolvedValueOnce({ count: 0 }) // Attempt 2: Version conflict
|
|
.mockResolvedValueOnce({ count: 0 }); // Attempt 3: Version conflict
|
|
|
|
await expect(
|
|
service.updateStatus(jobId, workspaceId, RunnerJobStatus.COMPLETED, {
|
|
result: { outcome: "success-B" },
|
|
})
|
|
).rejects.toThrow(ConflictException);
|
|
});
|
|
});
|
|
|
|
describe("concurrent cancel operations", () => {
|
|
it("should handle concurrent cancel attempts", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 1,
|
|
};
|
|
|
|
const cancelledJob = {
|
|
...mockJob,
|
|
status: RunnerJobStatus.CANCELLED,
|
|
version: 2,
|
|
completedAt: new Date(),
|
|
};
|
|
|
|
// Setup mocks
|
|
vi.mocked(prisma.runnerJob.findUnique)
|
|
.mockResolvedValueOnce(mockJob as any) // First cancel - initial read
|
|
.mockResolvedValueOnce(cancelledJob as any) // First cancel - after update
|
|
.mockResolvedValueOnce(cancelledJob as any); // Second cancel - sees already cancelled
|
|
|
|
vi.mocked(prisma.runnerJob.updateMany).mockResolvedValueOnce({ count: 1 });
|
|
|
|
const result1 = await service.cancel(jobId, workspaceId);
|
|
expect(result1.status).toBe(RunnerJobStatus.CANCELLED);
|
|
|
|
// Second cancel attempt should fail (job already cancelled)
|
|
await expect(service.cancel(jobId, workspaceId)).rejects.toThrow(BadRequestException);
|
|
});
|
|
});
|
|
|
|
describe("retry mechanism", () => {
|
|
it("should retry up to max attempts on version conflicts", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 1,
|
|
};
|
|
|
|
vi.mocked(prisma.runnerJob.findUnique).mockResolvedValue(mockJob as any);
|
|
|
|
// All retry attempts fail
|
|
vi.mocked(prisma.runnerJob.updateMany)
|
|
.mockResolvedValueOnce({ count: 0 })
|
|
.mockResolvedValueOnce({ count: 0 })
|
|
.mockResolvedValueOnce({ count: 0 });
|
|
|
|
// Should throw after max retries (3)
|
|
await expect(
|
|
service.updateStatus(jobId, workspaceId, RunnerJobStatus.COMPLETED)
|
|
).rejects.toThrow(ConflictException);
|
|
|
|
expect(prisma.runnerJob.updateMany).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("should use exponential backoff between retries", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.RUNNING,
|
|
version: 1,
|
|
};
|
|
|
|
vi.mocked(prisma.runnerJob.findUnique).mockResolvedValue(mockJob as any);
|
|
|
|
const updateManyCalls: number[] = [];
|
|
|
|
vi.mocked(prisma.runnerJob.updateMany).mockImplementation(async () => {
|
|
updateManyCalls.push(Date.now());
|
|
return { count: 0 };
|
|
});
|
|
|
|
await expect(
|
|
service.updateStatus(jobId, workspaceId, RunnerJobStatus.COMPLETED)
|
|
).rejects.toThrow(ConflictException);
|
|
|
|
// Verify delays between calls increase (exponential backoff)
|
|
expect(updateManyCalls.length).toBe(3);
|
|
if (updateManyCalls.length >= 3) {
|
|
const delay1 = updateManyCalls[1] - updateManyCalls[0];
|
|
const delay2 = updateManyCalls[2] - updateManyCalls[1];
|
|
// Second delay should be >= first delay (exponential)
|
|
expect(delay2).toBeGreaterThanOrEqual(delay1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("status transition validation with concurrency", () => {
|
|
it("should prevent invalid transitions even under concurrent updates", async () => {
|
|
const jobId = "job-123";
|
|
const workspaceId = "workspace-123";
|
|
|
|
// Job is already completed
|
|
const mockJob = {
|
|
id: jobId,
|
|
workspaceId,
|
|
status: RunnerJobStatus.COMPLETED,
|
|
version: 5,
|
|
completedAt: new Date(),
|
|
};
|
|
|
|
vi.mocked(prisma.runnerJob.findUnique).mockResolvedValue(mockJob as any);
|
|
|
|
// Should reject transition from COMPLETED to RUNNING
|
|
await expect(
|
|
service.updateStatus(jobId, workspaceId, RunnerJobStatus.RUNNING)
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
});
|