Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions src/common/events/webhooks.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Promise<unknown>>();

constructor(
private readonly prisma: PrismaService,
Expand Down Expand Up @@ -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<void> {
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<void> {
let lastError: Error | undefined;

// #719 — Serialize and sign the payload once before the retry loop
Expand Down Expand Up @@ -461,4 +481,35 @@ export class WebhooksService {
private sleep(ms: number): Promise<void> {
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<void> {
const pending = this.inFlightDeliveries.size;
if (pending === 0) return;

const timeoutMs =
this.config.get<number>('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<boolean>((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.',
);
}
}
}
18 changes: 14 additions & 4 deletions src/policy/policy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand Down Expand Up @@ -660,12 +667,15 @@ export class PolicyService {
async getPolicy(policyId: string): Promise<PolicySummary | null> {
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) {
Expand Down
Loading