diff --git a/changelog.d/235.fixed.md b/changelog.d/235.fixed.md new file mode 100644 index 0000000..93f4e65 --- /dev/null +++ b/changelog.d/235.fixed.md @@ -0,0 +1,7 @@ +**`CacheManager.get_or_set()` returns the same value on a miss as on a hit.** +On a miss it used to return the object the factory produced, and on a later +hit the JSON-decoded copy, so a tuple came back as a tuple and then as a list, +and `{1: "a"}` as itself and then as `{"1": "a"}`. A miss now encodes the value +once, stores those bytes and returns them decoded. A value JSON cannot encode +(`datetime`, `Decimal`, `UUID`, a pydantic model) still raises `TypeError` +after the factory has run, and nothing is stored. diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index e756d94..ce771db 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -46,6 +46,8 @@ Complete runnable example: [`examples/app_cache.py`](https://github.com/allen009 - `set()` lets `TypeError` propagate for values that are not JSON-serializable. - `get_or_set()` provides no stampede protection: concurrent misses for the same key each run `factory`. +- `get_or_set()` returns the JSON-decoded value on a miss as well as on a hit + (see [JSON round-trip](#json-round-trip)), so both paths give the same result. - `add()` stores a value only when the key is free and returns whether it did. The check and the write are one atomic backend operation (`set_if_absent`), so it suits "do this once per key" work such as webhook or email @@ -70,4 +72,24 @@ Complete runnable example: [`examples/app_cache.py`](https://github.com/allen009 > `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory > backend if you need bulk clearing. +## JSON round-trip + +Values are stored as JSON (`json.dumps` with its defaults) and read back with +`json.loads`, so what you get back is the JSON-decoded form, not the object you +stored: + +| Stored | Read back | +|---|---| +| `dict`, `list`, `str`, `int`, `float`, `bool`, `None` | unchanged | +| `tuple` | `list`: `(1, 2)` → `[1, 2]` | +| `dict` with `int`/`float`/`bool`/`None` keys | string keys: `{1: "a"}` → `{"1": "a"}` | +| `datetime`, `Decimal`, `UUID`, `set`, a pydantic model, … | `TypeError`, nothing stored | + +`get_or_set()` applies this on a miss too: it encodes the factory's value once, +stores those bytes and returns them decoded, so the first call returns exactly +what later hits return. A value JSON cannot encode raises `TypeError` only +after `factory` has run, since the value doesn't exist before; convert it +first, e.g. `lambda: model.model_dump(mode="json")` or +`lambda: when.isoformat()`. + The full method list is in the [API reference](api/cache-manager.md). diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index 3a86010..ab69f56 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -171,6 +171,13 @@ async def get_or_set( returned directly. This method does not provide stampede protection: concurrent misses for the same key may each invoke ``factory``. + A miss returns the value as it will be read back from the cache, not + the object ``factory`` returned: it goes through the same JSON + round-trip as a hit, so a tuple comes back as a list and integer + dict keys as strings. A value JSON cannot encode (``datetime``, + ``Decimal``, ``UUID``, a pydantic model) raises ``TypeError`` only + after ``factory`` has run; nothing is stored. + Args: key: Logical cache key (without the manager's prefix). factory: Zero-argument callable that produces the JSON-serializable @@ -181,7 +188,8 @@ async def get_or_set( uses ``self.default_ttl``. Returns: - The cached value (existing or newly created). + The cached value (existing or newly created), JSON-decoded in + both cases. Raises: TypeError: If the value produced by ``factory`` is not JSON-serializable. @@ -200,8 +208,14 @@ async def get_or_set( if inspect.isawaitable(value): value = await value - await self.set(key, value, ttl=ttl) - return value + # Encode once, store those bytes and return them decoded, so a miss + # returns exactly what a later hit will: a tuple comes back as a + # list, int dict keys as strings. + effective_ttl = ttl if ttl is not None else self.default_ttl + entry = self._encode(value) + await self.backend.set(self._cache_key(key), entry, ttl=effective_ttl) + logger.debug("Cache SET; key=%s ttl=%s", key, effective_ttl) + return json.loads(entry.content) async def clear_pattern(self, pattern: str) -> int: """Clear all keys under this manager's namespace matching a glob pattern. diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index b3f6c23..336aea9 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -42,6 +42,7 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" - `get()` 在快取未命中時回傳 `None`(或你提供的 `default=`),遇到不存在或損毀的項目也絕不會拋出例外。 - `set()` 遇到無法 JSON 序列化的值時,會讓 `TypeError` 直接往外拋出。 - `get_or_set()` 不提供 cache stampede 保護:同一個鍵同時發生多次未命中時,每一次都會執行 `factory`。 +- `get_or_set()` 在未命中與命中時都回傳經 JSON 解碼後的值(見 [JSON 往返](#json-round-trip)),因此兩條路徑的結果相同。 - `add()` 只在鍵尚未被占用時寫入值,並回傳是否有寫入。檢查與寫入是同一個後端原子操作(`set_if_absent`),因此適合「每個鍵只做一次」的工作,例如 webhook 或電子郵件的去重。已過期的鍵視為未被占用;存放無法解碼之值的鍵則不算,即使 `get()` 會把它當成未命中。 - 鍵預設位於獨立、以 `cache:` 為前綴的命名空間,與 HTTP 路由快取及 OAuth state 分開,因此 `clear()`/`clear_prefix()` 絕不會動到無關的快取項目。 - 前綴是以單純的字串前綴比對。因此 `key_prefix="cache:"` 的 manager 也會清除 `key_prefix="cache:users:"` 的 manager 的項目;而空的 `key_prefix` 會讓 `clear()` 移除後端中的所有內容,包括 HTTP 回應、鎖、OAuth state 與 Session。請讓每個 manager 的前綴都不以另一個 manager 的前綴開頭。 @@ -50,4 +51,17 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" > [!NOTE] > `clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是一次批次 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `clear_pattern()` 在 Memcached 後端上不會有任何作用;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。 +## JSON 往返 {#json-round-trip} + +值以 JSON 儲存(`json.dumps` 的預設設定),讀取時以 `json.loads` 解碼,因此取回的是 JSON 解碼後的形式,而不是你存入的物件: + +| 存入 | 讀回 | +|---|---| +| `dict`、`list`、`str`、`int`、`float`、`bool`、`None` | 不變 | +| `tuple` | `list`:`(1, 2)` → `[1, 2]` | +| 鍵為 `int`/`float`/`bool`/`None` 的 `dict` | 字串鍵:`{1: "a"}` → `{"1": "a"}` | +| `datetime`、`Decimal`、`UUID`、`set`、pydantic 模型…… | `TypeError`,不會儲存任何內容 | + +`get_or_set()` 在未命中時也套用同樣的規則:它將 `factory` 的值編碼一次、儲存這些位元組,並回傳其解碼結果,因此第一次呼叫的回傳值與之後命中時完全相同。JSON 無法編碼的值要等到 `factory` 執行後才會拋出 `TypeError`,因為在那之前這個值還不存在;請先轉換,例如 `lambda: model.model_dump(mode="json")` 或 `lambda: when.isoformat()`。 + 完整的方法清單請見 [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/cache-manager/)(英文)。 diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 59e7a09..9887100 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -231,6 +231,71 @@ async def test_get_or_set_treats_corrupted_content_as_miss( assert await manager.get("bad") == "repaired" +@pytest.mark.parametrize( + ("value", "decoded"), + [ + pytest.param((1, 2), [1, 2], id="tuple"), + pytest.param({1: "a"}, {"1": "a"}, id="int-keyed-dict"), + pytest.param( + {"items": [(1, {2: (3,)})], "n": None}, + {"items": [[1, {"2": [3]}]], "n": None}, + id="nested", + ), + ], +) +async def test_get_or_set_miss_returns_the_value_a_hit_returns( + cache_manager: CacheManager, value: Any, decoded: Any +) -> None: + """get_or_set() returns the JSON round-tripped value on a miss, like a hit.""" + miss = await cache_manager.get_or_set("key", lambda: value) + hit = await cache_manager.get_or_set("key", lambda: pytest.fail("factory ran")) + + assert miss == decoded + assert hit == decoded + assert type(miss) is type(hit) + + +async def test_get_or_set_miss_encodes_the_value_once( + memory_backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch +) -> None: + """get_or_set() serialises the factory's value once and stores those bytes.""" + manager = CacheManager(backend=memory_backend) + encoded: list[Any] = [] + original = CacheManager._encode + + def counting_encode(value: Any) -> CacheEntry: + encoded.append(value) + return original(value) + + monkeypatch.setattr(CacheManager, "_encode", staticmethod(counting_encode)) + + result = await manager.get_or_set("key", lambda: (1, 2)) + + assert encoded == [(1, 2)] + assert result == [1, 2] + stored = await memory_backend.get(f"{manager.key_prefix}key") + assert stored is not None + assert stored.content == b"[1, 2]" + + +async def test_get_or_set_non_json_serializable_raises_and_stores_nothing( + cache_manager: CacheManager, +) -> None: + """get_or_set() raises TypeError after factory runs and leaves the key free.""" + calls = 0 + + def factory() -> object: + nonlocal calls + calls += 1 + return object() + + with pytest.raises(TypeError): + await cache_manager.get_or_set("key", factory) + + assert calls == 1 + assert not await cache_manager.has("key") + + # --- add ------------------------------------------------------------------------