feat(cooler): Cache.Pull returns and removes a value atomically

- Cache.Pull(ctx, key) ([]byte, bool, error), Laravel's Cache::pull
- Redis driver runs GETDEL (Redis 6.2+); memory driver uses otter Compute
  with InvalidateOp under the per-key lock; expired keys report not found
- TestPullSmoke over the memory driver and miniredis, ExampleCache_Pull
- README and docs/services/caching.md ("One-time values")
This commit is contained in:
Jakub Zych
2026-10-20 22:58:44 +02:00
parent 26ebb6c98c
commit 5e0a04e4da
7 changed files with 164 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ WinterCMS and Laravel cache through the `Cache` facade: `Cache::get`, `Cache::pu
| Laravel | cooler |
|---------|--------|
| `Cache::get($key)` | `cooler.Cache.Get` returns the bytes and whether the key was found. |
| `Cache::pull($key)` | `cooler.Cache.Pull` returns the bytes and removes the key in one atomic step. |
| `Cache::put($key, $value, $ttl)` | `cooler.Cache.Set` stores bytes for a TTL. |
| `Cache::forever($key, $value)` | `cooler.Cache.Set` with a TTL of 0. |
| `Cache::has($key)` | `cooler.Cache.Has`. |
@@ -83,6 +84,42 @@ Every key is stored as `<prefix>cache:<key>`. The prefix is `cache.prefix` when
A TTL of 0 or less keeps a value until it is forgotten, flushed or, in the memory driver, evicted. A positive TTL expires the value after that long; Redis stores it with millisecond precision. `cooler.Cache.Increment` starts a missing key at 0 without a TTL and keeps the expiry of an existing key, as Redis `INCRBY` does.
## One-time values
`cooler.Cache.Pull` is the read for values that may be used once, such as a sign-in code handed from a redirect to a single-page app: it returns the value and removes the key in one atomic step. Of two requests that pull the same key at the same time, at most one gets the value; the other sees it as not found, exactly as after expiry. Store the value with a short TTL, so a code nobody pulls disappears on its own. Here the second pull of the same code finds nothing:
```go src=modules/cooler/example_test.go#ExampleCache_Pull
// A one-time code: one request stores it with a short TTL, the request
// that redeems it pulls it, and a second redemption finds nothing.
cache, err := cooler.NewMemoryCache("acme:", cooler.DefaultMaxEntries)
if err != nil {
fmt.Println(err)
return
}
ctx := context.Background()
if err := cache.Set(ctx, "signin:4f9c", []byte(`{"user_id":7}`), 5*time.Minute); err != nil {
fmt.Println(err)
return
}
for range 2 {
payload, ok, err := cache.Pull(ctx, "signin:4f9c")
if err != nil {
fmt.Println(err)
return
}
if !ok {
fmt.Println("unknown, expired or already used")
continue
}
fmt.Println(string(payload))
}
// Output:
// {"user_id":7}
// unknown, expired or already used
```
The Redis driver runs `GETDEL`, which needs Redis 6.2 or newer. The memory driver removes the entry under its per-key lock, but it lives in one process: a value written on one instance cannot be pulled on another. An application that runs more than one instance behind a load balancer and hands one-time values between requests needs `cache.driver: redis`.
## Remember degrades instead of failing
`cooler.Cache.Remember` reads the key and, on a miss, calls the callback, stores its value for the TTL and returns it. When the cache itself fails, for example while Redis is down, it does not fail the page: a failed read still calls the callback and returns its value, a failed write is skipped, and both are logged as a warning at most once per minute. Only an error from the callback is returned. The other methods return cache errors to the caller.