1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4# decorator makes wrappers that have the same API as their wrapped function
5from collections import Counter, defaultdict
6from decorator import decorator
7from inspect import signature
8import logging
9
10unsafe_eval = eval
11
12_logger = logging.getLogger(__name__)
13
14
15class ormcache_counter(object):
16    """ Statistic counters for cache entries. """
17    __slots__ = ['hit', 'miss', 'err']
18
19    def __init__(self):
20        self.hit = 0
21        self.miss = 0
22        self.err = 0
23
24    @property
25    def ratio(self):
26        return 100.0 * self.hit / (self.hit + self.miss or 1)
27
28# statistic counters dictionary, maps (dbname, modelname, method) to counter
29STAT = defaultdict(ormcache_counter)
30
31
32class ormcache(object):
33    """ LRU cache decorator for model methods.
34    The parameters are strings that represent expressions referring to the
35    signature of the decorated method, and are used to compute a cache key::
36
37        @ormcache('model_name', 'mode')
38        def _compute_domain(self, model_name, mode="read"):
39            ...
40
41    For the sake of backward compatibility, the decorator supports the named
42    parameter `skiparg`::
43
44        @ormcache(skiparg=1)
45        def _compute_domain(self, model_name, mode="read"):
46            ...
47
48    Methods implementing this decorator should never return a Recordset,
49    because the underlying cursor will eventually be closed and raise a
50    `psycopg2.OperationalError`.
51    """
52    def __init__(self, *args, **kwargs):
53        self.args = args
54        self.skiparg = kwargs.get('skiparg')
55
56    def __call__(self, method):
57        self.method = method
58        self.determine_key()
59        lookup = decorator(self.lookup, method)
60        lookup.clear_cache = self.clear
61        return lookup
62
63    def determine_key(self):
64        """ Determine the function that computes a cache key from arguments. """
65        if self.skiparg is None:
66            # build a string that represents function code and evaluate it
67            args = str(signature(self.method))[1:-1]
68            if self.args:
69                code = "lambda %s: (%s,)" % (args, ", ".join(self.args))
70            else:
71                code = "lambda %s: ()" % (args,)
72            self.key = unsafe_eval(code)
73        else:
74            # backward-compatible function that uses self.skiparg
75            self.key = lambda *args, **kwargs: args[self.skiparg:]
76
77    def lru(self, model):
78        counter = STAT[(model.pool.db_name, model._name, self.method)]
79        return model.pool._Registry__cache, (model._name, self.method), counter
80
81    def lookup(self, method, *args, **kwargs):
82        d, key0, counter = self.lru(args[0])
83        key = key0 + self.key(*args, **kwargs)
84        try:
85            r = d[key]
86            counter.hit += 1
87            return r
88        except KeyError:
89            counter.miss += 1
90            value = d[key] = self.method(*args, **kwargs)
91            return value
92        except TypeError:
93            _logger.warning("cache lookup error on %r", key, exc_info=True)
94            counter.err += 1
95            return self.method(*args, **kwargs)
96
97    def clear(self, model, *args):
98        """ Clear the registry cache """
99        model.pool._clear_cache()
100
101
102class ormcache_context(ormcache):
103    """ This LRU cache decorator is a variant of :class:`ormcache`, with an
104    extra parameter ``keys`` that defines a sequence of dictionary keys. Those
105    keys are looked up in the ``context`` parameter and combined to the cache
106    key made by :class:`ormcache`.
107    """
108    def __init__(self, *args, **kwargs):
109        super(ormcache_context, self).__init__(*args, **kwargs)
110        self.keys = kwargs['keys']
111
112    def determine_key(self):
113        """ Determine the function that computes a cache key from arguments. """
114        assert self.skiparg is None, "ormcache_context() no longer supports skiparg"
115        # build a string that represents function code and evaluate it
116        sign = signature(self.method)
117        args = str(sign)[1:-1]
118        cont_expr = "(context or {})" if 'context' in sign.parameters else "self._context"
119        keys_expr = "tuple(%s.get(k) for k in %r)" % (cont_expr, self.keys)
120        if self.args:
121            code = "lambda %s: (%s, %s)" % (args, ", ".join(self.args), keys_expr)
122        else:
123            code = "lambda %s: (%s,)" % (args, keys_expr)
124        self.key = unsafe_eval(code)
125
126
127class ormcache_multi(ormcache):
128    """ This LRU cache decorator is a variant of :class:`ormcache`, with an
129    extra parameter ``multi`` that gives the name of a parameter. Upon call, the
130    corresponding argument is iterated on, and every value leads to a cache
131    entry under its own key.
132    """
133    def __init__(self, *args, **kwargs):
134        super(ormcache_multi, self).__init__(*args, **kwargs)
135        self.multi = kwargs['multi']
136
137    def determine_key(self):
138        """ Determine the function that computes a cache key from arguments. """
139        assert self.skiparg is None, "ormcache_multi() no longer supports skiparg"
140        assert isinstance(self.multi, str), "ormcache_multi() parameter multi must be an argument name"
141
142        super(ormcache_multi, self).determine_key()
143
144        # key_multi computes the extra element added to the key
145        sign = signature(self.method)
146        args = str(sign)[1:-1]
147        code_multi = "lambda %s: %s" % (args, self.multi)
148        self.key_multi = unsafe_eval(code_multi)
149
150        # self.multi_pos is the position of self.multi in args
151        self.multi_pos = list(sign.parameters).index(self.multi)
152
153    def lookup(self, method, *args, **kwargs):
154        d, key0, counter = self.lru(args[0])
155        base_key = key0 + self.key(*args, **kwargs)
156        ids = self.key_multi(*args, **kwargs)
157        result = {}
158        missed = []
159
160        # first take what is available in the cache
161        for i in ids:
162            key = base_key + (i,)
163            try:
164                result[i] = d[key]
165                counter.hit += 1
166            except Exception:
167                counter.miss += 1
168                missed.append(i)
169
170        if missed:
171            # call the method for the ids that were not in the cache; note that
172            # thanks to decorator(), the multi argument will be bound and passed
173            # positionally in args.
174            args = list(args)
175            args[self.multi_pos] = missed
176            result.update(method(*args, **kwargs))
177
178            # store those new results back in the cache
179            for i in missed:
180                key = base_key + (i,)
181                d[key] = result[i]
182
183        return result
184
185
186class dummy_cache(object):
187    """ Cache decorator replacement to actually do no caching. """
188    def __init__(self, *l, **kw):
189        pass
190
191    def __call__(self, fn):
192        fn.clear_cache = self.clear
193        return fn
194
195    def clear(self, *l, **kw):
196        pass
197
198
199def log_ormcache_stats(sig=None, frame=None):
200    """ Log statistics of ormcache usage by database, model, and method. """
201    from odoo.modules.registry import Registry
202    import threading
203
204    me = threading.currentThread()
205    me_dbname = getattr(me, 'dbname', 'n/a')
206
207    for dbname, reg in sorted(Registry.registries.d.items()):
208        # set logger prefix to dbname
209        me.dbname = dbname
210        entries = Counter(k[:2] for k in reg._Registry__cache.d)
211        # show entries sorted by model name, method name
212        for key in sorted(entries, key=lambda key: (key[0], key[1].__name__)):
213            model, method = key
214            stat = STAT[(dbname, model, method)]
215            _logger.info(
216                "%6d entries, %6d hit, %6d miss, %6d err, %4.1f%% ratio, for %s.%s",
217                entries[key], stat.hit, stat.miss, stat.err, stat.ratio, model, method.__name__,
218            )
219
220    me.dbname = me_dbname
221
222
223def get_cache_key_counter(bound_method, *args, **kwargs):
224    """ Return the cache, key and stat counter for the given call. """
225    model = bound_method.__self__
226    ormcache = bound_method.clear_cache.__self__
227    cache, key0, counter = ormcache.lru(model)
228    key = key0 + ormcache.key(model, *args, **kwargs)
229    return cache, key, counter
230
231# For backward compatibility
232cache = ormcache
233