@@ -5,6 +5,7 @@ import { IdempotencyService } from '#libs/idempotency/index.js'
55import { QUEUE_ADAPTER } from '#libs/queue/index.js'
66import type { IQueueAdapter } from '#libs/queue/index.js'
77import { Inject } from '@nestjs/common'
8+ import { RedisService } from '#libs/redis/index.js'
89import type { CreateTaskDto , TaskCreatedResponseDto } from './dto/tasks.dto.js'
910import 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