fix(transactions): stop job-status polling when the job never starts

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.
This commit is contained in:
Víctor Falcón 2026-07-03 13:26:18 +02:00
parent 9a806c0565
commit efe60282c8
3 changed files with 25 additions and 1 deletions

View File

@ -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<void> => {
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);

View File

@ -18,6 +18,7 @@ export function useReEvaluateAllTransactions() {
const jobId = bulkResponse.data.job_id;
await new Promise<void>((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);
}

View File

@ -940,6 +940,7 @@ export default function Transactions({
const jobId = bulkResponse.data.job_id;
await new Promise<void>((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')}