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
36 changes: 18 additions & 18 deletions cmd/cachew/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ func main() {
}

type GetCmd struct {
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Output *os.File `short:"o" help:"Output file (default: stdout)." default:"-"`
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Output *os.File `short:"o" help:"Output file (default: stdout)." default:"-"`
}

func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error {
Expand All @@ -88,8 +88,8 @@ func (c *GetCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type StatCmd struct {
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
}

func (c *StatCmd) Run(ctx context.Context, cache cache.Cache) error {
Expand All @@ -109,7 +109,7 @@ func (c *StatCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type PutCmd struct {
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Input *os.File `arg:"" help:"Input file (default: stdin)." default:"-"`
TTL time.Duration `help:"Time to live for the object."`
Expand Down Expand Up @@ -142,8 +142,8 @@ func (c *PutCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type DeleteCmd struct {
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
}

func (c *DeleteCmd) Run(ctx context.Context, cache cache.Cache) error {
Expand Down Expand Up @@ -171,12 +171,12 @@ func (c *NamespacesCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type SnapshotCmd struct {
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Directory string `arg:"" help:"Directory to archive." type:"path"`
TTL time.Duration `help:"Time to live for the object."`
Exclude []string `help:"Patterns to exclude (tar --exclude syntax)."`
ZstdThreads int `help:"Threads for zstd compression (0 = all CPU cores)." default:"0"`
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Directory string `arg:"" help:"Directory to archive." type:"path"`
TTL time.Duration `help:"Time to live for the object."`
Exclude []string `help:"Patterns to exclude (tar --exclude syntax)."`
ZstdThreads int `help:"Threads for zstd compression (0 = all CPU cores)." default:"0"`
}

func (c *SnapshotCmd) Run(ctx context.Context, cache cache.Cache) error {
Expand All @@ -191,10 +191,10 @@ func (c *SnapshotCmd) Run(ctx context.Context, cache cache.Cache) error {
}

type RestoreCmd struct {
Namespace string `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Directory string `arg:"" help:"Target directory for extraction." type:"path"`
ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"`
Namespace cache.Namespace `arg:"" help:"Namespace for organizing cache objects."`
Key PlatformKey `arg:"" help:"Object key (hex or string)."`
Directory string `arg:"" help:"Target directory for extraction." type:"path"`
ZstdThreads int `help:"Threads for zstd decompression (0 = all CPU cores)." default:"0"`
}

func (c *RestoreCmd) Run(ctx context.Context, cache cache.Cache) error {
Expand Down
37 changes: 36 additions & 1 deletion internal/cache/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,47 @@ import (
"io"
"net/http"
"os"
"regexp"
"time"

"github.com/alecthomas/errors"
"github.com/alecthomas/hcl/v2"
)

var namespaceRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)

// Namespace identifies a logical partition within a cache or metadata store.
// Valid names start with an alphanumeric character and contain only
// alphanumerics, hyphens, and underscores.
type Namespace string

// ValidateNamespace checks that a namespace name is valid.
func ValidateNamespace(name string) error {
if !namespaceRe.MatchString(name) {
return errors.Errorf("invalid namespace %q: must match %s", name, namespaceRe)
}
return nil
}

// ParseNamespace validates and returns a Namespace from a plain string.
func ParseNamespace(name string) (Namespace, error) {
if err := ValidateNamespace(name); err != nil {
return "", err
}
return Namespace(name), nil
}

func (n *Namespace) String() string { return string(*n) }

// UnmarshalText implements encoding.TextUnmarshaler with validation.
func (n *Namespace) UnmarshalText(text []byte) error {
if err := ValidateNamespace(string(text)); err != nil {
return err
}
*n = Namespace(text)
return nil
}

// ErrNotFound is returned when a cache backend is not found.
var ErrNotFound = errors.New("cache backend not found")

Expand Down Expand Up @@ -143,7 +178,7 @@ type Cache interface {
String() string
// Namespace creates a namespaced view of this cache.
// All operations on the returned cache will use the given namespace prefix.
Namespace(namespace string) Cache
Namespace(namespace Namespace) Cache
// Stat returns the headers of an existing object in the cache.
//
// Expired files MUST not be returned.
Expand Down
40 changes: 40 additions & 0 deletions internal/cache/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cache_test

import (
"testing"

"github.com/alecthomas/assert/v2"

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

func TestValidateNamespace(t *testing.T) {
tests := []struct {
name string
input string
valid bool
}{
{name: "Simple", input: "git", valid: true},
{name: "WithHyphen", input: "go-mod", valid: true},
{name: "WithUnderscore", input: "go_mod", valid: true},
{name: "WithNumbers", input: "v2cache", valid: true},
{name: "UpperCase", input: "GitLFS", valid: true},
{name: "Empty", input: "", valid: false},
{name: "DotPrefix", input: ".metadata", valid: false},
{name: "DotInMiddle", input: "go.mod", valid: false},
{name: "Slash", input: "a/b", valid: false},
{name: "Space", input: "a b", valid: false},
{name: "HyphenPrefix", input: "-foo", valid: false},
{name: "UnderscorePrefix", input: "_foo", valid: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := cache.ValidateNamespace(tt.input)
if tt.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}
16 changes: 8 additions & 8 deletions internal/cache/disk.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type DiskConfig struct {
type Disk struct {
logger *slog.Logger
config DiskConfig
namespace string
namespace Namespace
db *diskMetaDB
size *atomic.Int64
runEviction chan struct{}
Expand Down Expand Up @@ -287,12 +287,12 @@ func (d *Disk) Open(ctx context.Context, key Key) (io.ReadCloser, http.Header, e
return f, headers, nil
}

func (d *Disk) keyToPath(namespace string, key Key) string {
func (d *Disk) keyToPath(namespace Namespace, key Key) string {
hexKey := key.String()

// Use first two hex digits as directory, full hex as filename
if namespace != "" {
return filepath.Join(namespace, hexKey[:2], hexKey)
return filepath.Join(string(namespace), hexKey[:2], hexKey)
}
return filepath.Join(hexKey[:2], hexKey)
}
Expand Down Expand Up @@ -320,7 +320,7 @@ func (d *Disk) evictionLoop(ctx context.Context) {
}

type evictFileInfo struct {
namespace string
namespace Namespace
key Key
path string
size int64
Expand All @@ -329,7 +329,7 @@ type evictFileInfo struct {
}

type evictEntryKey struct {
namespace string
namespace Namespace
key Key
}

Expand All @@ -338,7 +338,7 @@ func (d *Disk) evict() error {
var expiredEntries []evictEntryKey
now := time.Now()

err := d.db.walk(func(key Key, namespace string, expiresAt time.Time) error {
err := d.db.walk(func(key Key, namespace Namespace, expiresAt time.Time) error {
path := d.keyToPath(namespace, key)
fullPath := filepath.Join(d.config.Root, path)

Expand Down Expand Up @@ -421,7 +421,7 @@ type diskWriter struct {
disk *Disk
file *os.File
key Key
namespace string
namespace Namespace
path string
tempPath string
expiresAt time.Time
Expand Down Expand Up @@ -477,7 +477,7 @@ func (w *diskWriter) Close() error {
}

// Namespace creates a namespaced view of the disk cache.
func (d *Disk) Namespace(namespace string) Cache {
func (d *Disk) Namespace(namespace Namespace) Cache {
// Create a shallow copy with the namespace set
c := *d
c.namespace = namespace
Expand Down
28 changes: 14 additions & 14 deletions internal/cache/disk_metadb.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ var (
// diskMetaDB manages expiration times and headers for cache entries using bbolt.
type diskMetaDB struct {
db *bbolt.DB
namespacesCache sync.Map // map[string]bool - concurrent-safe
namespacesCache sync.Map // map[Namespace]bool - concurrent-safe
}

// compositeKey creates a unique database key from namespace and cache key.
// Format: "namespace/hexkey" when namespace is set, or just "hexkey" when empty.
func compositeKey(namespace string, key Key) []byte {
func compositeKey(namespace Namespace, key Key) []byte {
if namespace == "" {
return []byte(key.String())
}
return []byte(namespace + "/" + key.String())
return []byte(string(namespace) + "/" + key.String())
}

// newDiskMetaDB creates a new bbolt-backed metadata storage for the disk cache.
Expand Down Expand Up @@ -65,7 +65,7 @@ func newDiskMetaDB(dbPath string) (*diskMetaDB, error) {
return ttlBucket.ForEach(func(k, _ []byte) error {
namespace, _, found := bytes.Cut(k, []byte("/"))
if found && len(namespace) > 0 {
metaDB.namespacesCache.Store(string(namespace), true)
metaDB.namespacesCache.Store(Namespace(namespace), true)
}
return nil
})
Expand All @@ -77,7 +77,7 @@ func newDiskMetaDB(dbPath string) (*diskMetaDB, error) {
return metaDB, nil
}

func (s *diskMetaDB) setTTL(namespace string, key Key, expiresAt time.Time) error {
func (s *diskMetaDB) setTTL(namespace Namespace, key Key, expiresAt time.Time) error {
ttlBytes, err := expiresAt.MarshalBinary()
if err != nil {
return errors.Errorf("failed to marshal TTL: %w", err)
Expand All @@ -100,7 +100,7 @@ func (s *diskMetaDB) setTTL(namespace string, key Key, expiresAt time.Time) erro
return nil
}

func (s *diskMetaDB) set(key Key, namespace string, expiresAt time.Time, headers http.Header) error {
func (s *diskMetaDB) set(key Key, namespace Namespace, expiresAt time.Time, headers http.Header) error {
ttlBytes, err := expiresAt.MarshalBinary()
if err != nil {
return errors.Errorf("failed to marshal TTL: %w", err)
Expand Down Expand Up @@ -133,7 +133,7 @@ func (s *diskMetaDB) set(key Key, namespace string, expiresAt time.Time, headers
return nil
}

func (s *diskMetaDB) getTTL(namespace string, key Key) (time.Time, error) {
func (s *diskMetaDB) getTTL(namespace Namespace, key Key) (time.Time, error) {
var expiresAt time.Time
dbKey := compositeKey(namespace, key)
err := s.db.View(func(tx *bbolt.Tx) error {
Expand All @@ -147,7 +147,7 @@ func (s *diskMetaDB) getTTL(namespace string, key Key) (time.Time, error) {
return expiresAt, errors.WithStack(err)
}

func (s *diskMetaDB) getHeaders(namespace string, key Key) (http.Header, error) {
func (s *diskMetaDB) getHeaders(namespace Namespace, key Key) (http.Header, error) {
var headers http.Header
dbKey := compositeKey(namespace, key)
err := s.db.View(func(tx *bbolt.Tx) error {
Expand All @@ -161,7 +161,7 @@ func (s *diskMetaDB) getHeaders(namespace string, key Key) (http.Header, error)
return headers, errors.WithStack(err)
}

func (s *diskMetaDB) delete(namespace string, key Key) error {
func (s *diskMetaDB) delete(namespace Namespace, key Key) error {
dbKey := compositeKey(namespace, key)
return errors.WithStack(s.db.Update(func(tx *bbolt.Tx) error {
ttlBucket := tx.Bucket(ttlBucketName)
Expand Down Expand Up @@ -195,19 +195,19 @@ func (s *diskMetaDB) deleteAll(entries []evictEntryKey) error {
}))
}

func (s *diskMetaDB) walk(fn func(key Key, namespace string, expiresAt time.Time) error) error {
func (s *diskMetaDB) walk(fn func(key Key, namespace Namespace, expiresAt time.Time) error) error {
return errors.WithStack(s.db.View(func(tx *bbolt.Tx) error {
ttlBucket := tx.Bucket(ttlBucketName)
if ttlBucket == nil {
return nil
}
return ttlBucket.ForEach(func(k, v []byte) error {
var namespace string
var namespace Namespace
var key Key

before, hexKey, found := bytes.Cut(k, []byte("/"))
if found {
namespace = string(before)
namespace = Namespace(before)
} else {
hexKey = k
}
Expand Down Expand Up @@ -251,8 +251,8 @@ func (s *diskMetaDB) close() error {
func (s *diskMetaDB) listNamespaces() ([]string, error) {
var namespaces []string
s.namespacesCache.Range(func(key, _ any) bool {
if ns, ok := key.(string); ok {
namespaces = append(namespaces, ns)
if ns, ok := key.(Namespace); ok {
namespaces = append(namespaces, string(ns))
}
return true
})
Expand Down
Loading