From 340ea6c207c4ce9f9e5788930f2a407a6896fac1 Mon Sep 17 00:00:00 2001 From: Daniel Gergely Date: Mon, 17 Aug 2026 10:04:36 +0200 Subject: [PATCH 1/3] [T3374] FIX: batch and commit the communication attachment vacuum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vacuum ran as inline code on the cron, deleting every match in one transaction. Once a busy week crossed the rolling 2-year cutoff it could no longer finish within the 300s limit, and each timeout discarded the work and retried the same slice — livelocking every scheduled action --- .../models/communication_job.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/partner_communication/models/communication_job.py b/partner_communication/models/communication_job.py index 328fd6a49..ac60a03f5 100644 --- a/partner_communication/models/communication_job.py +++ b/partner_communication/models/communication_job.py @@ -11,6 +11,7 @@ import logging import re import threading +import time from collections import defaultdict from html.parser import HTMLParser from io import BytesIO @@ -1055,3 +1056,43 @@ def get_first(source): self[:1].printed_pdf_data = base64.b64encode(to_print[0]) return print_options + + @api.model + def vacuum_old_attachments(self, years=2, batch=500, max_seconds=120): + """Delete the attachments of communication jobs older than `years`. + + T3374: the previous DB-only cron deleted every match in a single + transaction. Once a busy week crossed the rolling cutoff it could no + longer finish within the 300s cron limit, and each kill discarded the + work and retried the same slice forever. Committing per batch means a + timeout keeps what was already deleted. + """ + cutoff = fields.Datetime.subtract(fields.Datetime.now(), years=years) + removed = 0 + freed = 0 + deadline = time.time() + max_seconds + while time.time() < deadline: + jobs = self.search( + [("date", "<", cutoff), ("attachment_ids", "!=", False)], + order="date asc", + limit=batch, + ) + if not jobs: + break + job_attachments = jobs.mapped("attachment_ids") + orphans = self.env["ir.attachment"].search( + [("res_model", "=", self._name), ("res_id", "in", jobs.ids)] + ) - job_attachments.mapped("attachment_id") + removed += len(orphans) + len(job_attachments) + freed += sum(orphans.mapped("file_size")) + sum( + job_attachments.mapped("attachment_id.file_size") + ) + orphans.unlink() + job_attachments.unlink() + self.env.cr.commit() # pylint: disable=invalid-commit + _logger.info( + "Vacuum old attachments: removed %s attachments, freed %.1f MB", + removed, + freed / 1e6, + ) + return True From 78b9085df489611488c8bbfa00da3437dbec673a Mon Sep 17 00:00:00 2001 From: Daniel Gergely Date: Mon, 17 Aug 2026 13:49:40 +0200 Subject: [PATCH 2/3] [T3374] FIX: batch the communication attachment vacuum and clean wrapperless jobs --- .../models/communication_job.py | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/partner_communication/models/communication_job.py b/partner_communication/models/communication_job.py index ac60a03f5..9c82fc0f1 100644 --- a/partner_communication/models/communication_job.py +++ b/partner_communication/models/communication_job.py @@ -24,6 +24,10 @@ from odoo.addons.phone_validation.tools import phone_validation _logger = logging.getLogger(__name__) + +# Cursor for vacuum_old_attachments, so each run resumes where the last stopped. +_VACUUM_CURSOR_DATE = "partner_communication.vacuum_last_date" +_VACUUM_CURSOR_ID = "partner_communication.vacuum_last_id" testing = tools.config.get("test_enable") try: @@ -1068,27 +1072,43 @@ def vacuum_old_attachments(self, years=2, batch=500, max_seconds=120): timeout keeps what was already deleted. """ cutoff = fields.Datetime.subtract(fields.Datetime.now(), years=years) + attachment_obj = self.env["ir.attachment"] + params = self.env["ir.config_parameter"].sudo() removed = 0 freed = 0 + last_date = params.get_param(_VACUUM_CURSOR_DATE) + last_id = int(params.get_param(_VACUUM_CURSOR_ID, 0)) deadline = time.time() + max_seconds while time.time() < deadline: - jobs = self.search( - [("date", "<", cutoff), ("attachment_ids", "!=", False)], - order="date asc", - limit=batch, - ) + domain = [("date", "<", cutoff)] + if last_date: + domain += [ + "|", + ("date", ">", last_date), + "&", + ("date", "=", last_date), + ("id", ">", last_id), + ] + jobs = self.search(domain, order="date asc, id asc", limit=batch) if not jobs: break - job_attachments = jobs.mapped("attachment_ids") - orphans = self.env["ir.attachment"].search( + last_date = fields.Datetime.to_string(jobs[-1].date) + last_id = jobs[-1].id + params.set_param(_VACUUM_CURSOR_DATE, last_date) + params.set_param(_VACUUM_CURSOR_ID, last_id) + wrappers = jobs.mapped("attachment_ids") + # Direct ir.attachment rows on those jobs, including jobs with no + # wrapper at all - those were never reached before. + direct = attachment_obj.search( [("res_model", "=", self._name), ("res_id", "in", jobs.ids)] - ) - job_attachments.mapped("attachment_id") - removed += len(orphans) + len(job_attachments) - freed += sum(orphans.mapped("file_size")) + sum( - job_attachments.mapped("attachment_id.file_size") - ) - orphans.unlink() - job_attachments.unlink() + ) - wrappers.mapped("attachment_id") + if direct or wrappers: + removed += len(direct) + len(wrappers) + freed += sum(direct.mapped("file_size")) + sum( + wrappers.mapped("attachment_id.file_size") + ) + direct.unlink() + wrappers.unlink() self.env.cr.commit() # pylint: disable=invalid-commit _logger.info( "Vacuum old attachments: removed %s attachments, freed %.1f MB", From 00f460f8e20fac1a674d8c0df8d4640583781ece Mon Sep 17 00:00:00 2001 From: Daniel Gergely Date: Mon, 17 Aug 2026 14:10:06 +0200 Subject: [PATCH 3/3] [T3374] REF: drop the vacuum cursor, keep the batching - kept already existing & tested version, and only improve it slightly - removed unnecessary complexity --- .../models/communication_job.py | 48 ++++++------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/partner_communication/models/communication_job.py b/partner_communication/models/communication_job.py index 9c82fc0f1..ac60a03f5 100644 --- a/partner_communication/models/communication_job.py +++ b/partner_communication/models/communication_job.py @@ -24,10 +24,6 @@ from odoo.addons.phone_validation.tools import phone_validation _logger = logging.getLogger(__name__) - -# Cursor for vacuum_old_attachments, so each run resumes where the last stopped. -_VACUUM_CURSOR_DATE = "partner_communication.vacuum_last_date" -_VACUUM_CURSOR_ID = "partner_communication.vacuum_last_id" testing = tools.config.get("test_enable") try: @@ -1072,43 +1068,27 @@ def vacuum_old_attachments(self, years=2, batch=500, max_seconds=120): timeout keeps what was already deleted. """ cutoff = fields.Datetime.subtract(fields.Datetime.now(), years=years) - attachment_obj = self.env["ir.attachment"] - params = self.env["ir.config_parameter"].sudo() removed = 0 freed = 0 - last_date = params.get_param(_VACUUM_CURSOR_DATE) - last_id = int(params.get_param(_VACUUM_CURSOR_ID, 0)) deadline = time.time() + max_seconds while time.time() < deadline: - domain = [("date", "<", cutoff)] - if last_date: - domain += [ - "|", - ("date", ">", last_date), - "&", - ("date", "=", last_date), - ("id", ">", last_id), - ] - jobs = self.search(domain, order="date asc, id asc", limit=batch) + jobs = self.search( + [("date", "<", cutoff), ("attachment_ids", "!=", False)], + order="date asc", + limit=batch, + ) if not jobs: break - last_date = fields.Datetime.to_string(jobs[-1].date) - last_id = jobs[-1].id - params.set_param(_VACUUM_CURSOR_DATE, last_date) - params.set_param(_VACUUM_CURSOR_ID, last_id) - wrappers = jobs.mapped("attachment_ids") - # Direct ir.attachment rows on those jobs, including jobs with no - # wrapper at all - those were never reached before. - direct = attachment_obj.search( + job_attachments = jobs.mapped("attachment_ids") + orphans = self.env["ir.attachment"].search( [("res_model", "=", self._name), ("res_id", "in", jobs.ids)] - ) - wrappers.mapped("attachment_id") - if direct or wrappers: - removed += len(direct) + len(wrappers) - freed += sum(direct.mapped("file_size")) + sum( - wrappers.mapped("attachment_id.file_size") - ) - direct.unlink() - wrappers.unlink() + ) - job_attachments.mapped("attachment_id") + removed += len(orphans) + len(job_attachments) + freed += sum(orphans.mapped("file_size")) + sum( + job_attachments.mapped("attachment_id.file_size") + ) + orphans.unlink() + job_attachments.unlink() self.env.cr.commit() # pylint: disable=invalid-commit _logger.info( "Vacuum old attachments: removed %s attachments, freed %.1f MB",