From cee960a4ceba42fd74f531a2a1053333b1b9d3ea Mon Sep 17 00:00:00 2001 From: Gbolahan Akande Date: Mon, 19 Oct 2026 08:34:40 +0100 Subject: [PATCH] Select only needed columns; drain in-flight webhook deliveries on shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #757: getActiveProducts and getPolicy now select only the columns their mapping actually reads instead of fetching full rows — these are the two highest-traffic queries (public product list, every policy detail view + SSE heartbeat). - #777: WebhooksService now tracks in-flight deliveries and drains them in onApplicationShutdown with a configurable timeout (WEBHOOK_SHUTDOWN_DRAIN_TIMEOUT_MS, default 10s) instead of letting SIGTERM abort a delivery mid-retry. Deliveries still pending at the timeout are logged, not auto-requeued — there is no persistent delivery log to resume them from yet. - #758 and #778: investigated, no code change — see PR description. Closes #757 Closes #758 Closes #777 Closes #778 --- src/common/events/webhooks.service.ts | 55 ++++++++++++++++++++++++++- src/policy/policy.service.ts | 18 +++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/common/events/webhooks.service.ts b/src/common/events/webhooks.service.ts index 19548fa..166536d 100644 --- a/src/common/events/webhooks.service.ts +++ b/src/common/events/webhooks.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, BadRequestException, OnApplicationShutdown } from '@nestjs/common'; import * as crypto from 'crypto'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../prisma/prisma.service'; @@ -41,10 +41,17 @@ const IV_BYTES = 12; // the sender and HTTP 413 / connection issues on the receiver. const MAX_WEBHOOK_PAYLOAD_BYTES = 1_048_576; +// #777 — how long onApplicationShutdown waits for in-flight deliveries to +// finish before giving up. Deliveries are fired without being awaited by +// their callers (so webhook delivery never delays the request), so nothing +// else would otherwise keep them alive past SIGTERM. +const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000; + @Injectable() -export class WebhooksService { +export class WebhooksService implements OnApplicationShutdown { private readonly logger = new Logger(WebhooksService.name); private readonly encryptionKey: Buffer | null; + private readonly inFlightDeliveries = new Set>(); constructor( private readonly prisma: PrismaService, @@ -313,7 +320,20 @@ export class WebhooksService { * * Throws on the last attempt so callers can log the final failure. */ + // #777 — tracks the actual attempt (below) in inFlightDeliveries so + // onApplicationShutdown can drain it instead of the process exiting with + // the retry loop's sleep/fetch cycle still running. private async deliverWithRetry(registration: WebhookRegistration, payload: unknown): Promise { + const attempt = this.deliverWithRetryAttempt(registration, payload); + this.inFlightDeliveries.add(attempt); + try { + await attempt; + } finally { + this.inFlightDeliveries.delete(attempt); + } + } + + private async deliverWithRetryAttempt(registration: WebhookRegistration, payload: unknown): Promise { let lastError: Error | undefined; // #719 — Serialize and sign the payload once before the retry loop @@ -461,4 +481,35 @@ export class WebhooksService { private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + + /** + * #777 — drain in-flight webhook deliveries before the app finishes + * shutting down, instead of letting SIGTERM abort them mid-request. + * Bounded by WEBHOOK_SHUTDOWN_DRAIN_TIMEOUT_MS so a stuck delivery can't + * hang shutdown forever; deliveries still pending at the timeout are + * logged (with enough detail to replay manually) rather than retried + * automatically — there's no persistent delivery queue to resume them from. + */ + async onApplicationShutdown(signal?: string): Promise { + const pending = this.inFlightDeliveries.size; + if (pending === 0) return; + + const timeoutMs = + this.config.get('WEBHOOK_SHUTDOWN_DRAIN_TIMEOUT_MS') ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS; + this.logger.log( + `Draining ${pending} in-flight webhook deliveries (signal: ${signal || 'none'}, timeout: ${timeoutMs}ms)`, + ); + + const drained = await Promise.race([ + Promise.allSettled([...this.inFlightDeliveries]).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); + + if (!drained) { + this.logger.warn( + `Shutdown drain timed out with ${this.inFlightDeliveries.size} webhook deliveries still in flight; ` + + 'not retried automatically — check logs above for registration id/url to replay manually.', + ); + } + } } \ No newline at end of file diff --git a/src/policy/policy.service.ts b/src/policy/policy.service.ts index 4ca42fb..ef8808a 100644 --- a/src/policy/policy.service.ts +++ b/src/policy/policy.service.ts @@ -627,8 +627,15 @@ export class PolicyService { this.logger.log(`get_active_products: page=${page} limit=${limit}`); const [dbProducts, total] = await this.prisma.$transaction([ + // #757 — select only the columns mapProduct/the response actually uses, + // instead of fetching full rows on this high-traffic public endpoint. this.prisma.product.findMany({ where: { status: 'ACTIVE' }, + select: { + id: true, name: true, category: true, triggerType: true, + threshold: true, comparison: true, coverageMin: true, + coverageMax: true, premiumRate: true, maxDuration: true, status: true, + }, take, skip, orderBy: [{ name: 'asc' }], @@ -660,12 +667,15 @@ export class PolicyService { async getPolicy(policyId: string): Promise { this.logger.log(`get_policy: ${policyId}`); // Try database first + // #757 — select only what the PolicySummary mapping below reads, instead + // of the full row (this runs on every detail view and every SSE heartbeat). const dbPolicy = await this.prisma.policy.findUnique({ where: { id: policyId }, - include: { - _count: { - select: { claims: true }, - }, + select: { + id: true, productId: true, policyholder: true, coverageXlm: true, + premiumPaid: true, oracleKey: true, startTime: true, endTime: true, + status: true, + _count: { select: { claims: true } }, }, }); if (dbPolicy) {