Bug Description
batchTrigger and single trigger behave differently when an idempotency key points to a run that already failed.
With single trigger, if a previous run with the same idempotency key ended up in a terminal failure state (CRASHED, SYSTEM_FAILURE, TIMED_OUT, EXPIRED, COMPLETED_WITH_ERRORS, INTERRUPTED), it correctly clears the key and creates a fresh run. This works because IdempotencyKeyConcern.handleExistingRun calls shouldIdempotencyKeyBeCleared(status) before deciding whether to return a cached result.
With batchTrigger, that same check is missing. The batch path in BatchTriggerV3Service.#prepareRunData only checks time-based expiration (idempotencyKeyExpiresAt < now), so a failed run gets returned as isCached: true — silently handing back a dead run that will never produce output.
Steps to Reproduce
// 1. A task that always fails
export const failingTask = task({
id: "failing-task",
run: async () => { throw new Error("boom"); },
});
// 2. Batch trigger with idempotency key
await tasks.batchTrigger("failing-task", [
{ payload: {}, options: { idempotencyKey: "key-1" } },
]);
// Wait for the run to reach CRASHED / COMPLETED_WITH_ERRORS...
// 3. Trigger again with the same key
await tasks.batchTrigger("failing-task", [
{ payload: {}, options: { idempotencyKey: "key-1" } },
]);
// Expected: isCached: false (fresh run created)
// Actual: isCached: true (dead run returned)
Compare with single trigger — this already works correctly:
await tasks.trigger("failing-task", {}, { idempotencyKey: "key-2" });
// Wait for failure...
await tasks.trigger("failing-task", {}, { idempotencyKey: "key-2" });
// Correctly returns isCached: false and creates a new run
Root Cause
The SQL query in PostgresRunStore.findRunsByIdempotencyKeys never selects the status column, and the IdempotencyKeyRunMatch type doesn't include it. So #prepareRunData has no way to check whether a matched run is in a clearable failure state — it can only check time-based expiration.
The single-trigger path works because IdempotencyKeyConcern.handleExistingRun fetches the full run record and calls shouldIdempotencyKeyBeCleared(status) at idempotencyKeys.server.ts:463. The batch path at batchTriggerV3.server.ts:452-469 skips this entirely.
| Check |
Single trigger |
Batch trigger |
| Time-based expiry |
✅ idempotencyKeyExpiresAt < now |
✅ idempotencyKeyExpiresAt < now |
| Failure status check |
✅ shouldIdempotencyKeyBeCleared(status) |
❌ Missing |
Affected Statuses
These statuses should clear the idempotency key and re-trigger (works for single trigger, broken for batch):
CRASHED
SYSTEM_FAILURE
TIMED_OUT
EXPIRED
COMPLETED_WITH_ERRORS
INTERRUPTED
Suggested Fix
I already wrote and tested a patch for this. My PR #4818 was auto-closed since I'm not a vouched contributor, but the fix is ready on my fork if you'd like to pull it in or use it as reference:
Branch: Jaimin2687:fix/batch-trigger-idempotency-key-status-check
The fix is three small changes:
internal-packages/run-store/src/types.ts — Add status: string to IdempotencyKeyRunMatch
internal-packages/run-store/src/PostgresRunStore.ts — Add "status" to the raw SQL SELECT in findRunsByIdempotencyKeys
apps/webapp/app/v3/services/batchTriggerV3.server.ts — Import shouldIdempotencyKeyBeCleared and add the guard after the expiry check in #prepareRunData, mirroring the single-trigger path
The branch also includes 21 unit tests and 2 integration tests (testcontainers) covering all TaskRunStatus values. Everything passes typecheck, build, format, and lint.
Environment
- trigger.dev
main branch (as of Aug 28, 2025)
- Affects all environments (dev, staging, production)
- Backend only, no UI impact
Bug Description
batchTriggerand singletriggerbehave differently when an idempotency key points to a run that already failed.With single
trigger, if a previous run with the same idempotency key ended up in a terminal failure state (CRASHED,SYSTEM_FAILURE,TIMED_OUT,EXPIRED,COMPLETED_WITH_ERRORS,INTERRUPTED), it correctly clears the key and creates a fresh run. This works becauseIdempotencyKeyConcern.handleExistingRuncallsshouldIdempotencyKeyBeCleared(status)before deciding whether to return a cached result.With
batchTrigger, that same check is missing. The batch path inBatchTriggerV3Service.#prepareRunDataonly checks time-based expiration (idempotencyKeyExpiresAt < now), so a failed run gets returned asisCached: true— silently handing back a dead run that will never produce output.Steps to Reproduce
Compare with single trigger — this already works correctly:
Root Cause
The SQL query in
PostgresRunStore.findRunsByIdempotencyKeysnever selects thestatuscolumn, and theIdempotencyKeyRunMatchtype doesn't include it. So#prepareRunDatahas no way to check whether a matched run is in a clearable failure state — it can only check time-based expiration.The single-trigger path works because
IdempotencyKeyConcern.handleExistingRunfetches the full run record and callsshouldIdempotencyKeyBeCleared(status)at idempotencyKeys.server.ts:463. The batch path at batchTriggerV3.server.ts:452-469 skips this entirely.idempotencyKeyExpiresAt < nowidempotencyKeyExpiresAt < nowshouldIdempotencyKeyBeCleared(status)Affected Statuses
These statuses should clear the idempotency key and re-trigger (works for single trigger, broken for batch):
CRASHEDSYSTEM_FAILURETIMED_OUTEXPIREDCOMPLETED_WITH_ERRORSINTERRUPTEDSuggested Fix
I already wrote and tested a patch for this. My PR #4818 was auto-closed since I'm not a vouched contributor, but the fix is ready on my fork if you'd like to pull it in or use it as reference:
Branch:
Jaimin2687:fix/batch-trigger-idempotency-key-status-checkThe fix is three small changes:
internal-packages/run-store/src/types.ts— Addstatus: stringtoIdempotencyKeyRunMatchinternal-packages/run-store/src/PostgresRunStore.ts— Add"status"to the raw SQL SELECT infindRunsByIdempotencyKeysapps/webapp/app/v3/services/batchTriggerV3.server.ts— ImportshouldIdempotencyKeyBeClearedand add the guard after the expiry check in#prepareRunData, mirroring the single-trigger pathThe branch also includes 21 unit tests and 2 integration tests (testcontainers) covering all
TaskRunStatusvalues. Everything passes typecheck, build, format, and lint.Environment
mainbranch (as of Aug 28, 2025)