diff --git a/edi_queue_oca/README.rst b/edi_queue_oca/README.rst index b01ee1c75..d8ddd0cf4 100644 --- a/edi_queue_oca/README.rst +++ b/edi_queue_oca/README.rst @@ -64,22 +64,22 @@ Per-type job configuration Each **Exchange Type** gains a *Queue* tab with optional settings: -+---------------------------+------------------------------------------+ -| Field | Purpose | -+===========================+==========================================+ -| **Job channel** | Route jobs to a specific channel (e.g. | -| | ``root.edi.high``). | -+---------------------------+------------------------------------------+ -| **Job priority** | Integer priority passed to the queue job | -| | (lower = higher priority). | -+---------------------------+------------------------------------------+ -| **Enable ETA Scheduling** | Toggle to activate daily job | -| | accumulation (see below). | -+---------------------------+------------------------------------------+ -| **Execution time** | Hour, minute, and timezone at which | -| | accumulated jobs are released. Visible | -| | only when ETA Scheduling is enabled. | -+---------------------------+------------------------------------------+ ++---------------------------+-----------------------------------------+ +| Field | Purpose | ++===========================+=========================================+ +| **Job channel** | Route jobs to a specific channel (e.g. | +| | ``root.edi.high``). | ++---------------------------+-----------------------------------------+ +| **Job priority** | Integer priority passed to the queue | +| | job (lower = higher priority). | ++---------------------------+-----------------------------------------+ +| **Enable ETA Scheduling** | Toggle to activate daily job | +| | accumulation (see below). | ++---------------------------+-----------------------------------------+ +| **Execution time** | Hour, minute, and timezone at which | +| | accumulated jobs are released. Visible | +| | only when ETA Scheduling is enabled. | ++---------------------------+-----------------------------------------+ Accumulating jobs until a fixed daily time ------------------------------------------ @@ -91,20 +91,20 @@ during the day accumulate and are released together at that moment. **Typical use cases:** -- A trading partner's receiving system only processes incoming files at - a specific nightly window (e.g. 22:00). -- Resource-intensive EDI operations (large exports, heavy - transformations) should be deferred to off-peak hours to avoid - competing with daytime workloads. -- Operational preference to send a batch of documents at a predictable - daily time instead of dispatching them one by one in real time. +- A trading partner's receiving system only processes incoming files at + a specific nightly window (e.g. 22:00). +- Resource-intensive EDI operations (large exports, heavy + transformations) should be deferred to off-peak hours to avoid + competing with daytime workloads. +- Operational preference to send a batch of documents at a predictable + daily time instead of dispatching them one by one in real time. The execution time is configured with three fields: -- **Hour** — hour of the day (00–23). -- **Minute** — minute of the hour (00–59). -- **Timezone** — the timezone in which the hour and minute are - interpreted. Defaults to the current user's timezone. +- **Hour** — hour of the day (00–23). +- **Minute** — minute of the hour (00–59). +- **Timezone** — the timezone in which the hour and minute are + interpreted. Defaults to the current user's timezone. At runtime the configured time is converted to the next matching UTC datetime and set as the queue job ETA. If the target time for today has @@ -121,6 +121,18 @@ An identity key is attached to every queued job, so re-triggering an action for a record that already has a pending job does not enqueue a duplicate. +Garbage collection of stranded jobs +----------------------------------- + +``queue_job`` cancels dependent jobs only when a parent is explicitly +cancelled, so a dependent of a *failed* parent stays in *Wait +Dependencies* forever. The cron *EDI exchange garbage collect stale +jobs* cancels those jobs once no parent can still bring them to +execution, and only after a grace period (24 hours by default, set by +the system parameter ``edi_queue_oca.gc_stale_jobs_grace_hours``) that +leaves time to requeue the failed parent by hand. The cron is disabled +by default. + Bug Tracker =========== @@ -143,11 +155,11 @@ Authors Contributors ------------ -- Simone Orsi -- Enric Tobella -- Manuel Regidor -- Thien Vo -- Jordi Masvidal +- Simone Orsi +- Enric Tobella +- Manuel Regidor +- Thien Vo +- Jordi Masvidal Maintainers ----------- diff --git a/edi_queue_oca/__manifest__.py b/edi_queue_oca/__manifest__.py index 3545fe19b..4a76f9b38 100644 --- a/edi_queue_oca/__manifest__.py +++ b/edi_queue_oca/__manifest__.py @@ -14,6 +14,7 @@ "security/ir_model_access.xml", "data/job_channel.xml", "data/job_function.xml", + "data/cron.xml", "views/edi_exchange_record.xml", ], "demo": [], diff --git a/edi_queue_oca/data/cron.xml b/edi_queue_oca/data/cron.xml new file mode 100644 index 000000000..c5c990470 --- /dev/null +++ b/edi_queue_oca/data/cron.xml @@ -0,0 +1,13 @@ + + + + EDI exchange garbage collect stale jobs + + + 1 + days + + code + model.search([])._job_gc_stale() + + diff --git a/edi_queue_oca/models/edi_backend.py b/edi_queue_oca/models/edi_backend.py index b06cda1b7..097491424 100644 --- a/edi_queue_oca/models/edi_backend.py +++ b/edi_queue_oca/models/edi_backend.py @@ -3,10 +3,25 @@ # Copyright 2025 Dixmit # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import logging +from datetime import timedelta -from odoo import models +from odoo import fields, models from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.queue_job.job import ( + CANCELLED, + DONE, + ENQUEUED, + PENDING, + STARTED, + WAIT_DEPENDENCIES, +) + +_logger = logging.getLogger(__name__) + +# States a parent job can still leave on its own to unblock its dependents. +LIVE_JOB_STATES = (PENDING, ENQUEUED, STARTED, WAIT_DEPENDENCIES) class EDIBackend(models.Model): @@ -20,3 +35,67 @@ def _send_retryable_exceptions(self): def _retryable_exception(self): return RetryableJobError + + def _job_gc_stale_grace_hours(self): + """Return the delay before a stranded job is collected. + + The grace period leaves time to requeue the failed parent by hand, + which brings its dependents back to life on its own. + """ + param = self.env["ir.config_parameter"].sudo() + return int(param.get_param("edi_queue_oca.gc_stale_jobs_grace_hours", 24)) + + def _job_gc_stale_domain(self, grace_hours): + deadline = fields.Datetime.now() - timedelta(hours=grace_hours) + return [ + ("model_name", "=", self.exchange_record_model._name), + ("state", "=", WAIT_DEPENDENCIES), + ("date_created", "<=", deadline), + ] + + def _job_gc_is_stale(self, job, parent_states): + parent_uuids = (job.dependencies or {}).get("depends_on") or [] + if not parent_uuids: + return False + # A parent missing from the mapping has been vacuumed away: nothing + # will ever wake this job up again. + blocking = [ + parent_states.get(uuid) + for uuid in parent_uuids + if parent_states.get(uuid) != DONE + ] + return bool(blocking) and not any(x in LIVE_JOB_STATES for x in blocking) + + def _job_gc_stale_result(self): + return self.env._( + "Cancelled by the EDI garbage collector: " + "no remaining parent job can ever resume it." + ) + + def _job_gc_stale(self): + """Cancel exchange jobs that can never be woken up. + + ``queue_job`` cancels dependents only when a parent is explicitly + cancelled: when a parent fails they stay in ``wait_dependencies`` + forever, as EDI retries spawn a brand new job graph. + """ + job_model = self.env["queue.job"].sudo() + candidates = job_model.search( + self._job_gc_stale_domain(self._job_gc_stale_grace_hours()) + ) + if not candidates: + return job_model.browse() + parent_uuids = set() + for job in candidates: + parent_uuids.update((job.dependencies or {}).get("depends_on") or []) + parent_states = { + job.uuid: job.state + for job in job_model.search([("uuid", "in", list(parent_uuids))]) + } + stale = candidates.filtered( + lambda job: self._job_gc_is_stale(job, parent_states) + ) + if stale: + _logger.info("EDI exchange GC: cancelling %d stale jobs.", len(stale)) + stale._change_job_state(CANCELLED, result=self._job_gc_stale_result()) + return stale diff --git a/edi_queue_oca/readme/USAGE.md b/edi_queue_oca/readme/USAGE.md index 0303a275d..7e5b8d985 100644 --- a/edi_queue_oca/readme/USAGE.md +++ b/edi_queue_oca/readme/USAGE.md @@ -50,3 +50,13 @@ usual. An identity key is attached to every queued job, so re-triggering an action for a record that already has a pending job does not enqueue a duplicate. + +## Garbage collection of stranded jobs + +`queue_job` cancels dependent jobs only when a parent is explicitly cancelled, +so a dependent of a *failed* parent stays in *Wait Dependencies* forever. The +cron *EDI exchange garbage collect stale jobs* cancels those jobs once no +parent can still bring them to execution, and only after a grace period (24 +hours by default, set by the system parameter +`edi_queue_oca.gc_stale_jobs_grace_hours`) that leaves time to requeue the +failed parent by hand. The cron is disabled by default. diff --git a/edi_queue_oca/static/description/index.html b/edi_queue_oca/static/description/index.html index 7a194313d..d99d14a43 100644 --- a/edi_queue_oca/static/description/index.html +++ b/edi_queue_oca/static/description/index.html @@ -392,13 +392,14 @@

Edi Queue Oca

  • Per-type job configuration
  • Accumulating jobs until a fixed daily time
  • Duplicate-job prevention
  • +
  • Garbage collection of stranded jobs
  • -
  • Bug Tracker
  • -
  • Credits @@ -417,8 +418,8 @@

    Per-type job configurationEach Exchange Type gains a Queue tab with optional settings:

    --++ @@ -431,8 +432,8 @@

    Per-type job configurationroot.edi.high).

    - +
    Field
    Job priorityInteger priority passed to the queue job -(lower = higher priority).Integer priority passed to the queue +job (lower = higher priority).
    Enable ETA Scheduling Toggle to activate daily job @@ -482,9 +483,20 @@

    Duplicate-job prevention

    action for a record that already has a pending job does not enqueue a duplicate.

    +
    +

    Garbage collection of stranded jobs

    +

    queue_job cancels dependent jobs only when a parent is explicitly +cancelled, so a dependent of a failed parent stays in Wait +Dependencies forever. The cron EDI exchange garbage collect stale +jobs cancels those jobs once no parent can still bring them to +execution, and only after a grace period (24 hours by default, set by +the system parameter edi_queue_oca.gc_stale_jobs_grace_hours) that +leaves time to requeue the failed parent by hand. The cron is disabled +by default.

    +
    -

    Bug Tracker

    +

    Bug Tracker

    Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -492,16 +504,16 @@

    Bug Tracker

    Do not contact contributors directly about support or help with technical issues.

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Dixmit
    • Camptocamp
    -

    Contributors

    +

    Contributors

    -

    Maintainers

    +

    Maintainers

    This module is maintained by the OCA.

    Odoo Community Association diff --git a/edi_queue_oca/tests/__init__.py b/edi_queue_oca/tests/__init__.py index 0beb51b79..86f8941a3 100644 --- a/edi_queue_oca/tests/__init__.py +++ b/edi_queue_oca/tests/__init__.py @@ -1,6 +1,7 @@ from . import test_exchange_type from . import test_record from . import test_backend_jobs +from . import test_backend_gc_jobs from . import test_backend_input_jobs from . import test_backend_output_jobs diff --git a/edi_queue_oca/tests/test_backend_gc_jobs.py b/edi_queue_oca/tests/test_backend_gc_jobs.py new file mode 100644 index 000000000..7a1550fdf --- /dev/null +++ b/edi_queue_oca/tests/test_backend_gc_jobs.py @@ -0,0 +1,48 @@ +# Copyright 2026 ForgeFlow S.L. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from datetime import timedelta + +from odoo.tests import tagged + +from odoo.addons.queue_job.job import Job + +from .common import EDIQueueCommonTestCase + + +@tagged("-at_install", "post_install") +class EDIBackendTestGCJobsCase(EDIQueueCommonTestCase): + def _make_chained_jobs(self, parent_state, age_hours=0): + record = self._make_record() + parent = Job(record.action_exchange_receive) + child = Job(record.action_exchange_process) + child.add_depends({parent}) + parent.state = parent_state + child.date_created -= timedelta(hours=age_hours) + parent.store() + child.store() + return parent, child + + def _set_grace_hours(self, hours): + self.env["ir.config_parameter"].sudo().set_param( + "edi_queue_oca.gc_stale_jobs_grace_hours", hours + ) + + def test_gc_stale_exchange_jobs(self): + pending_parent, live_child = self._make_chained_jobs("pending", age_hours=25) + failed_parent, stale_child = self._make_chained_jobs("failed", age_hours=25) + self.assertEqual(live_child.db_record().state, "wait_dependencies") + self.assertEqual(stale_child.db_record().state, "wait_dependencies") + self._set_grace_hours(48) + self.assertFalse(self.backend._job_gc_stale()) + self.assertEqual(stale_child.db_record().state, "wait_dependencies") + self._set_grace_hours(24) + collected = self.backend._job_gc_stale() + self.assertEqual(collected, stale_child.db_record()) + self.assertEqual(stale_child.db_record().state, "cancelled") + self.assertEqual( + stale_child.db_record().result, self.backend._job_gc_stale_result() + ) + self.assertEqual(live_child.db_record().state, "wait_dependencies") + self.assertEqual(failed_parent.db_record().state, "failed") + self.assertEqual(pending_parent.db_record().state, "pending")