317 lines
11 KiB
Python
Executable File
317 lines
11 KiB
Python
Executable File
# Copyright 2025 Opener B.V. <https://opener.amsterdam>
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from odoo import api, fields, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class IrModelSize(models.Model):
|
|
_name = "ir.model.size"
|
|
_description = "Disk space usage per model"
|
|
_order = "measurement_date desc, total_model_size desc"
|
|
_rec_name = "model"
|
|
_sql_constraints = [
|
|
(
|
|
"uniq_model_measurement_date",
|
|
"unique(model, measurement_date)",
|
|
"There is already a measurement for this model on the given date",
|
|
),
|
|
]
|
|
model = fields.Char(index=True)
|
|
model_name = fields.Char(
|
|
compute="_compute_model_name",
|
|
store=True,
|
|
)
|
|
measurement_date = fields.Date(
|
|
"Date of Measurement",
|
|
help="For the exact time, check the record's write_date.",
|
|
required=True,
|
|
)
|
|
total_model_size = fields.Integer(
|
|
compute="_compute_total_sizes",
|
|
help="Total model size in MB. This includes attachments.",
|
|
store=True,
|
|
)
|
|
total_database_size = fields.Integer(
|
|
compute="_compute_total_sizes",
|
|
help="Total Model Size in MB. This includes many2many tables",
|
|
store=True,
|
|
)
|
|
total_table_size = fields.Integer(
|
|
help="Total Table Size in MB. This includes indexes and toast tables",
|
|
)
|
|
table_size = fields.Integer(
|
|
string="Bare Table Size",
|
|
help="Bare Table Size in MB.",
|
|
)
|
|
ir_model_index_size_ids = fields.One2many(
|
|
comodel_name="ir.model.index.size",
|
|
inverse_name="ir_model_size_id",
|
|
string="Indexes",
|
|
)
|
|
ir_model_relation_size_ids = fields.One2many(
|
|
comodel_name="ir.model.relation.size",
|
|
inverse_name="ir_model_size_id",
|
|
string="Relations",
|
|
)
|
|
indexes_size = fields.Integer(
|
|
compute="_compute_indexes_size",
|
|
help="Total Size of Indexes in MB",
|
|
store=True,
|
|
string="Index Size",
|
|
)
|
|
relations_size = fields.Integer(
|
|
compute="_compute_relations_size",
|
|
help="Total Size of many2many relations in MB",
|
|
store=True,
|
|
string="Many2many Tables Size",
|
|
)
|
|
tuples = fields.Integer(
|
|
string="Estimated Rows",
|
|
help="Rows in use, including dead tuples",
|
|
)
|
|
attachment_size = fields.Integer(
|
|
help=(
|
|
"Attachment Size in MB. Includes overlap of files that are also "
|
|
"attached to other models."
|
|
),
|
|
)
|
|
|
|
@api.depends("model")
|
|
def _compute_model_name(self):
|
|
"""Assign the model's label"""
|
|
model2name = {
|
|
model.model: model.name for model in self.env["ir.model"].sudo().search([])
|
|
}
|
|
for size in self:
|
|
size.model_name = model2name.get(size.model, "<removed>")
|
|
|
|
@api.model
|
|
def read_group(
|
|
self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True
|
|
):
|
|
"""Enforce that grouped results are ordered.
|
|
|
|
Odoo will happily use the grouping field for ordering unless groupby is a
|
|
list, and as it happens the grouping is usually passed as a list, for
|
|
example: ['measurement_date:day']
|
|
"""
|
|
if not orderby and groupby and isinstance(groupby, list | set):
|
|
field = groupby[0].split(":")[0]
|
|
orderby = f"{field} desc"
|
|
return super().read_group(
|
|
domain,
|
|
fields,
|
|
groupby,
|
|
offset=offset,
|
|
limit=limit,
|
|
orderby=orderby,
|
|
lazy=lazy,
|
|
)
|
|
|
|
@api.depends(
|
|
"total_table_size",
|
|
"relations_size",
|
|
"attachment_size",
|
|
)
|
|
def _compute_total_sizes(self):
|
|
for size in self:
|
|
size.total_database_size = size.total_table_size + size.relations_size
|
|
size.total_model_size = size.total_database_size + size.attachment_size
|
|
|
|
@api.depends("ir_model_index_size_ids", "ir_model_index_size_ids.size")
|
|
def _compute_indexes_size(self):
|
|
for size in self:
|
|
size.indexes_size = sum(size.ir_model_index_size_ids.mapped("size"))
|
|
|
|
@api.depends("ir_model_relation_size_ids", "ir_model_relation_size_ids.size")
|
|
def _compute_relations_size(self):
|
|
for size in self:
|
|
size.relations_size = sum(size.ir_model_relation_size_ids.mapped("size"))
|
|
|
|
@staticmethod
|
|
def _normalize_size(size):
|
|
"""Filter out -1s and compute as MB"""
|
|
if not size:
|
|
return 0
|
|
return int(max(0, size) / (1024 * 1024))
|
|
|
|
@api.model
|
|
def _measure(self):
|
|
"""Create the entries for today's report"""
|
|
today = fields.Date.context_today(self)
|
|
# Remove any previous report for the same day
|
|
self.search([("measurement_date", "=", today)]).unlink()
|
|
table2model = {}
|
|
for model in self.env.values():
|
|
if not model._abstract and not model._transient:
|
|
model_model = model._name
|
|
table2model[model._table] = model_model
|
|
model2vals = {
|
|
model_model: {
|
|
"model": model_model,
|
|
"measurement_date": today,
|
|
"ir_model_index_size_ids": [],
|
|
"ir_model_relation_size_ids": [],
|
|
}
|
|
for model_model in table2model.values()
|
|
}
|
|
# Some many2many relation objects are linked explicitely to both models
|
|
# involved. To prevent counting them double, we will link them to the
|
|
# largest table. Gather all the related models first.
|
|
self.env.cr.execute(
|
|
"""
|
|
select name, array_agg(model)
|
|
from ir_model_relation group by name;
|
|
"""
|
|
)
|
|
relation2model = dict(self.env.cr.fetchall())
|
|
self.env.cr.execute(
|
|
"""
|
|
SELECT relname,
|
|
reltuples,
|
|
pg_total_relation_size (C.oid),
|
|
pg_relation_size (C.oid)
|
|
FROM pg_class C
|
|
LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace)
|
|
WHERE nspname NOT IN (
|
|
'information_schema',
|
|
'pg_catalog',
|
|
'pg_logical',
|
|
'pg_toast'
|
|
)
|
|
AND C.relkind = 'r'
|
|
"""
|
|
)
|
|
# Gather sizes of model tables and many2many tables
|
|
rows = self.env.cr.fetchall()
|
|
for table, tuples, total_table_size, table_size in rows:
|
|
model = table2model.get(table)
|
|
if model:
|
|
model2vals[model].update(
|
|
{
|
|
"table_size": self._normalize_size(table_size),
|
|
"total_table_size": self._normalize_size(total_table_size),
|
|
"tuples": max(tuples, 0),
|
|
}
|
|
)
|
|
# Second pass to throw in the relation tables with the largest relation
|
|
for table, _tuples, total_table_size, _table_size in rows:
|
|
if table in relation2model:
|
|
models = relation2model[table]
|
|
model = sorted(
|
|
models,
|
|
key=lambda model: model2vals.get(model, {"tuples": -99})["tuples"],
|
|
reverse=True,
|
|
)[0]
|
|
vals = model2vals.get(model)
|
|
if vals:
|
|
vals["ir_model_relation_size_ids"].append(
|
|
fields.Command.create(
|
|
{
|
|
"name": table,
|
|
"size": self._normalize_size(total_table_size),
|
|
}
|
|
)
|
|
)
|
|
# Gather sizes of indexes
|
|
self.env.cr.execute(
|
|
"""
|
|
SELECT i.relname table_name,
|
|
indexrelname index_name,
|
|
pg_relation_size(indexrelid) index_size
|
|
FROM pg_stat_all_indexes i
|
|
JOIN pg_class c ON i.relid=c.oid
|
|
WHERE schemaname NOT IN (
|
|
'information_schema',
|
|
'pg_catalog',
|
|
'pg_toast',
|
|
'pg_logical'
|
|
);
|
|
"""
|
|
)
|
|
for table, index, size in self.env.cr.fetchall():
|
|
vals = model2vals.get(table2model.get(table))
|
|
if vals:
|
|
vals["ir_model_index_size_ids"].append(
|
|
fields.Command.create(
|
|
{
|
|
"name": index,
|
|
"size": self._normalize_size(size),
|
|
}
|
|
)
|
|
)
|
|
# Gather sizes of attachments. Deduplicate by checksum such that the
|
|
# attachment is attributed to the first model it was linked to.
|
|
self.env.cr.execute(
|
|
"""
|
|
with unique_attachments as (
|
|
select res_model,
|
|
file_size,
|
|
row_number() over (partition by checksum order by id) as rowno
|
|
from ir_attachment
|
|
)
|
|
select res_model, sum(file_size)
|
|
from unique_attachments
|
|
where rowno = 1
|
|
group by res_model;
|
|
"""
|
|
)
|
|
for model, size in self.env.cr.fetchall():
|
|
vals = model2vals.get(model)
|
|
if vals:
|
|
vals["attachment_size"] = self._normalize_size(size)
|
|
vals_list = [val for val in model2vals.values() if "table_size" in val]
|
|
self.create(vals_list)
|
|
_logger.info("Created %s model database size records", len(vals_list))
|
|
|
|
@api.autovacuum
|
|
def _purge(self):
|
|
"""Remove older model size records, if enabled in the General Settings."""
|
|
if (
|
|
not self.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("database_size.purge_enable")
|
|
):
|
|
return
|
|
retention_daily = int(
|
|
self.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("database_size.retention_daily", 366)
|
|
)
|
|
retention_monthly = int(
|
|
self.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("database_size.retention_monthly", 0)
|
|
)
|
|
if retention_daily:
|
|
cutoff_date = fields.Date.today() - timedelta(days=retention_daily)
|
|
self.env.cr.execute(
|
|
"""
|
|
delete from ir_model_size
|
|
where measurement_date < %(cutoff_date)s
|
|
and extract(day from measurement_date) != 1;
|
|
""",
|
|
{"cutoff_date": cutoff_date},
|
|
)
|
|
_logger.info(
|
|
f"Deleted {self.env.cr.rowcount} ir_model_size from before "
|
|
f"{cutoff_date} from any other day than the first day of the month."
|
|
)
|
|
if retention_monthly and retention_monthly > retention_daily:
|
|
cutoff_date = fields.Date.today() - timedelta(days=retention_monthly)
|
|
self.env.cr.execute(
|
|
"""
|
|
delete from ir_model_size
|
|
where measurement_date < %(cutoff_date)s;
|
|
""",
|
|
{"cutoff_date": cutoff_date},
|
|
)
|
|
_logger.info(
|
|
f"Deleted {self.env.cr.rowcount} ir_model_size from before "
|
|
f"{cutoff_date}."
|
|
)
|