From efe60282c8e01e9a11ff20d0a8d71dcabdc65a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Fri, 3 Jul 2026 13:26:18 +0200 Subject: [PATCH] fix(transactions): stop job-status polling when the job never starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-evaluate and apply-rule pollers rescheduled on any non-terminal status, including 'pending'. The controllers seed a 'pending' entry before dispatch, so if the queue worker is down the job never runs, failed() never fires, and the client polls for the entire hour-long TTL with a spinner stuck at '0 of 0'. Give up after 30 consecutive 'pending' ticks (~30s at the 1s interval), mirroring the guard the AI-categorization poller already has. A long 'processing' run is unaffected — only a job that never leaves 'pending' trips the cap. --- .../automation-rules/apply-automation-rule-flow.tsx | 8 ++++++++ .../js/hooks/use-re-evaluate-all-transactions.tsx | 8 ++++++++ resources/js/pages/transactions/index.tsx | 10 +++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/resources/js/components/automation-rules/apply-automation-rule-flow.tsx b/resources/js/components/automation-rules/apply-automation-rule-flow.tsx index bd5c672c..d1b5da26 100644 --- a/resources/js/components/automation-rules/apply-automation-rule-flow.tsx +++ b/resources/js/components/automation-rules/apply-automation-rule-flow.tsx @@ -178,6 +178,7 @@ export function ApplyAutomationRuleFlow({ const pollStatus = useCallback( async (jobId: string) => { const url = statusRoute(jobId).url; + let pendingTicks = 0; const poll = async (): Promise => { const res = await fetch(url, { headers: { Accept: 'application/json' }, @@ -202,6 +203,13 @@ export function ApplyAutomationRuleFlow({ setApplying(false); return; } + // The job never started (e.g. no queue worker running) — give + // up instead of polling forever. + if (data.status === 'pending' && ++pendingTicks > 30) { + toast.error(__('Failed to apply rule to transactions.')); + setApplying(false); + return; + } setTimeout(() => { void poll(); }, 1000); diff --git a/resources/js/hooks/use-re-evaluate-all-transactions.tsx b/resources/js/hooks/use-re-evaluate-all-transactions.tsx index b0bae253..f6762280 100644 --- a/resources/js/hooks/use-re-evaluate-all-transactions.tsx +++ b/resources/js/hooks/use-re-evaluate-all-transactions.tsx @@ -18,6 +18,7 @@ export function useReEvaluateAllTransactions() { const jobId = bulkResponse.data.job_id; await new Promise((resolve, reject) => { + let pendingTicks = 0; const poll = async () => { try { const statusResponse = await axios.get<{ @@ -47,6 +48,13 @@ export function useReEvaluateAllTransactions() { resolve(); } else if (status === 'failed') { reject(new Error('Job failed')); + } else if ( + status === 'pending' && + ++pendingTicks > 30 + ) { + // The job never started (e.g. no queue worker + // running) — give up instead of polling forever. + reject(new Error('Job did not start')); } else { setTimeout(poll, 1000); } diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 9fa09fdd..2fba0680 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -940,6 +940,7 @@ export default function Transactions({ const jobId = bulkResponse.data.job_id; await new Promise((resolve, reject) => { + let pendingTicks = 0; const poll = async () => { try { const statusResponse = await axios.get<{ @@ -969,6 +970,13 @@ export default function Transactions({ resolve(); } else if (status === 'failed') { reject(new Error('Job failed')); + } else if ( + status === 'pending' && + ++pendingTicks > 30 + ) { + // The job never started (e.g. no queue worker + // running) — give up instead of polling forever. + reject(new Error('Job did not start')); } else { setTimeout(poll, 1000); } @@ -1472,7 +1480,7 @@ export default function Transactions({ handleDismissAiConsent } disabled={aiConsentSaving} - className="opacity-20 hover:opacity-100 transition-all duration-300" + className="opacity-20 transition-all duration-300 hover:opacity-100" variant="ghost" size="icon" aria-label={__('Dismiss')}