|
|
@@ -76,16 +76,8 @@ export class DefaultSchedulerStrategy implements SchedulerStrategy {
|
|
|
await this.ensureTaskIsRegistered(task);
|
|
|
await this.staleTaskService.cleanStaleLocksForTask(task);
|
|
|
|
|
|
- const taskEntity = await this.connection.rawConnection
|
|
|
- .getRepository(ScheduledTaskRecord)
|
|
|
- .createQueryBuilder('task')
|
|
|
- .update()
|
|
|
- .set({ lockedAt: new Date() })
|
|
|
- .where('taskId = :taskId', { taskId: task.id })
|
|
|
- .andWhere('lockedAt IS NULL')
|
|
|
- .andWhere('enabled = TRUE')
|
|
|
- .execute();
|
|
|
- if (!taskEntity.affected) {
|
|
|
+ const lockAcquired = await this.tryAcquireLock(task);
|
|
|
+ if (!lockAcquired) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
@@ -236,6 +228,67 @@ export class DefaultSchedulerStrategy implements SchedulerStrategy {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 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.
|
|
|
+ */
|
|
|
+ private async tryAcquireLock(task: ScheduledTask): Promise<boolean> {
|
|
|
+ const dbType = this.connection.rawConnection.options.type;
|
|
|
+ const supportsPessimisticLocking = ['postgres', 'mysql', 'mariadb'].includes(dbType);
|
|
|
+
|
|
|
+ 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 taskRecord = await manager
|
|
|
+ .getRepository(ScheduledTaskRecord)
|
|
|
+ .createQueryBuilder('task')
|
|
|
+ .setLock('pessimistic_write')
|
|
|
+ .where('task.taskId = :taskId', { taskId: task.id })
|
|
|
+ .andWhere('task.lockedAt IS NULL')
|
|
|
+ .andWhere('task.enabled = TRUE')
|
|
|
+ .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(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 result = await this.connection.rawConnection
|
|
|
+ .getRepository(ScheduledTaskRecord)
|
|
|
+ .createQueryBuilder('task')
|
|
|
+ .update()
|
|
|
+ .set({ lockedAt: new Date() })
|
|
|
+ .where('taskId = :taskId', { taskId: task.id })
|
|
|
+ .andWhere('lockedAt IS NULL')
|
|
|
+ .andWhere('enabled = TRUE')
|
|
|
+ .execute();
|
|
|
+
|
|
|
+ return !!result.affected;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private async ensureTaskIsRegistered(taskOrId: ScheduledTask | string) {
|
|
|
const taskId = typeof taskOrId === 'string' ? taskOrId : taskOrId.id;
|
|
|
const task = this.tasks.get(taskId);
|