[FIX] models: use transitive triggers instead of recursion

On average, this reduces the total time spent in method `modified` by
half (times measured on invoice creation and post).
This commit is contained in:
Raphael Collet
2019-09-09 13:18:48 +00:00
parent 3d2df88ffd
commit cfb08e87b0
6 changed files with 102 additions and 87 deletions
+2 -2
View File
@@ -433,8 +433,8 @@ class SaleOrderLine(models.Model):
# Another possibility is to add on product.product a one2many to sale.order.line 'order_line_ids',
# and then add the depends @api.depends('discount_line_product_id.order_line_ids'),
# but I am not sure this will as efficient as the below.
def modified(self, fnames, modified=None, create=False):
super(SaleOrderLine, self).modified(fnames, modified=modified)
def modified(self, fnames, create=False):
super(SaleOrderLine, self).modified(fnames, create)
if 'product_id' in fnames:
Program = self.env['sale.coupon.program']
field_order_count = Program._fields['order_count']
+2 -6
View File
@@ -608,7 +608,7 @@ class Attachment(models.Model):
rec.name = self.env[rec.res_model].browse(rec.res_id).display_name
# DLE P55: `test_cache_invalidation`
def modified(self, fnames, modified=None, create=False):
def modified(self, fnames, create=False):
if not self:
return
comodel = self.env[self.res_model]
@@ -617,11 +617,7 @@ class Attachment(models.Model):
record = comodel.browse(self.res_id)
self.env.cache.invalidate([(field, record._ids)])
record.modified(['attachment_ids'])
if modified is None:
modified = {field: record}
else:
modified[field] = modified.get(field, record) | record
return super(Attachment, self).modified(fnames, modified=modified)
return super(Attachment, self).modified(fnames, create)
class AttachmentHost(models.Model):
+7 -5
View File
@@ -636,14 +636,16 @@ class Environment(Mapping):
""" Return whether ``field`` must be computed on ``record``. """
return record.id in self.all.tocompute.get(field, ())
def not_to_compute(self, field, records):
""" Return the subset of ``records`` for which ``field`` must not be computed. """
ids = self.all.tocompute.get(field, ())
return records.browse(id_ for id_ in records._ids if id_ not in ids)
def add_to_compute(self, field, records):
""" Mark ``field`` to be computed on ``records``, return newly added records. """
""" Mark ``field`` to be computed on ``records``. """
if not records:
return records
ids = self.all.tocompute[field]
added_ids = [id_ for id_ in records._ids if id_ not in ids]
ids.update(added_ids)
return records.browse(added_ids)
self.all.tocompute[field].update(records._ids)
def remove_to_compute(self, field, records):
""" Mark ``field`` as computed on ``records``. """
+15 -46
View File
@@ -692,64 +692,33 @@ class Field(MetaField('DummyField', (object,), {})):
#
# Setup of field triggers
#
# The triggers of a field F is a tree that contains the fields that depend
# on F, together with the fields to inverse to find out which records to
# recompute.
#
# For instance, assume that G depends on F, H depends on X.F, I depends on
# W.X.F, and J depends on Y.F. The triggers of F will be the tree:
#
# [G]
# X/ \Y
# [H] [J]
# W/
# [I]
#
# This tree provides perfect support for the trigger mechanism:
# when F is # modified on records,
# - mark G to recompute on records,
# - mark H to recompute on inverse(X, records),
# - mark I to recompute on inverse(W, inverse(X, records)),
# - mark J to recompute on inverse(Y, records).
def setup_triggers(self, model):
def add_trigger(field, path):
""" add a trigger on field to recompute self """
field_model = model.env[field.model_name]
# trigger computations depending on one2many fields only at creation
nodes = [field_model._field_triggers_create.setdefault(field, {})]
if (field.type != 'one2many') or not field_model._field_inverses[field]:
nodes.append(field_model._field_triggers.setdefault(field, {}))
for node in nodes:
for f in reversed(path):
node = node.setdefault(f, {})
n = node.setdefault(None, [])
if self not in n: n.append(self)
def resolve_depends(self, model):
""" Return the dependencies of `self` as a collection of field tuples. """
for dotnames in self.depends:
field_seq = []
field_model = model
path = [] # fields from model to field_model
for fname in dotnames.split('.'):
field = field_model._fields[fname]
for index, fname in enumerate(dotnames.split('.')):
if model._transient and not field_model._transient:
# modifying fields on regular models should not trigger
# recomputations of fields on transient models
break
# Do not make self trigger itself
# e.g. `fields.One2many('stock.move.line', 'move_id', domain=[('product_qty', '=', 0.0)])`
# will have 'move_line_nosuggest_ids.product_qty' as a dependency
if (field is not self) or path:
add_trigger(field, path)
if (field is self) and path:
field = field_model._fields[fname]
if field is self and index:
self.recursive = True
path.append(field)
field_seq.append(field)
# do not make self trigger itself: for instance, a one2many
# field line_ids with domain [('foo', ...)] will have
# 'line_ids.foo' as a dependency
if not (field is self and not index):
yield tuple(field_seq)
if field.type in ('one2many', 'many2many'):
for inv_field in field_model._field_inverses[field]:
add_trigger(inv_field, path)
yield tuple(field_seq) + (inv_field,)
field_model = model.env.get(field.comodel_name)
+33 -28
View File
@@ -2587,8 +2587,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
# 4. initialize more field metadata
cls._field_computed = {} # fields computed with the same method
cls._field_inverses = Collector() # inverse fields for related fields
cls._field_triggers = {} # {depfield: {depfield: {...}, None: [compute_fields]}}
cls._field_triggers_create = {} # {depfield: {depfield: {...}, None: [compute_fields]}}
cls._setup_done = True
@@ -2633,13 +2631,27 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
""" Setup recomputation triggers, and complete the model setup. """
cls = type(self)
if isinstance(self, Model):
# set up field triggers (on database-persisted models only)
for field in cls._fields.values():
# dependencies of custom fields may not exist; ignore that case
exceptions = (Exception,) if field.manual else ()
with tools.ignore(*exceptions):
field.setup_triggers(self)
# The triggers of a field F is a tree that contains the fields that
# depend on F, together with the fields to inverse to find out which
# records to recompute.
#
# For instance, assume that G depends on F, H depends on X.F, I depends
# on W.X.F, and J depends on Y.F. The triggers of F will be the tree:
#
# [G]
# X/ \Y
# [H] [J]
# W/
# [I]
#
# This tree provides perfect support for the trigger mechanism:
# when F is # modified on records,
# - mark G to recompute on records,
# - mark H to recompute on inverse(X, records),
# - mark I to recompute on inverse(W, inverse(X, records)),
# - mark J to recompute on inverse(Y, records).
cls._field_triggers = cls.pool.field_triggers
cls._field_triggers_create = cls.pool.field_triggers_create
# register constraints and onchange methods
cls._init_constraints_onchanges()
@@ -5470,14 +5482,13 @@ Record ids: %(records)s
[(invf, None) for f in fields for invf in self._field_inverses[f]]
self.env.cache.invalidate(spec)
def modified(self, fnames, modified=None, create=False):
def modified(self, fnames, create=False):
""" Notify that fields have been modified on ``self``. This invalidates
the cache, and prepares the recomputation of stored function fields
(new-style fields only).
:param fnames: iterable of field names that have been modified on
records ``self``
:param modified: don't use this
:param create: whether modified is called in the context of record creation
"""
if not self or not fnames:
@@ -5493,38 +5504,32 @@ Record ids: %(records)s
if node:
trigger_tree_merge(tree, node)
if tree:
self.sudo()._modified_triggers(tree, modified=modified)
self.sudo()._modified_triggers(tree)
def _modified_triggers(self, tree, modified=None):
def _modified_triggers(self, tree):
""" Process a tree of field triggers on ``self``. """
if not self:
return
for key, val in tree.items():
if key is None:
# val is a list of fields to mark as todo
todo = defaultdict(list)
modified = modified or {}
for field in val:
records = self - self.env.protected(field)
if modified and field in modified:
records -= modified[field]
if not records:
continue
# Dont force the recomputation of compute fields which are
# not stored as this is not really necessary.
if field.compute and field.store:
if field.recursive:
added = self.env.not_to_compute(field, records)
self.env.add_to_compute(field, records)
else:
if field.recursive:
added = self & self.env.cache.get_records(self, field)
self.env.cache.invalidate([(field, records._ids)])
# recursively trigger recomputation of field's dependents
todo[records].append(field.name)
for records, fieldnames in todo.items():
for fname in fieldnames:
if records._fields[fname] in modified:
modified[records._fields[fname]] += records
else:
modified[records._fields[fname]] = records
records.modified(fieldnames, modified=modified)
if field.recursive:
added.modified([field.name])
else:
# val is another tree of dependencies
model = self.env[key.model_name]
@@ -5539,9 +5544,9 @@ Record ids: %(records)s
records = model.browse(rec_ids)
else:
try:
records = self.mapped(invf.name)
records = self[invf.name]
except MissingError:
records = self.exists().mapped(invf.name)
records = self.exists()[invf.name]
# TODO: find a better fix
if key.model_name == records._name:
@@ -5555,7 +5560,7 @@ Record ids: %(records)s
if new_records:
cache_records = self.env.cache.get_records(model, key)
records |= cache_records.filtered(lambda r: set(r[key.name]._ids) & set(self._ids))
records._modified_triggers(val, modified=modified)
records._modified_triggers(val)
@api.model
def recompute(self, fnames=None, records=None):
+43
View File
@@ -247,6 +247,49 @@ class Registry(Mapping):
for model in models:
model._setup_fields()
# determine field dependencies
Model = odoo.models.Model
dependencies = {
field: set(field.resolve_depends(model))
for model in models
if isinstance(model, Model)
for field in model._fields.values()
}
# determine transitive dependencies
def transitive_dependencies(field, seen=[]):
if field in seen:
return
for seq1 in dependencies[field]:
yield seq1
for seq2 in transitive_dependencies(seq1[-1], seen + [field]):
yield concat(seq1[:-1], seq2)
def concat(seq1, seq2):
if seq1 and seq2:
f1, f2 = seq1[-1], seq2[0]
if f1.type == 'one2many' and f2.type == 'many2one' and \
f1.model_name == f2.comodel_name and f1.inverse_name == f2.name:
return concat(seq1[:-1], seq2[1:])
return seq1 + seq2
# determine triggers based on transitive dependencies
triggers = {}
for field in dependencies:
for path in transitive_dependencies(field):
if path:
tree = triggers
for label in reversed(path):
tree = tree.setdefault(label, {})
tree.setdefault(None, set()).add(field)
self.field_triggers_create = triggers
self.field_triggers = {
field: tree
for field, tree in triggers.items()
if not (field.type == 'one2many' and field.inverse)
}
for model in models:
model._setup_complete()