Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,10 @@ linters:
# Default: false
os-temp-dir: true

wrapcheck:
ignore-package-globs:
- "github.com/alecthomas/errors"

exclusions:
# Predefined exclusion rules.
# Default: []
Expand Down
7 changes: 5 additions & 2 deletions Justfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
set positional-arguments := true
set shell := ["bash", "-c"]

_help:
@just -l

# Run tests
test:
go test ./...
@gotestsum --hide-summary output,skipped --format-hide-empty-pkg ${CI:+--format github-actions}

# Lint code
lint:
Expand All @@ -13,5 +16,5 @@ lint:
# Format code
fmt:
just --unstable --fmt
git ls-files | grep '\.go$' | xargs gosimports -local github.com/block -w
golangci-lint fmt
go mod tidy
1 change: 1 addition & 0 deletions bin/.gotestsum-1.13.0.pkg
1 change: 1 addition & 0 deletions bin/gotestsum
26 changes: 25 additions & 1 deletion internal/cache/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,31 @@ func Register[Config any, C Cache](id string, factory Factory[Config, C]) {
// Key represents a unique identifier for a cached object.
type Key [32]byte

// ParseKey from its hex-encoded string form.
func ParseKey(key string) (Key, error) {
var k Key
return k, k.UnmarshalText([]byte(key))
}

func NewKey(url string) Key { return Key(sha256.Sum256([]byte(url))) }

func (k Key) String() string { return hex.EncodeToString(k[:]) }
func (k *Key) String() string { return hex.EncodeToString(k[:]) }

func (k *Key) UnmarshalText(text []byte) error {
bytes, err := hex.DecodeString(string(text))
if err != nil {
return errors.WithStack(err)
}
if len(bytes) != len(*k) {
return errors.New("invalid key length")
}
copy(k[:], bytes)
return nil
}

func (k *Key) MarshalText() ([]byte, error) {
return []byte(k.String()), nil
}

// A Cache knows how to retrieve, create and delete objects from a cache.
type Cache interface {
Expand All @@ -44,6 +66,8 @@ type Cache interface {
Open(ctx context.Context, key Key) (io.ReadCloser, error)
// Create a new file in the cache.
//
// If "ttl" is zero, a maximum TTL MUST be used by the implementation.
//
// The file MUST not be available for read until completely written and closed.
Create(ctx context.Context, key Key, ttl time.Duration) (io.WriteCloser, error)
// Delete a file from the cache.
Expand Down
194 changes: 194 additions & 0 deletions internal/cache/cachetest/suite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package cachetest

import (
"io"
"os"
"testing"
"time"

"github.com/alecthomas/assert/v2"

"github.com/block/sfptc/internal/cache"
)

// Suite runs a comprehensive test suite against a cache.Cache implementation.
// All cache implementations should pass this test suite to ensure consistent semantics.
func Suite(t *testing.T, newCache func(t *testing.T) cache.Cache) {
t.Run("CreateAndOpen", func(t *testing.T) {
testCreateAndOpen(t, newCache(t))
})

t.Run("NotFound", func(t *testing.T) {
testNotFound(t, newCache(t))
})

t.Run("Expiration", func(t *testing.T) {
testExpiration(t, newCache(t))
})

t.Run("DefaultTTL", func(t *testing.T) {
testDefaultTTL(t, newCache(t))
})

t.Run("Delete", func(t *testing.T) {
testDelete(t, newCache(t))
})

t.Run("MultipleWrites", func(t *testing.T) {
testMultipleWrites(t, newCache(t))
})

t.Run("NotAvailableUntilClosed", func(t *testing.T) {
testNotAvailableUntilClosed(t, newCache(t))
})
}

func testCreateAndOpen(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

key := cache.NewKey("test-key")

writer, err := c.Create(ctx, key, time.Hour)
assert.NoError(t, err)

_, err = writer.Write([]byte("hello world"))
assert.NoError(t, err)

err = writer.Close()
assert.NoError(t, err)

reader, err := c.Open(ctx, key)
assert.NoError(t, err)
defer reader.Close()

data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "hello world", string(data))
}

func testNotFound(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

key := cache.NewKey("nonexistent")

_, err := c.Open(ctx, key)
assert.IsError(t, err, os.ErrNotExist)
}

func testExpiration(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

key := cache.NewKey("test-key")

writer, err := c.Create(ctx, key, 10*time.Millisecond)
assert.NoError(t, err)

_, err = writer.Write([]byte("test data"))
assert.NoError(t, err)

err = writer.Close()
assert.NoError(t, err)

reader, err := c.Open(ctx, key)
assert.NoError(t, err)
assert.NoError(t, reader.Close())

time.Sleep(20 * time.Millisecond)

_, err = c.Open(ctx, key)
assert.IsError(t, err, os.ErrNotExist)
}

func testDefaultTTL(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

key := cache.NewKey("test-key")

writer, err := c.Create(ctx, key, 0)
assert.NoError(t, err)

_, err = writer.Write([]byte("test data"))
assert.NoError(t, err)

err = writer.Close()
assert.NoError(t, err)

reader, err := c.Open(ctx, key)
assert.NoError(t, err)
assert.NoError(t, reader.Close())
}

func testDelete(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

key := cache.NewKey("test-key")

writer, err := c.Create(ctx, key, time.Hour)
assert.NoError(t, err)

_, err = writer.Write([]byte("test data"))
assert.NoError(t, err)

err = writer.Close()
assert.NoError(t, err)

err = c.Delete(ctx, key)
assert.NoError(t, err)

_, err = c.Open(ctx, key)
assert.IsError(t, err, os.ErrNotExist)
}

func testMultipleWrites(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

key := cache.NewKey("test-key")

writer, err := c.Create(ctx, key, time.Hour)
assert.NoError(t, err)

_, err = writer.Write([]byte("hello "))
assert.NoError(t, err)

_, err = writer.Write([]byte("world"))
assert.NoError(t, err)

err = writer.Close()
assert.NoError(t, err)

reader, err := c.Open(ctx, key)
assert.NoError(t, err)
defer reader.Close()

data, err := io.ReadAll(reader)
assert.NoError(t, err)
assert.Equal(t, "hello world", string(data))
}

func testNotAvailableUntilClosed(t *testing.T, c cache.Cache) {
defer c.Close()
ctx := t.Context()

key := cache.NewKey("test-key")

writer, err := c.Create(ctx, key, time.Hour)
assert.NoError(t, err)

_, err = writer.Write([]byte("test data"))
assert.NoError(t, err)

_, err = c.Open(ctx, key)
assert.IsError(t, err, os.ErrNotExist)

err = writer.Close()
assert.NoError(t, err)

_, err = c.Open(ctx, key)
assert.NoError(t, err)
}
2 changes: 1 addition & 1 deletion internal/cache/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func init() {

type DiskConfig struct {
Root string `hcl:"root" help:"Root directory for the disk storage."`
LimitMB int `hcl:"limit-mb,optional" help:"Maximum size of the disk cache in megabytes (defaults to 1GB)." default:"1024"`
LimitMB int `hcl:"limit-mb,optional" help:"Maximum size of the disk cache in megabytes (defaults to 10GB)." default:"10240"`
MaxTTL time.Duration `hcl:"max-ttl,optional" help:"Maximum time-to-live for entries in the disk cache (defaults to 1 hour)." default:"1h"`
EvictInterval time.Duration `hcl:"evict-interval,optional" help:"Interval at which to check files for eviction (defaults to 1 minute)." default:"1m"`
}
Expand Down
Loading