Files
summercms/modules/cooler/example_test.go
Jakub Zych 5e0a04e4da 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")
2026-10-20 22:58:44 +02:00

160 lines
4.4 KiB
Go

package cooler_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/pact"
)
func ExampleFromApp() {
// serve publishes the Cache at start-up; a job or console command asks
// FromApp, which builds the configured driver (memory here) once.
app := backpack.New(nil)
cache, err := cooler.FromApp(app)
if err != nil {
fmt.Println(err)
return
}
ctx := context.Background()
loads := 0
popular := func(context.Context) ([]byte, error) {
loads++
return json.Marshal([]string{"Hello", "Summer"})
}
var titles []string
for range 2 {
raw, err := cache.Remember(ctx, "blog:popular", 10*time.Minute, popular)
if err != nil {
fmt.Println(err)
return
}
if err := json.Unmarshal(raw, &titles); err != nil {
fmt.Println(err)
return
}
}
fmt.Println(titles, loads)
// 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
}
// articlesController is a blog's admin controller (its pact.AdminController
// methods are left out here).
type articlesController struct {
app func() *backpack.App
}
// purge queues one site-cache purge for the saved article once the save
// transaction commits (at once outside one); the save never waits for the
// site. Without a purge configured the purger is nil and nothing is sent.
func (c articlesController) purge(ctx context.Context, model any) error {
article, ok := model.(*Article)
if !ok {
return nil
}
tx, _ := cabana.TxFromContext(ctx)
cooler.PurgeSiteCacheAfterCommit(ctx, tx, cooler.SiteCacheFromApp(c.app()), "article", article.Slug)
return nil
}
// FormAfterCreate runs inside the create's transaction.
func (c articlesController) FormAfterCreate(ctx context.Context, model any) error {
return c.purge(ctx, model)
}
// FormAfterUpdate runs inside the update's transaction.
func (c articlesController) FormAfterUpdate(ctx context.Context, model any) error {
return c.purge(ctx, model)
}
// FormAfterDelete runs inside the delete's transaction.
func (c articlesController) FormAfterDelete(ctx context.Context, model any) error {
return c.purge(ctx, model)
}
var (
_ pact.FormAfterCreate = articlesController{}
_ pact.FormAfterUpdate = articlesController{}
_ pact.FormAfterDelete = articlesController{}
)
func ExampleSiteCacheFromApp() {
// The host site's purge route, standing in on loopback.
site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
fmt.Println(r.Method, r.URL.Path, string(body))
_, _ = w.Write([]byte(`{"purged":3}`))
}))
defer site.Close()
dir, err := os.MkdirTemp("", "cooler-example")
if err != nil {
fmt.Println(err)
return
}
defer os.RemoveAll(dir)
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_SITE_CACHE__PURGE_URL=" + site.URL + "/api/__site__/cache/purge",
"SUMMER_SITE_CACHE__PURGE_TOKEN=example-purge-token-0123456789abcdef",
}})
if err != nil {
fmt.Println(err)
return
}
app := backpack.New(cfg)
c := articlesController{app: func() *backpack.App { return app }}
if err := c.FormAfterUpdate(context.Background(), &Article{Slug: "hello-summer"}); err != nil {
fmt.Println(err)
}
// The purge is queued; Wait drains the queue (serve does it at shutdown).
if err := cooler.SiteCacheFromApp(app).Wait(context.Background()); err != nil {
fmt.Println(err)
}
// Output: POST /api/__site__/cache/purge {"kind":"article","slug":"hello-summer"}
}