From d66a759eec5d203afecef42e25e988ef3a0e5e05 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Thu, 11 Feb 2021 05:37:54 -0500 Subject: [PATCH] internal/cache: factor out cache into its own package Put the Redis-based page cache logic into its own package. Mostly for the non-trivial DeletePrefix method. For golang/go#44217 Change-Id: I7248f60b5d71402a38ebd4c96143a70e21ae3c63 Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/291249 Trust: Jonathan Amsterdam Run-TryBot: Jonathan Amsterdam Reviewed-by: Julie Qiu --- internal/cache/cache.go | 89 ++++++++++++++++++++++++++++++++++++ internal/cache/cache_test.go | 87 +++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 000000000..7bd3b4528 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,89 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package cache implements a redis-based page cache +// for pkgsite. +package cache + +import ( + "context" + "time" + + "github.com/go-redis/redis/v8" + "golang.org/x/pkgsite/internal/derrors" +) + +// Cache is a Redis-based cache. +type Cache struct { + client *redis.Client +} + +// New creates a new Cache using the given Redis client. +func New(client *redis.Client) *Cache { + return &Cache{client: client} +} + +// Get returns the value for key, or nil if the key does not exist. +func (c *Cache) Get(ctx context.Context, key string) (value []byte, err error) { + defer derrors.Wrap(&err, "Get(%q)", key) + val, err := c.client.Get(ctx, key).Bytes() + if err == redis.Nil { // not found + return nil, nil + } + if err != nil { + return nil, err + } + return val, nil +} + +// Put inserts the key with the given data and time-to-live. +func (c *Cache) Put(ctx context.Context, key string, data []byte, ttl time.Duration) (err error) { + defer derrors.Wrap(&err, "Put(%q, data, %s)", key, ttl) + _, err = c.client.Set(ctx, key, data, ttl).Result() + return err +} + +// Clear deletes all entries from the cache. +func (c *Cache) Clear(ctx context.Context) (err error) { + defer derrors.Wrap(&err, "Clear()") + status := c.client.FlushAll(ctx) + return status.Err() +} + +// Delete deletes the given keys. It does not return an error if a key does not +// exist. +func (c *Cache) Delete(ctx context.Context, keys ...string) (err error) { + defer derrors.Wrap(&err, "Delete(%q)", keys) + cmd := c.client.Unlink(ctx, keys...) // faster, asynchronous delete + return cmd.Err() +} + +// DeletePrefix deletes all keys beginning with prefix. +func (c *Cache) DeletePrefix(ctx context.Context, prefix string) (err error) { + defer derrors.Wrap(&err, "DeletePrefix(%q)", prefix) + iter := c.client.Scan(ctx, 0, prefix+"*", int64(scanCount)).Iterator() + var keys []string + for iter.Next(ctx) { + keys = append(keys, iter.Val()) + if len(keys) > scanCount { + if err := c.Delete(ctx, keys...); err != nil { + return err + } + keys = keys[:0] + } + } + if iter.Err() != nil { + return iter.Err() + } + if len(keys) > 0 { + return c.Delete(ctx, keys...) + } + return nil +} + +// The "count" argument to the Redis SCAN command, which is a hint for how much +// work to perform. +// Also used as the batch size for Delete calls in DeletePrefix. +// var for testing. +var scanCount = 100 diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 000000000..6ac0a4cd8 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,87 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cache + +import ( + "context" + "sort" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/go-redis/redis/v8" + "github.com/google/go-cmp/cmp" +) + +func TestBasics(t *testing.T) { + ctx := context.Background() + s, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer s.Close() + c := New(redis.NewClient(&redis.Options{Addr: s.Addr()})) + + val := []byte("value") + must(t, c.Put(ctx, "key", val, 0)) + got, err := c.Get(ctx, "key") + if err != nil { + t.Fatal(err) + } + if !cmp.Equal(got, val) { + t.Fatalf("got %v, want %v", got, val) + } + + must(t, c.Delete(ctx, "key")) + got, err = c.Get(ctx, "key") + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("got %v, want nil", got) + } +} + +func TestDeletePrefix(t *testing.T) { + ctx := context.Background() + s, err := miniredis.Run() + if err != nil { + t.Fatal(err) + } + defer s.Close() + c := New(redis.NewClient(&redis.Options{Addr: s.Addr()})) + + check := func(want []string) { + t.Helper() + got, err := c.client.Keys(ctx, "*").Result() + if err != nil { + t.Fatal(err) + } + sort.Strings(want) + sort.Strings(got) + if !cmp.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } + } + + all := []string{"a", "b", "c", "a@x", "a/x"} + for _, k := range all { + must(t, c.Put(ctx, k, []byte("value"), 0)) + } + check(all) + + scanCount = 1 + must(t, c.DeletePrefix(ctx, "a")) + check([]string{"b", "c"}) + + must(t, c.Clear(ctx)) + check([]string{}) +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +}