Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/235.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
20 changes: 17 additions & 3 deletions fastapi_cachex/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions i18n/zh-TW/docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的前綴開頭。
Expand All @@ -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/)(英文)。
65 changes: 65 additions & 0 deletions tests/test_cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------------------


Expand Down
Loading