UNPKG

@voilajsx/appkit

Version:

Minimal and framework agnostic Node.js toolkit designed for AI agentic backend development

415 lines 14.1 kB
/** * In-memory queue transport for development and testing * @module @voilajsx/appkit/queue * @file src/queue/transports/memory.ts * * @llm-rule WHEN: Development mode or when no Redis/Database available * @llm-rule AVOID: Production use - jobs lost on restart, no persistence * @llm-rule NOTE: Perfect for development and testing - fast, simple, no external dependencies */ /** * In-memory transport for development and testing */ export class MemoryTransport { config; jobs = new Map(); handlers = new Map(); paused = new Set(); processing = new Set(); // Timers for scheduling and cleanup schedulerTimer = null; cleanupTimer = null; processingLoop = null; /** * Creates memory transport with direct config access (like logging pattern) * @llm-rule WHEN: Auto-detected as fallback or explicitly configured for development * @llm-rule AVOID: Manual memory transport creation - use queuing.get() instead */ constructor(config) { this.config = config; // Start processing loops if worker enabled if (config.worker.enabled) { this.startProcessing(); this.setupCleanup(); } } /** * Add job to memory queue * @llm-rule WHEN: Adding jobs for background processing * @llm-rule AVOID: Adding too many jobs - memory transport has limits */ async add(id, jobType, data, options) { // Check memory limits if (this.jobs.size >= this.config.memory.maxJobs) { throw new Error(`Memory queue full (${this.config.memory.maxJobs} jobs)`); } const job = { id, type: jobType, data, options, status: 'waiting', attempts: 0, maxAttempts: options.attempts || this.config.maxAttempts, createdAt: new Date(), runAt: new Date(), }; this.jobs.set(id, job); } /** * Register job processor * @llm-rule WHEN: Setting up job handlers for specific job types * @llm-rule AVOID: Multiple handlers for same type - overwrites previous handler */ process(jobType, handler) { this.handlers.set(jobType, handler); } /** * Schedule job for future execution * @llm-rule WHEN: Need delayed job execution (reminders, scheduled tasks) * @llm-rule AVOID: Very long delays - memory transport resets on restart */ async schedule(id, jobType, data, delay) { // Check memory limits if (this.jobs.size >= this.config.memory.maxJobs) { throw new Error(`Memory queue full (${this.config.memory.maxJobs} jobs)`); } const job = { id, type: jobType, data, options: { attempts: this.config.maxAttempts }, status: 'delayed', attempts: 0, maxAttempts: this.config.maxAttempts, createdAt: new Date(), runAt: new Date(Date.now() + delay), }; this.jobs.set(id, job); } /** * Pause queue processing * @llm-rule WHEN: Maintenance mode or controlled processing stop * @llm-rule AVOID: Pausing without resume - jobs accumulate in memory */ async pause(jobType) { if (jobType) { this.paused.add(jobType); } else { // Pause all by clearing handlers temporarily for (const type of this.handlers.keys()) { this.paused.add(type); } } } /** * Resume queue processing * @llm-rule WHEN: Resuming after maintenance pause * @llm-rule AVOID: Resuming without checking system capacity */ async resume(jobType) { if (jobType) { this.paused.delete(jobType); } else { // Resume all this.paused.clear(); } } /** * Get queue statistics * @llm-rule WHEN: Monitoring queue health and performance * @llm-rule AVOID: Frequent polling - can be expensive with many jobs */ async getStats(jobType) { const filteredJobs = Array.from(this.jobs.values()).filter(job => !jobType || job.type === jobType); return { waiting: filteredJobs.filter(job => job.status === 'waiting').length, active: filteredJobs.filter(job => job.status === 'active').length, completed: filteredJobs.filter(job => job.status === 'completed').length, failed: filteredJobs.filter(job => job.status === 'failed').length, delayed: filteredJobs.filter(job => job.status === 'delayed').length, paused: this.paused.size, }; } /** * Get jobs by status * @llm-rule WHEN: Debugging failed jobs or monitoring specific job states * @llm-rule AVOID: Getting all jobs frequently - can impact memory performance */ async getJobs(status, jobType) { return Array.from(this.jobs.values()) .filter(job => { const statusMatch = job.status === status; const typeMatch = !jobType || job.type === jobType; return statusMatch && typeMatch; }) .map(job => this.jobToInfo(job)) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); // Newest first } /** * Retry failed job * @llm-rule WHEN: Manual retry of failed jobs * @llm-rule AVOID: Retrying jobs that will fail again without fixing root cause */ async retry(jobId) { const job = this.jobs.get(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } if (job.status !== 'failed') { throw new Error(`Job ${jobId} is not in failed state`); } // Reset for retry job.status = 'waiting'; job.attempts = 0; job.error = undefined; job.failedAt = undefined; job.runAt = new Date(); } /** * Remove job from queue * @llm-rule WHEN: Canceling scheduled jobs or cleanup * @llm-rule AVOID: Removing active jobs - can cause inconsistent state */ async remove(jobId) { const job = this.jobs.get(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } if (job.status === 'active') { throw new Error(`Cannot remove active job ${jobId}`); } this.jobs.delete(jobId); } /** * Clean old jobs by status * @llm-rule WHEN: Periodic cleanup to prevent memory growth * @llm-rule AVOID: Aggressive cleanup - keep some jobs for debugging */ async clean(status, grace = 24 * 60 * 60 * 1000) { const cutoff = new Date(Date.now() - grace); const toDelete = []; for (const [id, job] of this.jobs) { if (job.status === status) { const jobDate = job.completedAt || job.failedAt || job.createdAt; if (jobDate < cutoff) { toDelete.push(id); } } } for (const id of toDelete) { this.jobs.delete(id); } } /** * Get transport health status * @llm-rule WHEN: Health checks and monitoring * @llm-rule AVOID: Complex health logic - memory transport is simple */ getHealth() { const jobCount = this.jobs.size; const maxJobs = this.config.memory.maxJobs; if (jobCount >= maxJobs) { return { status: 'unhealthy', message: 'Memory queue full' }; } if (jobCount >= maxJobs * 0.8) { return { status: 'degraded', message: 'Memory queue nearly full' }; } return { status: 'healthy' }; } /** * Close transport and cleanup resources * @llm-rule WHEN: App shutdown or testing cleanup * @llm-rule AVOID: Abrupt close - finish processing current jobs first */ async close() { // Stop all timers if (this.schedulerTimer) { clearInterval(this.schedulerTimer); this.schedulerTimer = null; } if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } if (this.processingLoop) { clearTimeout(this.processingLoop); this.processingLoop = null; } // Wait for current jobs to complete (with timeout) const timeout = this.config.worker.gracefulShutdownTimeout; const startTime = Date.now(); while (this.processing.size > 0 && Date.now() - startTime < timeout) { await new Promise(resolve => setTimeout(resolve, 100)); } // Clear all data this.jobs.clear(); this.handlers.clear(); this.paused.clear(); this.processing.clear(); } // ============================================================================ // PRIVATE PROCESSING METHODS // ============================================================================ /** * Start background processing loop */ startProcessing() { this.processJobs(); } /** * Main job processing loop */ async processJobs() { try { // Move delayed jobs to waiting if ready this.promoteDelayedJobs(); // Process waiting jobs await this.processWaitingJobs(); } catch (error) { console.error('Memory transport processing error:', error.message); } // Schedule next processing cycle this.processingLoop = setTimeout(() => this.processJobs(), 1000); } /** * Promote delayed jobs that are ready to run */ promoteDelayedJobs() { const now = new Date(); for (const job of this.jobs.values()) { if (job.status === 'delayed' && job.runAt <= now) { job.status = 'waiting'; } } } /** * Process waiting jobs up to concurrency limit */ async processWaitingJobs() { const concurrency = this.config.concurrency; const currentActive = this.processing.size; if (currentActive >= concurrency) { return; // At capacity } // Get waiting jobs sorted by priority const waitingJobs = Array.from(this.jobs.values()) .filter(job => { const isWaiting = job.status === 'waiting'; const hasHandler = this.handlers.has(job.type); const notPaused = !this.paused.has(job.type); return isWaiting && hasHandler && notPaused; }) .sort((a, b) => (b.options.priority || 0) - (a.options.priority || 0)); // Process jobs up to concurrency limit const toProcess = waitingJobs.slice(0, concurrency - currentActive); for (const job of toProcess) { this.processJob(job).catch(error => { console.error(`Error processing job ${job.id}:`, error); }); } } /** * Process individual job */ async processJob(job) { const handler = this.handlers.get(job.type); if (!handler) { return; // No handler available } // Mark as processing this.processing.add(job.id); job.status = 'active'; job.processedAt = new Date(); job.attempts++; try { // Execute job handler const result = await handler(job.data); // Job completed successfully job.status = 'completed'; job.completedAt = new Date(); // Store result if needed if (result !== undefined) { job.result = result; } } catch (error) { // Job failed job.error = { message: error.message, stack: error.stack, name: error.name, }; if (job.attempts < job.maxAttempts) { // Retry with backoff job.status = 'waiting'; job.runAt = this.calculateRetryDelay(job); } else { // Max attempts reached job.status = 'failed'; job.failedAt = new Date(); } } finally { // Remove from processing set this.processing.delete(job.id); } } /** * Calculate retry delay with backoff */ calculateRetryDelay(job) { const baseDelay = this.config.retryDelay; let delay = baseDelay; if (this.config.retryBackoff === 'exponential') { delay = baseDelay * Math.pow(2, job.attempts - 1); } // Add jitter (±25%) const jitter = delay * 0.25 * (Math.random() - 0.5); delay += jitter; return new Date(Date.now() + delay); } /** * Setup periodic cleanup */ setupCleanup() { this.cleanupTimer = setInterval(() => { this.performCleanup().catch(error => { console.error('Memory transport cleanup error:', error); }); }, this.config.memory.cleanupInterval); } /** * Perform automatic cleanup */ async performCleanup() { // Clean completed jobs older than 1 hour await this.clean('completed', 60 * 60 * 1000); // Clean failed jobs older than 24 hours await this.clean('failed', 24 * 60 * 60 * 1000); } /** * Convert MemoryJob to JobInfo */ jobToInfo(job) { return { id: job.id, type: job.type, data: job.data, status: job.status, progress: job.progress, attempts: job.attempts, maxAttempts: job.maxAttempts, error: job.error, createdAt: job.createdAt, processedAt: job.processedAt, completedAt: job.completedAt, failedAt: job.failedAt, }; } } //# sourceMappingURL=memory.js.map