chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
import { db } from '@sim/db'
import { idempotencyKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { and, count, inArray, like, lt, max, min, sql } from 'drizzle-orm'
const logger = createLogger('IdempotencyCleanup')
export interface CleanupOptions {
/**
* Maximum age of idempotency keys in seconds before they're considered expired
* Default: 7 days (604800 seconds)
*/
maxAgeSeconds?: number
/**
* Maximum number of keys to delete in a single batch
* Default: 1000
*/
batchSize?: number
/**
* Specific namespace prefix to clean up (e.g., 'webhook', 'polling')
* Keys are prefixed with namespace, so this filters by key prefix
*/
namespace?: string
}
/**
* Clean up expired idempotency keys from the database
*/
export async function cleanupExpiredIdempotencyKeys(
options: CleanupOptions = {}
): Promise<{ deleted: number; errors: string[] }> {
const {
maxAgeSeconds = 7 * 24 * 60 * 60, // 7 days
batchSize = 1000,
namespace,
} = options
const errors: string[] = []
let totalDeleted = 0
try {
const cutoffDate = new Date(Date.now() - maxAgeSeconds * 1000)
logger.info('Starting idempotency key cleanup', {
cutoffDate: cutoffDate.toISOString(),
namespace: namespace || 'all',
batchSize,
})
let hasMore = true
let batchCount = 0
while (hasMore) {
try {
// Build where condition - filter by cutoff date and optionally by namespace prefix
const whereCondition = namespace
? and(
lt(idempotencyKey.createdAt, cutoffDate),
like(idempotencyKey.key, `${namespace}:%`)
)
: lt(idempotencyKey.createdAt, cutoffDate)
// Find keys to delete with limit
const toDelete = await db
.select({ key: idempotencyKey.key })
.from(idempotencyKey)
.where(whereCondition)
.limit(batchSize)
if (toDelete.length === 0) {
break
}
// Delete the found records by key
const deleteResult = await db
.delete(idempotencyKey)
.where(
inArray(
idempotencyKey.key,
toDelete.map((item) => item.key)
)
)
.returning({ key: idempotencyKey.key })
const deletedCount = deleteResult.length
totalDeleted += deletedCount
batchCount++
if (deletedCount === 0) {
hasMore = false
logger.info('No more expired idempotency keys found')
} else if (deletedCount < batchSize) {
hasMore = false
logger.info(`Deleted final batch of ${deletedCount} expired idempotency keys`)
} else {
logger.info(`Deleted batch ${batchCount}: ${deletedCount} expired idempotency keys`)
await sleep(100)
}
} catch (batchError) {
const errorMessage = getErrorMessage(batchError, 'Unknown batch error')
logger.error(`Error deleting batch ${batchCount + 1}:`, batchError)
errors.push(`Batch ${batchCount + 1}: ${errorMessage}`)
batchCount++
if (errors.length > 5) {
logger.error('Too many batch errors, stopping cleanup')
break
}
}
}
logger.info('Idempotency key cleanup completed', {
totalDeleted,
batchCount,
errors: errors.length,
})
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error')
logger.error('Failed to cleanup expired idempotency keys:', error)
errors.push(`General error: ${errorMessage}`)
}
return { deleted: totalDeleted, errors }
}
/**
* Get statistics about idempotency key usage
* Uses SQL aggregations to avoid loading all keys into memory
*/
export async function getIdempotencyKeyStats(): Promise<{
totalKeys: number
keysByNamespace: Record<string, number>
oldestKey: Date | null
newestKey: Date | null
}> {
try {
// Get total count and date range in a single query
const [statsResult] = await db
.select({
totalKeys: count(),
oldestKey: min(idempotencyKey.createdAt),
newestKey: max(idempotencyKey.createdAt),
})
.from(idempotencyKey)
// Get counts by namespace prefix using SQL substring
// Extracts everything before the first ':' as the namespace
const namespaceStats = await db
.select({
namespace: sql<string>`split_part(${idempotencyKey.key}, ':', 1)`.as('namespace'),
count: count(),
})
.from(idempotencyKey)
.groupBy(sql`split_part(${idempotencyKey.key}, ':', 1)`)
const keysByNamespace: Record<string, number> = {}
for (const row of namespaceStats) {
keysByNamespace[row.namespace || 'unknown'] = row.count
}
return {
totalKeys: statsResult?.totalKeys ?? 0,
keysByNamespace,
oldestKey: statsResult?.oldestKey ?? null,
newestKey: statsResult?.newestKey ?? null,
}
} catch (error) {
logger.error('Failed to get idempotency key stats:', error)
return {
totalKeys: 0,
keysByNamespace: {},
oldestKey: null,
newestKey: null,
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export * from './cleanup'
export * from './service'
export {
pollingIdempotency,
webhookIdempotency,
} from './service'
@@ -0,0 +1,38 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { IdempotencyService } from '@/lib/core/idempotency/service'
describe('IdempotencyService.createWebhookIdempotencyKey', () => {
it('uses Greenhouse-Event-ID when present', () => {
const key = IdempotencyService.createWebhookIdempotencyKey(
'wh_1',
{ 'greenhouse-event-id': 'evt-gh-99' },
{},
'greenhouse'
)
expect(key).toBe('wh_1:evt-gh-99')
})
it('prefers svix-id for Resend / Svix duplicate delivery deduplication', () => {
const key = IdempotencyService.createWebhookIdempotencyKey(
'wh_1',
{ 'svix-id': 'msg_abc123' },
{ type: 'email.sent' },
'resend'
)
expect(key).toBe('wh_1:msg_abc123')
})
it('prefers Linear-Delivery so repeated updates to the same entity are not treated as one idempotent run', () => {
const key = IdempotencyService.createWebhookIdempotencyKey(
'wh_linear',
{ 'linear-delivery': '234d1a4e-b617-4388-90fe-adc3633d6b72' },
{ action: 'update', data: { id: 'shared-entity-id' } },
'linear'
)
expect(key).toBe('wh_linear:234d1a4e-b617-4388-90fe-adc3633d6b72')
})
})
+558
View File
@@ -0,0 +1,558 @@
import { db } from '@sim/db'
import { idempotencyKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { eq, lt } from 'drizzle-orm'
import { getRedisClient } from '@/lib/core/config/redis'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getStorageMethod, type StorageMethod } from '@/lib/core/storage'
import { extractProviderIdentifierFromBody } from '@/lib/webhooks/providers'
const logger = createLogger('IdempotencyService')
export interface IdempotencyConfig {
ttlSeconds?: number
namespace?: string
/** When true, failed keys are deleted rather than stored so the operation is retried on the next attempt. */
retryFailures?: boolean
/**
* When false, only `{ success, status, error? }` is persisted — not the
* operation's return value. Duplicate calls still short-circuit but
* resolve to `undefined`. Use when callers don't consume the cached
* body (e.g. webhook receivers, where the provider just wants a 2xx).
* Defaults to true.
*/
storeResultBody?: boolean
/**
* Force a specific storage backend regardless of the environment's
* auto-detection. Use `'database'` for correctness-critical flows
* (money, billing, compliance) where the claim + operation should
* fate-share with the Postgres transaction — this closes the narrow
* window where the operation commits to DB but `storeResult` to Redis
* fails and the retry re-runs the operation. Latency cost is 15ms
* per call, imperceptible on webhook code paths.
*
* Leave unset (or set `'redis'`) for latency-sensitive, high-volume
* flows like app webhook triggers where the scale benefits of Redis
* outweigh the narrow durability window.
*/
forceStorage?: StorageMethod
}
export interface IdempotencyResult {
isFirstTime: boolean
normalizedKey: string
previousResult?: any
storageMethod: StorageMethod
}
export interface ProcessingResult {
success: boolean
result?: any
error?: string
status?: 'in-progress' | 'completed' | 'failed'
startedAt?: number
}
export interface AtomicClaimResult {
claimed: boolean
existingResult?: ProcessingResult
normalizedKey: string
storageMethod: StorageMethod
}
const DEFAULT_TTL = 60 * 60 * 24 * 7
const REDIS_KEY_PREFIX = 'idempotency:'
const MAX_WAIT_TIME_MS = getMaxExecutionTimeout()
const POLL_INTERVAL_MS = 1000
/**
* Universal idempotency service for webhooks, triggers, and any other operations
* that need duplicate prevention.
*
* Storage is determined once based on configuration:
* - If `forceStorage` is set → that backend unconditionally
* - Else if `REDIS_URL` is set → Redis
* - Else → PostgreSQL
*/
export class IdempotencyService {
private config: Required<Omit<IdempotencyConfig, 'forceStorage'>>
private storageMethod: StorageMethod
constructor(config: IdempotencyConfig = {}) {
this.config = {
ttlSeconds: config.ttlSeconds ?? DEFAULT_TTL,
namespace: config.namespace ?? 'default',
retryFailures: config.retryFailures ?? false,
storeResultBody: config.storeResultBody ?? true,
}
this.storageMethod = config.forceStorage ?? getStorageMethod()
logger.info(`IdempotencyService using ${this.storageMethod} storage`, {
namespace: this.config.namespace,
forced: Boolean(config.forceStorage),
})
}
private normalizeKey(
provider: string,
identifier: string,
additionalContext?: Record<string, any>
): string {
const base = `${this.config.namespace}:${provider}:${identifier}`
if (additionalContext && Object.keys(additionalContext).length > 0) {
const sortedKeys = Object.keys(additionalContext).sort()
const contextStr = sortedKeys.map((key) => `${key}=${additionalContext[key]}`).join('&')
return `${base}:${contextStr}`
}
return base
}
async checkIdempotency(
provider: string,
identifier: string,
additionalContext?: Record<string, any>
): Promise<IdempotencyResult> {
const normalizedKey = this.normalizeKey(provider, identifier, additionalContext)
if (this.storageMethod === 'redis') {
return this.checkIdempotencyRedis(normalizedKey)
}
return this.checkIdempotencyDb(normalizedKey)
}
private async checkIdempotencyRedis(normalizedKey: string): Promise<IdempotencyResult> {
const redis = getRedisClient()
if (!redis) {
throw new Error('Redis not available for idempotency check')
}
const redisKey = `${REDIS_KEY_PREFIX}${normalizedKey}`
const cachedResult = await redis.get(redisKey)
if (cachedResult) {
logger.debug(`Idempotency hit in Redis: ${normalizedKey}`)
return {
isFirstTime: false,
normalizedKey,
previousResult: JSON.parse(cachedResult),
storageMethod: 'redis',
}
}
logger.debug(`Idempotency miss in Redis: ${normalizedKey}`)
return {
isFirstTime: true,
normalizedKey,
storageMethod: 'redis',
}
}
private async checkIdempotencyDb(normalizedKey: string): Promise<IdempotencyResult> {
const existing = await db
.select({ result: idempotencyKey.result, createdAt: idempotencyKey.createdAt })
.from(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.limit(1)
if (existing.length > 0) {
const item = existing[0]
const isExpired = Date.now() - item.createdAt.getTime() > this.config.ttlSeconds * 1000
if (!isExpired) {
logger.debug(`Idempotency hit in database: ${normalizedKey}`)
return {
isFirstTime: false,
normalizedKey,
previousResult: item.result,
storageMethod: 'database',
}
}
await db
.delete(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.catch((err) => logger.warn(`Failed to clean up expired key ${normalizedKey}:`, err))
}
logger.debug(`Idempotency miss in database: ${normalizedKey}`)
return {
isFirstTime: true,
normalizedKey,
storageMethod: 'database',
}
}
async atomicallyClaim(
provider: string,
identifier: string,
additionalContext?: Record<string, any>
): Promise<AtomicClaimResult> {
const normalizedKey = this.normalizeKey(provider, identifier, additionalContext)
const inProgressResult: ProcessingResult = {
success: false,
status: 'in-progress',
startedAt: Date.now(),
}
if (this.storageMethod === 'redis') {
return this.atomicallyClaimRedis(normalizedKey, inProgressResult)
}
return this.atomicallyClaimDb(normalizedKey, inProgressResult)
}
private async atomicallyClaimRedis(
normalizedKey: string,
inProgressResult: ProcessingResult
): Promise<AtomicClaimResult> {
const redis = getRedisClient()
if (!redis) {
throw new Error('Redis not available for atomic claim')
}
const redisKey = `${REDIS_KEY_PREFIX}${normalizedKey}`
const claimed = await redis.set(
redisKey,
JSON.stringify(inProgressResult),
'EX',
this.config.ttlSeconds,
'NX'
)
if (claimed === 'OK') {
logger.debug(`Atomically claimed idempotency key in Redis: ${normalizedKey}`)
return {
claimed: true,
normalizedKey,
storageMethod: 'redis',
}
}
const existingData = await redis.get(redisKey)
const existingResult = existingData ? JSON.parse(existingData) : null
logger.debug(`Idempotency key already claimed in Redis: ${normalizedKey}`)
return {
claimed: false,
existingResult,
normalizedKey,
storageMethod: 'redis',
}
}
private async atomicallyClaimDb(
normalizedKey: string,
inProgressResult: ProcessingResult
): Promise<AtomicClaimResult> {
const now = new Date()
const expiredBefore = new Date(now.getTime() - this.config.ttlSeconds * 1000)
// `ON CONFLICT DO UPDATE WHERE created_at < expiredBefore` steals the
// claim when the existing row has outlived the TTL (e.g. a prior
// holder crashed mid-operation and never wrote `completed`/`failed`
// or released the key). RETURNING yields a row in two cases:
// (1) fresh INSERT — no prior row existed;
// (2) UPDATE of an expired row — WHERE matched.
// An empty RETURNING means conflict with an unexpired row; the
// existing holder is still live and we must not steal.
const insertResult = await db
.insert(idempotencyKey)
.values({
key: normalizedKey,
result: inProgressResult,
createdAt: now,
})
.onConflictDoUpdate({
target: [idempotencyKey.key],
set: {
result: inProgressResult,
createdAt: now,
},
setWhere: lt(idempotencyKey.createdAt, expiredBefore),
})
.returning({ key: idempotencyKey.key })
if (insertResult.length > 0) {
logger.debug(`Atomically claimed idempotency key in database: ${normalizedKey}`)
return {
claimed: true,
normalizedKey,
storageMethod: 'database',
}
}
const existing = await db
.select({ result: idempotencyKey.result })
.from(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.limit(1)
const existingResult =
existing.length > 0 ? (existing[0].result as ProcessingResult) : undefined
logger.debug(`Idempotency key already claimed in database: ${normalizedKey}`)
return {
claimed: false,
existingResult,
normalizedKey,
storageMethod: 'database',
}
}
async waitForResult<T>(normalizedKey: string, storageMethod: 'redis' | 'database'): Promise<T> {
const startTime = Date.now()
const redisKey = `${REDIS_KEY_PREFIX}${normalizedKey}`
while (Date.now() - startTime < MAX_WAIT_TIME_MS) {
let currentResult: ProcessingResult | null = null
if (storageMethod === 'redis') {
const redis = getRedisClient()
if (!redis) {
throw new Error('Redis not available')
}
const data = await redis.get(redisKey)
currentResult = data ? JSON.parse(data) : null
} else {
const existing = await db
.select({ result: idempotencyKey.result })
.from(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.limit(1)
currentResult = existing.length > 0 ? (existing[0].result as ProcessingResult) : null
}
if (currentResult?.status === 'completed') {
logger.debug(`Operation completed, returning result: ${normalizedKey}`)
if (currentResult.success === false) {
throw new Error(currentResult.error || 'Previous operation failed')
}
return currentResult.result as T
}
if (currentResult?.status === 'failed') {
logger.debug(`Operation failed, throwing error: ${normalizedKey}`)
throw new Error(currentResult.error || 'Previous operation failed')
}
await sleep(POLL_INTERVAL_MS)
}
throw new Error(`Timeout waiting for idempotency operation to complete: ${normalizedKey}`)
}
async storeResult(
normalizedKey: string,
result: ProcessingResult,
storageMethod: 'redis' | 'database'
): Promise<void> {
if (storageMethod === 'redis') {
return this.storeResultRedis(normalizedKey, result)
}
return this.storeResultDb(normalizedKey, result)
}
private async storeResultRedis(normalizedKey: string, result: ProcessingResult): Promise<void> {
const redis = getRedisClient()
if (!redis) {
throw new Error('Redis not available for storing result')
}
await redis.setex(
`${REDIS_KEY_PREFIX}${normalizedKey}`,
this.config.ttlSeconds,
JSON.stringify(result)
)
logger.debug(`Stored idempotency result in Redis: ${normalizedKey}`)
}
private async storeResultDb(normalizedKey: string, result: ProcessingResult): Promise<void> {
await db
.insert(idempotencyKey)
.values({
key: normalizedKey,
result: result,
createdAt: new Date(),
})
.onConflictDoUpdate({
target: [idempotencyKey.key],
set: {
result: result,
createdAt: new Date(),
},
})
logger.debug(`Stored idempotency result in database: ${normalizedKey}`)
}
async release(normalizedKey: string, storageMethod: 'redis' | 'database'): Promise<void> {
return this.deleteKey(normalizedKey, storageMethod)
}
private async deleteKey(
normalizedKey: string,
storageMethod: 'redis' | 'database'
): Promise<void> {
if (storageMethod === 'redis') {
const redis = getRedisClient()
if (redis) await redis.del(`${REDIS_KEY_PREFIX}${normalizedKey}`).catch(() => {})
} else {
await db
.delete(idempotencyKey)
.where(eq(idempotencyKey.key, normalizedKey))
.catch(() => {})
}
}
async executeWithIdempotency<T>(
provider: string,
identifier: string,
operation: () => Promise<T>,
additionalContext?: Record<string, any>
): Promise<T> {
const claimResult = await this.atomicallyClaim(provider, identifier, additionalContext)
if (!claimResult.claimed) {
const existingResult = claimResult.existingResult
if (existingResult?.status === 'completed') {
logger.info(`Returning cached result for: ${claimResult.normalizedKey}`)
if (existingResult.success === false) {
throw new Error(existingResult.error || 'Previous operation failed')
}
return existingResult.result as T
}
if (existingResult?.status === 'failed') {
if (this.config.retryFailures) {
await this.deleteKey(claimResult.normalizedKey, claimResult.storageMethod)
return this.executeWithIdempotency(provider, identifier, operation, additionalContext)
}
logger.info(`Previous operation failed for: ${claimResult.normalizedKey}`)
throw new Error(existingResult.error || 'Previous operation failed')
}
if (existingResult?.status === 'in-progress') {
logger.info(`Waiting for in-progress operation: ${claimResult.normalizedKey}`)
return await this.waitForResult<T>(claimResult.normalizedKey, claimResult.storageMethod)
}
if (existingResult) {
return existingResult.result as T
}
throw new Error(`Unexpected state: key claimed but no existing result found`)
}
try {
logger.info(`Executing new operation: ${claimResult.normalizedKey}`)
const result = await operation()
await this.storeResult(
claimResult.normalizedKey,
this.config.storeResultBody
? { success: true, result, status: 'completed' }
: { success: true, status: 'completed' },
claimResult.storageMethod
)
logger.debug(`Successfully completed operation: ${claimResult.normalizedKey}`)
return result
} catch (error) {
const errorMessage = getErrorMessage(error, 'Unknown error')
if (this.config.retryFailures) {
await this.deleteKey(claimResult.normalizedKey, claimResult.storageMethod)
} else {
await this.storeResult(
claimResult.normalizedKey,
{ success: false, error: errorMessage, status: 'failed' },
claimResult.storageMethod
)
}
logger.warn(`Operation failed: ${claimResult.normalizedKey} - ${errorMessage}`)
throw error
}
}
static createWebhookIdempotencyKey(
webhookId: string,
headers?: Record<string, string>,
body?: any,
provider?: string
): string {
const normalizedHeaders = headers
? Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
: undefined
const webhookIdHeader =
normalizedHeaders?.['x-sim-idempotency-key'] ||
normalizedHeaders?.['webhook-id'] ||
normalizedHeaders?.['x-webhook-id'] ||
normalizedHeaders?.['x-shopify-webhook-id'] ||
normalizedHeaders?.['x-github-delivery'] ||
normalizedHeaders?.['x-gitlab-event-uuid'] ||
normalizedHeaders?.['x-event-id'] ||
normalizedHeaders?.['x-teams-notification-id'] ||
normalizedHeaders?.['svix-id'] ||
normalizedHeaders?.['linear-delivery'] ||
normalizedHeaders?.['greenhouse-event-id'] ||
normalizedHeaders?.['x-zm-request-id'] ||
normalizedHeaders?.['x-atlassian-webhook-identifier'] ||
normalizedHeaders?.['idempotency-key']
if (webhookIdHeader) {
return `${webhookId}:${webhookIdHeader}`
}
if (body && provider) {
const bodyIdentifier = extractProviderIdentifierFromBody(provider, body)
if (bodyIdentifier) {
return `${webhookId}:${bodyIdentifier}`
}
}
const uniqueId = generateId()
logger.warn('No unique identifier found, duplicate executions may occur', {
webhookId,
provider,
})
return `${webhookId}:${uniqueId}`
}
}
/**
* As a webhook receiver we only need a "we saw this delivery" marker —
* the provider's retry just needs a 2xx, not our cached response body.
* TTL must exceed the longest provider retry window (Gmail / Pub-Sub: 7d).
*/
export const webhookIdempotency = new IdempotencyService({
namespace: 'webhook',
ttlSeconds: 60 * 60 * 24 * 7, // 7 days
storeResultBody: false,
})
export const pollingIdempotency = new IdempotencyService({
namespace: 'polling',
ttlSeconds: 60 * 60 * 24 * 3, // 3 days
retryFailures: true,
storeResultBody: false,
})
/**
* Used by the internal `/api/billing/update-cost` endpoint (copilot,
* workspace-chat, MCP, mothership) to dedupe cost-recording calls. Storage
* is forced to Postgres: the operation writes AI cost to `user_stats`,
* and if Redis evicts the dedup key under memory pressure (high call
* volume) or drops it on restart, a retry would double-record usage —
* real money. DB storage fate-shares with `user_stats` and is
* eviction-proof; ~1-5ms added latency is invisible against LLM call
* latency.
*/
export const billingIdempotency = new IdempotencyService({
namespace: 'billing',
ttlSeconds: 60 * 60, // 1 hour
forceStorage: 'database',
})