Skip to content

Commit daf4d68

Browse files
authored
feat(backend): add weighted fair job queue with priority scheduling (#648)
* feat(backend): add weighted fair job queue with priority scheduling Introduce WFQ scheduler across critical/high/normal/low priority classes, BullMQ-backed persistence, billing job worker integration, and Prometheus queue metrics to keep time-sensitive jobs ahead of analytics under load. * chore: remove accidental embedded repo from merge commit * chore: remove accidental SubTrackr-cdn-merge gitlink from branch * chore: automated code formatting fixes via CI pipeline
1 parent c37e150 commit daf4d68

37 files changed

Lines changed: 2397 additions & 288 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Analytics aggregation background jobs.
3+
*
4+
* Heavy aggregation and reporting — low priority.
5+
*/
6+
7+
import type { PriorityClass, PriorityQueue, QueueJob, WeightedFairQueue } from '../../shared/queue';
8+
9+
export const ANALYTICS_JOB_PRIORITY: PriorityClass = 'low';
10+
11+
export const ANALYTICS_JOB_NAMES = {
12+
AGGREGATE_DAILY: 'analytics:aggregate-daily',
13+
AGGREGATE_HOURLY: 'analytics:aggregate-hourly',
14+
MAINTENANCE_CLEANUP: 'analytics:maintenance-cleanup',
15+
} as const;
16+
17+
export type AnalyticsJobName = (typeof ANALYTICS_JOB_NAMES)[keyof typeof ANALYTICS_JOB_NAMES];
18+
19+
export interface AnalyticsAggregationPayload {
20+
reportType: string;
21+
merchantId?: string;
22+
dateRange: { start: string; end: string };
23+
}
24+
25+
export async function enqueueAnalyticsAggregation(
26+
queue: PriorityQueue<AnalyticsAggregationPayload>,
27+
payload: AnalyticsAggregationPayload,
28+
): Promise<QueueJob<AnalyticsAggregationPayload>> {
29+
return queue.add(ANALYTICS_JOB_NAMES.AGGREGATE_DAILY, payload);
30+
}
31+
32+
export async function enqueueMaintenanceCleanup(
33+
scheduler: WeightedFairQueue,
34+
olderThanDays: number,
35+
): Promise<QueueJob<{ olderThanDays: number }>> {
36+
return scheduler.spawnSubJob(ANALYTICS_JOB_PRIORITY, ANALYTICS_JOB_NAMES.MAINTENANCE_CLEANUP, {
37+
olderThanDays,
38+
});
39+
}

backend/analytics/jobs/mvRefreshJob.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
1+
/**
2+
* Materialized View Refresh Job
3+
*
4+
* Incrementally refreshes each materialized view using
5+
* REFRESH MATERIALIZED VIEW CONCURRENTLY so reads are never blocked.
6+
*
7+
* Runs on a configurable interval (default 60 s for real-time views).
8+
* Exposes a Prometheus-style metric for view freshness monitoring.
9+
*
10+
* Queue priority: low (analytics / maintenance class).
11+
*/
12+
113
import { QueryClient } from '../../../backend/shared/query/queryRouter';
14+
import type { PriorityClass } from '../../shared/queue';
15+
16+
/** Background queue priority for MV refresh work. */
17+
export const MV_REFRESH_JOB_PRIORITY: PriorityClass = 'low';
218

319
interface ViewConfig {
420
name: string;
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { BillingJobQueue } from '../billingJobQueue';
2+
import {
3+
PAYMENT_JOB_NAMES,
4+
type PaymentConfirmationPayload,
5+
} from '../paymentConfirmationJob';
6+
import type { QueueJob } from '../../../shared/queue';
7+
8+
describe('BillingJobQueue', () => {
9+
it('enqueues and processes payment confirmation end-to-end', async () => {
10+
const sent: string[] = [];
11+
const queue = new BillingJobQueue({
12+
connection: { host: 'localhost', port: 6379 },
13+
paymentHandlers: {
14+
[PAYMENT_JOB_NAMES.CONFIRMATION_EMAIL]: async (job: QueueJob<PaymentConfirmationPayload>) => {
15+
sent.push(job.data.transactionId);
16+
},
17+
},
18+
});
19+
20+
await queue.schedulePaymentConfirmation({
21+
subscriptionId: 'sub_1',
22+
subscriberId: 'user_1',
23+
merchantId: 'merch_1',
24+
amount: 29.99,
25+
currency: 'USD',
26+
transactionId: 'tx_abc123',
27+
});
28+
29+
const processed = await queue.processNext();
30+
expect(processed).toBe(true);
31+
expect(sent).toEqual(['tx_abc123']);
32+
expect(queue.scheduler.getStats().critical.totalProcessed).toBe(1);
33+
34+
await queue.close();
35+
});
36+
37+
it('processes critical payment before low analytics under load', async () => {
38+
const order: string[] = [];
39+
const queue = new BillingJobQueue({
40+
connection: { host: 'localhost', port: 6379 },
41+
paymentHandlers: {
42+
[PAYMENT_JOB_NAMES.CONFIRMATION_EMAIL]: async (job: QueueJob<PaymentConfirmationPayload>) => {
43+
order.push(`pay:${job.data.transactionId}`);
44+
},
45+
},
46+
});
47+
48+
const scheduler = queue.scheduler;
49+
50+
for (let i = 0; i < 5; i++) {
51+
await scheduler.enqueue('low', 'analytics:aggregate', { i });
52+
}
53+
await queue.schedulePaymentConfirmation({
54+
subscriptionId: 'sub_1',
55+
subscriberId: 'user_1',
56+
merchantId: 'merch_1',
57+
amount: 10,
58+
currency: 'USD',
59+
transactionId: 'tx_urgent',
60+
});
61+
62+
await scheduler.processNext({
63+
[PAYMENT_JOB_NAMES.CONFIRMATION_EMAIL]: async (job: QueueJob<PaymentConfirmationPayload>) => {
64+
order.push(`pay:${job.data.transactionId}`);
65+
},
66+
'analytics:aggregate': async (job: QueueJob<{ i: number }>) => {
67+
order.push(`analytics:${job.data.i}`);
68+
},
69+
});
70+
71+
expect(order[0]).toBe('pay:tx_urgent');
72+
73+
await queue.close();
74+
});
75+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Billing job queue — wires payment/dunning jobs to the WFQ scheduler.
3+
*/
4+
5+
import type { ConnectionOptions } from 'bullmq';
6+
import {
7+
createJobQueueSystem,
8+
type JobHandlerMap,
9+
type JobQueueSystem,
10+
type QueueJob,
11+
type WeightedFairQueue,
12+
} from '../../shared/queue';
13+
import { createPaymentJobHandlers } from './paymentJobHandlers';
14+
import {
15+
PAYMENT_JOB_PRIORITY,
16+
type ChargeSettlementPayload,
17+
type PaymentConfirmationPayload,
18+
enqueueChargeSettlement,
19+
enqueuePaymentConfirmation,
20+
} from './paymentConfirmationJob';
21+
22+
export interface BillingJobQueueConfig {
23+
connection: ConnectionOptions;
24+
baseQueueName?: string;
25+
maxQueueSize?: number;
26+
paymentHandlers?: JobHandlerMap;
27+
}
28+
29+
export class BillingJobQueue {
30+
private readonly system: JobQueueSystem;
31+
private readonly handlers: JobHandlerMap;
32+
33+
constructor(config: BillingJobQueueConfig) {
34+
this.system = createJobQueueSystem({
35+
connection: config.connection,
36+
baseQueueName: config.baseQueueName ?? 'subtrackr:billing',
37+
maxQueueSize: config.maxQueueSize,
38+
});
39+
this.handlers = config.paymentHandlers ?? createPaymentJobHandlers();
40+
}
41+
42+
get scheduler(): WeightedFairQueue {
43+
return this.system.scheduler;
44+
}
45+
46+
/** Enqueue a payment confirmation at critical priority (with auto backpressure). */
47+
async schedulePaymentConfirmation(
48+
payload: PaymentConfirmationPayload,
49+
): Promise<QueueJob<PaymentConfirmationPayload>> {
50+
return this.system.scheduler.enqueue(
51+
PAYMENT_JOB_PRIORITY,
52+
'payment:confirmation-email',
53+
payload,
54+
);
55+
}
56+
57+
/** Enqueue charge settlement at critical priority. */
58+
async scheduleChargeSettlement(
59+
payload: ChargeSettlementPayload,
60+
): Promise<QueueJob<ChargeSettlementPayload>> {
61+
return this.system.scheduler.enqueue(PAYMENT_JOB_PRIORITY, 'payment:charge-settlement', payload);
62+
}
63+
64+
/** Process one job from the queue. */
65+
async processNext(): Promise<boolean> {
66+
return this.system.scheduler.processNext(this.handlers);
67+
}
68+
69+
/** Start the background worker loop. */
70+
start(intervalMs = 50): void {
71+
this.system.scheduler.startProcessing(this.handlers, intervalMs);
72+
}
73+
74+
stop(): void {
75+
this.system.scheduler.stopProcessing();
76+
}
77+
78+
async close(): Promise<void> {
79+
await this.system.scheduler.close();
80+
}
81+
}
82+
83+
// Re-export convenience enqueue functions that use WFQ
84+
export { enqueuePaymentConfirmation, enqueueChargeSettlement };

backend/billing/jobs/dunningJob.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Dunning background jobs.
3+
*
4+
* Payment recovery and dunning communications — critical priority.
5+
*/
6+
7+
import type { PriorityClass, PriorityQueue, QueueJob, WeightedFairQueue } from '../../shared/queue';
8+
9+
export const DUNNING_JOB_PRIORITY: PriorityClass = 'critical';
10+
11+
export const DUNNING_JOB_NAMES = {
12+
SEND_REMINDER: 'dunning:send-reminder',
13+
ESCALATE_STAGE: 'dunning:escalate-stage',
14+
SUSPEND_SUBSCRIPTION: 'dunning:suspend-subscription',
15+
} as const;
16+
17+
export type DunningJobName = (typeof DUNNING_JOB_NAMES)[keyof typeof DUNNING_JOB_NAMES];
18+
19+
export interface DunningReminderPayload {
20+
subscriptionId: string;
21+
subscriberId: string;
22+
merchantId: string;
23+
stage: string;
24+
failedAttempts: number;
25+
}
26+
27+
export async function enqueueDunningReminder(
28+
queue: PriorityQueue<DunningReminderPayload>,
29+
payload: DunningReminderPayload,
30+
): Promise<QueueJob<DunningReminderPayload>> {
31+
return queue.add(DUNNING_JOB_NAMES.SEND_REMINDER, payload);
32+
}
33+
34+
export async function enqueueDunningEscalation(
35+
scheduler: WeightedFairQueue,
36+
payload: DunningReminderPayload,
37+
parentPriority: PriorityClass = DUNNING_JOB_PRIORITY,
38+
): Promise<QueueJob<DunningReminderPayload>> {
39+
return scheduler.spawnSubJob(parentPriority, DUNNING_JOB_NAMES.ESCALATE_STAGE, payload);
40+
}

backend/billing/jobs/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export {
2+
PAYMENT_JOB_PRIORITY,
3+
PAYMENT_JOB_NAMES,
4+
enqueuePaymentConfirmation,
5+
enqueueChargeSettlement,
6+
} from './paymentConfirmationJob';
7+
export type {
8+
PaymentConfirmationPayload,
9+
ChargeSettlementPayload,
10+
PaymentJobName,
11+
} from './paymentConfirmationJob';
12+
13+
export { DUNNING_JOB_PRIORITY, DUNNING_JOB_NAMES, enqueueDunningReminder, enqueueDunningEscalation } from './dunningJob';
14+
export type { DunningReminderPayload, DunningJobName } from './dunningJob';
15+
16+
export { createPaymentJobHandlers, defaultPaymentConfirmationHandler } from './paymentJobHandlers';
17+
export type { PaymentConfirmationHandler, PaymentConfirmationResult } from './paymentJobHandlers';
18+
19+
export { BillingJobQueue } from './billingJobQueue';
20+
export type { BillingJobQueueConfig } from './billingJobQueue';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Payment confirmation background jobs.
3+
*
4+
* Time-sensitive billing notifications — scheduled at critical priority.
5+
*/
6+
7+
import type { PriorityClass, PriorityQueue, QueueJob } from '../../shared/queue';
8+
9+
export const PAYMENT_JOB_PRIORITY: PriorityClass = 'critical';
10+
11+
export const PAYMENT_JOB_NAMES = {
12+
CONFIRMATION_EMAIL: 'payment:confirmation-email',
13+
RECEIPT_GENERATION: 'payment:receipt-generation',
14+
CHARGE_SETTLEMENT: 'payment:charge-settlement',
15+
} as const;
16+
17+
export type PaymentJobName = (typeof PAYMENT_JOB_NAMES)[keyof typeof PAYMENT_JOB_NAMES];
18+
19+
export interface PaymentConfirmationPayload {
20+
subscriptionId: string;
21+
subscriberId: string;
22+
merchantId: string;
23+
amount: number;
24+
currency: string;
25+
transactionId: string;
26+
}
27+
28+
export interface ChargeSettlementPayload {
29+
batchRunId: string;
30+
subscriptionIds: string[];
31+
}
32+
33+
export async function enqueuePaymentConfirmation(
34+
queue: PriorityQueue<PaymentConfirmationPayload>,
35+
payload: PaymentConfirmationPayload,
36+
): Promise<QueueJob<PaymentConfirmationPayload>> {
37+
return queue.add(PAYMENT_JOB_NAMES.CONFIRMATION_EMAIL, payload);
38+
}
39+
40+
export async function enqueueChargeSettlement(
41+
queue: PriorityQueue<ChargeSettlementPayload>,
42+
payload: ChargeSettlementPayload,
43+
): Promise<QueueJob<ChargeSettlementPayload>> {
44+
return queue.add(PAYMENT_JOB_NAMES.CHARGE_SETTLEMENT, payload);
45+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Payment job handlers — executed by the WFQ worker loop.
3+
*/
4+
5+
import type { JobHandlerMap, QueueJob } from '../../shared/queue';
6+
import {
7+
PAYMENT_JOB_NAMES,
8+
type ChargeSettlementPayload,
9+
type PaymentConfirmationPayload,
10+
} from './paymentConfirmationJob';
11+
12+
export interface PaymentConfirmationResult {
13+
transactionId: string;
14+
sentAt: number;
15+
channel: 'email';
16+
}
17+
18+
export type PaymentConfirmationHandler = (
19+
payload: PaymentConfirmationPayload,
20+
) => Promise<PaymentConfirmationResult>;
21+
22+
/** Default handler — logs confirmation; swap for real email service in production. */
23+
export const defaultPaymentConfirmationHandler: PaymentConfirmationHandler = async (payload) => {
24+
console.info(
25+
`[PaymentJob] Sending confirmation email for tx ${payload.transactionId} ` +
26+
`(subscriber=${payload.subscriberId}, amount=${payload.amount} ${payload.currency})`,
27+
);
28+
return {
29+
transactionId: payload.transactionId,
30+
sentAt: Date.now(),
31+
channel: 'email',
32+
};
33+
};
34+
35+
async function handleConfirmationEmail(
36+
job: QueueJob<PaymentConfirmationPayload>,
37+
sendFn: PaymentConfirmationHandler,
38+
): Promise<void> {
39+
await sendFn(job.data);
40+
}
41+
42+
async function handleChargeSettlement(job: QueueJob<ChargeSettlementPayload>): Promise<void> {
43+
console.info(
44+
`[PaymentJob] Settling batch ${job.data.batchRunId} (${job.data.subscriptionIds.length} subscriptions)`,
45+
);
46+
}
47+
48+
export function createPaymentJobHandlers(
49+
sendConfirmation: PaymentConfirmationHandler = defaultPaymentConfirmationHandler,
50+
): JobHandlerMap {
51+
return {
52+
[PAYMENT_JOB_NAMES.CONFIRMATION_EMAIL]: (job) =>
53+
handleConfirmationEmail(job as QueueJob<PaymentConfirmationPayload>, sendConfirmation),
54+
[PAYMENT_JOB_NAMES.CHARGE_SETTLEMENT]: (job) =>
55+
handleChargeSettlement(job as QueueJob<ChargeSettlementPayload>),
56+
};
57+
}

0 commit comments

Comments
 (0)