@@ -16,6 +16,16 @@ export interface IdempotencyConfig {
1616 namespace ?: string
1717 /** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */
1818 retryFailures ?: boolean
19+ /**
20+ * When false, the operation's return value is not persisted alongside
21+ * the dedupe marker — only `{ success, status, error? }` is stored.
22+ * Duplicate calls still short-circuit, but `executeWithIdempotency`
23+ * resolves to `undefined` on the dedupe path. Use for webhook/polling
24+ * flows where the cached body is large (multi-KB execution results)
25+ * and callers don't consume the value of a duplicated delivery.
26+ * Defaults to true.
27+ */
28+ storeResultBody ?: boolean
1929 /**
2030 * Force a specific storage backend regardless of the environment's
2131 * auto-detection. Use `'database'` for correctness-critical flows
@@ -77,6 +87,7 @@ export class IdempotencyService {
7787 ttlSeconds : config . ttlSeconds ?? DEFAULT_TTL ,
7888 namespace : config . namespace ?? 'default' ,
7989 retryFailures : config . retryFailures ?? false ,
90+ storeResultBody : config . storeResultBody ?? true ,
8091 }
8192 this . storageMethod = config . forceStorage ?? getStorageMethod ( )
8293 logger . info ( `IdempotencyService using ${ this . storageMethod } storage` , {
@@ -441,7 +452,9 @@ export class IdempotencyService {
441452
442453 await this . storeResult (
443454 claimResult . normalizedKey ,
444- { success : true , result, status : 'completed' } ,
455+ this . config . storeResultBody
456+ ? { success : true , result, status : 'completed' }
457+ : { success : true , status : 'completed' } ,
445458 claimResult . storageMethod
446459 )
447460
@@ -510,15 +523,29 @@ export class IdempotencyService {
510523 }
511524}
512525
526+ /**
527+ * Webhook idempotency. We're the receiver of provider-initiated webhooks,
528+ * not the originator — duplicate deliveries from the provider's retry
529+ * machinery just need a "we saw this" marker, not a replayable response
530+ * body. `storeResultBody: false` drops the cached workflow result from
531+ * each key, eliminating the long tail of large gmail/outlook payloads
532+ * that pushed Redis Cloud into OOM on 2026-05-15.
533+ *
534+ * TTL stays at 7 days because that's the longest provider retry window
535+ * we care about (Gmail / Pub/Sub). With body-stripping the per-key cost
536+ * is ~150 bytes, so the long TTL is essentially free.
537+ */
513538export const webhookIdempotency = new IdempotencyService ( {
514539 namespace : 'webhook' ,
515- ttlSeconds : 60 * 60 * 24 * 7 , // 7 days
540+ ttlSeconds : 60 * 60 * 24 * 7 , // 7 days — must exceed Gmail/Pub-Sub retry window
541+ storeResultBody : false ,
516542} )
517543
518544export const pollingIdempotency = new IdempotencyService ( {
519545 namespace : 'polling' ,
520546 ttlSeconds : 60 * 60 * 24 * 3 , // 3 days
521547 retryFailures : true ,
548+ storeResultBody : false ,
522549} )
523550
524551/**
0 commit comments