Skip to content

Commit 5c8979f

Browse files
authored
feat: implement issues #990, #991, #994, #997 (#1021)
- Add priority queue background job system (#990) - Add CDN edge caching service (#991) - Add WebSocket connection pool with message batching (#994) - Add read replica router with automatic failover (#997) Closes #990 Closes #991 Closes #994 Closes #997
1 parent 4a62952 commit 5c8979f

5 files changed

Lines changed: 755 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* CDN Edge Caching Service — SubTrackr
3+
*
4+
* Manages edge caching for API responses with TTL, purge, and invalidation.
5+
*/
6+
7+
export interface CdnConfig {
8+
defaultTtlSeconds: number;
9+
maxTtlSeconds: number;
10+
staleWhileRevalidateSeconds: number;
11+
purgeBatchSize: number;
12+
}
13+
14+
export interface CacheEntry<T = unknown> {
15+
key: string;
16+
data: T;
17+
headers: Record<string, string>;
18+
cachedAt: number;
19+
ttlMs: number;
20+
tags: string[];
21+
}
22+
23+
export interface PurgeRequest {
24+
patterns: string[];
25+
tags?: string[];
26+
}
27+
28+
export interface PurgeResult {
29+
purgedCount: number;
30+
patterns: string[];
31+
}
32+
33+
export interface CdnMetrics {
34+
hits: number;
35+
misses: number;
36+
purges: number;
37+
hitRate: number;
38+
}
39+
40+
export class CdnService {
41+
private cache = new Map<string, CacheEntry>();
42+
private tagIndex = new Map<string, Set<string>>();
43+
private config: CdnConfig;
44+
private metrics: CdnMetrics = { hits: 0, misses: 0, purges: 0, hitRate: 0 };
45+
46+
constructor(config: Partial<CdnConfig> = {}) {
47+
this.config = {
48+
defaultTtlSeconds: config.defaultTtlSeconds ?? 300,
49+
maxTtlSeconds: config.maxTtlSeconds ?? 86400,
50+
staleWhileRevalidateSeconds: config.staleWhileRevalidateSeconds ?? 60,
51+
purgeBatchSize: config.purgeBatchSize ?? 100,
52+
};
53+
}
54+
55+
get<T>(key: string): CacheEntry<T> | null {
56+
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
57+
if (!entry) {
58+
this.metrics.misses++;
59+
this.updateHitRate();
60+
return null;
61+
}
62+
63+
const now = Date.now();
64+
if (now > entry.cachedAt + entry.ttlMs) {
65+
this.cache.delete(key);
66+
this.removeFromTagIndex(key, entry.tags);
67+
this.metrics.misses++;
68+
this.updateHitRate();
69+
return null;
70+
}
71+
72+
this.metrics.hits++;
73+
this.updateHitRate();
74+
return entry;
75+
}
76+
77+
set<T>(key: string, data: T, options: { ttlSeconds?: number; tags?: string[] } = {}): void {
78+
const ttlMs = Math.min(
79+
(options.ttlSeconds ?? this.config.defaultTtlSeconds) * 1000,
80+
this.config.maxTtlSeconds * 1000,
81+
);
82+
83+
const entry: CacheEntry<T> = {
84+
key,
85+
data,
86+
headers: {
87+
'cache-control': `public, max-age=${Math.floor(ttlMs / 1000)}, stale-while-revalidate=${this.config.staleWhileRevalidateSeconds}`,
88+
'cdn-cache-status': 'HIT',
89+
},
90+
cachedAt: Date.now(),
91+
ttlMs,
92+
tags: options.tags ?? [],
93+
};
94+
95+
this.cache.set(key, entry);
96+
for (const tag of entry.tags) {
97+
if (!this.tagIndex.has(tag)) this.tagIndex.set(tag, new Set());
98+
this.tagIndex.get(tag)!.add(key);
99+
}
100+
}
101+
102+
purge(request: PurgeRequest): PurgeResult {
103+
let purgedCount = 0;
104+
const purgedPatterns: string[] = [];
105+
106+
for (const pattern of request.patterns) {
107+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
108+
for (const [key, entry] of this.cache) {
109+
if (regex.test(key)) {
110+
this.cache.delete(key);
111+
this.removeFromTagIndex(key, entry.tags);
112+
purgedCount++;
113+
}
114+
}
115+
purgedPatterns.push(pattern);
116+
}
117+
118+
if (request.tags) {
119+
for (const tag of request.tags) {
120+
const keys = this.tagIndex.get(tag);
121+
if (keys) {
122+
for (const key of keys) {
123+
this.cache.delete(key);
124+
purgedCount++;
125+
}
126+
this.tagIndex.delete(tag);
127+
}
128+
}
129+
}
130+
131+
this.metrics.purges++;
132+
return { purgedCount, patterns: purgedPatterns };
133+
}
134+
135+
invalidate(key: string): boolean {
136+
const entry = this.cache.get(key);
137+
if (entry) {
138+
this.removeFromTagIndex(key, entry.tags);
139+
this.cache.delete(key);
140+
return true;
141+
}
142+
return false;
143+
}
144+
145+
getMetrics(): CdnMetrics {
146+
return { ...this.metrics };
147+
}
148+
149+
private removeFromTagIndex(key: string, tags: string[]): void {
150+
for (const tag of tags) {
151+
this.tagIndex.get(tag)?.delete(key);
152+
}
153+
}
154+
155+
private updateHitRate(): void {
156+
const total = this.metrics.hits + this.metrics.misses;
157+
this.metrics.hitRate = total > 0 ? this.metrics.hits / total : 0;
158+
}
159+
}
160+
161+
export const cdnService = new CdnService();

backend/services/shared/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,19 @@ export type {
169169
LeakRecord,
170170
PoolTuningRecommendation,
171171
} from './poolMonitor';
172+
173+
// ── Background Job Queue (#990) ──────────────────────────────────────────────
174+
export { PriorityQueue, jobQueue } from './jobQueue';
175+
export type { Job, JobHandler, QueueConfig, QueueMetrics, JobStatus, JobPriority } from './jobQueue';
176+
177+
// ── CDN Edge Caching (#991) ──────────────────────────────────────────────────
178+
export { CdnService, cdnService } from './cdnService';
179+
export type { CdnConfig, CacheEntry, PurgeRequest, PurgeResult, CdnMetrics } from './cdnService';
180+
181+
// ── WebSocket Connection Pool (#994) ──────────────────────────────────────────
182+
export { WsConnectionPool } from './wsConnectionPool';
183+
export type { WsPoolConfig, WsConnection, WsMessage, WsPoolMetrics } from './wsConnectionPool';
184+
185+
// ── Read Replica Router (#997) ────────────────────────────────────────────────
186+
export { ReadReplicaRouter } from './readReplicaRouter';
187+
export type { ReplicaConfig, ReplicaHealth, ReadRouteOptions, QueryRoute } from './readReplicaRouter';
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Background Job Queue — SubTrackr
3+
*
4+
* Priority-based job queue for background processing (email, billing, analytics).
5+
*/
6+
7+
export type JobPriority = 'critical' | 'high' | 'medium' | 'low';
8+
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'retrying';
9+
10+
export interface Job {
11+
id: string;
12+
type: string;
13+
payload: Record<string, unknown>;
14+
priority: JobPriority;
15+
status: JobStatus;
16+
attempts: number;
17+
maxAttempts: number;
18+
createdAt: number;
19+
startedAt?: number;
20+
completedAt?: number;
21+
error?: string;
22+
nextRetryAt?: number;
23+
}
24+
25+
export type JobHandler = (job: Job) => Promise<void>;
26+
27+
export interface QueueConfig {
28+
maxConcurrent: number;
29+
maxRetries: number;
30+
retryDelayMs: number;
31+
processIntervalMs: number;
32+
}
33+
34+
interface QueueMetrics {
35+
totalProcessed: number;
36+
totalFailed: number;
37+
avgProcessingTimeMs: number;
38+
currentlyProcessing: number;
39+
}
40+
41+
const PRIORITY_WEIGHTS: Record<JobPriority, number> = {
42+
critical: 100,
43+
high: 75,
44+
medium: 50,
45+
low: 25,
46+
};
47+
48+
export class PriorityQueue {
49+
private queues: Map<JobPriority, Job[]> = new Map();
50+
private handlers: Map<string, JobHandler> = new Map();
51+
private processing = new Set<string>();
52+
private config: QueueConfig;
53+
private metrics: QueueMetrics = {
54+
totalProcessed: 0,
55+
totalFailed: 0,
56+
avgProcessingTimeMs: 0,
57+
currentlyProcessing: 0,
58+
};
59+
private timer?: ReturnType<typeof setInterval>;
60+
61+
constructor(config: Partial<QueueConfig> = {}) {
62+
this.config = {
63+
maxConcurrent: config.maxConcurrent ?? 5,
64+
maxRetries: config.maxRetries ?? 3,
65+
retryDelayMs: config.retryDelayMs ?? 1000,
66+
processIntervalMs: config.processIntervalMs ?? 100,
67+
};
68+
69+
for (const priority of Object.keys(PRIORITY_WEIGHTS) as JobPriority[]) {
70+
this.queues.set(priority, []);
71+
}
72+
}
73+
74+
registerHandler(type: string, handler: JobHandler): void {
75+
this.handlers.set(type, handler);
76+
}
77+
78+
enqueue(type: string, payload: Record<string, unknown>, priority: JobPriority = 'medium'): Job {
79+
const job: Job = {
80+
id: `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
81+
type,
82+
payload,
83+
priority,
84+
status: 'pending',
85+
attempts: 0,
86+
maxAttempts: this.config.maxRetries,
87+
createdAt: Date.now(),
88+
};
89+
90+
this.queues.get(priority)!.push(job);
91+
return job;
92+
}
93+
94+
private dequeue(): Job | undefined {
95+
const sortedPriorities = (Object.keys(PRIORITY_WEIGHTS) as JobPriority[]).sort(
96+
(a, b) => PRIORITY_WEIGHTS[b] - PRIORITY_WEIGHTS[a],
97+
);
98+
99+
for (const priority of sortedPriorities) {
100+
const queue = this.queues.get(priority)!;
101+
if (queue.length > 0) {
102+
return queue.shift();
103+
}
104+
}
105+
return undefined;
106+
}
107+
108+
async processNext(): Promise<boolean> {
109+
if (this.processing.size >= this.config.maxConcurrent) return false;
110+
111+
const job = this.dequeue();
112+
if (!job) return false;
113+
114+
const handler = this.handlers.get(job.type);
115+
if (!handler) {
116+
job.status = 'failed';
117+
job.error = `No handler registered for job type: ${job.type}`;
118+
this.metrics.totalFailed++;
119+
return false;
120+
}
121+
122+
job.status = 'processing';
123+
job.startedAt = Date.now();
124+
job.attempts++;
125+
this.processing.add(job.id);
126+
this.metrics.currentlyProcessing = this.processing.size;
127+
128+
try {
129+
await handler(job);
130+
job.status = 'completed';
131+
job.completedAt = Date.now();
132+
this.metrics.totalProcessed++;
133+
134+
const duration = job.completedAt - job.startedAt;
135+
this.metrics.avgProcessingTimeMs =
136+
(this.metrics.avgProcessingTimeMs * (this.metrics.totalProcessed - 1) + duration) /
137+
this.metrics.totalProcessed;
138+
} catch (err) {
139+
job.error = err instanceof Error ? err.message : String(err);
140+
141+
if (job.attempts < job.maxAttempts) {
142+
job.status = 'retrying';
143+
job.nextRetryAt = Date.now() + this.config.retryDelayMs * job.attempts;
144+
this.queues.get(job.priority)!.push(job);
145+
} else {
146+
job.status = 'failed';
147+
this.metrics.totalFailed++;
148+
}
149+
} finally {
150+
this.processing.delete(job.id);
151+
this.metrics.currentlyProcessing = this.processing.size;
152+
}
153+
154+
return true;
155+
}
156+
157+
start(): void {
158+
if (this.timer) return;
159+
this.timer = setInterval(() => {
160+
void this.processNext();
161+
}, this.config.processIntervalMs);
162+
}
163+
164+
stop(): void {
165+
if (this.timer) {
166+
clearInterval(this.timer);
167+
this.timer = undefined;
168+
}
169+
}
170+
171+
getMetrics(): QueueMetrics {
172+
return { ...this.metrics };
173+
}
174+
175+
getPendingCount(): number {
176+
let count = 0;
177+
for (const queue of this.queues.values()) {
178+
count += queue.length;
179+
}
180+
return count;
181+
}
182+
183+
getJob(id: string): Job | undefined {
184+
for (const queue of this.queues.values()) {
185+
const job = queue.find((j) => j.id === id);
186+
if (job) return job;
187+
}
188+
return undefined;
189+
}
190+
}
191+
192+
export const jobQueue = new PriorityQueue();

0 commit comments

Comments
 (0)