From 8b718a707e7c55abbf229189d3c28acc2aa6551c Mon Sep 17 00:00:00 2001 From: Moises Lopez - Vauxoo Date: Fri, 30 Oct 2026 04:38:26 +0000 Subject: [PATCH] [IMP] mail_tracking: index the messages that need action res.users._init_messaging calls mail.message.get_failed_count, so every web client load (/mail/data) searches is_failed_message. That search filters on mail_tracking_needs_action, which has no index. Only a handful of messages carry the flag, but PostgreSQL starts from the failed trackings and probes mail_message by primary key once per tracking. With 122M messages and 29k failed trackings the query took 152 s cold and 5 s warm. Add a partial index on the flagged messages. It is tiny (584 kB on that database), and a write only touches it when the flag changes. The index is built once, by the first update after this change. Later updates keep it, because Odoo only rebuilds an index whose definition differs from the one stored in its comment. On a very large table that build blocks writes (13 minutes on 122M messages), so INSTALL.md explains how to create it beforehand with CREATE INDEX CONCURRENTLY and the comment the ORM stores on it. --- mail_tracking/models/mail_message.py | 10 ++++++++++ mail_tracking/readme/INSTALL.md | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/mail_tracking/models/mail_message.py b/mail_tracking/models/mail_message.py index 8cb742f9c..3d0e5f1f7 100644 --- a/mail_tracking/models/mail_message.py +++ b/mail_tracking/models/mail_message.py @@ -34,6 +34,16 @@ class MailMessage(models.Model): search="_search_is_failed_message", ) + # get_failed_count runs on every web client load (res.users._init_messaging) + # and filters on this flag, which only a handful of messages carry. Without a + # partial index the planner walks the failed trackings and probes mail_message + # by primary key once per tracking. Keep "IS TRUE": it is what the ORM generates + # for ("mail_tracking_needs_action", "=", True), and PostgreSQL does not use a + # plain "WHERE mail_tracking_needs_action" index for it. + _mail_tracking_needs_action_index = models.Index( + "(id) WHERE mail_tracking_needs_action IS TRUE" + ) + @api.model def get_failed_states(self): """The 'failed' states of the message""" diff --git a/mail_tracking/readme/INSTALL.md b/mail_tracking/readme/INSTALL.md index f9e4eab48..bcaa7a320 100644 --- a/mail_tracking/readme/INSTALL.md +++ b/mail_tracking/readme/INSTALL.md @@ -3,3 +3,20 @@ option) where /web/databse/selector returns a list of more than one database, then you need to add `mail_tracking` addon to wide load addons list (by default, only `web` addon), setting `--load` option. For example, `--load=web,mail,mail_tracking` + +The first update of the module after this index was added creates a partial +index on `mail_message` (`mail_message_mail_tracking_needs_action_index`). Later +updates do not build it again: Odoo keeps it while its definition does not change. + +On a database with a very large `mail_message` table that first build blocks +writes to the table while it runs (13 minutes on 122 million rows). To avoid the +lock, create the index beforehand exactly as the ORM would, with the comment it +stores on it; Odoo compares that comment with the index definition and keeps +the index when they match: + +```sql +CREATE INDEX CONCURRENTLY mail_message_mail_tracking_needs_action_index + ON mail_message (id) WHERE mail_tracking_needs_action IS TRUE; +COMMENT ON INDEX mail_message_mail_tracking_needs_action_index + IS 'INDEX (id) WHERE mail_tracking_needs_action IS TRUE'; +```