details: https://code.tryton.org/tryton/commit/5bf24cd6c16a
branch: default
user: Nicolas Évrard <[email protected]>
date: Tue Mar 24 09:30:17 2026 +0100
description:
Remove the files of Binary fields from the Filestore
Closes #13861
diffstat:
trytond/CHANGELOG | 1 +
trytond/doc/ref/fields.rst | 5 +
trytond/doc/topics/configuration.rst | 10 +++
trytond/trytond/config.py | 1 +
trytond/trytond/ir/cron.py | 1 +
trytond/trytond/ir/filestore.py | 81 ++++++++++++++++++++++++++++++
trytond/trytond/ir/filestore.xml | 10 +++
trytond/trytond/ir/tryton.cfg | 2 +
trytond/trytond/model/fields/binary.py | 33 +++++++++++-
trytond/trytond/model/modelstorage.py | 5 +
trytond/trytond/tests/field_binary.py | 3 +
trytond/trytond/tests/test_field_binary.py | 59 +++++++++++++++++++++
12 files changed, 210 insertions(+), 1 deletions(-)
diffs (352 lines):
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/CHANGELOG
--- a/trytond/CHANGELOG Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/CHANGELOG Tue Mar 24 09:30:17 2026 +0100
@@ -1,3 +1,4 @@
+* Remove the files of Binary fields from the Filestore
* Add delete and delete_many to Filestore
* Deprecate ``grouped_slice`` without size
* Replace ``Database.IN_MAX`` by ``backend.MAX_QUERY_PARAMS``
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/doc/ref/fields.rst
--- a/trytond/doc/ref/fields.rst Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/doc/ref/fields.rst Tue Mar 24 09:30:17 2026 +0100
@@ -574,6 +574,11 @@
Default value is ``None`` which means the database name is used.
+:class:`Binary` has extra method:
+
+.. method:: Binary.queue_for_removal(Model, name, ids)
+
+
Selection
---------
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/doc/topics/configuration.rst
--- a/trytond/doc/topics/configuration.rst Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/doc/topics/configuration.rst Tue Mar 24 09:30:17 2026 +0100
@@ -783,6 +783,16 @@
Default: ``None``
+.. _config-attachment.retention_delay:
+
+retention_days
+~~~~~~~~~~~~~~
+
+The number of days before unused files are actually removed from the
+:ref:`FileStore <ref-filestore>`.
+
+Default: 90
+
.. _config-bus:
bus
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/config.py
--- a/trytond/trytond/config.py Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/trytond/config.py Tue Mar 24 09:30:17 2026 +0100
@@ -107,6 +107,7 @@
self.set('bus', 'select_timeout', '5')
self.add_section('report')
self.add_section('html')
+ self.add_section('attachment')
self.update_environ()
self.update_etc()
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/ir/cron.py
--- a/trytond/trytond/ir/cron.py Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/trytond/ir/cron.py Tue Mar 24 09:30:17 2026 +0100
@@ -96,6 +96,7 @@
('ir.queue|clean', "Clean Task Queue"),
('ir.error|clean', "Clean Errors"),
('ir.cron.log|clean', "Clean Cron Logs"),
+ ('ir.filestore.queue|remove', "Remove Deleted Binaries"),
('ir.model|refresh_materialized', "Refresh Materialized Models"),
], "Method", required=True, states=_states)
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/ir/filestore.py
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/ir/filestore.py Tue Mar 24 09:30:17 2026 +0100
@@ -0,0 +1,81 @@
+# This file is part of Tryton. The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+
+import datetime
+from collections import defaultdict
+from itertools import groupby
+from operator import itemgetter
+
+from sql import Column
+
+from trytond import config
+from trytond.filestore import filestore
+from trytond.model import ModelSQL, fields
+from trytond.pool import Pool
+from trytond.pyson import Eval
+from trytond.transaction import Transaction
+
+
+class Queue(
+ fields.fmany2one(
+ 'model_ref', 'model', 'ir.model,name', "Model",
+ required=True, ondelete='CASCADE'),
+ fields.fmany2one(
+ 'field_ref', 'field,model', 'ir.model.field,name,model', "Field",
+ required=True, ondelete='CASCADE',
+ domain=[
+ ('model_ref', '=', Eval('model_ref', -1)),
+ ]),
+ ModelSQL):
+ __name__ = 'ir.filestore.queue'
+
+ file_id = fields.Char("File ID", required=True)
+ prefix = fields.Char("Prefix")
+ model = fields.Char("Model", required=True)
+ field = fields.Char("Field", required=True)
+
+ @classmethod
+ def remove(cls):
+ pool = Pool()
+ table = cls.__table__()
+ transaction = Transaction()
+ cursor = transaction.connection.cursor()
+
+ now = datetime.datetime.now()
+ retention_delay = datetime.timedelta(
+ days=config.getint('attachment', 'retention_days', default=90))
+ columns = [
+ table.file_id, table.model, table.field, table.prefix]
+ where = table.create_date < now - retention_delay
+ if transaction.database.has_returning():
+ cursor.execute(*table.delete(where=where, returning=columns))
+ records = cursor.fetchall()
+ records.sort(key=itemgetter(1, 2, 3))
+ else:
+ cursor.execute(*table.select(
+ *columns, where=where,
+ order_by=[table.model, table.field, table.prefix]))
+ records = cursor.fetchall()
+ cursor.execute(*table.delete(where=where))
+
+ to_remove = defaultdict(list)
+ for (model, fname, prefix), deleted in groupby(
+ records, key=itemgetter(1, 2, 3)):
+ Model = pool.get(model)
+ model_tbl = Model.__table__()
+ field = Model._fields[fname]
+ if not field.file_id:
+ continue
+ file_id_col = Column(model_tbl, field.file_id)
+
+ deleted_ids = {d[0] for d in deleted}
+ active_ids = set()
+ cursor.execute(*model_tbl.select(
+ file_id_col,
+ where=fields.SQL_OPERATORS['in'](
+ file_id_col, deleted_ids)))
+ active_ids.update(r[0] for r in cursor)
+ to_remove[prefix].extend(deleted_ids - active_ids)
+
+ for store_prefix, to_delete in to_remove.items():
+ filestore.delete_many(to_delete, prefix=store_prefix)
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/ir/filestore.xml
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/trytond/ir/filestore.xml Tue Mar 24 09:30:17 2026 +0100
@@ -0,0 +1,10 @@
+<?xml version="1.0"?>
+<tryton>
+ <data noupdate="1">
+ <record model="ir.cron" id="cron_filestore_queue_remove">
+ <field name="method">ir.filestore.queue|remove</field>
+ <field name="interval_number" eval="1"/>
+ <field name="interval_type">months</field>
+ </record>
+ </data>
+</tryton>
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/ir/tryton.cfg
--- a/trytond/trytond/ir/tryton.cfg Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/trytond/ir/tryton.cfg Tue Mar 24 09:30:17 2026 +0100
@@ -22,12 +22,14 @@
queue.xml
email.xml
error.xml
+ filestore.xml
[register]
model:
# register first for model char migration
model.ModelField
configuration.Configuration
+ filestore.Queue
translation.Translation
translation.TranslationSetStart
translation.TranslationSetSucceed
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/model/fields/binary.py
--- a/trytond/trytond/model/fields/binary.py Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/trytond/model/fields/binary.py Tue Mar 24 09:30:17 2026 +0100
@@ -3,9 +3,11 @@
import logging
-from sql import Column, Null
+from sql import Column, Literal, Null
+from sql.functions import CurrentTimestamp
from trytond.filestore import filestore
+from trytond.pool import Pool
from trytond.tools import cached_property
from trytond.transaction import Transaction
@@ -109,6 +111,34 @@
res.setdefault(i, default)
return res
+ def queue_for_removal(self, Model, name, ids):
+ pool = Pool()
+ Queue = pool.get('ir.filestore.queue')
+ queue = Queue.__table__()
+
+ assert name == self.name
+
+ if not self.file_id:
+ return
+
+ transaction = Transaction()
+ table = Model.__table__()
+ cursor = transaction.connection.cursor()
+
+ prefix = self.store_prefix
+ fileid_col = Column(table, self.file_id)
+ cursor.execute(*queue.insert(
+ [queue.create_date, queue.create_uid,
+ queue.file_id, queue.model,
+ queue.prefix, queue.field],
+ table.select(
+ CurrentTimestamp(), Literal(transaction.user),
+ fileid_col, Literal(Model.__name__),
+ Literal(prefix), Literal(name),
+ where=(SQL_OPERATORS['in'](table.id, ids)
+ & (fileid_col != Null)))
+ ))
+
def set(self, Model, name, ids, value, *args):
transaction = Transaction()
table = Model.__table__()
@@ -120,6 +150,7 @@
args = iter((ids, value) + args)
for ids, value in zip(args, args):
+ self.queue_for_removal(Model, name, ids)
if self.file_id:
columns = [Column(table, self.file_id), Column(table, name)]
values = [
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/model/modelstorage.py
--- a/trytond/trytond/model/modelstorage.py Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/trytond/model/modelstorage.py Tue Mar 24 09:30:17 2026 +0100
@@ -449,6 +449,11 @@
# Do not queue because records will be deleted
trigger.trigger_action(records)
+ record_ids = [r.id for r in records]
+ for fname, field in cls._fields.items():
+ if isinstance(field, fields.Binary):
+ field.queue_for_removal(cls, fname, record_ids)
+
# Increase transaction counter
transaction.counter += 1
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/tests/field_binary.py
--- a/trytond/trytond/tests/field_binary.py Thu Jan 09 18:53:09 2025 +0100
+++ b/trytond/trytond/tests/field_binary.py Tue Mar 24 09:30:17 2026 +0100
@@ -42,6 +42,9 @@
__name__ = 'test.binary_filestorage'
binary = fields.Binary('Binary', file_id='binary_id')
binary_id = fields.Char('Binary ID')
+ deleted_binary = fields.Binary(
+ 'Deleted Binary', file_id='deleted_binary_id', store_prefix='test')
+ deleted_binary_id = fields.Char('Deleted Binary ID')
def register(module):
diff -r a7acadd009dd -r 5bf24cd6c16a trytond/trytond/tests/test_field_binary.py
--- a/trytond/trytond/tests/test_field_binary.py Thu Jan 09 18:53:09
2025 +0100
+++ b/trytond/trytond/tests/test_field_binary.py Tue Mar 24 09:30:17
2026 +0100
@@ -6,6 +6,7 @@
from sql import Literal
from trytond import config
+from trytond.filestore import filestore
from trytond.model import fields
from trytond.model.exceptions import (
RequiredValidationError, SQLConstraintError)
@@ -28,9 +29,12 @@
def setUp(self):
super().setUp()
path = config.get('database', 'path')
+ days = config.get('attachment', 'retention_days', '')
dtemp = tempfile.mkdtemp()
config.set('database', 'path', dtemp)
+ config.set('attachment', 'retention_days', '-1')
self.addCleanup(config.set, 'database', 'path', path)
+ self.addCleanup(config.set, 'attachment', 'retention_days', days)
self.addCleanup(shutil.rmtree, dtemp)
@with_transaction()
@@ -269,3 +273,58 @@
copy, = Binary.copy([binary], default={'binary': b'bar'})
self.assertEqual(copy.binary, b'bar')
+
+ @with_transaction()
+ def test_set_to_None(self):
+ "Test setting a binary field with a filestore to None"
+ pool = Pool()
+ Queue = pool.get('ir.filestore.queue')
+ Binary = pool.get('test.binary_filestorage')
+
+ binary = Binary(deleted_binary=b'foo')
+ binary.save()
+ file_id = binary.deleted_binary_id
+
+ binary.deleted_binary = None
+ binary.save()
+ Queue.remove()
+
+ with self.assertRaises(IOError):
+ filestore.get(file_id, prefix='test')
+ self.assertEqual(Queue.search([]), [])
+
+ @with_transaction()
+ def test_delete(self):
+ "Test the delete method of binary fields"
+ pool = Pool()
+ Queue = pool.get('ir.filestore.queue')
+ Binary = pool.get('test.binary_filestorage')
+
+ binary = Binary(deleted_binary=b'foo')
+ binary.save()
+ file_id = binary.deleted_binary_id
+
+ Binary.delete([binary])
+ Queue.remove()
+
+ with self.assertRaises(IOError):
+ filestore.get(file_id, prefix='test')
+ self.assertEqual(Queue.search([]), [])
+
+ @with_transaction()
+ def test_delete_copy(self):
+ "Test the delete method of copied binary fields"
+ pool = Pool()
+ Queue = pool.get('ir.filestore.queue')
+ Binary = pool.get('test.binary_filestorage')
+
+ binary = Binary(deleted_binary=b'foo')
+ binary.save()
+ file_id = binary.deleted_binary_id
+ copy, = Binary.copy([binary])
+
+ Binary.delete([binary])
+ Queue.remove()
+
+ self.assertTrue(filestore.get(file_id, prefix='test'))
+ self.assertEqual(Queue.search([]), [])