@vendure/core
Version:
A modern, headless ecommerce framework
312 lines • 14.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DefaultSchedulerStrategy = void 0;
const ms_1 = __importDefault(require("ms"));
const utils_1 = require("../../common/utils");
const config_service_1 = require("../../config/config.service");
const vendure_logger_1 = require("../../config/logger/vendure-logger");
const connection_1 = require("../../connection");
const process_context_1 = require("../../process-context");
const constants_1 = require("./constants");
const scheduled_task_record_entity_1 = require("./scheduled-task-record.entity");
const stale_task_service_1 = require("./stale-task.service");
/**
* @description
* The default {@link SchedulerStrategy} implementation that uses the database to
* execute scheduled tasks. This strategy is configured when you use the
* {@link DefaultSchedulerPlugin}.
*
* @since 3.3.0
* @docsCategory scheduled-tasks
*/
class DefaultSchedulerStrategy {
constructor() {
this.tasks = new Map();
this.runningTasks = [];
}
init(injector) {
this.connection = injector.get(connection_1.TransactionalConnection);
this.pluginOptions = injector.get(constants_1.DEFAULT_SCHEDULER_PLUGIN_OPTIONS);
this.injector = injector;
this.staleTaskService = injector.get(stale_task_service_1.StaleTaskService);
const runTriggerCheck = injector.get(config_service_1.ConfigService).schedulerOptions.runTasksInWorkerOnly === false ||
injector.get(process_context_1.ProcessContext).isWorker;
if (runTriggerCheck) {
this.intervalRef = setInterval(() => this.checkForManuallyTriggeredTasks(), this.pluginOptions.manualTriggerCheckInterval);
}
}
async destroy() {
if (this.intervalRef) {
clearInterval(this.intervalRef);
}
for (const task of this.runningTasks) {
await this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.update({ taskId: task.id }, { lockedAt: null });
vendure_logger_1.Logger.info(`Released lock for task "${task.id}"`);
}
}
registerTask(task) {
this.tasks.set(task.id, {
task,
isRegistered: false,
});
}
executeTask(task) {
return async (_job) => {
await this.runTask(task, { skipHoldCheck: false });
};
}
async runManually(task) {
await this.runTask(task, { skipHoldCheck: true });
}
async runTask(task, options) {
var _a;
await this.ensureTaskIsRegistered(task);
await this.staleTaskService.cleanStaleLocksForTask(task);
const lockAcquired = await this.tryAcquireLock(task, {
skipHoldCheck: options.skipHoldCheck,
});
if (!lockAcquired) {
return;
}
vendure_logger_1.Logger.verbose(`Executing scheduled task "${task.id}"`);
let timeoutTimer;
try {
this.runningTasks.push(task);
const timeout = (_a = task.options.timeout) !== null && _a !== void 0 ? _a : this.pluginOptions.defaultTimeout;
const timeoutMs = typeof timeout === 'number' ? timeout : (0, ms_1.default)(timeout);
const timeoutPromise = new Promise((_, reject) => {
timeoutTimer = setTimeout(() => {
vendure_logger_1.Logger.warn(`Scheduled task ${task.id} timed out after ${timeoutMs}ms`);
reject(new Error('Task timed out'));
}, timeoutMs);
});
const result = await Promise.race([task.execute(this.injector), timeoutPromise]);
await this.connection.rawConnection.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord).update({ taskId: task.id }, {
lastExecutedAt: new Date(),
lockedAt: null,
lastResult: result !== null && result !== void 0 ? result : '',
});
vendure_logger_1.Logger.verbose(`Scheduled task "${task.id}" completed successfully`);
}
catch (error) {
let errorMessage = 'Unknown error';
if (error instanceof Error) {
errorMessage = error.message;
}
vendure_logger_1.Logger.error(`Scheduled task "${task.id}" failed with error: ${errorMessage}`);
await this.connection.rawConnection.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord).update({ taskId: task.id }, {
lastExecutedAt: new Date(),
lockedAt: null,
lastResult: { error: errorMessage },
});
}
finally {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
this.runningTasks = this.runningTasks.filter(t => t !== task);
}
}
async getTasks() {
await this.ensureAllTasksAreRegistered();
return this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder('task')
.getMany()
.then(tasks => {
return tasks.map(task => this.entityToReport(task));
});
}
async getTask(id) {
await this.ensureTaskIsRegistered(id);
return this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder('task')
.where('task.taskId = :id', { id })
.getOne()
.then(task => (task ? this.entityToReport(task) : undefined));
}
async updateTask(input) {
await this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder('task')
.update()
.set({ enabled: input.enabled })
.where('taskId = :id', { id: input.id })
.execute();
return (0, utils_1.assertFound)(this.getTask(input.id));
}
async triggerTask(task) {
vendure_logger_1.Logger.info(`Triggering task: ${task.id}`);
await this.ensureTaskIsRegistered(task);
await this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder('task')
.update()
.set({ manuallyTriggeredAt: new Date() })
.where('taskId = :id', { id: task.id })
.execute();
}
async checkForManuallyTriggeredTasks() {
// Since this is run on an interval, there is an edge case where, during shutdown,
// the connection may not be initialized anymore.
if (!this.connection.rawConnection.isInitialized) {
return;
}
let taskEntities = [];
try {
taskEntities = await this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder('task')
.where('task.manuallyTriggeredAt IS NOT NULL')
.getMany();
}
catch (e) {
// This branch can be reached if the connection is closed and then this method
// is called on the interval. Usually encountered in tests.
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
vendure_logger_1.Logger.error(`Error checking for manually triggered tasks: ${errorMessage}`);
}
vendure_logger_1.Logger.debug(`Checking for manually triggered tasks: ${taskEntities.length}`);
for (const taskEntity of taskEntities) {
await this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.update({ taskId: taskEntity.taskId }, { manuallyTriggeredAt: null });
const task = this.tasks.get(taskEntity.taskId);
if (task) {
vendure_logger_1.Logger.info(`Executing manually triggered task: ${task.task.id}`);
void this.runManually(task.task);
}
}
}
entityToReport(task) {
return {
id: task.taskId,
lastExecutedAt: task.lastExecutedAt,
isRunning: task.lockedAt !== null,
lastResult: task.lastResult,
enabled: task.enabled,
};
}
/**
* Hold window after task completion during which scheduled re-acquisitions
* are rejected. Prevents a worker with a lagging clock from re-running a
* task that has just completed on a faster worker.
*/
computeLockHoldMs(task) {
let intervalMs;
try {
intervalMs = this.staleTaskService.getScheduleIntervalMs(task);
}
catch (_a) {
return constants_1.DEFAULT_MAX_LOCK_HOLD_MS;
}
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
return constants_1.DEFAULT_MAX_LOCK_HOLD_MS;
}
return Math.floor(Math.min(intervalMs * constants_1.DEFAULT_LOCK_HOLD_FRACTION, constants_1.DEFAULT_MAX_LOCK_HOLD_MS));
}
async ensureAllTasksAreRegistered() {
for (const task of this.tasks.values()) {
await this.ensureTaskIsRegistered(task.task);
}
}
/**
* Attempts to acquire a lock for the given task.
*
* For databases that support pessimistic locking (PostgreSQL, MySQL, MariaDB),
* we use SELECT ... FOR UPDATE to ensure only one worker can acquire the lock.
* This is necessary because PostgreSQL's MVCC can allow multiple concurrent
* UPDATE statements to succeed when using a simple "UPDATE ... WHERE lockedAt IS NULL" pattern.
*
* For databases that don't support pessimistic locking (SQLite, SQL.js),
* we fall back to the atomic UPDATE approach which works correctly for single-connection scenarios.
*
* `skipHoldCheck` lets manual triggers bypass the post-completion hold
* window (see `computeLockHoldMs`); they are already deduplicated via
* `manuallyTriggeredAt` and have no inter-worker race.
*/
async tryAcquireLock(task, options = {}) {
const dbType = this.connection.rawConnection.options.type;
const supportsPessimisticLocking = ['postgres', 'mysql', 'mariadb'].includes(dbType);
const holdThreshold = options.skipHoldCheck
? null
: new Date(Date.now() - this.computeLockHoldMs(task));
if (supportsPessimisticLocking) {
// Use a transaction with pessimistic locking to ensure only one worker
// can acquire the lock.
return this.connection.rawConnection.transaction(async (manager) => {
// First, try to select the task row with a FOR UPDATE lock.
// This will block other transactions trying to select the same row
// until this transaction commits or rolls back.
const qb = manager
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder('task')
.setLock('pessimistic_write')
.where('task.taskId = :taskId', { taskId: task.id })
.andWhere('task.lockedAt IS NULL')
.andWhere('task.enabled = TRUE');
if (holdThreshold) {
qb.andWhere('(task.lastExecutedAt IS NULL OR task.lastExecutedAt <= :holdThreshold)', {
holdThreshold,
});
}
const taskRecord = await qb.getOne();
if (!taskRecord) {
// Task is either already locked, disabled, or doesn't exist
return false;
}
// Now update the lock within the same transaction
await manager
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.update({ id: taskRecord.id }, { lockedAt: new Date() });
return true;
});
}
else {
// For databases without pessimistic locking support (SQLite, SQL.js),
// use the atomic UPDATE approach. This works for single-connection scenarios
// but may have race conditions with multiple connections.
const qb = this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder('task')
.update()
.set({ lockedAt: new Date() })
.where('taskId = :taskId', { taskId: task.id })
.andWhere('lockedAt IS NULL')
.andWhere('enabled = TRUE');
if (holdThreshold) {
qb.andWhere('(lastExecutedAt IS NULL OR lastExecutedAt <= :holdThreshold)', {
holdThreshold,
});
}
const result = await qb.execute();
return !!result.affected;
}
}
async ensureTaskIsRegistered(taskOrId) {
const taskId = typeof taskOrId === 'string' ? taskOrId : taskOrId.id;
const task = this.tasks.get(taskId);
if (task && !task.isRegistered) {
await this.connection.rawConnection
.getRepository(scheduled_task_record_entity_1.ScheduledTaskRecord)
.createQueryBuilder()
.insert()
.into(scheduled_task_record_entity_1.ScheduledTaskRecord)
.values({ taskId })
// Fix for versions lower than MariaDB v10.5 and MySQL: updateEntity(false) prevents TypeORM from
// using the RETURNING clause after an INSERT. Keep in mind that this query won't return the id of the inserted record.
.updateEntity(false)
.orIgnore()
.execute();
this.tasks.set(taskId, { task: task.task, isRegistered: true });
}
}
}
exports.DefaultSchedulerStrategy = DefaultSchedulerStrategy;
//# sourceMappingURL=default-scheduler-strategy.js.map