[FIX] openerp.api.Environment: move recomputation todos into a shared object
authorRaphael Collet <rco@openerp.com>
Wed, 3 Sep 2014 12:33:14 +0000 (14:33 +0200)
committerRaphael Collet <rco@openerp.com>
Thu, 4 Sep 2014 08:18:55 +0000 (10:18 +0200)
This fixes a bug which is usually triggered in module account_followup, but
does not occur deterministically.  Some recomputations of computed fields are
apparently missing.  Environment objects containing recomputations todos and
kept alive by a WeakSet, are removed by the Python garbage collector before
recomputation takes place.  We fix the bug by moving the recomputation todos in
a non-weakref'ed object.

openerp/api.py
openerp/models.py

index 38fd393..83190f8 100644 (file)
@@ -59,6 +59,7 @@ __all__ = [
 ]
 
 import logging
+import operator
 
 from inspect import currentframe, getargspec
 from collections import defaultdict, MutableMapping
@@ -685,7 +686,7 @@ class Environment(object):
             yield
         else:
             try:
-                cls._local.environments = WeakSet()
+                cls._local.environments = Environments()
                 yield
             finally:
                 release_local(cls._local)
@@ -708,8 +709,6 @@ class Environment(object):
         self.prefetch = defaultdict(set)    # {model_name: set(id), ...}
         self.computed = defaultdict(set)    # {field: set(id), ...}
         self.dirty = set()                  # set(record)
-        self.todo = {}                      # {field: records, ...}
-        self.mode = env.mode if env else Mode()
         self.all = envs
         envs.add(self)
         return self
@@ -746,14 +745,14 @@ class Environment(object):
 
     @contextmanager
     def _do_in_mode(self, mode):
-        if self.mode.value:
+        if self.all.mode:
             yield
         else:
             try:
-                self.mode.value = mode
+                self.all.mode = mode
                 yield
             finally:
-                self.mode.value = False
+                self.all.mode = False
                 self.dirty.clear()
 
     def do_in_draft(self):
@@ -765,7 +764,7 @@ class Environment(object):
     @property
     def in_draft(self):
         """ Return whether we are in draft mode. """
-        return bool(self.mode.value)
+        return bool(self.all.mode)
 
     def do_in_onchange(self):
         """ Context-switch to 'onchange' draft mode, which is a specialized
@@ -776,7 +775,7 @@ class Environment(object):
     @property
     def in_onchange(self):
         """ Return whether we are in 'onchange' draft mode. """
-        return self.mode.value == 'onchange'
+        return self.all.mode == 'onchange'
 
     def invalidate(self, spec):
         """ Invalidate some fields for some records in the cache of all
@@ -788,7 +787,7 @@ class Environment(object):
         """
         if not spec:
             return
-        for env in list(iter(self.all)):
+        for env in list(self.all):
             c = env.cache
             for field, ids in spec:
                 if ids is None:
@@ -801,12 +800,49 @@ class Environment(object):
 
     def invalidate_all(self):
         """ Clear the cache of all environments. """
-        for env in list(iter(self.all)):
+        for env in list(self.all):
             env.cache.clear()
             env.prefetch.clear()
             env.computed.clear()
             env.dirty.clear()
 
+    def field_todo(self, field):
+        """ Check whether `field` must be recomputed, and returns a recordset
+            with all records to recompute for `field`.
+        """
+        if field in self.all.todo:
+            return reduce(operator.or_, self.all.todo[field])
+
+    def check_todo(self, field, record):
+        """ Check whether `field` must be recomputed on `record`, and if so,
+            returns the corresponding recordset to recompute.
+        """
+        for recs in self.all.todo.get(field, []):
+            if recs & record:
+                return recs
+
+    def add_todo(self, field, records):
+        """ Mark `field` to be recomputed on `records`. """
+        recs_list = self.all.todo.setdefault(field, [])
+        recs_list.append(records)
+
+    def remove_todo(self, field, records):
+        """ Mark `field` as recomputed on `records`. """
+        recs_list = self.all.todo.get(field, [])
+        if records in recs_list:
+            recs_list.remove(records)
+            if not recs_list:
+                del self.all.todo[field]
+
+    def has_todo(self):
+        """ Return whether some fields must be recomputed. """
+        return bool(self.all.todo)
+
+    def get_todo(self):
+        """ Return a pair `(field, records)` to recompute. """
+        for field, recs_list in self.all.todo.iteritems():
+            return field, recs_list[0]
+
     def check_cache(self):
         """ Check the cache consistency. """
         # make a full copy of the cache, and invalidate it
@@ -835,9 +871,20 @@ class Environment(object):
             raise Warning('Invalid cache for fields\n' + pformat(invalids))
 
 
-class Mode(object):
-    """ A mode flag shared among environments. """
-    value = False           # False, True (draft) or 'onchange' (onchange draft)
+class Environments(object):
+    """ A common object for all environments in a request. """
+    def __init__(self):
+        self.envs = WeakSet()           # weak set of environments
+        self.todo = {}                  # recomputations {field: [records]}
+        self.mode = False               # flag for draft/onchange
+
+    def add(self, env):
+        """ Add the environment `env`. """
+        self.envs.add(env)
+
+    def __iter__(self):
+        """ Iterate over environments. """
+        return iter(self.envs)
 
 
 # keep those imports here in order to handle cyclic dependencies correctly
index 909de45..121186c 100644 (file)
@@ -3149,9 +3149,9 @@ class BaseModel(object):
         if self.env.in_draft:
             # we may be doing an onchange, do not prefetch other fields
             pass
-        elif field in self.env.todo:
+        elif self.env.field_todo(field):
             # field must be recomputed, do not prefetch records to recompute
-            records -= self.env.todo[field]
+            records -= self.env.field_todo(field)
         elif self._columns[field.name]._prefetch:
             # here we can optimize: prefetch all classic and many2one fields
             fnames = set(fname
@@ -5498,42 +5498,34 @@ class BaseModel(object):
         """ If `field` must be recomputed on some record in `self`, return the
             corresponding records that must be recomputed.
         """
-        for env in [self.env] + list(iter(self.env.all)):
-            if env.todo.get(field) and env.todo[field] & self:
-                return env.todo[field]
+        return self.env.check_todo(field, self)
 
     def _recompute_todo(self, field):
         """ Mark `field` to be recomputed. """
-        todo = self.env.todo
-        todo[field] = (todo.get(field) or self.browse()) | self
+        self.env.add_todo(field, self)
 
     def _recompute_done(self, field):
-        """ Mark `field` as being recomputed. """
-        todo = self.env.todo
-        if field in todo:
-            recs = todo.pop(field) - self
-            if recs:
-                todo[field] = recs
+        """ Mark `field` as recomputed. """
+        self.env.remove_todo(field, self)
 
     @api.model
     def recompute(self):
         """ Recompute stored function fields. The fields and records to
             recompute have been determined by method :meth:`modified`.
         """
-        for env in list(iter(self.env.all)):
-            while env.todo:
-                field, recs = next(env.todo.iteritems())
-                # evaluate the fields to recompute, and save them to database
-                for rec, rec1 in zip(recs, recs.with_context(recompute=False)):
-                    try:
-                        values = rec._convert_to_write({
-                            f.name: rec[f.name] for f in field.computed_fields
-                        })
-                        rec1._write(values)
-                    except MissingError:
-                        pass
-                # mark the computed fields as done
-                map(recs._recompute_done, field.computed_fields)
+        while self.env.has_todo():
+            field, recs = self.env.get_todo()
+            # evaluate the fields to recompute, and save them to database
+            for rec, rec1 in zip(recs, recs.with_context(recompute=False)):
+                try:
+                    values = rec._convert_to_write({
+                        f.name: rec[f.name] for f in field.computed_fields
+                    })
+                    rec1._write(values)
+                except MissingError:
+                    pass
+            # mark the computed fields as done
+            map(recs._recompute_done, field.computed_fields)
 
     #
     # Generic onchange method