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:
@@ -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.
|
||||
|
||||
@@ -8,7 +8,7 @@ Cache drivers and shared counters for SummerCMS: memory and Redis caches with a
|
||||
|
||||
## Overview
|
||||
|
||||
cooler holds the state that several application instances share. `cooler.Cache` is a Laravel-core cache (get, set, has, forget, remember, increment, flush) with per-entry TTLs under a per-application key prefix, kept in process by an otter cache or shared through Redis. The second piece is a Redis-backed rate-limit store: with `http.rate_limit.store: redis`, the `serve` command of [surf](../surf/README.md) calls `cooler.Install`, which builds a Redis client from `redis.url`, and publishes the store so the `throttle` middleware counts in Redis instead of in the process. Two instances behind one load balancer then enforce one limit together. The third piece is the site-cache purge: with `site_cache.purge_url` and `site_cache.purge_token` set, an admin save in a plugin sends one bearer-token POST to the host site's purge route after the save commits, so a cached page shows the change on its next view. It is the counterpart of Laravel's `Cache` facade with its array and Redis stores, and of the Redis store behind `RateLimiter` and the `throttle` middleware.
|
||||
cooler holds the state that several application instances share. `cooler.Cache` is a Laravel-core cache (get, pull, set, has, forget, remember, increment, flush) with per-entry TTLs under a per-application key prefix, kept in process by an otter cache or shared through Redis. The second piece is a Redis-backed rate-limit store: with `http.rate_limit.store: redis`, the `serve` command of [surf](../surf/README.md) calls `cooler.Install`, which builds a Redis client from `redis.url`, and publishes the store so the `throttle` middleware counts in Redis instead of in the process. Two instances behind one load balancer then enforce one limit together. The third piece is the site-cache purge: with `site_cache.purge_url` and `site_cache.purge_token` set, an admin save in a plugin sends one bearer-token POST to the host site's purge route after the save commits, so a cached page shows the change on its next view. It is the counterpart of Laravel's `Cache` facade with its array and Redis stores, and of the Redis store behind `RateLimiter` and the `throttle` middleware.
|
||||
|
||||
## Role in the framework
|
||||
|
||||
@@ -23,10 +23,11 @@ Use cases:
|
||||
|
||||
## Features
|
||||
|
||||
- `cooler.Cache` methods: `cooler.Cache.Get`, `cooler.Cache.Set`, `cooler.Cache.Has`, `cooler.Cache.Forget`, `cooler.Cache.Remember`, `cooler.Cache.Increment` and `cooler.Cache.Flush`, with a TTL per entry (0 or less: no expiry). No tags and no locks.
|
||||
- `cooler.Cache` methods: `cooler.Cache.Get`, `cooler.Cache.Pull`, `cooler.Cache.Set`, `cooler.Cache.Has`, `cooler.Cache.Forget`, `cooler.Cache.Remember`, `cooler.Cache.Increment` and `cooler.Cache.Flush`, with a TTL per entry (0 or less: no expiry). No tags and no locks.
|
||||
- Memory driver (`cache.driver: memory`, the default): `cooler.NewMemoryCache` on otter v2, bounded by `cache.memory.max_entries` (default `cooler.DefaultMaxEntries`, 10000) so request-derived keys cannot grow memory without limit. Values are copied in and out, and `cooler.Cache.Increment` is atomic per key.
|
||||
- Redis driver (`cache.driver: redis`): `cooler.NewRedisCache` with keys `<prefix>cache:<key>`, `SET … PX` for TTLs, `EXISTS`, `UNLINK` and `INCRBY`. `cooler.Cache.Flush` walks `SCAN MATCH <prefix>cache:*` in batches of 500 and removes each batch with `UNLINK`; it never empties the whole database, so rate-limit counters and other applications' keys stay.
|
||||
- `cooler.Cache.Increment` starts a missing key at 0 without a TTL and keeps an existing key's expiry, as Redis `INCRBY` does.
|
||||
- `cooler.Cache.Pull` returns a value and removes it in one atomic step, as Laravel's `Cache::pull`: Redis runs `GETDEL` (Redis 6.2 or newer), the memory driver removes the entry under otter's per-key lock. Of two concurrent callers at most one gets the value, and an expired key is reported as not found, which makes it the primitive for one-time codes. The memory driver is per process, so one-time values that another instance must read need the Redis driver.
|
||||
- `cooler.Cache.Remember` degrades instead of failing the page: a failed read still runs the callback and returns its value, a failed write is skipped, and both are logged at most once per minute. The other methods return their errors.
|
||||
- `cooler.Install` reads `cache.driver` and `http.rate_limit.store` (each `memory`, the default, or `redis`), publishes the Cache on the app (reusing one `cooler.FromApp` published earlier) and, when either selects Redis, builds one client from `redis.url`, shared by both, and pings it within `cooler.PingTimeout` (3 s).
|
||||
- `cooler.FromApp` gives commands that do not run `serve` (a dedicated job worker, console commands) the published Cache, or builds the configured one and publishes it, without a ping: go-redis dials on the first command.
|
||||
@@ -154,7 +155,8 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `cooler.Cache` | The cache: `Get`, `Set`, `Has`, `Forget`, `Remember`, `Increment` and `Flush` with per-entry TTLs under the app's key prefix. |
|
||||
| `cooler.Cache` | The cache: `Get`, `Pull`, `Set`, `Has`, `Forget`, `Remember`, `Increment` and `Flush` with per-entry TTLs under the app's key prefix. |
|
||||
| `cooler.Cache.Pull` | Returns the value at a key and removes it atomically; at most one concurrent caller gets it. |
|
||||
| `cooler.Cache.Remember` | Returns the cached value or computes, stores and returns it; degrades on cache errors. |
|
||||
| `cooler.Cache.Increment` | Adds to an integer value; a missing key starts at 0 without a TTL. |
|
||||
| `cooler.Cache.Flush` | Removes this application's cache keys only. |
|
||||
@@ -222,6 +224,6 @@ go test -short ./modules/cooler/... # no Docker: memory driver, miniredis, htt
|
||||
go test ./modules/cooler/... # also the Redis and Postgres containers (needs Docker)
|
||||
```
|
||||
|
||||
The `-short` suites need no external services. `TestMemoryCache`, `TestMemoryCacheBounded` and `TestMemoryIncrementConcurrent` cover the memory driver; `TestRedisCache` and `TestRedisFlushOnlyPrefix` run the Redis driver on miniredis (TTLs through `FastForward`). `TestRateLimitParity` runs one table of attempts through `surf.NewMemoryStore` and the Redis store and expects the same answers; `TestRateLimitFallbackOnError` stops Redis and checks the fallback store answers with one warning. `TestInstallConfigErrors`, `TestInstallPingWarning` and `TestFromAppLazy` cover start-up; `TestSiteCachePurgeConfig` and `TestSiteCachePurgeRequest` the purge configuration and request against an `httptest` receiver.
|
||||
The `-short` suites need no external services. `TestMemoryCache`, `TestMemoryCacheBounded` and `TestMemoryIncrementConcurrent` cover the memory driver; `TestPullSmoke` takes a value exactly once through `cooler.Cache.Pull` on the memory driver and on miniredis, and checks that an expired key is not found; `TestRedisCache` and `TestRedisFlushOnlyPrefix` run the Redis driver on miniredis (TTLs through `FastForward`). `TestRateLimitParity` runs one table of attempts through `surf.NewMemoryStore` and the Redis store and expects the same answers; `TestRateLimitFallbackOnError` stops Redis and checks the fallback store answers with one warning. `TestInstallConfigErrors`, `TestInstallPingWarning` and `TestFromAppLazy` cover start-up; `TestSiteCachePurgeConfig` and `TestSiteCachePurgeRequest` the purge configuration and request against an `httptest` receiver.
|
||||
|
||||
The Docker suites skip under `-short`: `TestRateLimitParityDocker` and `TestRedisCacheDocker` run the same checks on a `redis:7-alpine` container, `TestSiteCacheAfterCommitOnly` and `TestSiteCacheFailureNeverFails` run the after-commit purge in Postgres transactions, and `TestRedisCacheSmoke` is the original Redis smoke test. The cross-instance rate-limit test lives in surf (`go test ./modules/surf -run TestRateLimitAcrossInstancesSmoke`).
|
||||
|
||||
@@ -74,6 +74,11 @@ type Attempter interface {
|
||||
type Cache interface {
|
||||
// Get returns the value at key and whether it was found.
|
||||
Get(ctx context.Context, key string) ([]byte, bool, error)
|
||||
// Pull returns the value at key and removes it in one atomic step, as
|
||||
// Laravel's Cache::pull. found is false when the key is missing or
|
||||
// expired; of two concurrent callers at most one gets found=true. The
|
||||
// Redis driver uses GETDEL (Redis 6.2 or newer).
|
||||
Pull(ctx context.Context, key string) ([]byte, bool, error)
|
||||
// Set stores value at key for ttl (0 or less: no expiry).
|
||||
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
|
||||
// Has reports whether key holds a live value.
|
||||
|
||||
@@ -48,6 +48,36 @@ func ExampleFromApp() {
|
||||
// Output: [Hello Summer] 1
|
||||
}
|
||||
|
||||
func 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
|
||||
}
|
||||
|
||||
// Article is a blog's article model.
|
||||
type Article struct {
|
||||
Slug string
|
||||
|
||||
@@ -84,6 +84,27 @@ func (m *memoryCache) Get(_ context.Context, key string) ([]byte, bool, error) {
|
||||
return clone(e.val), true, nil
|
||||
}
|
||||
|
||||
// Pull returns the value at key and removes it under otter's per-key lock, so
|
||||
// of two concurrent callers at most one sees found=true. An expired entry is
|
||||
// removed too and reported as not found.
|
||||
func (m *memoryCache) Pull(_ context.Context, key string) ([]byte, bool, error) {
|
||||
var (
|
||||
out []byte
|
||||
found bool
|
||||
)
|
||||
now := time.Now()
|
||||
m.c.Compute(m.key(key), func(old memEntry, ok bool) (memEntry, otter.ComputeOp) {
|
||||
if !ok {
|
||||
return old, otter.CancelOp
|
||||
}
|
||||
if !old.expired(now) {
|
||||
out, found = clone(old.val), true
|
||||
}
|
||||
return memEntry{}, otter.InvalidateOp
|
||||
})
|
||||
return out, found, nil
|
||||
}
|
||||
|
||||
func (m *memoryCache) Set(_ context.Context, key string, value []byte, ttl time.Duration) error {
|
||||
e := memEntry{val: clone(value)}
|
||||
if ttl > 0 {
|
||||
|
||||
49
modules/cooler/pull_smoke_test.go
Normal file
49
modules/cooler/pull_smoke_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cooler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPullSmoke takes a value exactly once through Pull on both drivers, and
|
||||
// reports an expired value as not found.
|
||||
func TestPullSmoke(t *testing.T) {
|
||||
run := func(t *testing.T, c Cache, advance func(time.Duration)) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
if err := c.Set(ctx, "code", []byte("v"), 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok, err := c.Pull(ctx, "code"); err != nil || !ok || string(v) != "v" {
|
||||
t.Fatalf("first Pull = %q %v %v, want \"v\" true nil", v, ok, err)
|
||||
}
|
||||
if v, ok, err := c.Pull(ctx, "code"); err != nil || ok || v != nil {
|
||||
t.Fatalf("second Pull = %q %v %v, want nil false nil", v, ok, err)
|
||||
}
|
||||
if v, ok, err := c.Get(ctx, "code"); err != nil || ok || v != nil {
|
||||
t.Fatalf("Get after Pull = %q %v %v, want nil false nil", v, ok, err)
|
||||
}
|
||||
|
||||
if err := c.Set(ctx, "short", []byte("x"), 50*time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
advance(100 * time.Millisecond)
|
||||
if v, ok, err := c.Pull(ctx, "short"); err != nil || ok || v != nil {
|
||||
t.Fatalf("Pull of an expired key = %q %v %v, want nil false nil", v, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("memory", func(t *testing.T) {
|
||||
c, err := NewMemoryCache("acme:", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run(t, c, func(time.Duration) { time.Sleep(80 * time.Millisecond) })
|
||||
})
|
||||
|
||||
t.Run("redis", func(t *testing.T) {
|
||||
mr, client := miniClient(t)
|
||||
run(t, NewRedisCache(client, "acme:"), mr.FastForward)
|
||||
})
|
||||
}
|
||||
@@ -52,6 +52,22 @@ func (r *redisCache) Get(ctx context.Context, key string) ([]byte, bool, error)
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// Pull runs GETDEL (Redis 6.2 or newer): the value is returned and removed in
|
||||
// one command, so of two concurrent callers at most one sees found=true.
|
||||
func (r *redisCache) Pull(ctx context.Context, key string) ([]byte, bool, error) {
|
||||
if err := r.ready(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
b, err := r.client.GetDel(ctx, r.key(key)).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// Set stores value with SET … PX; a ttl of 0 or less stores it without expiry.
|
||||
func (r *redisCache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
|
||||
if err := r.ready(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user