Skip to content

Commit 5fd440e

Browse files
feat(cache): add opt-in cache_none to dojo_settings_cache
By default a None result is not cached (so the next call retries). Pass cache_none=True for getters where None is a legitimate steady-state answer (e.g. "no default row configured"): it caches the None via a sentinel too, so a missing row costs one DB read per request/task instead of one per call site. Safe only when the getter is signal-invalidated on the row's create/save.
1 parent e0be2d6 commit 5fd440e

1 file changed

Lines changed: 28 additions & 8 deletions

File tree

dojo/caching.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
In-process read-through cache for global, low-cardinality singleton config.
33
44
A single per-thread **L1** tier resolves a getter L1 → DB: a hit wins; a ``None``
5-
result means "not cached, compute it" and is never stored. This is deliberately
6-
simple — it is only for global, user-INDEPENDENT, signal-invalidated singletons
7-
(feature flags, system settings, and the like), never per-user or per-object data.
5+
result means "not cached, compute it" and is never stored (unless the getter opts
6+
in with ``cache_none=True``, for getters where ``None`` is a legitimate answer).
7+
This is deliberately simple — it is only for global, user-INDEPENDENT,
8+
signal-invalidated singletons (feature flags, system settings, and the like),
9+
never per-user or per-object data.
810
911
There is intentionally **no shared/cross-process (L2) tier**: freshness is provided
1012
by resetting L1 at every request and task boundary (middleware + the Celery task
@@ -89,27 +91,45 @@ def clear(self):
8991

9092
_L1_STORE = _L1Store()
9193

94+
# Sentinel stored in L1 to represent a cached ``None`` result, so a legitimately
95+
# ``None`` value (e.g. "no default configured") is distinguishable from "absent".
96+
# Only used by getters that opt in via ``cache_none=True``.
97+
_CACHED_NONE = object()
9298

93-
def dojo_settings_cache(*, key: str):
99+
100+
def dojo_settings_cache(*, key: str, cache_none: bool = False):
94101
"""
95102
Read-through in-process (L1) cache for a fixed-key singleton getter.
96103
97-
Resolves L1 → wrapped function. A ``None`` result is treated as "no value" and
98-
is not cached (so the next call retries). Becomes a pass-through when L1 is
99-
disabled (``SETTINGS_CACHE_L1_TTL=-1``). Freshness across processes comes from
100-
resetting L1 each request/task (see ``reset_l1_cache``), not a shared tier.
104+
Resolves L1 → wrapped function. Becomes a pass-through when L1 is disabled
105+
(``SETTINGS_CACHE_L1_TTL=-1``). Freshness across processes comes from resetting
106+
L1 each request/task (see ``reset_l1_cache``), not a shared tier.
107+
108+
By default a ``None`` result is treated as "no value" and is not cached (so the
109+
next call retries) — right for singletons that always exist and where ``None``
110+
means "not yet computed / transient error".
111+
112+
Pass ``cache_none=True`` for getters where ``None`` is a legitimate steady-state
113+
answer (e.g. "no default row configured"). It caches the ``None`` too, so a
114+
missing row costs one DB read per request/task instead of one per call site.
115+
Safe only when the getter is signal-invalidated on the row's create/save (so the
116+
cached ``None`` is dropped the moment a value appears).
101117
"""
102118

103119
def decorator(fn):
104120
@wraps(fn)
105121
def wrapper(*args, **kwargs):
106122
value = _L1_STORE.get(key) # ---- L1 ----
123+
if value is _CACHED_NONE: # cached "no value" (cache_none=True)
124+
return None
107125
if value is not None:
108126
return value
109127

110128
value = fn(*args, **kwargs) # ---- miss: compute ----
111129
if value is not None:
112130
_L1_STORE.set(key, value)
131+
elif cache_none:
132+
_L1_STORE.set(key, _CACHED_NONE)
113133
return value
114134

115135
return wrapper

0 commit comments

Comments
 (0)