diff --git a/docs/services/caching.md b/docs/services/caching.md index 1ab5e981..52d2f3a0 100644 --- a/docs/services/caching.md +++ b/docs/services/caching.md @@ -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 `cache:`. 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. diff --git a/modules/cooler/README.md b/modules/cooler/README.md index 1b0a1076..5497ca06 100644 --- a/modules/cooler/README.md +++ b/modules/cooler/README.md @@ -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 `cache:`, `SET … PX` for TTLs, `EXISTS`, `UNLINK` and `INCRBY`. `cooler.Cache.Flush` walks `SCAN MATCH 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`). diff --git a/modules/cooler/cooler.go b/modules/cooler/cooler.go index d780ff18..be860383 100644 --- a/modules/cooler/cooler.go +++ b/modules/cooler/cooler.go @@ -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. diff --git a/modules/cooler/example_test.go b/modules/cooler/example_test.go index 73e0daae..fd1a5bdb 100644 --- a/modules/cooler/example_test.go +++ b/modules/cooler/example_test.go @@ -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 diff --git a/modules/cooler/memory.go b/modules/cooler/memory.go index d3b954f4..49181e83 100644 --- a/modules/cooler/memory.go +++ b/modules/cooler/memory.go @@ -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 { diff --git a/modules/cooler/pull_smoke_test.go b/modules/cooler/pull_smoke_test.go new file mode 100644 index 00000000..641c696a --- /dev/null +++ b/modules/cooler/pull_smoke_test.go @@ -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) + }) +} diff --git a/modules/cooler/rediscache.go b/modules/cooler/rediscache.go index 4d4add99..ce38f756 100644 --- a/modules/cooler/rediscache.go +++ b/modules/cooler/rediscache.go @@ -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 {