From 9b17985bb0902e2d635bd799b8dbae65893a429e Mon Sep 17 00:00:00 2001 From: Simon <187317140+stamtos@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:20:00 -0300 Subject: [PATCH 1/2] [FIX] queue_job_batch: prevent batches stuck in progress - Remove identity_exact from check_state delay to prevent race conditions - Trigger check_state for all terminal states (done, cancelled, failed) Fixes OCA/queue#810 --- queue_job_batch/models/queue_job.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/queue_job_batch/models/queue_job.py b/queue_job_batch/models/queue_job.py index ddc3efe879..4dcf635d68 100644 --- a/queue_job_batch/models/queue_job.py +++ b/queue_job_batch/models/queue_job.py @@ -3,8 +3,6 @@ from odoo import api, fields, models -from odoo.addons.queue_job.job import identity_exact - class QueueJob(models.Model): _inherit = "queue.job" @@ -20,13 +18,19 @@ def create(self, vals_list): return super().create(vals_list) def write(self, vals): - if vals.get("state", "") == "done": + new_state = vals.get("state", "") + # Trigger check_state for any terminal state (done, cancelled, failed) + if new_state in ("done", "cancelled", "failed"): batches = self.env["queue.job.batch"] for record in self: - if record.state != "done" and record.job_batch_id: + # Only trigger if the job wasn't already in a terminal state + if ( + record.state not in ("done", "cancelled", "failed") + and record.job_batch_id + ): batches |= record.job_batch_id for batch in batches: - # We need to make it with delay in order to prevent two jobs - # to work with the same batch - batch.with_delay(identity_key=identity_exact).check_state() + # Run check_state without identity_key to prevent race condition + # where deduplication causes the last job's check_state to be skipped + batch.with_delay().check_state() return super().write(vals) From 3783e9aeb55a4dedb8f4c6323cdebbb990067720 Mon Sep 17 00:00:00 2001 From: Simon <187317140+stamtos@users.noreply.github.com> Date: Mon, 26 Jan 2026 03:41:50 -0300 Subject: [PATCH 2/2] [TEST] queue_job_batch: add tests for batch stuck in progress fix Add tests to verify: - Failed jobs trigger check_state on the batch - Cancelled jobs trigger check_state on the batch - No deduplication occurs when multiple jobs complete (race condition fix) --- test_queue_job_batch/tests/__init__.py | 1 + .../tests/test_fix_batch_stuck.py | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 test_queue_job_batch/tests/test_fix_batch_stuck.py diff --git a/test_queue_job_batch/tests/__init__.py b/test_queue_job_batch/tests/__init__.py index 39cec46423..03c1d25f62 100644 --- a/test_queue_job_batch/tests/__init__.py +++ b/test_queue_job_batch/tests/__init__.py @@ -1 +1,2 @@ from . import test_queue_job_batch +from . import test_fix_batch_stuck diff --git a/test_queue_job_batch/tests/test_fix_batch_stuck.py b/test_queue_job_batch/tests/test_fix_batch_stuck.py new file mode 100644 index 0000000000..1e21b98bee --- /dev/null +++ b/test_queue_job_batch/tests/test_fix_batch_stuck.py @@ -0,0 +1,97 @@ +from odoo.tests.common import TransactionCase + + +class TestQueueJobBatchFix(TransactionCase): + def setUp(self): + super().setUp() + self.QueueJob = self.env["queue.job"] + self.Batch = self.env["queue.job.batch"] + self.TestModel = self.env["test.queue.job"] + + def test_batch_failed_job_triggers_check(self): + """Test that a failed job triggers check_state on the batch.""" + self.cr.execute("delete from queue_job") + batch = self.Batch.get_new_batch("TEST_FAIL") + + # Create a job in the batch + job = self.TestModel.with_context(job_batch=batch).with_delay().testing_method() + job_record = job.db_record() + + # Verify initial state + self.assertEqual(batch.state, "pending") + self.assertEqual(job_record.state, "pending") + + # Set job to failed + # Depending on how queue_job works, writing state might trigger the logic + job_record.write({"state": "failed", "exc_info": "Fail"}) + + # Find jobs for queue.job.batch + check_jobs = self.QueueJob.search( + [ + ("model_name", "=", "queue.job.batch"), + ("method_name", "=", "check_state"), + ] + ) + + # Filter for our batch + check_jobs = check_jobs.filtered(lambda j: batch in j.records) + + # WITHOUT FIX: This should be empty because "failed" state doesn't trigger + self.assertTrue( + check_jobs, "check_state job should be created when a job fails" + ) + + def test_batch_cancelled_job_triggers_check(self): + """Test that a cancelled job triggers check_state on the batch.""" + self.cr.execute("delete from queue_job") + batch = self.Batch.get_new_batch("TEST_CANCEL") + job = self.TestModel.with_context(job_batch=batch).with_delay().testing_method() + job_record = job.db_record() + + job_record.write({"state": "cancelled"}) + + check_jobs = self.QueueJob.search( + [ + ("model_name", "=", "queue.job.batch"), + ("method_name", "=", "check_state"), + ] + ) + check_jobs = check_jobs.filtered(lambda j: batch in j.records) + + self.assertTrue( + check_jobs, "check_state job should be created when a job is cancelled" + ) + + def test_no_deduplication_race_condition(self): + """Test that multiple jobs trigger multiple check_state calls.""" + self.cr.execute("delete from queue_job") + batch = self.Batch.get_new_batch("TEST_RACE") + + # Create 2 jobs + job1 = ( + self.TestModel.with_context(job_batch=batch).with_delay().testing_method() + ) + job2 = ( + self.TestModel.with_context(job_batch=batch).with_delay().testing_method() + ) + + # Set job1 to done -> creates CheckJob1 + job1.db_record().write({"state": "done"}) + + # Set job2 to done -> creates CheckJob2 + # If identity_exact is used, CheckJob2 might be deduplicated + job2.db_record().write({"state": "done"}) + + check_jobs = self.QueueJob.search( + [ + ("model_name", "=", "queue.job.batch"), + ("method_name", "=", "check_state"), + ] + ) + check_jobs = check_jobs.filtered(lambda j: batch in j.records) + + # WITH FIX: Should have 2 check jobs (no deduplication) + # WITHOUT FIX: Should have 1 check job because of deduplication + self.assertEqual( + len(check_jobs), 2, "Should have 2 check_state jobs (no deduplication)" + )