fix(#196): fix race condition in job status updates
Implemented optimistic locking with version field and SELECT FOR UPDATE transactions to prevent data corruption from concurrent job status updates. Changes: - Added version field to RunnerJob schema for optimistic locking - Created migration 20260202_add_runner_job_version_for_concurrency - Implemented ConcurrentUpdateException for conflict detection - Updated RunnerJobsService methods with optimistic locking: * updateStatus() - with version checking and retry logic * updateProgress() - with version checking and retry logic * cancel() - with version checking and retry logic - Updated CoordinatorIntegrationService with SELECT FOR UPDATE: * updateJobStatus() - transaction with row locking * completeJob() - transaction with row locking * failJob() - transaction with row locking * updateJobProgress() - optimistic locking - Added retry mechanism (3 attempts) with exponential backoff - Added comprehensive concurrency tests (10 tests, all passing) - Updated existing test mocks to support updateMany Test Results: - All 10 concurrency tests passing ✓ - Tests cover concurrent status updates, progress updates, completions, cancellations, retry logic, and exponential backoff This fix prevents race conditions that could cause: - Lost job results (double completion) - Lost progress updates - Invalid status transitions - Data corruption under concurrent access Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,33 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Prisma, Project } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { ActivityService } from "../activity/activity.service";
|
||||
import { ProjectStatus } from "@prisma/client";
|
||||
import type { CreateProjectDto, UpdateProjectDto, QueryProjectsDto } from "./dto";
|
||||
|
||||
type ProjectWithRelations = Project & {
|
||||
creator: { id: string; name: string; email: string };
|
||||
_count: { tasks: number; events: number };
|
||||
};
|
||||
|
||||
type ProjectWithDetails = Project & {
|
||||
creator: { id: string; name: string; email: string };
|
||||
tasks: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
}[];
|
||||
events: {
|
||||
id: string;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date | null;
|
||||
}[];
|
||||
_count: { tasks: number; events: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Service for managing projects
|
||||
*/
|
||||
@@ -18,7 +41,11 @@ export class ProjectsService {
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async create(workspaceId: string, userId: string, createProjectDto: CreateProjectDto) {
|
||||
async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
createProjectDto: CreateProjectDto
|
||||
): Promise<ProjectWithRelations> {
|
||||
const data: Prisma.ProjectCreateInput = {
|
||||
name: createProjectDto.name,
|
||||
description: createProjectDto.description ?? null,
|
||||
@@ -56,7 +83,15 @@ export class ProjectsService {
|
||||
/**
|
||||
* Get paginated projects with filters
|
||||
*/
|
||||
async findAll(query: QueryProjectsDto) {
|
||||
async findAll(query: QueryProjectsDto): Promise<{
|
||||
data: ProjectWithRelations[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 50;
|
||||
const skip = (page - 1) * limit;
|
||||
@@ -117,7 +152,7 @@ export class ProjectsService {
|
||||
/**
|
||||
* Get a single project by ID
|
||||
*/
|
||||
async findOne(id: string, workspaceId: string) {
|
||||
async findOne(id: string, workspaceId: string): Promise<ProjectWithDetails> {
|
||||
const project = await this.prisma.project.findUnique({
|
||||
where: {
|
||||
id,
|
||||
@@ -167,7 +202,7 @@ export class ProjectsService {
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
updateProjectDto: UpdateProjectDto
|
||||
) {
|
||||
): Promise<ProjectWithRelations> {
|
||||
// Verify project exists
|
||||
const existingProject = await this.prisma.project.findUnique({
|
||||
where: { id, workspaceId },
|
||||
@@ -217,7 +252,7 @@ export class ProjectsService {
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
async remove(id: string, workspaceId: string, userId: string) {
|
||||
async remove(id: string, workspaceId: string, userId: string): Promise<void> {
|
||||
// Verify project exists
|
||||
const project = await this.prisma.project.findUnique({
|
||||
where: { id, workspaceId },
|
||||
|
||||
Reference in New Issue
Block a user