fix(scheduled-tasks): prevent dual-server double dispatch of daily tasks (#2713)
* fix(scheduled-tasks): claim schedule occurrences across server instances Two OpenChamber servers sharing project config each armed timers and both dispatched the same daily/weekly/cron/once slot (#2710). Claim the occurrence in shared config under a cross-process write lock before creating a session. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * fix(scheduled-tasks): harden occurrence claim failure and lock ownership Address PR review blockers: release running-slot bookkeeping when claim throws, avoid silently dropping an armed occurrence after a due-slack sync, verify lock-file ownership on release, and cover real on-disk lock behavior. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * fix(scheduled-tasks): always release running slot on state-write failures Wrap runTask bookkeeping in finally so claim, manual-start, and completion lock timeouts cannot stuck-run a task; drop the diskNext claim guard that suppressed later occurrences; recover unparseable locks via mtime age. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * fix(scheduled-tasks): stop re-arming past nextRunAt and clear stuck running Only schedule future nextRunAt values so once-task losers and claim-failed paths cannot spin delay-0 retries. Clear past once nextRunAt on claim, and on completion-write failure retry terminal status so manual runNow still returns the session instead of a hard 500 with lastStatus stuck running. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * fix(scheduled-tasks): release write chain on lock acquire timeout withProjectWriteLock left the in-process promise chain pending when acquireProjectFileLock timed out, wedging every later project write and stranding runTask before finally. Always release the chain; surface persistError on run; record once claim failures in task state. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
parent
86e6a2ae76
commit
e99f6560be
@@ -382,12 +382,16 @@ export function ScheduledTasksDialog() {
|
||||
}
|
||||
setMutatingTaskID(task.id);
|
||||
try {
|
||||
const { sessionId } = await runScheduledTaskNow(selectedProjectID, task.id);
|
||||
const { sessionId, persistError } = await runScheduledTaskNow(selectedProjectID, task.id);
|
||||
await Promise.all([
|
||||
reloadTasks(selectedProjectID, { silent: true }),
|
||||
refreshGlobalSessions(),
|
||||
]);
|
||||
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
||||
if (persistError) {
|
||||
toast.warning(t('sessions.scheduledTasks.dialog.toast.startedPersistWarning'));
|
||||
} else {
|
||||
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
||||
}
|
||||
if (sessionId) {
|
||||
// Jump straight into the started session; selecting it also closes
|
||||
// this surface (MainLayout closes surfaces on session selection).
|
||||
|
||||
@@ -216,6 +216,7 @@ export const dict = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': 'Geplante Aufgabe gelöscht',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Fehler beim Löschen der Aufgabe',
|
||||
'sessions.scheduledTasks.dialog.toast.started': 'Aufgabe gestartet',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Aufgabe gestartet, aber der Status konnte nicht gespeichert werden. Sie wird möglicherweise weiterhin als laufend angezeigt, bis der nächste erfolgreiche Lauf abgeschlossen ist.',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Fehler beim Ausführen der Aufgabe',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Geplante Aufgabe "{taskName}" löschen?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Loop-Aufgabe „{taskName}“ und ihre Markdown-Datei löschen?',
|
||||
|
||||
@@ -236,6 +236,7 @@ export const dict = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': 'Scheduled task deleted',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Failed to delete task',
|
||||
'sessions.scheduledTasks.dialog.toast.started': 'Task started',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Task started, but its status could not be saved. It may still show as running until the next successful run.',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Failed to run task',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Delete scheduled task "{taskName}"?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Delete loop task "{taskName}" and its markdown file?',
|
||||
|
||||
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.scheduledTasks.dialog.toast.deleted": "Tarea programada eliminada",
|
||||
"sessions.scheduledTasks.dialog.toast.deleteFailed": "No se pudo eliminar la tarea",
|
||||
"sessions.scheduledTasks.dialog.toast.started": "Tarea iniciada",
|
||||
"sessions.scheduledTasks.dialog.toast.startedPersistWarning": "La tarea se inició, pero no se pudo guardar su estado. Puede seguir apareciendo como en ejecución hasta el siguiente inicio correcto.",
|
||||
"sessions.scheduledTasks.dialog.toast.runFailed": "No se pudo ejecutar la tarea",
|
||||
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Eliminar tarea programada \"{taskName}\"?",
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '¿Eliminar la tarea de bucle «{taskName}» y su archivo Markdown?',
|
||||
|
||||
@@ -68,6 +68,7 @@ export const dict = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': 'Tâche planifiée supprimée',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Échec de la suppression de la tâche',
|
||||
'sessions.scheduledTasks.dialog.toast.started': 'Tâche démarrée',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Tâche démarrée, mais son statut n’a pas pu être enregistré. Elle peut encore apparaître comme en cours jusqu’à la prochaine exécution réussie.',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Échec de l\'exécution de la tâche',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Supprimer la tâche planifiée « {taskName} » ?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Supprimer la tâche en boucle « {taskName} » et son fichier Markdown ?',
|
||||
|
||||
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': 'スケジュールタスクを削除しました',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'タスクの削除に失敗しました',
|
||||
'sessions.scheduledTasks.dialog.toast.started': 'タスクを開始しました',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'タスクは開始されましたが、状態を保存できませんでした。次回の正常実行まで「実行中」と表示される場合があります。',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'タスクの実行に失敗しました',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'スケジュールタスク「{taskName}」を削除しますか?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'ループタスク「{taskName}」とその Markdown ファイルを削除しますか?',
|
||||
|
||||
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': '예약 작업 삭제됨',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': '작업 삭제 실패',
|
||||
'sessions.scheduledTasks.dialog.toast.started': '작업 시작됨',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': '작업은 시작되었지만 상태를 저장하지 못했습니다. 다음 성공 실행까지 계속 실행 중으로 표시될 수 있습니다.',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': '작업 실행 실패',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': '예약 작업 "{taskName}" 삭제?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '루프 작업 "{taskName}" 및 해당 Markdown 파일을 삭제할까요?',
|
||||
|
||||
@@ -364,6 +364,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': 'Zaplanowane zadanie usunięte',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Nie udało się usunąć zadania',
|
||||
'sessions.scheduledTasks.dialog.toast.started': 'Zadanie uruchomione',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Zadanie zostało uruchomione, ale nie udało się zapisać jego stanu. Może nadal być pokazywane jako uruchomione do następnego udanego uruchomienia.',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': 'Nie udało się uruchomić zadania',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Usunąć zaplanowane zadanie "{taskName}"?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Usunąć zadanie pętli „{taskName}” i jego plik Markdown?',
|
||||
|
||||
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.scheduledTasks.dialog.toast.deleted": "Tarefa agendada excluída",
|
||||
"sessions.scheduledTasks.dialog.toast.deleteFailed": "Não foi possível excluir a tarefa",
|
||||
"sessions.scheduledTasks.dialog.toast.started": "Tarefa iniciada",
|
||||
"sessions.scheduledTasks.dialog.toast.startedPersistWarning": "A tarefa foi iniciada, mas o status não pôde ser salvo. Ela pode continuar aparecendo como em execução até a próxima execução bem-sucedida.",
|
||||
"sessions.scheduledTasks.dialog.toast.runFailed": "Não foi possível executar a tarefa",
|
||||
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Excluir tarefa agendada \"{taskName}\"?",
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Excluir a tarefa de loop “{taskName}” e seu arquivo Markdown?',
|
||||
|
||||
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.scheduledTasks.dialog.toast.deleted": "Заплановане завдання видалено",
|
||||
"sessions.scheduledTasks.dialog.toast.deleteFailed": "Не вдалося видалити завдання",
|
||||
"sessions.scheduledTasks.dialog.toast.started": "Завдання розпочато",
|
||||
"sessions.scheduledTasks.dialog.toast.startedPersistWarning": "Завдання запущено, але його стан не вдалося зберегти. Воно може й надалі відображатися як виконуване до наступного успішного запуску.",
|
||||
"sessions.scheduledTasks.dialog.toast.runFailed": "Не вдалося виконати завдання",
|
||||
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Видалити заплановане завдання \"{taskName}\"?",
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Видалити циклічне завдання «{taskName}» і його Markdown-файл?',
|
||||
|
||||
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': '计划任务已删除',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': '删除任务失败',
|
||||
'sessions.scheduledTasks.dialog.toast.started': '任务已启动',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': '任务已启动,但未能保存其状态。在下一次成功运行之前,它可能仍显示为正在运行。',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': '运行任务失败',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': '删除计划任务“{taskName}”?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '删除循环任务“{taskName}”及其 Markdown 文件?',
|
||||
|
||||
@@ -250,6 +250,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.scheduledTasks.dialog.toast.deleted': '排程任務已刪除',
|
||||
'sessions.scheduledTasks.dialog.toast.deleteFailed': '刪除任務失敗',
|
||||
'sessions.scheduledTasks.dialog.toast.started': '任務已啟動',
|
||||
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': '任務已啟動,但無法儲存其狀態。在下一次成功執行之前,它可能仍顯示為執行中。',
|
||||
'sessions.scheduledTasks.dialog.toast.runFailed': '執行任務失敗',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteTask': '刪除排程任務「{taskName}」?',
|
||||
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '刪除迴圈任務「{taskName}」及其 Markdown 檔案?',
|
||||
|
||||
@@ -143,7 +143,10 @@ export const syncScheduledTaskLoops = async (projectID: string): Promise<void> =
|
||||
await fetchScheduledTasks(projectID);
|
||||
};
|
||||
|
||||
export const runScheduledTaskNow = async (projectID: string, taskID: string): Promise<{ sessionId?: string }> => {
|
||||
export const runScheduledTaskNow = async (
|
||||
projectID: string,
|
||||
taskID: string,
|
||||
): Promise<{ sessionId?: string; persistError?: string }> => {
|
||||
const safeProjectID = ensureProjectID(projectID);
|
||||
const safeTaskID = ensureProjectID(taskID);
|
||||
const response = await runtimeFetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}/run`, {
|
||||
@@ -158,5 +161,8 @@ export const runScheduledTaskNow = async (projectID: string, taskID: string): Pr
|
||||
const parsed = await response.json().catch(() => null);
|
||||
return {
|
||||
sessionId: typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0 ? parsed.sessionId : undefined,
|
||||
persistError: typeof parsed?.persistError === 'string' && parsed.persistError.trim().length > 0
|
||||
? parsed.persistError.trim()
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user