UNPKG

fib-flow

Version:

A robust workflow management system for fibjs with task orchestration, state management, and distributed execution capabilities

1,364 lines (1,180 loc) 111 kB
/** * Provides a unified interface for task persistence across different database systems. * Abstracts database-specific complexities to ensure consistent task management. */ const db = require('db'); const Pool = require('fib-pool'); const coroutine = require('coroutine'); const createLogger = require('../logger'); const { parseTask } = require('./util'); // Create logger for database operations const logger = createLogger('fib-flow'); function serializeEventMetadata(metadata) { if (metadata === undefined || metadata === null) { return null; } return typeof metadata === 'string' ? metadata : JSON.stringify(metadata); } function parseEventRow(event) { if (!event) return null; if (typeof event.metadata === 'string' && event.metadata !== '') { try { event.metadata = JSON.parse(event.metadata); } catch (_error) { // Keep original metadata when parsing fails. } } return event; } function parseAttemptRow(attempt) { if (!attempt) return null; if (attempt.timeout_flag !== undefined && attempt.timeout_flag !== null) { attempt.timeout_flag = Boolean(attempt.timeout_flag); } return attempt; } function normalizeRootId(taskId, rootId) { return rootId || taskId; } function hasFilterValue(value) { return value !== undefined && value !== null; } function normalizePagination(filters = {}) { const order = String(filters.order || 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC'; let limit = null; if (hasFilterValue(filters.limit)) { limit = Number(filters.limit); if (!Number.isInteger(limit) || limit <= 0) { throw new Error('limit must be a positive integer'); } } let offset = 0; if (hasFilterValue(filters.offset)) { offset = Number(filters.offset); if (!Number.isInteger(offset) || offset < 0) { throw new Error('offset must be a non-negative integer'); } } return { limit, offset, order }; } function toPageResult(items, total, limit, offset) { return { items, total, limit, offset, has_more: limit !== null ? offset + items.length < total : false }; } function incrementCounter(bucket, key) { bucket[key] = (bucket[key] || 0) + 1; } function sortObjectKeys(input) { const output = {}; Object.keys(input).sort().forEach(key => { output[key] = input[key]; }); return output; } function compareNullableNumberAsc(left, right) { const normalizedLeft = left === null || left === undefined ? Number.MAX_SAFE_INTEGER : left; const normalizedRight = right === null || right === undefined ? Number.MAX_SAFE_INTEGER : right; return normalizedLeft - normalizedRight; } function hasOwn(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); } const DEFAULT_RETENTION_STATUSES = ['completed', 'permanently_failed']; const ALLOWED_RETENTION_STATUSES = ['completed', 'permanently_failed', 'paused']; const AUDIT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; function normalizeRetentionPolicy(policy, fallbackExpireTime = null) { let input = policy; if (typeof input === 'number') { input = { expire_time: input }; } if (!input || typeof input !== 'object') { input = {}; } const rawExpireTime = hasOwn(input, 'expire_time') ? input.expire_time : fallbackExpireTime; const now = hasOwn(input, 'now') ? Number(input.now) : Math.floor(Date.now() / 1000); if (rawExpireTime === null || rawExpireTime === undefined || rawExpireTime === false) { return { expire_time: null, statuses: DEFAULT_RETENTION_STATUSES.slice(), now }; } const expireTime = Number(rawExpireTime); if (!Number.isFinite(expireTime) || expireTime <= 0) { throw new Error('retention expire_time must be a positive number'); } const statuses = hasOwn(input, 'statuses') ? input.statuses : DEFAULT_RETENTION_STATUSES; if (!Array.isArray(statuses) || statuses.length === 0) { throw new Error('retention statuses must be a non-empty array'); } const normalizedStatuses = statuses.map(status => String(status)); normalizedStatuses.forEach(status => { if (!ALLOWED_RETENTION_STATUSES.includes(status)) { throw new Error(`Unsupported retention status: ${status}`); } }); return { expire_time: expireTime, statuses: Array.from(new Set(normalizedStatuses)), now }; } function normalizeTrimmedString(value, fieldName) { if (typeof value !== 'string') { throw new Error(`${fieldName} must be a string`); } const trimmed = value.trim(); if (!trimmed) { throw new Error(`${fieldName} must not be empty`); } return trimmed; } function normalizeAuditName(value, fieldName) { const normalized = normalizeTrimmedString(value, fieldName); if (!AUDIT_NAME_PATTERN.test(normalized)) { throw new Error(`${fieldName} must use lowercase snake_case`); } return normalized; } function normalizeWorkerIdentifier(value, fieldName) { return normalizeTrimmedString(value, fieldName); } function normalizeMetadata(metadata, fieldName) { if (metadata === undefined) { return undefined; } if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { throw new Error(`${fieldName} must be an object`); } return metadata; } function normalizeCheckpointPayload(checkpoint) { if (!checkpoint || !checkpoint.code) { throw new Error('Checkpoint code is required'); } return { ...checkpoint, code: normalizeAuditName(checkpoint.code, 'Checkpoint code'), ...(hasOwn(checkpoint, 'message') ? { message: normalizeTrimmedString(checkpoint.message, 'Checkpoint message') } : {}), ...(hasOwn(checkpoint, 'metadata') ? { metadata: normalizeMetadata(checkpoint.metadata, 'Checkpoint metadata') } : {}) }; } function normalizeProgressPayload(progress) { const hasProgressField = hasOwn(progress, 'stage_name') || hasOwn(progress, 'progress_text') || hasOwn(progress, 'progress_percent'); if (!hasProgressField) { throw new Error('Task progress requires stage_name, progress_text, or progress_percent'); } if (hasOwn(progress, 'progress_percent')) { const percent = Number(progress.progress_percent); if (!Number.isFinite(percent) || percent < 0 || percent > 100) { throw new Error('progress_percent must be a number between 0 and 100'); } } return { ...progress, ...(hasOwn(progress, 'stage_name') ? { stage_name: normalizeAuditName(progress.stage_name, 'Progress stage_name') } : {}), ...(hasOwn(progress, 'progress_text') ? { progress_text: normalizeTrimmedString(progress.progress_text, 'Progress text') } : {}), ...(hasOwn(progress, 'message') ? { message: normalizeTrimmedString(progress.message, 'Progress message') } : {}), ...(hasOwn(progress, 'metadata') ? { metadata: normalizeMetadata(progress.metadata, 'Progress metadata') } : {}) }; } function uniqueTaskIds(rows) { const seen = new Set(); const ids = []; (rows || []).forEach(row => { if (!row || row.id === undefined || row.id === null || seen.has(row.id)) { return; } seen.add(row.id); ids.push(row.id); }); return ids; } /** * Centralizes task management logic to support distributed, resilient task processing. * Enables flexible connection handling and provides a standardized task lifecycle. */ class BaseDBAdapter { /** * Configures connection pooling to optimize database resource utilization. * Supports multiple connection initialization strategies for maximum flexibility. * * @param {string|object|function} conn - Flexible connection configuration * @param {number} [poolSize=5] - Limits concurrent database connections * @throws {Error} Prevents misconfigured database connections */ constructor(conn, poolSize = 5) { logger.info(`[BaseDBAdapter] Initializing with connection type: ${typeof conn}, pool size: ${poolSize}`); // Default lock clause for row-level locking this.lockClause = 'FOR UPDATE SKIP LOCKED'; /** * Normalizes different connection types to a consistent pool-like interface. * Ensures uniform connection management regardless of underlying database. * * @param {*} conn - Raw database connection * @returns {function} Standardized connection pool wrapper */ function wrap_conn(conn) { logger.debug(`[BaseDBAdapter] Wrapping connection with thread-safe interface`); // Thread-safe connection wrapper using coroutine lock const locker = new coroutine.Lock(); // Provides a consistent execution and cleanup mechanism const pool = function (callback) { let result; try { // Ensure thread-safe execution of database operations locker.acquire(); logger.debug(`[BaseDBAdapter] Acquired lock for database operation`); result = callback(conn); } finally { // Always release the lock, even if an error occurs logger.debug(`[BaseDBAdapter] Releasing lock after database operation`); locker.release(); } return result; }; // Ensures thread-safe connection closure pool.clear = function () { try { // Prevent concurrent connection closure logger.debug(`[BaseDBAdapter] Acquiring lock for connection closure`); locker.acquire(); conn.close(); logger.info(`[BaseDBAdapter] Connection closed successfully`); } finally { locker.release(); } }.bind(this); return pool; } // Prioritizes connection initialization based on connection type // Prevents runtime errors and supports diverse database configurations if (conn == "sqlite::memory:") { logger.info(`[BaseDBAdapter] Creating in-memory SQLite connection`); this.pool = wrap_conn(db.open(conn)); } else if (typeof conn === 'function') { logger.debug(`[BaseDBAdapter] Using provided connection function`); this.pool = conn; } else if (typeof conn === 'string') { logger.info(`[BaseDBAdapter] Creating connection pool with connection string`); this.pool = Pool({ create: () => { logger.debug(`[BaseDBAdapter] Creating new connection in pool`); return this.createConnection(conn); }, destroy: conn => { logger.debug(`[BaseDBAdapter] Destroying connection in pool`); this.destroyConnection(conn); }, timeout: 30000, retry: 1, maxsize: poolSize }); } else if (typeof conn === 'object') { logger.debug(`[BaseDBAdapter] Wrapping provided connection object`); this.pool = wrap_conn(conn); } else { throw new Error('Invalid connection type: ' + conn); } logger.info(`[BaseDBAdapter] Connection pool initialized successfully`); } /** * Prepares database schema for task tracking. * Enforces implementation requirement for specific database adapters. * @throws {Error} If not overridden by subclass */ setup() { logger.info(`[BaseDBAdapter] Setting up database schema`); throw new Error('setup() must be implemented by subclass'); } /** * Establishes a database connection with error handling. * Allows database-specific connection logic in subclasses. * * @param {string} connStr - Database connection parameters * @returns {object} Initialized database connection * @protected */ createConnection(connStr) { logger.debug(`[BaseDBAdapter] Creating database connection with string: ${connStr}`); return db.open(connStr); } /** * Safely terminates database connection to prevent resource leaks. * Provides a hook for custom connection cleanup in subclasses. * * @param {object} conn - Active database connection * @protected */ destroyConnection(conn) { logger.debug(`[BaseDBAdapter] Destroying database connection`); conn.close(); } /** * Retrieves the ID of the last inserted row from database operation * Provides a default implementation that works with common databases * Each database adapter can override this method for specific implementations * * @protected * @param {Object} conn - Database connection object * @param {Object} rs - Result set from the insert operation * @returns {number} The ID of the last inserted row * @throws {Error} If the database doesn't support auto-incrementing IDs */ _getLastInsertedId(conn, rs) { return rs.insertId; } /** * Manages task insertion with comprehensive validation and workflow tracking. * Supports atomic insertion of single or multiple tasks with parent-child relationships. * * @param {object|Array<object>} tasks - Tasks to be inserted * @param {object} [options] - Insertion context and workflow metadata * @returns {number|Array<number>} Assigned task ID(s) * @throws {Error} If tasks fail validation or workflow constraints */ insertTask(tasks, options = {}) { logger.info(`[BaseDBAdapter] Inserting tasks with options:`, options); const isArray = Array.isArray(tasks); const taskArray = isArray ? tasks : [tasks]; const taskIds = []; this.pool(conn => conn.trans(() => { const now = Math.floor(Date.now() / 1000); const events = []; let parentTask = null; const childTaskNames = []; // Updates parent task state to reflect child task creation if (options.parent_id) { logger.info(`[BaseDBAdapter] Updating parent task ${options.parent_id}`); const parentRs = conn.execute( `SELECT id, name, status, stage, retry_count, root_id, parent_id, worker_id FROM fib_flow_tasks WHERE id = ?`, options.parent_id ); if (parentRs.length === 0) { throw new Error(`Parent task ${options.parent_id} not found`); } parentTask = parentRs[0]; let rs; // Check if context update is needed if (options.context !== undefined) { logger.info(`[BaseDBAdapter] Updating parent task ${options.parent_id} with context data`); const params = [taskArray.length, options.context, options.parent_id]; let sql = `UPDATE fib_flow_tasks SET total_children = total_children + ?, status = 'suspended', context = ?, result = null WHERE id = ? AND status = 'running'`; if (options.worker_id) { sql += ' AND worker_id = ?'; params.push(options.worker_id); } rs = conn.execute( sql, ...params ); } else { const params = [taskArray.length, options.parent_id]; let sql = `UPDATE fib_flow_tasks SET total_children = total_children + ?, status = 'suspended', result = null WHERE id = ? AND status = 'running'`; if (options.worker_id) { sql += ' AND worker_id = ?'; params.push(options.worker_id); } rs = conn.execute( sql, ...params ); } if (rs.affected === 0) { throw new Error(`Parent task ${options.parent_id} is not owned by worker ${options.worker_id || 'unknown'} or is not in running state`); } this._finishOpenTaskAttemptWithConnection(conn, parentTask.id, { ended_at: now, outcome: 'suspended', timeout_flag: false }); } for (const task of taskArray) { // Enforces strict task definition requirements if (!task) { throw new Error('Task object is required'); } if (!task.name) { throw new Error('Task name is required'); } if (!task.type) { throw new Error('Task type is required'); } if (options.parent_id && task.type !== 'async') { throw new Error('Parent tasks can only be of type "async"'); } if (!['async', 'cron'].includes(task.type)) { throw new Error('Task type must be either "async" or "cron"'); } // Validate task status if provided if (task.status && !['pending', 'running', 'completed', 'failed', 'timeout', 'permanently_failed', 'paused', 'suspended'].includes(task.status)) { throw new Error(`Invalid task status: ${task.status}`); } logger.info(`[BaseDBAdapter] Inserting task with name: ${task.name}`); const rs = conn.execute( `INSERT INTO fib_flow_tasks ( name, type, status, priority, payload, cron_expr, max_retries, retry_interval, next_run_time, timeout, created_at, root_id, parent_id, total_children, completed_children, tag ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)`, task.name, task.type, task.status || 'pending', // Use provided status or default to pending task.priority || 0, task.payload ? JSON.stringify(task.payload) : null, task.cron_expr || null, task.max_retries !== undefined ? task.max_retries : 3, task.retry_interval || 0, task.next_run_time || now, task.timeout || 60, now, options.root_id || null, options.parent_id || null, task.tag || null ); const taskId = this._getLastInsertedId(conn, rs); taskIds.push(taskId); childTaskNames.push(task.name); events.push({ task_id: taskId, root_id: normalizeRootId(taskId, options.root_id || null), parent_id: options.parent_id || null, event_type: 'task_created', to_status: task.status || 'pending', stage: 0, event_time: now, message: `Task ${task.name} created`, metadata: { name: task.name, type: task.type, tag: task.tag || null } }); } if (parentTask && taskIds.length > 0) { events.push({ task_id: parentTask.id, root_id: normalizeRootId(parentTask.id, parentTask.root_id), parent_id: parentTask.parent_id || null, event_type: 'task_subtasks_created', from_status: parentTask.status, to_status: 'suspended', stage: parentTask.stage, worker_id: parentTask.worker_id || null, event_time: now, message: `Task ${parentTask.name} created ${taskIds.length} subtasks`, metadata: { child_task_ids: taskIds, child_task_names: childTaskNames, child_count: taskIds.length } }); events.push({ task_id: parentTask.id, root_id: normalizeRootId(parentTask.id, parentTask.root_id), parent_id: parentTask.parent_id || null, event_type: 'task_status_changed', from_status: parentTask.status, to_status: 'suspended', stage: parentTask.stage, worker_id: parentTask.worker_id || null, event_time: now, message: `Task ${parentTask.name} suspended while waiting for child tasks`, metadata: { suspend_reason: 'awaiting_subtasks', child_count: taskIds.length, child_task_ids: taskIds } }); } if (events.length > 0) { this._insertTaskEventsWithConnection(conn, events); } })); return isArray ? taskIds : taskIds[0]; } /** * Insert one audit event. * @param {object} event - Audit event to persist * @returns {number} Inserted event id */ insertTaskEvent(event) { return this.insertTaskEvents([event])[0]; } /** * Insert multiple audit events in one transaction. * @param {Array<object>} events - Audit events to persist * @returns {Array<number>} Inserted event ids */ insertTaskEvents(events) { if (!Array.isArray(events) || events.length === 0) { return []; } let eventIds = []; this.pool(conn => conn.trans(() => { eventIds = this._insertTaskEventsWithConnection(conn, events); })); return eventIds; } _insertTaskEventsWithConnection(conn, events) { const eventIds = []; for (const event of events) { if (!event || !event.task_id) { throw new Error('Task event task_id is required'); } if (!event.event_type) { throw new Error('Task event event_type is required'); } const eventTime = event.event_time || Math.floor(Date.now() / 1000); const rs = conn.execute( `INSERT INTO fib_flow_task_events ( task_id, root_id, parent_id, event_type, from_status, to_status, stage, worker_id, attempt, event_time, message, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, event.task_id, event.root_id || null, event.parent_id || null, event.event_type, event.from_status || null, event.to_status || null, event.stage !== undefined ? event.stage : null, event.worker_id || null, event.attempt !== undefined ? event.attempt : null, eventTime, event.message || null, serializeEventMetadata(event.metadata) ); conn.execute( `UPDATE fib_flow_tasks SET last_event_time = ?, last_event_type = ? WHERE id = ?`, eventTime, event.event_type, event.task_id ); eventIds.push(this._getLastInsertedId(conn, rs)); } return eventIds; } _startTaskAttemptWithConnection(conn, attempt) { const rs = conn.execute( `INSERT INTO fib_flow_task_attempts ( task_id, attempt, worker_id, started_at ) VALUES (?, ?, ?, ?)`, attempt.task_id, attempt.attempt, attempt.worker_id || null, attempt.started_at ); return this._getLastInsertedId(conn, rs); } _getNextAttemptNumberWithConnection(conn, taskId) { const rs = conn.execute( `SELECT COALESCE(MAX(attempt), 0) AS max_attempt FROM fib_flow_task_attempts WHERE task_id = ?`, taskId ); return (rs[0] && rs[0].max_attempt ? rs[0].max_attempt : 0) + 1; } _finishTaskAttemptWithConnection(conn, attempt) { return conn.execute( `UPDATE fib_flow_task_attempts SET ended_at = ?, outcome = ?, error = ?, timeout_flag = ? WHERE task_id = ? AND attempt = ? AND ended_at IS NULL`, attempt.ended_at, attempt.outcome || null, attempt.error || null, Boolean(attempt.timeout_flag), attempt.task_id, attempt.attempt ); } _finishOpenTaskAttemptWithConnection(conn, taskId, values) { const openAttemptRs = conn.execute( `SELECT attempt FROM fib_flow_task_attempts WHERE task_id = ? AND ended_at IS NULL ORDER BY attempt DESC LIMIT 1`, taskId ); if (openAttemptRs.length === 0) { return null; } const attempt = openAttemptRs[0].attempt; this._finishTaskAttemptWithConnection(conn, { task_id: taskId, attempt, ...values }); return attempt; } _deleteTaskAuditDataWithConnection(conn, taskIds) { if (!taskIds || taskIds.length === 0) { return { events_deleted: 0, attempts_deleted: 0 }; } const deletedEvents = conn.execute('DELETE FROM fib_flow_task_events WHERE task_id IN ?', taskIds); const deletedAttempts = conn.execute('DELETE FROM fib_flow_task_attempts WHERE task_id IN ?', taskIds); return { events_deleted: deletedEvents && deletedEvents.affected ? deletedEvents.affected : 0, attempts_deleted: deletedAttempts && deletedAttempts.affected ? deletedAttempts.affected : 0 }; } _cleanupExpiredTasksWithConnection(conn, policy) { const retentionPolicy = normalizeRetentionPolicy(policy); if (!retentionPolicy.expire_time) { return { tasks_deleted: 0, events_deleted: 0, attempts_deleted: 0 }; } const expiredTaskRows = conn.execute( `SELECT id FROM fib_flow_tasks WHERE status IN ? AND last_active_time < ?`, retentionPolicy.statuses, retentionPolicy.now - retentionPolicy.expire_time ); const expiredTaskIds = uniqueTaskIds(expiredTaskRows); if (expiredTaskIds.length === 0) { return { tasks_deleted: 0, events_deleted: 0, attempts_deleted: 0 }; } const auditCleanup = this._deleteTaskAuditDataWithConnection(conn, expiredTaskIds); conn.execute('DELETE FROM fib_flow_tasks WHERE id IN ?', expiredTaskIds); return { tasks_deleted: expiredTaskIds.length, events_deleted: auditCleanup.events_deleted, attempts_deleted: auditCleanup.attempts_deleted }; } _resumeParentTaskWithConnection(conn, parentId, resultEntry, eventTime) { const parentRs = conn.execute( `SELECT id, name, status, stage, root_id, parent_id, worker_id FROM fib_flow_tasks WHERE id = ?`, parentId ); if (parentRs.length === 0) { throw new Error(`Parent task ${parentId} not found`); } const parentTask = parentRs[0]; conn.execute( `UPDATE fib_flow_tasks SET completed_children = completed_children + 1, result = CONCAT(COALESCE(result, ''), ?) WHERE id = ?`, resultEntry, parentId ); const resumeRs = conn.execute( `UPDATE fib_flow_tasks SET status = 'pending', stage = stage + 1 WHERE id = ? AND status = 'suspended' AND completed_children = total_children`, parentId ); if (resumeRs.affected === 1) { this._insertTaskEventsWithConnection(conn, [{ task_id: parentTask.id, root_id: normalizeRootId(parentTask.id, parentTask.root_id), parent_id: parentTask.parent_id || null, event_type: 'task_status_changed', from_status: parentTask.status, to_status: 'pending', stage: parentTask.stage + 1, worker_id: parentTask.worker_id || null, event_time: eventTime, message: `Parent task ${parentTask.name} resumed after child completion`, metadata: { resume_reason: 'children_completed' } }]); } } /** * Get all audit events for a task. * @param {string|number} taskId - Task id * @param {object} [filters] - Optional event filters * @returns {Array<object>} Ordered event rows */ getTaskEvents(taskId, filters = {}) { if (!taskId) { throw new Error('Task ID is required'); } return this._getEvents({ ...filters, task_id: taskId }); } /** * Get all audit events for a workflow rooted at rootId. * @param {string|number} rootId - Workflow root task id * @param {object} [filters] - Optional event filters * @returns {Array<object>} Ordered event rows */ getWorkflowEvents(rootId, filters = {}) { if (!rootId) { throw new Error('Root task ID is required'); } return this._getEvents({ ...filters, root_id: rootId }); } getTaskAttempts(taskId, filters = {}) { if (!taskId) { throw new Error('Task ID is required'); } return this._queryAttempts({ ...filters, task_id: taskId }).items; } queryTaskEvents(taskId, filters = {}) { if (!taskId) { throw new Error('Task ID is required'); } return this._queryEvents({ ...filters, task_id: taskId }); } queryWorkflowEvents(rootId, filters = {}) { if (!rootId) { throw new Error('Root task ID is required'); } return this._queryEvents({ ...filters, root_id: rootId }); } queryTaskAttempts(taskId, filters = {}) { if (!taskId) { throw new Error('Task ID is required'); } return this._queryAttempts({ ...filters, task_id: taskId }); } queryWorkflowAttempts(rootId, filters = {}) { if (!rootId) { throw new Error('Root task ID is required'); } return this._queryAttempts({ ...filters, workflow_root_id: rootId }); } queryTasks(filters = {}) { logger.info(`[queryTasks] Retrieving paged tasks with filters:`, filters); if (filters.status && !['pending', 'running', 'completed', 'failed', 'timeout', 'permanently_failed', 'paused', 'suspended'].includes(filters.status)) { throw new Error('Invalid status value'); } if (filters.type && !['async', 'cron'].includes(filters.type)) { throw new Error('Invalid task type'); } const pagination = normalizePagination(filters); return this.pool(conn => { const params = []; const conditions = []; if (filters.tag) { conditions.push('tag = ?'); params.push(filters.tag); } if (filters.status) { conditions.push('status = ?'); params.push(filters.status); } if (filters.name) { conditions.push('name = ?'); params.push(filters.name); } if (filters.worker_id) { conditions.push('worker_id = ?'); params.push(filters.worker_id); } if (filters.parent_id) { conditions.push('parent_id = ?'); params.push(filters.parent_id); } if (filters.root_id) { conditions.push('root_id = ?'); params.push(filters.root_id); } if (filters.type) { conditions.push('type = ?'); params.push(filters.type); } if (filters.workflow_root_id) { conditions.push('(id = ? OR root_id = ?)'); params.push(filters.workflow_root_id, filters.workflow_root_id); } let whereSql = ''; if (conditions.length > 0) { whereSql = ' WHERE ' + conditions.join(' AND '); } const totalRs = conn.execute(`SELECT COUNT(*) AS total FROM fib_flow_tasks${whereSql}`, ...params); const total = totalRs[0] ? totalRs[0].total : 0; let sql = `SELECT * FROM fib_flow_tasks${whereSql} ORDER BY created_at ${pagination.order}, id ${pagination.order}`; if (pagination.limit !== null) { sql += ` LIMIT ${pagination.limit}`; if (pagination.offset > 0) { sql += ` OFFSET ${pagination.offset}`; } } const items = conn.execute(sql, ...params).map(task => parseTask(task)); return toPageResult(items, total, pagination.limit, pagination.offset); }); } getTaskAudit(taskId, options = {}) { if (!taskId) { throw new Error('Task ID is required'); } return { task: this.getTask(taskId), events: this.queryTaskEvents(taskId, options.events || {}), attempts: this.queryTaskAttempts(taskId, options.attempts || {}) }; } getWorkflowAudit(rootId, options = {}) { if (!rootId) { throw new Error('Root task ID is required'); } return { root_task: this.getTask(rootId), tasks: this.queryTasks({ workflow_root_id: rootId, ...(options.tasks || {}) }), events: this.queryWorkflowEvents(rootId, options.events || {}) }; } getWorkflowAuditSummary(rootId) { if (!rootId) { throw new Error('Root task ID is required'); } const rootTask = this.getTask(rootId); const tasksPage = this.queryTasks({ workflow_root_id: rootId, order: 'asc' }); const attemptsPage = this.queryWorkflowAttempts(rootId, { order: 'asc' }); const eventsPage = this.queryWorkflowEvents(rootId, { order: 'asc' }); const rootEventsPage = this.queryTaskEvents(rootId, { order: 'asc' }); const tasks = tasksPage.items; const attempts = attemptsPage.items; const events = eventsPage.items; const rootEvents = rootEventsPage.items; const taskStatusCounts = {}; const attemptOutcomeCounts = {}; const taskNameCounts = {}; const workers = new Set(); const stageTaskCounts = {}; const failedTasks = []; const tasksById = new Map(); const childrenByParentId = new Map(); let maxAttempt = 0; let firstStartedAt = null; let lastEndedAt = null; let lastEventTime = null; tasks.forEach(task => { tasksById.set(task.id, task); incrementCounter(taskStatusCounts, task.status); incrementCounter(taskNameCounts, task.name); incrementCounter(stageTaskCounts, String(task.stage || 0)); if (task.parent_id !== null && task.parent_id !== undefined) { if (!childrenByParentId.has(task.parent_id)) { childrenByParentId.set(task.parent_id, []); } childrenByParentId.get(task.parent_id).push(task); } if (task.worker_id) { workers.add(task.worker_id); } if (['failed', 'timeout', 'permanently_failed', 'paused'].includes(task.status)) { failedTasks.push({ task_id: task.id, name: task.name, status: task.status, error: task.error || null }); } }); const attemptsByTaskId = new Map(); const attemptsWithDuration = attempts.map(attempt => { if (!attemptsByTaskId.has(attempt.task_id)) { attemptsByTaskId.set(attempt.task_id, []); } if (attempt.worker_id) { workers.add(attempt.worker_id); } if (attempt.outcome) { incrementCounter(attemptOutcomeCounts, attempt.outcome); } maxAttempt = Math.max(maxAttempt, attempt.attempt || 0); if (attempt.started_at !== null && attempt.started_at !== undefined) { firstStartedAt = firstStartedAt === null ? attempt.started_at : Math.min(firstStartedAt, attempt.started_at); } if (attempt.ended_at !== null && attempt.ended_at !== undefined) { lastEndedAt = lastEndedAt === null ? attempt.ended_at : Math.max(lastEndedAt, attempt.ended_at); } const duration_seconds = attempt.ended_at !== null && attempt.ended_at !== undefined ? Math.max(0, attempt.ended_at - attempt.started_at) : null; const attemptWithDuration = { ...attempt, duration_seconds }; attemptsByTaskId.get(attempt.task_id).push(attemptWithDuration); return attemptWithDuration; }); events.forEach(event => { if (event.worker_id) { workers.add(event.worker_id); } lastEventTime = lastEventTime === null ? event.event_time : Math.max(lastEventTime, event.event_time); }); attemptsWithDuration.sort((left, right) => { const leftDuration = left.duration_seconds === null ? -1 : left.duration_seconds; const rightDuration = right.duration_seconds === null ? -1 : right.duration_seconds; if (rightDuration !== leftDuration) { return rightDuration - leftDuration; } if (left.started_at !== right.started_at) { return left.started_at - right.started_at; } return left.id - right.id; }); const rootStartEventsByAttempt = new Map(); rootEvents.forEach(event => { if ((event.event_type === 'task_started' || event.event_type === 'task_retry_started') && event.attempt !== null && event.attempt !== undefined) { if (!rootStartEventsByAttempt.has(event.attempt)) { rootStartEventsByAttempt.set(event.attempt, event); } } }); const stageTimings = (attemptsByTaskId.get(rootId) || []) .slice() .sort((left, right) => left.attempt - right.attempt) .map(attempt => { const startEvent = rootStartEventsByAttempt.get(attempt.attempt); return { stage: startEvent && startEvent.stage !== null && startEvent.stage !== undefined ? startEvent.stage : Math.max(0, attempt.attempt - 1), attempt: attempt.attempt, started_at: attempt.started_at, ended_at: attempt.ended_at || null, duration_seconds: attempt.duration_seconds, outcome: attempt.outcome || null, worker_id: attempt.worker_id || null }; }); function selectRepresentativeAttempt(taskId) { const taskAttempts = (attemptsByTaskId.get(taskId) || []).slice(); if (taskAttempts.length === 0) { return null; } taskAttempts.sort((left, right) => { const durationCompare = (right.duration_seconds === null ? -1 : right.duration_seconds) - (left.duration_seconds === null ? -1 : left.duration_seconds); if (durationCompare !== 0) { return durationCompare; } const endCompare = compareNullableNumberAsc(left.ended_at, right.ended_at); if (endCompare !== 0) { return endCompare; } return left.attempt - right.attempt; }); return taskAttempts[0]; } const criticalPathCache = new Map(); const buildCriticalPath = (taskId) => { if (criticalPathCache.has(taskId)) { return criticalPathCache.get(taskId); } const task = tasksById.get(taskId); if (!task) { const emptyPath = { total_duration_seconds: 0, nodes: [] }; criticalPathCache.set(taskId, emptyPath); return emptyPath; } const representativeAttempt = selectRepresentativeAttempt(taskId); const ownDuration = representativeAttempt && representativeAttempt.duration_seconds !== null ? representativeAttempt.duration_seconds : 0; const ownNode = { task_id: task.id, name: task.name, status: task.status, stage: task.stage, attempt: representativeAttempt ? representativeAttempt.attempt : null, outcome: representativeAttempt ? representativeAttempt.outcome || null : null, duration_seconds: representativeAttempt ? representativeAttempt.duration_seconds : null, started_at: representativeAttempt ? representativeAttempt.started_at : null, ended_at: representativeAttempt ? representativeAttempt.ended_at || null : null }; const childTasks = (childrenByParentId.get(taskId) || []).slice().sort((left, right) => left.id - right.id); let bestChildPath = null; childTasks.forEach(childTask => { const candidate = buildCriticalPath(childTask.id); if (!bestChildPath || candidate.total_duration_seconds > bestChildPath.total_duration_seconds) { bestChildPath = candidate; return; } if (candidate.total_duration_seconds === bestChildPath.total_duration_seconds) { const candidateFirstId = candidate.nodes.length > 0 ? candidate.nodes[0].task_id : Number.MAX_SAFE_INTEGER; const bestFirstId = bestChildPath.nodes.length > 0 ? bestChildPath.nodes[0].task_id : Number.MAX_SAFE_INTEGER; if (candidateFirstId < bestFirstId) { bestChildPath = candidate; } } }); const path = { total_duration_seconds: ownDuration + (bestChildPath ? bestChildPath.total_duration_seconds : 0), nodes: [ownNode].concat(bestChildPath ? bestChildPath.nodes : []) }; criticalPathCache.set(taskId, path); return path; }; const criticalPath = buildCriticalPath(rootId); return { root_task: rootTask, totals: { tasks: tasks.length, attempts: attempts.length, events: events.length, checkpoints: events.filter(event => event.event_type === 'task_checkpoint').length, max_attempt: maxAttempt }, statuses: sortObjectKeys(taskStatusCounts), attempt_outcomes: sortObjectKeys(attemptOutcomeCounts), task_names: sortObjectKeys(taskNameCounts), stages: sortObjectKeys(stageTaskCounts), workers: Array.from(workers).sort(), timing: { created_at: rootTask ? rootTask.created_at || null : null, first_started_at: firstStartedAt, last_ended_at: lastEndedAt, last_event_time: lastEventTime, workflow_duration_seconds: rootTask && lastEndedAt !== null && rootTask.created_at !== undefined && rootTask.created_at !== null ? Math.max(0, lastEndedAt - rootTask.created_at) : null }, stage_timings: stageTimings, failed_tasks: failedTasks, critical_path: criticalPath, slowest_attempts: attemptsWithDuration.slice(0, 5) }; } recordTaskProgress(taskId, progress = {}, workerId = null) { if (!taskId) { throw new Error('Task ID is required'); } const normalizedProgress = normalizeProgressPayload(progress); let eventId = null; this.pool(conn => conn.trans(() => { const taskRs = conn.execute( `SELECT id, status, stage, root_id, parent_id, worker_id FROM fib_flow_tasks WHERE id = ?`, taskId ); if (taskRs.length === 0) { throw new Error(`Task ${taskId} not found`); } const task = taskRs[0]; if (workerId && (task.status !== 'running' || task.worker_id !== workerId)) { throw new Error(`Task ${taskId} is no longer owned by worker ${workerId}`); } const eventTime = normalizedProgress.event_time || Math.floor(Date.now() / 1000); const updates = ['last_active_time = ?']; const params = [eventTime]; if (hasOwn(normalizedProgress, 'stage_name')) { updates.push('current_stage_name = ?'); params.push(normalizedProgress.stage_name); } if (hasOwn(normalizedProgress, 'progress_text')) { updates.push('progress_text = ?'); params.push(normalizedProgress.progress_text); } if (hasOwn(normalizedProgress, 'progress_percent')) { updates.push('progress_percent = ?'); params.push(normalizedProgress.progress_percent); } params.push(taskId); let whereClause = 'id = ?'; if (workerId) { whereClause += ' AND status = \'running\' AND worker_id = ?'; params.push(workerId); } conn.execute( `UPDATE fib_flow_tasks SET ${updates.join(', ')} WHERE ${whereClause}`, ...params ); const openAttemptRs = conn.execute( `SELECT attempt FROM fib_flow_task_attempts WHERE task_id = ? AND ended_at IS NULL ORDER BY attempt DESC LIMIT 1`, taskId ); const metadata = { ...(hasOwn(normalizedProgress, 'stage_name') ? { stage_name: normalizedProgress.stage_name } : {}), ...(hasOwn(normalizedProgress, 'progress_text') ? { progress_text: normalizedProgress.progress_text } : {}), ...(hasOwn(normalizedProgress, 'progress_percent') ? { progress_percent: normalizedProgress.progress_percent } : {}), ...(normalizedProgress.metadata || {}) }; eventId = this._insertTaskEventsWithConnection(conn, [{ task_id: task.id, root_id: normalizeRootId(task.id, task.root_id), parent_id: task.parent_id || null, event_type: 'task_progress', from_status: task.status, to_status: task.status, stage: normalizedProgress.stage !== unde