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: The tables driving the backend's hottest queries have no indexes supporting them. WebhookDelivery declares no @@index at all, yet a cron polls it every sixty seconds with a filtered, sorted query. The Invoice model indexes merchantId, (isDraft, merchantId), and lastAutoSavedAt, none of which serve the daily overdue sweep or the merchant dashboard's status-filtered lists. These queries work fine on a demo dataset and degrade continuously as real data accumulates.
Details:
WebhookDelivery has only @@map("webhook_deliveries") — no indexes whatsoever. WebhooksService.processQueue() runs every minute and issues findMany({ where: { status: "pending", nextAttemptAt: { lte: now } }, take: 50, include: { user: true }, orderBy: { createdAt: "asc" } }). With no index on (status, nextAttemptAt) and none on createdAt, Postgres sequentially scans the table and sorts the matches on every tick.
Delivery rows are never pruned. Successful deliveries stay success forever, so the scanned table grows monotonically with payment volume while the number of actually-pending rows stays near zero. The query gets steadily more expensive to return the same empty result.
WebhookDelivery.invoiceId and WebhookDelivery.userId are foreign keys, and Postgres does not create indexes for foreign keys automatically. Cascade deletes on Invoice and User therefore scan the delivery table as well.
InvoicesService.handleOverdueInvoices() queries where: { status: "pending", dueDate: { lt: now } } daily. There is no (status, dueDate) index, so this is another full scan that grows with total invoice count rather than with the number of genuinely overdue invoices.
The merchant dashboard's primary list filters by merchant and status and orders by recency. The existing @@index([merchantId]) covers only the first predicate, so the database filters by status and sorts the remainder in memory for every page load — the most frequently executed query in the product.
Other tables show the right instincts, which makes the gaps look like oversights rather than a deliberate strategy: RecurringSchedule has @@index([status, nextRunDate]), exactly the shape the webhook queue needs, and WebhookDeadLetter has @@index([status, exhaustedAt]).
There is no slow-query visibility to catch this in production. SLOW_DB_THRESHOLD_MS is validated in the Joi schema and the observability module exists, so the hook is available but these paths are not surfacing anything actionable.
Scope:
Add the composite indexes the hot queries need, at minimum covering the webhook queue poll, the overdue sweep, and the merchant list-with-status-filter path.
Add indexes for the foreign keys on WebhookDelivery so cascade deletes and relation lookups do not scan.
Review every scheduled job and list endpoint against the schema and add the supporting index or fix the query, rather than only the paths named here.
WebhookDeliverydeclares no@@indexat all, yet a cron polls it every sixty seconds with a filtered, sorted query. TheInvoicemodel indexesmerchantId,(isDraft, merchantId), andlastAutoSavedAt, none of which serve the daily overdue sweep or the merchant dashboard's status-filtered lists. These queries work fine on a demo dataset and degrade continuously as real data accumulates.WebhookDeliveryhas only@@map("webhook_deliveries")— no indexes whatsoever.WebhooksService.processQueue()runs every minute and issuesfindMany({ where: { status: "pending", nextAttemptAt: { lte: now } }, take: 50, include: { user: true }, orderBy: { createdAt: "asc" } }). With no index on(status, nextAttemptAt)and none oncreatedAt, Postgres sequentially scans the table and sorts the matches on every tick.successforever, so the scanned table grows monotonically with payment volume while the number of actually-pending rows stays near zero. The query gets steadily more expensive to return the same empty result.WebhookDelivery.invoiceIdandWebhookDelivery.userIdare foreign keys, and Postgres does not create indexes for foreign keys automatically. Cascade deletes onInvoiceandUsertherefore scan the delivery table as well.InvoicesService.handleOverdueInvoices()querieswhere: { status: "pending", dueDate: { lt: now } }daily. There is no(status, dueDate)index, so this is another full scan that grows with total invoice count rather than with the number of genuinely overdue invoices.@@index([merchantId])covers only the first predicate, so the database filters by status and sorts the remainder in memory for every page load — the most frequently executed query in the product.RecurringSchedulehas@@index([status, nextRunDate]), exactly the shape the webhook queue needs, andWebhookDeadLetterhas@@index([status, exhaustedAt]).SLOW_DB_THRESHOLD_MSis validated in the Joi schema and the observability module exists, so the hook is available but these paths are not surfacing anything actionable.WebhookDeliveryso cascade deletes and relation lookups do not scan.backend/prisma/schema.prismabackend/prisma/migrations/backend/src/webhooks/webhooks.service.tsbackend/src/invoices/invoices.service.tsbackend/src/observability/structured-logger.service.tsbackend/src/config/observability.config.tsWebhookDeliveryare indexed.