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
JavaScript
/**
* 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