• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla-releng / balrog / #5186

05 Feb 2026 01:34PM UTC coverage: 89.901% (+0.001%) from 89.9%
#5186

Pull #3659

circleci

Eijebong
Bug 2013441 - Use orjson to serialize/deserialize objects for the redis cache

The main thing here is that Blobs cannot be deserialized from JSON
directly so we have to provide a specific loader to the redis cache,
which is why we weren't using orjson in the first place. The commit is
mostly plumbing to pass the redis_loads method through all of the right
cache layers/factories.

I originally thought about making this less invasive and just returning
the value as is from the cache and let the caller decide what to do with
what it got back from the cache (for blobs it could either get a dict or
a Blob object based on whether the cache was hit or not, which could
easily be detected by always calling createBlob on the result and
returning early if it was already a Blob). I went back on this because
it felt more fragile and error prone than doing the conversion
explicitly. It's still no perfect still those cache definitions aren't
tested properly and short of deactivating the LRU cache they're hard to
test locally.

I tested this by disabling the LRU cache completely and using an `assert
cache_value == value_getter()` in the cache hit path then hitting
`/update/3/Firefox/42.0/0/Linux_x86_64-gcc3/en-US/release/None/default/default/update.xml`
on the public API. Everything worked fine.

All the other public caches use trivial objects so we don't need special
loaders for them. The admin app doesn't have redis configured.

I removed test_release_blobs_can_log_after_pickling since it's
irrelevant now but decided against reverting
e8cb2ec09 entirely because getting a
logger once per class instead of once per object still sounds like an
improvement we'd want and the other test to deepcopy is also still
relevant for caches using `make_copies`.
Pull Request #3659: Bug 2013441 - Use orjson to serialize/deserialize objects for the redis cache

2165 of 2546 branches covered (85.04%)

Branch coverage included in aggregate %.

11 of 12 new or added lines in 1 file covered. (91.67%)

3 existing lines in 1 file now uncovered.

5731 of 6237 relevant lines covered (91.89%)

0.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.62
/src/auslib/util/cache.py
1
import orjson
1✔
2
import time
1✔
3
from copy import deepcopy
1✔
4

5
from repoze.lru import ExpiringLRUCache
1✔
6

7
from auslib.util.statsd import statsd
1✔
8

9
uncached_sentinel = object()
1✔
10

11

12
class MaybeCacher(object):
1✔
13
    """MaybeCacher is a very simple wrapper to work around the fact that we
14
    have two consumers of the auslib library (admin app, non-admin app) that
15
    require cache different things. Most notably, the non-admin app caches
16
    blobs and blob versions, while the admin app only caches blobs (because
17
    blob versions can change frequently). This class is intended to be
18
    instantiated as a global object, and then have caches created by consumers
19
    through calls to make_cache. Consumers that make changes (ie: the admin
20
    app) generally also set make_copies to True to get the cache to copy values
21
    on get/put to avoid the possibility of accidental cache pollution. For
22
    performance reasons, this should be disabled when not necessary.
23

24
    If the cache given to get/put/clear/invalidate doesn't exist, these methods
25
    are essentially no-ops. In a world where bug 1109295 is fixed, we might
26
    only need to handle the caching case."""
27

28
    def __init__(self):
1✔
29
        self.caches = {}
1✔
30
        self._make_copies = False
1✔
31
        # Ideally, we'd take in the cache class as an argument to this constructor.
32
        # Due to the way that everything in Balrog is initialized, this doesn't
33
        # work. We also can't use Flask in here for similar reasons, so we can't
34
        # pull it from application context. It's for these reasons we need to make
35
        # this customizable as a property. If that's all that mattered, we
36
        # could just set the property to a class. However, to support Redis we
37
        # _also_ need a Redis client passed in. The sanest way to do this is
38
        # to allow the caller to provide it in a closure, hence we end up
39
        # making this a callable instead of a simple class.
40
        self._factory = lambda _, maxsize, timeout, redis_loads=None: ExpiringLRUCache(maxsize, timeout)
1✔
41

42
    @property
1✔
43
    def factory(self):
1✔
44
        return self._factory
1✔
45

46
    @factory.setter
1✔
47
    def factory(self, value):
1✔
48
        if not callable(value):
1!
49
            raise ValueError("factory must be callable!")
×
50
        self._factory = value
1✔
51

52
    @property
1✔
53
    def make_copies(self):
1✔
54
        return self._make_copies
1✔
55

56
    @make_copies.setter
1✔
57
    def make_copies(self, value):
1✔
58
        if value not in (True, False):
1!
59
            raise TypeError("make_copies must be True or False")
×
60
        self._make_copies = value
1✔
61

62
    def make_cache(self, name, maxsize, timeout, redis_loads=None):
1✔
63
        if name in self.caches:
1!
64
            raise Exception()
×
65

66
        self.caches[name] = self.factory(name, maxsize, timeout, redis_loads)
1✔
67

68
    def reset(self):
1✔
69
        self.caches.clear()
1✔
70

71
    def get(self, name, key, value_getter=None):
1✔
72
        """Returns the value of the specified key from the named cache.
73
        If value_getter is provided and no cache is found, or no value is
74
        found for the key, the return value of value_getter will be returned
75
        instead."""
76

77
        if name not in self.caches:
1✔
78
            if callable(value_getter):
1✔
79
                return value_getter()
1✔
80
            else:
81
                return None
1✔
82

83
        value = None
1✔
84
        cached_value = self.caches[name].get(key, uncached_sentinel)
1✔
85
        # If we got something other than a sentinel value, the key was in the cache, and we should return it
86
        if cached_value != uncached_sentinel:
1✔
87
            value = cached_value
1✔
88
            statsd.incr(f"cache.{name}.hits")
1✔
89
        else:
90
            # If we know how to look up the value, go do it, cache it, and return it
91
            if callable(value_getter):
1✔
92
                value = value_getter()
1✔
93
                self.put(name, key, value)
1✔
94
            statsd.incr(f"cache.{name}.misses")
1✔
95

96
        if self.make_copies:
1✔
97
            return deepcopy(value)
1✔
98
        else:
99
            return value
1✔
100

101
    def put(self, name, key, value):
1✔
102
        if name not in self.caches:
1✔
103
            return
1✔
104

105
        if self.make_copies:
1✔
106
            value = deepcopy(value)
1✔
107
        return self.caches[name].put(key, value)
1✔
108

109
    def clear(self, name=None):
1✔
110
        if name and name not in self.caches:
×
111
            return
×
112

113
        if not name:
×
114
            for c in self.caches.values():
×
115
                c.clear()
×
116
        else:
117
            self.caches[name].clear()
×
118

119
    def invalidate(self, name, key):
1✔
120
        if name not in self.caches:
1✔
121
            return
1✔
122

123
        self.caches[name].invalidate(key)
1✔
124

125

126
class RedisCache:
1✔
127
    """A thin wrapper around the redis client to expose a similar interface
128
    as ExpiringLRUCache. Unlike ExpiringLRUCache, redis does not support
129
    non-trivial objects, so objects are jsonified before storage and parsed
130
    upon retrieval.
131

132
    This cache can be used on its own, but ideally it is only used through a
133
    TwoLayerCache (see below).
134
    """
135

136
    def __init__(self, redis, name, timeout, redis_loads=None):
1✔
137
        self._name = name
1✔
138
        self._redis = redis
1✔
139
        self._loads = redis_loads or orjson.loads
1✔
140
        # redis and repoze calculate expiry slightly differently; a timeout of
141
        # 5 seconds with repoze ends up being 6 seconds in redis. this really
142
        # doesn't matter...but it's better to be consistent than not, and redis'
143
        # behaviour is slightly confusing, so we make this small improvement
144
        # since we're wrapping it anyways.
145
        self._timeout = timeout - 1
1✔
146
        self.lookups = 0
1✔
147
        self.hits = 0
1✔
148
        self.misses = 0
1✔
149

150
    def fullkey(self, key):
1✔
151
        return f"v2-{self._name}-{key}"
1✔
152

153
    def get(self, key, default=None):
1✔
154
        self.lookups += 1
1✔
155
        value = self._redis.get(self.fullkey(key))
1✔
156
        if value is not None:
1✔
157
            self.hits += 1
1✔
158
            return self._loads(value)
1✔
159

160
        self.misses += 1
1✔
161
        return default
1✔
162

163
    def put(self, key, value):
1✔
164
        self._redis.setex(self.fullkey(key), self._timeout, orjson.dumps(value, option=orjson.OPT_NON_STR_KEYS))
1✔
165

166
    def clear(self):
1✔
NEW
167
        self._redis.delete(*self._redis.keys(f"v2-{self._name}"))
×
168

169
    def invalidate(self, key):
1✔
170
        self._redis.delete(self.fullkey(key))
×
171

172
    def remaining_timeout(self, key):
1✔
173
        absolute_timeout = self._redis.expiretime(self.fullkey(key))
1✔
174
        return absolute_timeout - time.time()
1✔
175

176

177
class TwoLayerCache:
1✔
178
    """A cache that wraps both a RedisCache and ExpiringLRUCache. The
179
    former is treated as authoritative, while the latter is used to minimize
180
    unnecessary fetches from Redis. This design allows caches to be shared
181
    across many pods while minimizing the perf impact of having an off-machine
182
    cache."""
183

184
    def __init__(self, redis, name, maxsize, timeout, redis_loads=None):
1✔
185
        self._redis_cache = RedisCache(redis, name, timeout, redis_loads)
1✔
186
        self._lru_cache = ExpiringLRUCache(maxsize, timeout)
1✔
187
        self.lookups = 0
1✔
188
        self.hits = 0
1✔
189
        self.misses = 0
1✔
190

191
    def get(self, key, default=None):
1✔
192
        self.lookups += 1
1✔
193
        value = self._lru_cache.get(key, default)
1✔
194
        if value == default:
1✔
195
            value = self._redis_cache.get(key, default)
1✔
196
            # ensure the LRU cache timeout matches the one in redis
197
            # this is important to ensure that when new pods spin up that
198
            # they will not cache keys for longer than redis
199
            # this ensures that we don't have to wait for multiple caches
200
            # to expire to refresh data.
201
            # in practice there will be a very small difference between the
202
            # timeouts, because the value we pass to put here is a relative
203
            # timeout in seconds, which the lru cache recalculates against
204
            # `time.time()`. this small (probably 1s in most cases) difference
205
            # is unlikely to be problematic in practice.
206
            self._lru_cache.put(key, value, self._redis_cache.remaining_timeout(key))
1✔
207
            if value == default:
1✔
208
                self.misses += 1
1✔
209
            else:
210
                self.hits += 1
1✔
211
        else:
212
            self.hits += 1
1✔
213

214
        return value
1✔
215

216
    def put(self, key, value):
1✔
217
        self._redis_cache.put(key, value)
1✔
218
        self._lru_cache.put(key, value)
1✔
219

220
    def clear(self):
1✔
UNCOV
221
        self._redis_cache.clear()
×
UNCOV
222
        self._lru_cache.clear()
×
223

224
    def invalidate(self, key):
1✔
225
        self._redis_cache.invalidate(key)
×
UNCOV
226
        self._lru_cache.invalidate(key)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc