You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Overview: Every background worker in the backend guards itself with an in-process boolean or nothing at all, which is only correct while exactly one instance is running. WebhooksService.processQueue() guards on this.isProcessing, and HorizonWatcherService.pollPayments() guards on this.polling. Those fields are per-process. As soon as the API runs more than one replica — which is the normal deployment shape behind a load balancer or on a platform that scales automatically — every replica runs every cron and every poll loop concurrently, and the guards do nothing.
Details:
processQueue() selects up to 50 webhookDelivery rows with status: "pending" and nextAttemptAt <= now, then delivers each one. There is no atomic claim — no transition to an in-flight status, no FOR UPDATE SKIP LOCKED, no lease. Two replicas hitting the same minute boundary select the same rows and deliver each webhook twice. The x-idempotency-key header is derived from ${delivery.id}-${delivery.attempts}, so both duplicates carry the same key and the merchant cannot distinguish a duplicate from a retry.
InvoicesService.handleOverdueInvoices() (@Cron("0 2 * * *")) selects overdue invoices and updates them with no claim, so concurrent replicas repeat the same status transitions and emit duplicate status-history rows, activity-feed events, and notifications.
NotificationsService runs a five-minute cron with the same exposure.
RecurringBillingService.processDueSchedules() is the one worker that is actually safe, because RecurringInvoiceRun carries a unique (scheduleId, periodKey) constraint that rejects duplicate generation at the database layer. That is the pattern the other workers should follow.
The practical result is that horizontal scaling is currently unsafe: it produces duplicate webhook deliveries and duplicate merchant-visible events rather than more throughput.
Scope:
Introduce a shared claim mechanism for queued work — an atomic status transition with a lease and expiry, or SELECT ... FOR UPDATE SKIP LOCKED — so a delivery row is processed by exactly one replica.
Add cross-instance coordination for the scheduled jobs, either via a leader lock or by making each job's effects idempotent at the database layer the way recurring billing already is.
Make the webhook idempotency key stable per logical delivery attempt so receivers can safely deduplicate.
Reclaim leases abandoned by a crashed replica so work is not stranded in an in-flight state.
Document the supported replica count and the coordination guarantees in the backend README.
WebhooksService.processQueue()guards onthis.isProcessing, andHorizonWatcherService.pollPayments()guards onthis.polling. Those fields are per-process. As soon as the API runs more than one replica — which is the normal deployment shape behind a load balancer or on a platform that scales automatically — every replica runs every cron and every poll loop concurrently, and the guards do nothing.processQueue()selects up to 50webhookDeliveryrows withstatus: "pending"andnextAttemptAt <= now, then delivers each one. There is no atomic claim — no transition to an in-flight status, noFOR UPDATE SKIP LOCKED, no lease. Two replicas hitting the same minute boundary select the same rows and deliver each webhook twice. Thex-idempotency-keyheader is derived from${delivery.id}-${delivery.attempts}, so both duplicates carry the same key and the merchant cannot distinguish a duplicate from a retry.InvoicesService.handleOverdueInvoices()(@Cron("0 2 * * *")) selects overdue invoices and updates them with no claim, so concurrent replicas repeat the same status transitions and emit duplicate status-history rows, activity-feed events, and notifications.NotificationsServiceruns a five-minute cron with the same exposure.RecurringBillingService.processDueSchedules()is the one worker that is actually safe, becauseRecurringInvoiceRuncarries a unique(scheduleId, periodKey)constraint that rejects duplicate generation at the database layer. That is the pattern the other workers should follow.SELECT ... FOR UPDATE SKIP LOCKED— so a delivery row is processed by exactly one replica.backend/src/webhooks/webhooks.service.tsbackend/src/invoices/invoices.service.tsbackend/src/notifications/notifications.service.tsbackend/src/stellar/horizon-watcher.service.tsbackend/src/recurring-billing/recurring-billing.service.tsbackend/prisma/schema.prisma