diff --git a/backend/services/shared/cdnService.ts b/backend/services/shared/cdnService.ts new file mode 100644 index 00000000..9f4c3b96 --- /dev/null +++ b/backend/services/shared/cdnService.ts @@ -0,0 +1,161 @@ +/** + * CDN Edge Caching Service — SubTrackr + * + * Manages edge caching for API responses with TTL, purge, and invalidation. + */ + +export interface CdnConfig { + defaultTtlSeconds: number; + maxTtlSeconds: number; + staleWhileRevalidateSeconds: number; + purgeBatchSize: number; +} + +export interface CacheEntry { + key: string; + data: T; + headers: Record; + cachedAt: number; + ttlMs: number; + tags: string[]; +} + +export interface PurgeRequest { + patterns: string[]; + tags?: string[]; +} + +export interface PurgeResult { + purgedCount: number; + patterns: string[]; +} + +export interface CdnMetrics { + hits: number; + misses: number; + purges: number; + hitRate: number; +} + +export class CdnService { + private cache = new Map(); + private tagIndex = new Map>(); + private config: CdnConfig; + private metrics: CdnMetrics = { hits: 0, misses: 0, purges: 0, hitRate: 0 }; + + constructor(config: Partial = {}) { + this.config = { + defaultTtlSeconds: config.defaultTtlSeconds ?? 300, + maxTtlSeconds: config.maxTtlSeconds ?? 86400, + staleWhileRevalidateSeconds: config.staleWhileRevalidateSeconds ?? 60, + purgeBatchSize: config.purgeBatchSize ?? 100, + }; + } + + get(key: string): CacheEntry | null { + const entry = this.cache.get(key) as CacheEntry | undefined; + if (!entry) { + this.metrics.misses++; + this.updateHitRate(); + return null; + } + + const now = Date.now(); + if (now > entry.cachedAt + entry.ttlMs) { + this.cache.delete(key); + this.removeFromTagIndex(key, entry.tags); + this.metrics.misses++; + this.updateHitRate(); + return null; + } + + this.metrics.hits++; + this.updateHitRate(); + return entry; + } + + set(key: string, data: T, options: { ttlSeconds?: number; tags?: string[] } = {}): void { + const ttlMs = Math.min( + (options.ttlSeconds ?? this.config.defaultTtlSeconds) * 1000, + this.config.maxTtlSeconds * 1000, + ); + + const entry: CacheEntry = { + key, + data, + headers: { + 'cache-control': `public, max-age=${Math.floor(ttlMs / 1000)}, stale-while-revalidate=${this.config.staleWhileRevalidateSeconds}`, + 'cdn-cache-status': 'HIT', + }, + cachedAt: Date.now(), + ttlMs, + tags: options.tags ?? [], + }; + + this.cache.set(key, entry); + for (const tag of entry.tags) { + if (!this.tagIndex.has(tag)) this.tagIndex.set(tag, new Set()); + this.tagIndex.get(tag)!.add(key); + } + } + + purge(request: PurgeRequest): PurgeResult { + let purgedCount = 0; + const purgedPatterns: string[] = []; + + for (const pattern of request.patterns) { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + for (const [key, entry] of this.cache) { + if (regex.test(key)) { + this.cache.delete(key); + this.removeFromTagIndex(key, entry.tags); + purgedCount++; + } + } + purgedPatterns.push(pattern); + } + + if (request.tags) { + for (const tag of request.tags) { + const keys = this.tagIndex.get(tag); + if (keys) { + for (const key of keys) { + this.cache.delete(key); + purgedCount++; + } + this.tagIndex.delete(tag); + } + } + } + + this.metrics.purges++; + return { purgedCount, patterns: purgedPatterns }; + } + + invalidate(key: string): boolean { + const entry = this.cache.get(key); + if (entry) { + this.removeFromTagIndex(key, entry.tags); + this.cache.delete(key); + return true; + } + return false; + } + + getMetrics(): CdnMetrics { + return { ...this.metrics }; + } + + private removeFromTagIndex(key: string, tags: string[]): void { + for (const tag of tags) { + this.tagIndex.get(tag)?.delete(key); + } + } + + private updateHitRate(): void { + const total = this.metrics.hits + this.metrics.misses; + this.metrics.hitRate = total > 0 ? this.metrics.hits / total : 0; + } +} + +export const cdnService = new CdnService(); diff --git a/backend/services/shared/index.ts b/backend/services/shared/index.ts index 7d632391..19df0818 100644 --- a/backend/services/shared/index.ts +++ b/backend/services/shared/index.ts @@ -169,3 +169,19 @@ export type { LeakRecord, PoolTuningRecommendation, } from './poolMonitor'; + +// ── Background Job Queue (#990) ────────────────────────────────────────────── +export { PriorityQueue, jobQueue } from './jobQueue'; +export type { Job, JobHandler, QueueConfig, QueueMetrics, JobStatus, JobPriority } from './jobQueue'; + +// ── CDN Edge Caching (#991) ────────────────────────────────────────────────── +export { CdnService, cdnService } from './cdnService'; +export type { CdnConfig, CacheEntry, PurgeRequest, PurgeResult, CdnMetrics } from './cdnService'; + +// ── WebSocket Connection Pool (#994) ────────────────────────────────────────── +export { WsConnectionPool } from './wsConnectionPool'; +export type { WsPoolConfig, WsConnection, WsMessage, WsPoolMetrics } from './wsConnectionPool'; + +// ── Read Replica Router (#997) ──────────────────────────────────────────────── +export { ReadReplicaRouter } from './readReplicaRouter'; +export type { ReplicaConfig, ReplicaHealth, ReadRouteOptions, QueryRoute } from './readReplicaRouter'; diff --git a/backend/services/shared/jobQueue.ts b/backend/services/shared/jobQueue.ts new file mode 100644 index 00000000..0496b127 --- /dev/null +++ b/backend/services/shared/jobQueue.ts @@ -0,0 +1,192 @@ +/** + * Background Job Queue — SubTrackr + * + * Priority-based job queue for background processing (email, billing, analytics). + */ + +export type JobPriority = 'critical' | 'high' | 'medium' | 'low'; +export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'retrying'; + +export interface Job { + id: string; + type: string; + payload: Record; + priority: JobPriority; + status: JobStatus; + attempts: number; + maxAttempts: number; + createdAt: number; + startedAt?: number; + completedAt?: number; + error?: string; + nextRetryAt?: number; +} + +export type JobHandler = (job: Job) => Promise; + +export interface QueueConfig { + maxConcurrent: number; + maxRetries: number; + retryDelayMs: number; + processIntervalMs: number; +} + +interface QueueMetrics { + totalProcessed: number; + totalFailed: number; + avgProcessingTimeMs: number; + currentlyProcessing: number; +} + +const PRIORITY_WEIGHTS: Record = { + critical: 100, + high: 75, + medium: 50, + low: 25, +}; + +export class PriorityQueue { + private queues: Map = new Map(); + private handlers: Map = new Map(); + private processing = new Set(); + private config: QueueConfig; + private metrics: QueueMetrics = { + totalProcessed: 0, + totalFailed: 0, + avgProcessingTimeMs: 0, + currentlyProcessing: 0, + }; + private timer?: ReturnType; + + constructor(config: Partial = {}) { + this.config = { + maxConcurrent: config.maxConcurrent ?? 5, + maxRetries: config.maxRetries ?? 3, + retryDelayMs: config.retryDelayMs ?? 1000, + processIntervalMs: config.processIntervalMs ?? 100, + }; + + for (const priority of Object.keys(PRIORITY_WEIGHTS) as JobPriority[]) { + this.queues.set(priority, []); + } + } + + registerHandler(type: string, handler: JobHandler): void { + this.handlers.set(type, handler); + } + + enqueue(type: string, payload: Record, priority: JobPriority = 'medium'): Job { + const job: Job = { + id: `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + type, + payload, + priority, + status: 'pending', + attempts: 0, + maxAttempts: this.config.maxRetries, + createdAt: Date.now(), + }; + + this.queues.get(priority)!.push(job); + return job; + } + + private dequeue(): Job | undefined { + const sortedPriorities = (Object.keys(PRIORITY_WEIGHTS) as JobPriority[]).sort( + (a, b) => PRIORITY_WEIGHTS[b] - PRIORITY_WEIGHTS[a], + ); + + for (const priority of sortedPriorities) { + const queue = this.queues.get(priority)!; + if (queue.length > 0) { + return queue.shift(); + } + } + return undefined; + } + + async processNext(): Promise { + if (this.processing.size >= this.config.maxConcurrent) return false; + + const job = this.dequeue(); + if (!job) return false; + + const handler = this.handlers.get(job.type); + if (!handler) { + job.status = 'failed'; + job.error = `No handler registered for job type: ${job.type}`; + this.metrics.totalFailed++; + return false; + } + + job.status = 'processing'; + job.startedAt = Date.now(); + job.attempts++; + this.processing.add(job.id); + this.metrics.currentlyProcessing = this.processing.size; + + try { + await handler(job); + job.status = 'completed'; + job.completedAt = Date.now(); + this.metrics.totalProcessed++; + + const duration = job.completedAt - job.startedAt; + this.metrics.avgProcessingTimeMs = + (this.metrics.avgProcessingTimeMs * (this.metrics.totalProcessed - 1) + duration) / + this.metrics.totalProcessed; + } catch (err) { + job.error = err instanceof Error ? err.message : String(err); + + if (job.attempts < job.maxAttempts) { + job.status = 'retrying'; + job.nextRetryAt = Date.now() + this.config.retryDelayMs * job.attempts; + this.queues.get(job.priority)!.push(job); + } else { + job.status = 'failed'; + this.metrics.totalFailed++; + } + } finally { + this.processing.delete(job.id); + this.metrics.currentlyProcessing = this.processing.size; + } + + return true; + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => { + void this.processNext(); + }, this.config.processIntervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + getMetrics(): QueueMetrics { + return { ...this.metrics }; + } + + getPendingCount(): number { + let count = 0; + for (const queue of this.queues.values()) { + count += queue.length; + } + return count; + } + + getJob(id: string): Job | undefined { + for (const queue of this.queues.values()) { + const job = queue.find((j) => j.id === id); + if (job) return job; + } + return undefined; + } +} + +export const jobQueue = new PriorityQueue(); diff --git a/backend/services/shared/readReplicaRouter.ts b/backend/services/shared/readReplicaRouter.ts new file mode 100644 index 00000000..4176a3a3 --- /dev/null +++ b/backend/services/shared/readReplicaRouter.ts @@ -0,0 +1,196 @@ +/** + * Read Replica Router — SubTrackr + * + * Routes read queries to replicas with automatic failover and health monitoring. + */ + +export interface ReplicaConfig { + healthCheckIntervalMs: number; + maxReplicaLagMs: number; + connectionTimeoutMs: number; + retryAttempts: number; +} + +export interface ReplicaHealth { + id: string; + url: string; + healthy: boolean; + lastChecked: number; + responseTimeMs: number; + replicationLagMs: number; + failoverCount: number; +} + +export interface ReadRouteOptions { + preferLowLag?: boolean; + requireHealthy?: boolean; + excludeReplica?: string; +} + +export interface QueryRoute { + replicaId: string; + url: string; + estimatedLatencyMs: number; +} + +export class ReadReplicaRouter { + private primary: ReplicaHealth; + private replicas: Map = new Map(); + private config: ReplicaConfig; + private healthTimer?: ReturnType; + private roundRobinIndex = 0; + + constructor( + primaryUrl: string, + replicaUrls: string[] = [], + config: Partial = {}, + ) { + this.config = { + healthCheckIntervalMs: config.healthCheckIntervalMs ?? 10000, + maxReplicaLagMs: config.maxReplicaLagMs ?? 5000, + connectionTimeoutMs: config.connectionTimeoutMs ?? 3000, + retryAttempts: config.retryAttempts ?? 2, + }; + + this.primary = { + id: 'primary', + url: primaryUrl, + healthy: true, + lastChecked: Date.now(), + responseTimeMs: 0, + replicationLagMs: 0, + failoverCount: 0, + }; + + for (const url of replicaUrls) { + const id = `replica-${this.replicas.size + 1}`; + this.replicas.set(id, { + id, + url, + healthy: true, + lastChecked: 0, + responseTimeMs: 0, + replicationLagMs: 0, + failoverCount: 0, + }); + } + } + + routeRead(options: ReadRouteOptions = {}): QueryRoute { + const healthyReplicas = Array.from(this.replicas.values()).filter((r) => { + if (!r.healthy && options.requireHealthy !== false) return false; + if (r.id === options.excludeReplica) return false; + if (r.replicationLagMs > this.config.maxReplicaLagMs) return false; + return true; + }); + + if (healthyReplicas.length === 0) { + return { + replicaId: this.primary.id, + url: this.primary.url, + estimatedLatencyMs: this.primary.responseTimeMs, + }; + } + + if (options.preferLowLag) { + healthyReplicas.sort((a, b) => a.replicationLagMs - b.replicationLagMs); + const best = healthyReplicas[0]; + return { + replicaId: best.id, + url: best.url, + estimatedLatencyMs: best.responseTimeMs, + }; + } + + const replica = healthyReplicas[this.roundRobinIndex % healthyReplicas.length]; + this.roundRobinIndex = (this.roundRobinIndex + 1) % healthyReplicas.length; + + return { + replicaId: replica.id, + url: replica.url, + estimatedLatencyMs: replica.responseTimeMs, + }; + } + + addReplica(url: string): ReplicaHealth { + const id = `replica-${this.replicas.size + 1}`; + const replica: ReplicaHealth = { + id, + url, + healthy: true, + lastChecked: Date.now(), + responseTimeMs: 0, + replicationLagMs: 0, + failoverCount: 0, + }; + this.replicas.set(id, replica); + return replica; + } + + removeReplica(id: string): boolean { + return this.replicas.delete(id); + } + + reportHealth(id: string, data: Partial): void { + if (id === 'primary') { + Object.assign(this.primary, data, { lastChecked: Date.now() }); + } else { + const replica = this.replicas.get(id); + if (replica) { + Object.assign(replica, data, { lastChecked: Date.now() }); + } + } + } + + markUnhealthy(id: string): void { + if (id === 'primary') { + this.primary.healthy = false; + } else { + const replica = this.replicas.get(id); + if (replica) { + replica.healthy = false; + replica.failoverCount++; + } + } + } + + startHealthChecks(): void { + if (this.healthTimer) return; + this.healthTimer = setInterval(() => { + this.checkAllHealth(); + }, this.config.healthCheckIntervalMs); + } + + stopHealthChecks(): void { + if (this.healthTimer) { + clearInterval(this.healthTimer); + this.healthTimer = undefined; + } + } + + private checkAllHealth(): void { + const now = Date.now(); + for (const replica of this.replicas.values()) { + if (now - replica.lastChecked > this.config.healthCheckIntervalMs * 3) { + replica.healthy = false; + } + } + } + + getHealth(): ReplicaHealth[] { + return [this.primary, ...Array.from(this.replicas.values())]; + } + + getHealthyCount(): number { + let count = 0; + if (this.primary.healthy) count++; + for (const replica of this.replicas.values()) { + if (replica.healthy) count++; + } + return count; + } + + stop(): void { + this.stopHealthChecks(); + } +} diff --git a/backend/services/shared/wsConnectionPool.ts b/backend/services/shared/wsConnectionPool.ts new file mode 100644 index 00000000..654c69e0 --- /dev/null +++ b/backend/services/shared/wsConnectionPool.ts @@ -0,0 +1,190 @@ +/** + * WebSocket Connection Pool — SubTrackr + * + * Manages WebSocket connections with pooling, message batching, and health monitoring. + */ + +export interface WsPoolConfig { + maxConnections: number; + messageBatchSize: number; + messageBatchIntervalMs: number; + heartbeatIntervalMs: number; + connectionTimeoutMs: number; +} + +export interface WsConnection { + id: string; + url: string; + connected: boolean; + connectedAt?: number; + lastMessageAt?: number; + messageCount: number; + reconnects: number; +} + +export interface WsMessage { + id: string; + connectionId: string; + data: string | Buffer; + timestamp: number; + sent: boolean; +} + +export interface WsPoolMetrics { + totalConnections: number; + activeConnections: number; + messagesSent: number; + messagesFailed: number; + batchesSent: number; + avgBatchSize: number; +} + +export class WsConnectionPool { + private connections = new Map(); + private messageQueue: WsMessage[] = []; + private batchTimer?: ReturnType; + private heartbeatTimer?: ReturnType; + private config: WsPoolConfig; + private metrics: WsPoolMetrics = { + totalConnections: 0, + activeConnections: 0, + messagesSent: 0, + messagesFailed: 0, + batchesSent: 0, + avgBatchSize: 0, + }; + + constructor(config: Partial = {}) { + this.config = { + maxConnections: config.maxConnections ?? 50, + messageBatchSize: config.messageBatchSize ?? 10, + messageBatchIntervalMs: config.messageBatchIntervalMs ?? 50, + heartbeatIntervalMs: config.heartbeatIntervalMs ?? 30000, + connectionTimeoutMs: config.connectionTimeoutMs ?? 5000, + }; + } + + addConnection(url: string): WsConnection | null { + if (this.connections.size >= this.config.maxConnections) return null; + + const connection: WsConnection = { + id: `ws-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + url, + connected: true, + connectedAt: Date.now(), + messageCount: 0, + reconnects: 0, + }; + + this.connections.set(connection.id, connection); + this.metrics.totalConnections++; + this.metrics.activeConnections = this.connections.size; + return connection; + } + + removeConnection(id: string): boolean { + const removed = this.connections.delete(id); + if (removed) { + this.metrics.activeConnections = this.connections.size; + } + return removed; + } + + queueMessage(connectionId: string, data: string | Buffer): WsMessage | null { + const conn = this.connections.get(connectionId); + if (!conn || !conn.connected) return null; + + const message: WsMessage = { + id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + connectionId, + data, + timestamp: Date.now(), + sent: false, + }; + + this.messageQueue.push(message); + return message; + } + + async flushBatch(): Promise { + const batch = this.messageQueue.splice(0, this.config.messageBatchSize); + if (batch.length === 0) return []; + + const sent: WsMessage[] = []; + for (const msg of batch) { + const conn = this.connections.get(msg.connectionId); + if (conn && conn.connected) { + msg.sent = true; + conn.messageCount++; + conn.lastMessageAt = Date.now(); + this.metrics.messagesSent++; + sent.push(msg); + } else { + this.metrics.messagesFailed++; + } + } + + this.metrics.batchesSent++; + this.metrics.avgBatchSize = + (this.metrics.avgBatchSize * (this.metrics.batchesSent - 1) + batch.length) / + this.metrics.batchesSent; + + return sent; + } + + startBatching(): void { + if (this.batchTimer) return; + this.batchTimer = setInterval(() => { + void this.flushBatch(); + }, this.config.messageBatchIntervalMs); + } + + stopBatching(): void { + if (this.batchTimer) { + clearInterval(this.batchTimer); + this.batchTimer = undefined; + } + } + + startHeartbeat(): void { + if (this.heartbeatTimer) return; + this.heartbeatTimer = setInterval(() => { + const now = Date.now(); + for (const [id, conn] of this.connections) { + if (conn.lastMessageAt && now - conn.lastMessageAt > this.config.heartbeatIntervalMs * 2) { + conn.connected = false; + this.connections.delete(id); + this.metrics.activeConnections = this.connections.size; + } + } + }, this.config.heartbeatIntervalMs); + } + + stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + } + + getConnection(id: string): WsConnection | undefined { + return this.connections.get(id); + } + + getConnections(): WsConnection[] { + return Array.from(this.connections.values()); + } + + getMetrics(): WsPoolMetrics { + return { ...this.metrics }; + } + + getPendingMessageCount(): number { + return this.messageQueue.filter((m) => !m.sent).length; + } + + stop(): void { + this.stopBatching(); + this.stopHeartbeat(); + } +}