Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions queue_job_batch/models/queue_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
1 change: 1 addition & 0 deletions test_queue_job_batch/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_queue_job_batch
from . import test_fix_batch_stuck
97 changes: 97 additions & 0 deletions test_queue_job_batch/tests/test_fix_batch_stuck.py
Original file line number Diff line number Diff line change
@@ -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)"
)