UNPKG

@net3/queuer

Version:

738 lines (645 loc) 23.9 kB
import { QueueConfiguration, QueueState, QueueUsage, QueueItem, QueueStatus, } from './types'; import { StoreScope } from '@activepieces/pieces-framework'; /** * Central utility handling all queue storage and scheduling logic. * All data is persisted via the piece `context.store` (PROJECT scope). */ export class QueueManager { /* ---------------------------------------------------------------------- */ /* Helpers – configuration, state & usage */ /* ---------------------------------------------------------------------- */ static async getQueueConfiguration( context: any, queueId: string, ): Promise<QueueConfiguration | null> { return await context.store.get(`queue_config_${queueId}`, StoreScope.PROJECT); } /** * NOTE: Activepieces key-value store cannot list keys, so a full “list queues” * is not possible without maintaining an external index. Stub returns []. */ static async listAllQueues(/* context: any */): Promise<QueueConfiguration[]> { return []; } static async getQueueState( context: any, queueId: string, ): Promise<QueueState> { const key = `queue_state_${queueId}`; return ( (await context.store.get(key, StoreScope.PROJECT)) || { queueId, lastReleaseTime: 0, itemCount: 0, currentExecutingItem: null, lastExecutedTime: 0, version: 0, } ); } static async getQueueUsage( context: any, queueId: string, ): Promise<QueueUsage> { const now = new Date(); const dateKey = now.toISOString().split('T')[0]; const hourKey = now.toISOString().slice(0, 13); const key = `queue_usage_${queueId}`; const usage: QueueUsage = (await context.store.get(key, StoreScope.PROJECT)) || { date: dateKey, hour: hourKey, dailyCount: 0, hourlyCount: 0, }; // daily rollover if (usage.date !== dateKey) { usage.date = dateKey; usage.dailyCount = 0; } // hourly rollover if (usage.hour !== hourKey) { usage.hour = hourKey; usage.hourlyCount = 0; } return usage; } /* ---------------------------------------------------------------------- */ /* Status & rate-limit helpers */ /* ---------------------------------------------------------------------- */ static async getQueueStatus( context: any, queueId: string, ): Promise<QueueStatus | null> { const queue = await this.getQueueConfiguration(context, queueId); if (!queue) return null; const state = await this.getQueueState(context, queueId); const usage = await this.getQueueUsage(context, queueId); const isWithinActiveHours = queue.activeHours ? this.isWithinActiveHours(queue.activeHours) : true; const nextActiveWindow = queue.activeHours && !isWithinActiveHours ? this.getNextActiveWindow(queue.activeHours) : undefined; return { pendingItems: state.itemCount, currentlyExecuting: state.currentExecutingItem, lastExecutedTime: state.lastExecutedTime ? new Date(state.lastExecutedTime).toISOString() : null, dailyUsed: usage.dailyCount, dailyLimit: queue.dailyLimit, dailyRemaining: queue.dailyLimit > 0 ? queue.dailyLimit - usage.dailyCount : null, hourlyUsed: usage.hourlyCount, hourlyLimit: queue.hourlyLimit, hourlyRemaining: queue.hourlyLimit > 0 ? queue.hourlyLimit - usage.hourlyCount : null, isWithinActiveHours, nextActiveWindow, }; } /** Check daily/hourly rate limits and return availability. */ static async checkRateLimits( context: any, queueId: string, ): Promise<{ canSend: boolean; nextAvailableTime?: number; dailyRemaining?: number; hourlyRemaining?: number; }> { const queue = await this.getQueueConfiguration(context, queueId); if (!queue) return { canSend: true }; if (queue.dailyLimit === 0 && queue.hourlyLimit === 0) return { canSend: true }; const usage = await this.getQueueUsage(context, queueId); const now = new Date(); // daily if (queue.dailyLimit > 0 && usage.dailyCount >= queue.dailyLimit) { const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0, 0, 0, 0); return { canSend: false, nextAvailableTime: tomorrow.getTime(), dailyRemaining: 0, hourlyRemaining: queue.hourlyLimit > 0 ? queue.hourlyLimit - usage.hourlyCount : 0, }; } // hourly if (queue.hourlyLimit > 0 && usage.hourlyCount >= queue.hourlyLimit) { const nextHour = new Date(now); nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0); return { canSend: false, nextAvailableTime: nextHour.getTime(), dailyRemaining: queue.dailyLimit > 0 ? queue.dailyLimit - usage.dailyCount : undefined, hourlyRemaining: 0, }; } return { canSend: true, dailyRemaining: queue.dailyLimit > 0 ? queue.dailyLimit - usage.dailyCount : undefined, hourlyRemaining: queue.hourlyLimit > 0 ? queue.hourlyLimit - usage.hourlyCount : undefined, }; } /* ---------------------------------------------------------------------- */ /* Delay calculations */ /* ---------------------------------------------------------------------- */ static calculateDelayMs( delayType: 'fixed' | 'random', delayUnit: 'seconds' | 'minutes' | 'hours' | 'days', delayValue?: number, delayMin?: number, delayMax?: number, ): number { const mult: Record<typeof delayUnit, number> = { seconds: 1000, minutes: 60 * 1000, hours: 60 * 60 * 1000, days: 24 * 60 * 60 * 1000, }; if (delayType === 'fixed') { return Math.floor((delayValue || 0) * mult[delayUnit]); } const min = delayMin || 0; const max = delayMax || 0; const rnd = Math.random() * (max - min) + min; return Math.floor(rnd * mult[delayUnit]); } static async calculateItemDelay( context: any, queue: QueueConfiguration, limits: any, ): Promise<{ delayMs: number; limitDelay: number; activeHoursDelay: number; }> { let baseDelay = this.calculateDelayMs( queue.delayType, queue.delayUnit, queue.delayValue, queue.delayMin, queue.delayMax, ); let limitDelay = 0; let activeHoursDelay = 0; if (!limits.canSend && limits.nextAvailableTime) { const now = Date.now(); limitDelay = Math.max(0, limits.nextAvailableTime - now); baseDelay = Math.max(baseDelay, limitDelay); } if (queue.activeHours) { // First check if we're currently within active hours const isCurrentlyActive = this.isWithinActiveHours(queue.activeHours); if (!isCurrentlyActive) { // Only apply active hours delay if we're outside active hours const nextActive = this.getNextActiveTime(queue.activeHours); const now = Date.now(); if (nextActive > now) { activeHoursDelay = nextActive - now; baseDelay = Math.max(baseDelay, activeHoursDelay); } } // If we're within active hours, no additional delay needed } return { delayMs: baseDelay, limitDelay, activeHoursDelay }; } /* ---------------------------------------------------------------------- */ /* Active-hours helpers */ /* ---------------------------------------------------------------------- */ static isWithinActiveHours(activeHours: any): boolean { if (!activeHours?.schedule) return true; const now = new Date(); const tz = activeHours.timezone || 'UTC'; const local = new Date(now.toLocaleString('en-US', { timeZone: tz })); const day = local .toLocaleDateString('en-US', { weekday: 'long' }) .toLowerCase(); const time = local.toTimeString().slice(0, 5); // HH:MM const sched = activeHours.schedule[day]; if (!sched || !sched.enabled) return false; if (sched.start && sched.end) { return time >= sched.start && time <= sched.end; } return true; } static getNextActiveTime(activeHours: any): number { if (!activeHours?.schedule) return Date.now(); const now = new Date(); const tz = activeHours.timezone || 'UTC'; for (let i = 0; i < 7; i++) { const cand = new Date(now); cand.setDate(cand.getDate() + i); const local = new Date(cand.toLocaleString('en-US', { timeZone: tz })); const day = local .toLocaleDateString('en-US', { weekday: 'long' }) .toLowerCase(); const sched = activeHours.schedule[day]; if (sched && sched.enabled && sched.start) { const [h, m] = sched.start.split(':').map(Number); cand.setHours(h, m, 0, 0); if (cand.getTime() > now.getTime()) return cand.getTime(); } } return now.getTime() + 24 * 60 * 60 * 1000; } static getNextActiveWindow(activeHours: any): string { return new Date(this.getNextActiveTime(activeHours)).toISOString(); } /* ---------------------------------------------------------------------- */ /* Core scheduling – add, validate, update, cleanup */ /* ---------------------------------------------------------------------- */ static async addItemToQueue( context: any, queueId: string, targetAction: string, actionConfig: any, delayMs: number, ): Promise<{ queueItemId: string; releaseTime: number }> { const MAX_RETRIES = 5; let attempt = 0; const queue = await this.getQueueConfiguration(context, queueId); if (!queue) throw new Error(`Queue not found: ${queueId}`); while (attempt < MAX_RETRIES) { try { const ts = Date.now(); const rand = Math.random().toString(36).substr(2, 9); const queueItemId = `${queueId}_${ts}_${rand}`; const stateKey = `queue_state_${queueId}`; const currentState: QueueState = (await context.store.get(stateKey, StoreScope.PROJECT)) || { queueId, lastReleaseTime: 0, itemCount: 0, currentExecutingItem: null, lastExecutedTime: 0, version: 0, }; // Get actual latest release time from queued items (FIFO) const itemsListKey = `queue_${queueId}_items`; const itemIds: string[] = (await context.store.get(itemsListKey, StoreScope.PROJECT)) || []; let latestReleaseTime = 0; for (const itemId of itemIds) { const item = await this.getQueueItem(context, itemId); if (item && item.status === 'scheduled' && item.releaseTime > latestReleaseTime) { latestReleaseTime = item.releaseTime; } } const earliest = ts + delayMs; const nextRelease = Math.max( earliest, latestReleaseTime + delayMs, ); const uniqueOffset = Math.floor(Math.random() * 1000); let finalReleaseTime = nextRelease + uniqueOffset; // Apply active hours constraints AFTER calculating queue position if (queue.activeHours) { const isCurrentlyActive = this.isWithinActiveHours(queue.activeHours); if (!isCurrentlyActive) { // Only push to next active window if we're outside active hours const nextActive = this.getNextActiveTime(queue.activeHours); if (nextActive > finalReleaseTime) { finalReleaseTime = nextActive; } } // If we're within active hours, keep the calculated time } // slot reservation const slotKey = `queue_time_${queueId}_${finalReleaseTime}`; if (await context.store.get(slotKey, StoreScope.PROJECT)) { attempt++; await new Promise((r) => setTimeout(r, 50 * attempt)); continue; } await context.store.put(slotKey, queueItemId, StoreScope.PROJECT); const queueItem: QueueItem = { id: queueItemId, queueId, releaseTime: finalReleaseTime, targetAction, actionConfig, status: 'scheduled', createdAt: ts, }; await context.store.put( `queue_item_${queueItemId}`, queueItem, StoreScope.PROJECT, ); const newState: QueueState = { ...currentState, lastReleaseTime: finalReleaseTime, itemCount: currentState.itemCount + 1, version: currentState.version + 1, }; const verify = await context.store.get(stateKey, StoreScope.PROJECT); if (verify && verify.version !== currentState.version) { // optimistic lock failed – cleanup await context.store.delete(`queue_item_${queueItemId}`, StoreScope.PROJECT); await context.store.delete(slotKey, StoreScope.PROJECT); attempt++; await new Promise((r) => setTimeout(r, 100 * attempt)); continue; } await context.store.put(stateKey, newState, StoreScope.PROJECT); // track item list const list: string[] = (await context.store.get(itemsListKey, StoreScope.PROJECT)) || []; list.push(queueItemId); await context.store.put(itemsListKey, list, StoreScope.PROJECT); return { queueItemId, releaseTime: finalReleaseTime }; } catch (err: any) { attempt++; if (attempt >= MAX_RETRIES) throw new Error( `Failed to add item after ${MAX_RETRIES} attempts: ${err.message}`, ); await new Promise((r) => setTimeout(r, 100 * attempt)); } } throw new Error('Failed to add item to queue'); } static async getQueueItem( context: any, queueItemId: string, ): Promise<QueueItem | null> { return await context.store.get(`queue_item_${queueItemId}`, StoreScope.PROJECT); } static async updateItemStatus( context: any, queueItemId: string, status: QueueItem['status'], error?: string, ) { const item = await this.getQueueItem(context, queueItemId); if (item) { item.status = status; if (error) item.lastError = error; await context.store.put(`queue_item_${queueItemId}`, item, StoreScope.PROJECT); } } static async validateQueuePosition( context: any, queueItemId: string, ): Promise<{ isValid: boolean; shouldRepause: boolean; newReleaseTime?: number; reason?: string; }> { const item = await this.getQueueItem(context, queueItemId); if (!item) return { isValid: false, shouldRepause: false, reason: 'Item not found' }; const now = Date.now(); const state = await this.getQueueState(context, item.queueId); if ( state.currentExecutingItem && state.currentExecutingItem !== queueItemId ) { return { isValid: false, shouldRepause: true, newReleaseTime: now + 5000, reason: 'Another item executing', }; } const nextOk = await this.checkIfNextInQueue(context, item); if (!nextOk) { const queue = await this.getQueueConfiguration(context, item.queueId); if (!queue) return { isValid: false, shouldRepause: false, reason: 'Queue not found', }; const delayMs = this.calculateDelayMs( queue.delayType, queue.delayUnit, queue.delayValue, queue.delayMin, queue.delayMax, ); const newRelease = state.lastReleaseTime + delayMs; item.releaseTime = newRelease; await context.store.put(`queue_item_${item.id}`, item, StoreScope.PROJECT); return { isValid: false, shouldRepause: true, newReleaseTime: newRelease, reason: 'Not next in queue', }; } if (now >= item.releaseTime) { state.currentExecutingItem = queueItemId; await context.store.put(`queue_state_${item.queueId}`, state, StoreScope.PROJECT); return { isValid: true, shouldRepause: false }; } return { isValid: false, shouldRepause: true, newReleaseTime: item.releaseTime, reason: 'Not time yet', }; } static async checkIfNextInQueue( context: any, currentItem: QueueItem, ): Promise<boolean> { const list: string[] = (await context.store.get( `queue_${currentItem.queueId}_items`, StoreScope.PROJECT, )) || []; for (const id of list) { if (id === currentItem.id) continue; const it = await this.getQueueItem(context, id); if (it && it.status === 'scheduled' && it.releaseTime < currentItem.releaseTime) return false; } return true; } static async markExecutionComplete( context: any, queueId: string, queueItemId: string, ) { const state = await this.getQueueState(context, queueId); state.currentExecutingItem = null; state.lastExecutedTime = Date.now(); state.itemCount = Math.max(0, state.itemCount - 1); await context.store.put(`queue_state_${queueId}`, state, StoreScope.PROJECT); const listKey = `queue_${queueId}_items`; const list: string[] = (await context.store.get(listKey, StoreScope.PROJECT)) || []; await context.store.put( listKey, list.filter((id) => id !== queueItemId), StoreScope.PROJECT, ); } static async cleanupQueueItem( context: any, queueId: string, queueItemId: string, releaseTime: number, ) { await context.store.delete( `queue_time_${queueId}_${releaseTime}`, StoreScope.PROJECT, ); await context.store.delete(`queue_item_${queueItemId}`, StoreScope.PROJECT); } static async incrementUsageCounters(context: any, queueId: string) { const usage = await this.getQueueUsage(context, queueId); usage.dailyCount++; usage.hourlyCount++; await context.store.put(`queue_usage_${queueId}`, usage, StoreScope.PROJECT); const queue = await this.getQueueConfiguration(context, queueId); if (queue) { queue.totalProcessed++; queue.lastUsed = Date.now(); await context.store.put(`queue_config_${queueId}`, queue, StoreScope.PROJECT); } } /* ---------------------------------------------------------------------- */ /* Queue Management Operations */ /* ---------------------------------------------------------------------- */ /** * Get all items in a queue with their details */ static async listQueueItems(context: any, queueId: string): Promise<QueueItem[]> { const listKey = `queue_${queueId}_items`; const itemIds: string[] = (await context.store.get(listKey, StoreScope.PROJECT)) || []; const items: QueueItem[] = []; for (const itemId of itemIds) { const item = await this.getQueueItem(context, itemId); if (item) { items.push(item); } } // Sort by release time return items.sort((a, b) => a.releaseTime - b.releaseTime); } /** * Clear all items from a queue */ static async clearQueue(context: any, queueId: string): Promise<{ clearedCount: number; queueId: string; }> { if (!queueId) { throw new Error('Queue ID is required for clearing queue'); } if (!context) { throw new Error('Context is required for clearing queue'); } try { const listKey = `queue_${queueId}_items`; let itemIds: string[]; try { itemIds = (await context.store.get(listKey, StoreScope.PROJECT)) || []; } catch (error: any) { throw new Error(`Failed to get queue items list: ${error.message}`); } let clearedCount = 0; const errors: string[] = []; // Delete all queue items and their time slots for (const itemId of itemIds) { try { const item = await this.getQueueItem(context, itemId); if (item) { // Delete the item try { await context.store.delete(`queue_item_${itemId}`, StoreScope.PROJECT); } catch (error: any) { errors.push(`Failed to delete item ${itemId}: ${error.message}`); continue; } // Delete the time slot reservation try { const slotKey = `queue_time_${queueId}_${item.releaseTime}`; await context.store.delete(slotKey, StoreScope.PROJECT); } catch (error: any) { errors.push(`Failed to delete time slot for item ${itemId}: ${error.message}`); } clearedCount++; } } catch (error: any) { errors.push(`Failed to process item ${itemId}: ${error.message}`); } } // Clear the items list try { await context.store.delete(listKey, StoreScope.PROJECT); } catch (error: any) { throw new Error(`Failed to clear items list: ${error.message}`); } // Reset queue state const stateKey = `queue_state_${queueId}`; try { const currentState: QueueState = (await context.store.get(stateKey, StoreScope.PROJECT)) || { queueId, lastReleaseTime: 0, itemCount: 0, currentExecutingItem: null, lastExecutedTime: 0, version: 0, }; const resetState: QueueState = { ...currentState, itemCount: 0, currentExecutingItem: null, lastReleaseTime: 0, // Reset this so new items start fresh version: currentState.version + 1, }; await context.store.put(stateKey, resetState, StoreScope.PROJECT); } catch (error: any) { throw new Error(`Failed to reset queue state: ${error.message}`); } // If there were partial errors, include them in the response if (errors.length > 0) { throw new Error(`Queue cleared with ${errors.length} errors: ${errors.join('; ')}`); } return { clearedCount, queueId, }; } catch (error: any) { throw new Error(`Clear queue operation failed: ${error.message}`); } } /** * Get complete queue information including configuration and status */ static async getQueueDetails(context: any, queueId: string): Promise<{ configuration: QueueConfiguration | null; status: QueueStatus | null; state: QueueState; usage: QueueUsage; }> { const configuration = await this.getQueueConfiguration(context, queueId); const status = await this.getQueueStatus(context, queueId); const state = await this.getQueueState(context, queueId); const usage = await this.getQueueUsage(context, queueId); return { configuration, status, state, usage, }; } }