|
2 | 2 | In-process read-through cache for global, low-cardinality singleton config. |
3 | 3 |
|
4 | 4 | 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. |
8 | 10 |
|
9 | 11 | There is intentionally **no shared/cross-process (L2) tier**: freshness is provided |
10 | 12 | by resetting L1 at every request and task boundary (middleware + the Celery task |
@@ -89,27 +91,45 @@ def clear(self): |
89 | 91 |
|
90 | 92 | _L1_STORE = _L1Store() |
91 | 93 |
|
| 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() |
92 | 98 |
|
93 | | -def dojo_settings_cache(*, key: str): |
| 99 | + |
| 100 | +def dojo_settings_cache(*, key: str, cache_none: bool = False): |
94 | 101 | """ |
95 | 102 | Read-through in-process (L1) cache for a fixed-key singleton getter. |
96 | 103 |
|
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). |
101 | 117 | """ |
102 | 118 |
|
103 | 119 | def decorator(fn): |
104 | 120 | @wraps(fn) |
105 | 121 | def wrapper(*args, **kwargs): |
106 | 122 | value = _L1_STORE.get(key) # ---- L1 ---- |
| 123 | + if value is _CACHED_NONE: # cached "no value" (cache_none=True) |
| 124 | + return None |
107 | 125 | if value is not None: |
108 | 126 | return value |
109 | 127 |
|
110 | 128 | value = fn(*args, **kwargs) # ---- miss: compute ---- |
111 | 129 | if value is not None: |
112 | 130 | _L1_STORE.set(key, value) |
| 131 | + elif cache_none: |
| 132 | + _L1_STORE.set(key, _CACHED_NONE) |
113 | 133 | return value |
114 | 134 |
|
115 | 135 | return wrapper |
|
0 commit comments