Skip to content

Commit 3d7334e

Browse files
committed
chore: merge feat/tasks-cancel-and-redis into main
2 parents f9690a8 + 74bb2e1 commit 3d7334e

5 files changed

Lines changed: 82 additions & 29 deletions

File tree

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { Module } from '@nestjs/common'
22
import { TasksRepository } from '../tasks/tasks.repository.js'
3-
import { EventsRepository } from '../delivery/events.repository.js'
43
import { CallbackAuthGuard } from './guards/callback-auth.guard.js'
54
import { ExecutorService } from './executor.service.js'
65
import { ExecutorController } from './executor.controller.js'
6+
import { DeliveryModule } from '../delivery/delivery.module.js'
77

88
/**
9-
* API-specific module for the Executor domain.
10-
* Contains only HTTP controllers and their dependencies.
9+
* Imports DeliveryModule to share the EventsRepository singleton
10+
* already registered there — avoids a duplicate provider instance
11+
* that would break NestJS DI scope semantics.
1112
*/
1213
@Module({
14+
imports: [DeliveryModule],
1315
controllers: [ExecutorController],
14-
providers: [TasksRepository, EventsRepository, CallbackAuthGuard, ExecutorService],
16+
providers: [TasksRepository, CallbackAuthGuard, ExecutorService],
1517
})
1618
export class ExecutorModule {}

src/modules/tasks/schemas/tasks.sql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const tasks = pgTable(
110110
// Hot partial index for the timeout sweeper.
111111
index('tasks_sweeper_idx')
112112
.on(t.expiresAt)
113-
.where(sql`${t.status} = 'PROCESSING'`),
113+
.where(sql`${t.status} IN ('PENDING', 'PROCESSING')`),
114114

115115
uniqueIndex('tasks_idempotency_key_idx')
116116
.on(t.idempotencyKey)

src/modules/tasks/tasks.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'
22
import { TasksController } from './tasks.controller.js'
33
import { TasksService } from './tasks.service.js'
44
import { TasksRepository } from './tasks.repository.js'
5+
import { EventsRepository } from '../delivery/events.repository.js'
56

67
@Module({
78
controllers: [TasksController],
8-
providers: [TasksService, TasksRepository],
9+
providers: [TasksService, TasksRepository, EventsRepository],
910
exports: [],
1011
})
1112
export class TasksModule {}

src/modules/tasks/tasks.repository.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Injectable } from '@nestjs/common'
2-
import { and, eq, inArray } from 'drizzle-orm'
2+
import { and, eq, inArray, sql } from 'drizzle-orm'
33
import { tasks } from './schemas/tasks.sql.js'
44
import type { TaskInsert, TaskSelect, TaskStatus } from './schemas/tasks.sql.js'
5+
import { taskEvents } from '../delivery/schemas/events.sql.js'
6+
import type { TaskEventSelect } from '../delivery/schemas/events.sql.js'
57
import type { DrizzleDb } from '#src/database/drizzle/drizzle.provider.js'
68
import { InjectDb } from '#src/database/drizzle/drizzle.provider.js'
79

@@ -55,4 +57,45 @@ export class TasksRepository {
5557

5658
return result.length > 0 ? (result[0] ?? null) : null
5759
}
60+
61+
/**
62+
* Atomically transitions a task to CANCELLED and inserts a 'cancelled' event.
63+
* Eliminates the risk of a race condition or partial failure leaving the state
64+
* machine and event log out of sync.
65+
*/
66+
async cancelTask(
67+
id: string,
68+
): Promise<{ task: TaskSelect; event: TaskEventSelect } | null> {
69+
return this.db.transaction(async (tx) => {
70+
const updatedTasks = await tx
71+
.update(tasks)
72+
.set({ status: 'CANCELLED', completedAt: new Date() })
73+
.where(and(eq(tasks.id, id), inArray(tasks.status, ['PENDING', 'PROCESSING'])))
74+
.returning()
75+
76+
if (updatedTasks.length === 0) return null
77+
const task = updatedTasks[0]
78+
if (!task) return null
79+
80+
await tx.execute(
81+
sql`SELECT pg_advisory_xact_lock(hashtext('task_events'), hashtext(${id}))`,
82+
)
83+
const [seqRow] = await tx
84+
.select({ nextSeq: sql<number>`COALESCE(MAX(${taskEvents.seq}), 0) + 1` })
85+
.from(taskEvents)
86+
.where(eq(taskEvents.taskId, id))
87+
88+
const [event] = await tx
89+
.insert(taskEvents)
90+
.values({
91+
taskId: id,
92+
seq: seqRow?.nextSeq ?? 1,
93+
eventType: 'cancelled',
94+
payload: {},
95+
})
96+
.returning()
97+
98+
return { task, event: event as TaskEventSelect }
99+
})
100+
}
58101
}

src/modules/tasks/tasks.service.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { IdempotencyService } from '#libs/idempotency/index.js'
55
import { QUEUE_ADAPTER } from '#libs/queue/index.js'
66
import type { IQueueAdapter } from '#libs/queue/index.js'
77
import { Inject } from '@nestjs/common'
8+
import { RedisService } from '#libs/redis/index.js'
89
import type { CreateTaskDto, TaskCreatedResponseDto } from './dto/tasks.dto.js'
910
import type { TaskSelect } from './schemas/tasks.sql.js'
1011

@@ -26,13 +27,13 @@ export class TasksService {
2627
constructor(
2728
private readonly tasksRepo: TasksRepository,
2829
private readonly idempotency: IdempotencyService,
30+
private readonly redis: RedisService,
2931
@Inject(QUEUE_ADAPTER) private readonly queue: IQueueAdapter,
3032
) {}
3133

3234
async create(dto: CreateTaskDto, baseUrl: string): Promise<TaskCreatedResponseDto> {
3335
const { idempotencyKey } = dto
3436

35-
// --- Idempotency check (fast path) ---
3637
if (idempotencyKey) {
3738
const slot = await this.idempotency.occupySlot(
3839
idempotencyKey,
@@ -63,22 +64,20 @@ export class TasksService {
6364
cancelUrl: dto.cancelUrl,
6465
webhookUrl: dto.webhookUrl,
6566
timeoutSeconds: dto.timeoutSeconds,
67+
expiresAt: new Date(Date.now() + dto.timeoutSeconds * 1000),
6668
idempotencyKey: dto.idempotencyKey,
67-
// Generated once and stored; the worker embeds it in the Authorization header.
68-
// The executor must present it back when pushing results - never logged or
69-
// returned to clients.
7069
callbackToken: randomUUID(),
7170
})
7271
} catch (error) {
7372
// Release the slot so the next retry can attempt creation cleanly.
7473
if (idempotencyKey) {
75-
await this.idempotency.releaseSlot(idempotencyKey)
74+
await this.idempotency.releaseSlot(idempotencyKey).catch((e: unknown) => {
75+
this.logger.error(`Failed to release idempotency slot`, e)
76+
})
7677
}
7778
throw error
7879
}
7980

80-
// attempts=1: prevents BullMQ from re-dispatching a job that has already
81-
// transitioned the task to PROCESSING (double-execution guard).
8281
try {
8382
await this.queue.enqueue(
8483
DISPATCH_QUEUE,
@@ -93,9 +92,19 @@ export class TasksService {
9392
`Failed to enqueue dispatch for task ${task.id}, rolling back`,
9493
error instanceof Error ? error.stack : String(error),
9594
)
96-
await this.tasksRepo.deleteById(task.id)
97-
if (idempotencyKey) {
98-
await this.idempotency.releaseSlot(idempotencyKey)
95+
try {
96+
await this.tasksRepo.deleteById(task.id)
97+
} catch (rollbackErr) {
98+
this.logger.error(
99+
`Rollback failed for task ${task.id}`,
100+
rollbackErr instanceof Error ? rollbackErr.stack : String(rollbackErr),
101+
)
102+
} finally {
103+
if (idempotencyKey) {
104+
await this.idempotency.releaseSlot(idempotencyKey).catch((e: unknown) => {
105+
this.logger.error(`Failed to release idempotency slot for task ${task.id}`, e)
106+
})
107+
}
99108
}
100109
throw error
101110
}
@@ -114,16 +123,11 @@ export class TasksService {
114123
return task
115124
}
116125

117-
/** Cancels a PENDING task directly. Worker notifies executors for PROCESSING tasks via cancelUrl. */
126+
/** Cancels a PENDING or PROCESSING task. Publishes 'cancelled' event for SSE consumers. */
118127
async cancel(id: string): Promise<void> {
119-
const updatedTask = await this.tasksRepo.updateStatus(
120-
id,
121-
['PENDING', 'PROCESSING'],
122-
'CANCELLED',
123-
{ completedAt: new Date() },
124-
)
125-
126-
if (!updatedTask) {
128+
const result = await this.tasksRepo.cancelTask(id)
129+
130+
if (!result) {
127131
// Task was not PENDING/PROCESSING or doesn't exist. Fetch it to give an accurate error.
128132
const currentTask = await this.tasksRepo.findById(id)
129133
if (!currentTask) throw new NotFoundException(`Task ${id} not found`)
@@ -133,9 +137,12 @@ export class TasksService {
133137
)
134138
}
135139

136-
// cancelUrl notification is best-effort: executor receives it async
137-
// via cancel.processor.
138-
if (updatedTask.cancelUrl) {
140+
const { task, event } = result
141+
142+
// Notify live SSE clients.
143+
await this.redis.client.publish(`task:${id}`, JSON.stringify(event))
144+
145+
if (task.cancelUrl) {
139146
await this.queue.enqueue('cancel', { name: 'cancel', data: { taskId: id } })
140147
}
141148

0 commit comments

Comments
 (0)