( event: typeof outboxEvent.$inferSelect, errMsg: string )
| 394 | } |
| 395 | |
| 396 | async function recordTimedOutAttempt( |
| 397 | event: typeof outboxEvent.$inferSelect, |
| 398 | errMsg: string |
| 399 | ): Promise<'dead_letter' | 'lease_lost'> { |
| 400 | const nextAttempts = event.attempts + 1 |
| 401 | const isDead = nextAttempts >= event.maxAttempts |
| 402 | |
| 403 | if (isDead) { |
| 404 | const updated = await updateIfLeaseHeld(event, { |
| 405 | attempts: nextAttempts, |
| 406 | status: 'dead_letter', |
| 407 | lastError: errMsg, |
| 408 | processedAt: new Date(), |
| 409 | lockedAt: null, |
| 410 | }) |
| 411 | if (!updated) return 'lease_lost' |
| 412 | logger.error('Outbox event dead-lettered after handler timeout max attempts', { |
| 413 | eventId: event.id, |
| 414 | eventType: event.eventType, |
| 415 | attempts: nextAttempts, |
| 416 | error: errMsg, |
| 417 | }) |
| 418 | return 'dead_letter' |
| 419 | } |
| 420 | |
| 421 | const updated = await updateProcessingIfLeaseHeld(event, { |
| 422 | attempts: nextAttempts, |
| 423 | lastError: errMsg, |
| 424 | lockedAt: new Date(), |
| 425 | }) |
| 426 | if (!updated) return 'lease_lost' |
| 427 | |
| 428 | logger.warn('Outbox event handler timed out; leaving lease for stuck-row reaper', { |
| 429 | eventId: event.id, |
| 430 | eventType: event.eventType, |
| 431 | attempts: nextAttempts, |
| 432 | reaperThresholdMs: STUCK_PROCESSING_THRESHOLD_MS, |
| 433 | error: errMsg, |
| 434 | }) |
| 435 | return 'lease_lost' |
| 436 | } |
| 437 | |
| 438 | async function scheduleRetry( |
| 439 | event: typeof outboxEvent.$inferSelect, |
no test coverage detected