feat: configuration
/ publish (push) Successful in 3m27s

This commit is contained in:
2026-03-07 16:58:19 +01:00
parent 4efd3ee396
commit f5bcc7c381
20 changed files with 157 additions and 27 deletions
+49
View File
@@ -0,0 +1,49 @@
package config
import (
"context"
"github.com/sethvargo/go-envconfig"
)
type Cfg struct {
FileSystem *WebdavConfig `env:", prefix=FILESYSTEM_"`
Cache *ValkeyConfig `env:", prefix=CACHE_"`
}
type WebdavConfig struct {
URL string `env:"URL"`
User string `env:"USERNAME"`
Password string `env:"PASSWORD"`
}
type ValkeyConfig struct {
ClientName string `env:"CLIENTNAME"`
Username string `env:"USERNAME"`
Password string `env:"PASSWORD"`
// InitAddress point to valkey nodes.
// Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success.
// If len(InitAddress) == 1 and the address is not running in cluster mode, valkey will fall back to the single client mode.
// If ClientOption.Sentinel.MasterSet is set, then InitAddress will be used to connect sentinels
// You can bypass this behavior by using ClientOption.ForceSingleClient.
InitAddress []string `env:"ADDRESS"`
}
func Read(ctx context.Context) (cfg Cfg, err error) {
err = envconfig.ProcessWith(ctx, &envconfig.Config{
Target: &cfg,
// All fields will use a ";" delimiter and "@" separator, unless locally
// overridden.
DefaultDelimiter: ";",
DefaultSeparator: "@",
Lookuper: envconfig.MultiLookuper(secretLookuper(), envconfig.OsLookuper()),
})
if err != nil {
return
}
return
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"io"
"log/slog"
"os"
"strings"
"github.com/sethvargo/go-envconfig"
)
func secretLookuper() envconfig.LookuperFunc {
return func(key string) (string, bool) {
fileName := os.Getenv(key + "_FILE")
if fileName == "" {
return "", false
}
file, err := os.Open(fileName)
if err != nil {
slog.Error("could not read secret file", "path", fileName)
return "", false
}
defer file.Close()
sb := &strings.Builder{}
_, err = io.Copy(sb, file)
if err != nil {
return "", false
}
return sb.String(), true
}
}
+70
View File
@@ -0,0 +1,70 @@
package filesystem
import (
"context"
"log/slog"
"sync/atomic"
"time"
"github.com/valkey-io/valkey-go"
)
const keyStat = "webdav:stat:"
// CachedClient wraps a FileSystem implementation with Valkey caching.
type CachedClient struct {
impl FS
cache valkey.Client
ttl time.Duration
hits atomic.Int64
misses atomic.Int64
readDirHits atomic.Int64
readHits atomic.Int64
}
// NewCachedClient creates a new CachedClient.
func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *CachedClient {
c := &CachedClient{
impl: impl,
cache: client,
ttl: refreshtime * 5,
}
go func() {
t := time.NewTicker(refreshtime)
for {
<-t.C
c.revalidate(context.Background())
}
}()
return c
}
func (c *CachedClient) revalidate(ctx context.Context) {
go c.revalidateReadDir(ctx)
go c.revalidateRead(ctx)
go c.revalidateStat(ctx)
}
func (c *CachedClient) scanAndProcess(ctx context.Context, match string, process func(key string) error) {
cursor := uint64(0)
for {
res, err := c.cache.Do(ctx, c.cache.B().Scan().Cursor(cursor).Match(match).Build()).AsScanEntry()
if err != nil {
slog.Error("scan failed", "match", match, "err", err)
return
}
for _, key := range res.Elements {
if err := process(key); err != nil {
slog.Debug("process failed", "key", key, "err", err)
}
}
cursor = res.Cursor
if cursor == 0 {
break
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package filesystem
import (
"encoding/json"
"os"
"testing"
"time"
)
func TestCachedFile_Serialization(t *testing.T) {
now := time.Now().Truncate(time.Second) // JSON marshal of time might lose precision
cf := File{
name: "test.md",
size: 123,
mode: 0o644,
modTime: now,
isDir: false,
contentType: "text/markdown",
path: "/test.md",
}
data, err := json.Marshal(cf)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var cf2 File
if err := json.Unmarshal(data, &cf2); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if cf.name != cf2.name {
t.Errorf("Name mismatch: %s != %s", cf.name, cf2.name)
}
if cf.size != cf2.size {
t.Errorf("Size mismatch: %d != %d", cf.size, cf2.size)
}
if cf.mode != cf2.mode {
t.Errorf("Mode mismatch: %v != %v", cf.mode, cf2.mode)
}
if !cf.modTime.Equal(cf2.modTime) {
t.Errorf("ModTime mismatch: %v != %v", cf.modTime, cf2.modTime)
}
if cf.isDir != cf2.isDir {
t.Errorf("IsDir mismatch: %v != %v", cf.isDir, cf2.isDir)
}
if cf.contentType != cf2.contentType {
t.Errorf("ContentType mismatch: %s != %s", cf.contentType, cf2.contentType)
}
if cf.path != cf2.path {
t.Errorf("Path mismatch: %s != %s", cf.path, cf2.path)
}
}
func TestCachedFile_Interface(t *testing.T) {
// Verify CachedFile implements os.FileInfo
var _ os.FileInfo = File{}
}
@@ -0,0 +1,464 @@
package filesystem
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/valkey-io/valkey-go"
)
// MockFS implements filesystem.FS interface for testing.
type MockFS struct {
files map[string]*MockFile
}
// MockFile implements FileInfo and holds content.
type MockFile struct {
name string
content []byte
mode os.FileMode
modTime time.Time
isDir bool
contentType string
path string
}
func (f *MockFile) Name() string { return f.name }
func (f *MockFile) Size() int64 { return int64(len(f.content)) }
func (f *MockFile) Mode() os.FileMode { return f.mode }
func (f *MockFile) ModTime() time.Time { return f.modTime }
func (f *MockFile) IsDir() bool { return f.isDir }
func (f *MockFile) Sys() any { return nil }
func (f *MockFile) ContentType() string { return f.contentType }
func (f *MockFile) Path() string { return f.path }
func NewMockFS() *MockFS {
return &MockFS{
files: make(map[string]*MockFile),
}
}
func (m *MockFS) AddFile(path string, content []byte) {
m.files[path] = &MockFile{
name: path,
content: content,
mode: 0o644,
modTime: time.Now().Truncate(time.Second),
isDir: false,
contentType: "text/plain",
path: path,
}
}
func (m *MockFS) AddDir(path string) {
m.files[path] = &MockFile{
name: path,
mode: 0o755,
modTime: time.Now().Truncate(time.Second),
isDir: true,
path: path,
}
}
func (m *MockFS) Read(path string) ([]byte, error) {
f, ok := m.files[path]
if !ok {
return nil, os.ErrNotExist
}
if f.isDir {
return nil, errors.New("is a directory")
}
return f.content, nil
}
func (m *MockFS) ReadDir(path string) ([]os.FileInfo, error) {
// Simple implementation: return files that start with path + "/"
// For deeper nesting, simulate direct children only.
// Assume flat structure for simplicity or specific test setup.
var infos []os.FileInfo
for p, f := range m.files {
// Just check if it's logically "inside" the directory
// e.g. path="/foo", file="/foo/bar" -> yes
// file="/foo/bar/baz" -> no (if strict direct children)
// file="/other" -> no
if p == path {
continue // self
}
// Check prefix
// if path is root "/", check if p has no other slashes?
// if path is "/foo", check if p starts with "/foo/" and has no further slashes
// This logic is simple but sufficient for tests.
// Let's assume absolute paths.
// If path does not end with /, append it for prefix check
prefix := path
if len(prefix) > 0 && prefix[len(prefix)-1] != '/' {
prefix += "/"
}
if len(p) > len(prefix) && p[:len(prefix)] == prefix {
// Ensure direct child (no more slashes)
rel := p[len(prefix):]
// Count slashes in rel. If 0, it's a direct child.
slashCount := 0
for _, c := range rel {
if c == '/' {
slashCount++
}
}
if slashCount == 0 {
infos = append(infos, f)
}
}
}
if len(infos) == 0 {
// Check if directory exists at all
if _, ok := m.files[path]; !ok {
return nil, os.ErrNotExist
}
}
return infos, nil
}
func (m *MockFS) Stat(path string) (os.FileInfo, error) {
f, ok := m.files[path]
if !ok {
return nil, os.ErrNotExist
}
return f, nil
}
func TestCachedClient_Stat(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddFile("/test.txt", []byte("hello"))
c := NewCachedClient(mockFS, client, time.Hour)
// First call: Cache miss
info, err := c.Stat("/test.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info.Name() != "/test.txt" {
t.Errorf("expected name /test.txt, got %s", info.Name())
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
if c.hits.Load() != 0 {
t.Errorf("expected 0 hits, got %d", c.hits.Load())
}
// Second call: Cache hit
info2, err := c.Stat("/test.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info2.Size() != info.Size() {
t.Errorf("size mismatch")
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
// Verify valkey content
keys := mr.Keys()
if len(keys) != 1 {
t.Errorf("expected 1 key in redis, got %d", len(keys))
}
}
func TestCachedClient_Read(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
content := []byte("hello world")
mockFS.AddFile("/read.txt", content)
c := NewCachedClient(mockFS, client, time.Hour)
// First call: Miss
data, err := c.Read("/read.txt")
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if string(data) != string(content) {
t.Errorf("content mismatch")
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
// Second call: Hit
data2, err := c.Read("/read.txt")
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if string(data2) != string(content) {
t.Errorf("content mismatch")
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_ReadDir(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddDir("/dir")
mockFS.AddFile("/dir/f1.txt", []byte("1"))
mockFS.AddFile("/dir/f2.txt", []byte("2"))
c := NewCachedClient(mockFS, client, time.Hour)
// First call: Miss
infos, err := c.ReadDir("/dir")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(infos) != 2 {
t.Errorf("expected 2 files, got %d", len(infos))
}
if c.misses.Load() != 1 {
t.Errorf("expected 1 miss, got %d", c.misses.Load())
}
// Second call: Hit
infos2, err := c.ReadDir("/dir")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(infos2) != 2 {
t.Errorf("expected 2 files, got %d", len(infos2))
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_RevalidateStat(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddFile("/reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour)
// Populate cache
_, _ = c.Stat("/reval.txt")
// Update mock file (simulate external change)
// For simplicity, just update size
f := mockFS.files["/reval.txt"]
f.content = []byte("updated content")
// Important: update modTime to be newer than cached
f.modTime = time.Now().Add(time.Minute)
// Run revalidation manually
// Note: revalidateStat is a private method, so we can test it directly here
// because we are in package filesystem (if using package filesystem_test we couldn't)
// But `revalidateStat` only iterates over keys in redis. So we need to ensure the key is there.
// `Stat` put it there.
// Wait, Stat puts it there. `revalidateStat` iterates keys matching `keyStat*`.
// For each key, it checks `Stat` again.
// Wait, looking at `revalidateStat` implementation:
// It calls `c.impl.Stat(path)`. If successful, it updates cache.
// It doesn't check ModTime explicitly for `Stat` revalidation, it just blindly updates?
// Let's check `revalidateStat` implementation in `cache.go`.
// func (c *CachedClient) revalidateStat(ctx context.Context) { ...
// info, err := c.impl.Stat(path)
// ...
// bytes, err := json.Marshal(f)
// c.client.Do(...Set...)
// }
// Yes, it blindly updates. So modification time doesn't matter for `Stat`, it just fetches fresh info.
c.revalidateStat(context.Background())
// Verify cache is updated by fetching from cache again
// We can inspect redis directly or trust `Stat` returns cached value (which should be updated).
// Let's inspect redis to be sure.
// Or just call Stat again. It should be a cache HIT, but return NEW values.
// Reset stats to check hit
c.hits.Store(0)
c.misses.Store(0)
info, err := c.Stat("/reval.txt")
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if info.Size() != int64(len("updated content")) {
t.Errorf("expected size %d, got %d", len("updated content"), info.Size())
}
// Should be a hit because revalidate updated the cache entry, so it exists and is valid.
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_RevalidateRead(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddFile("/read_reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour)
// Populate cache
_, _ = c.Read("/read_reval.txt")
// Update mock file (simulate external change)
f := mockFS.files["/read_reval.txt"]
f.content = []byte("updated content")
f.modTime = time.Now().Add(time.Minute) // Newer ModTime triggers update
// Run revalidation manually
c.revalidateRead(context.Background())
// Verify cache is updated
// Reset stats
c.hits.Store(0)
c.misses.Store(0)
data, err := c.Read("/read_reval.txt")
if err != nil {
t.Fatalf("Read failed: %v", err)
}
if string(data) != "updated content" {
t.Errorf("expected updated content, got %s", string(data))
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
func TestCachedClient_RevalidateReadDir(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("failed to start miniredis: %v", err)
}
defer mr.Close()
client, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{mr.Addr()},
DisableCache: true,
})
if err != nil {
t.Fatalf("failed to create valkey client: %v", err)
}
defer client.Close()
mockFS := NewMockFS()
mockFS.AddDir("/dir_reval")
mockFS.AddFile("/dir_reval/f1.txt", []byte("1"))
c := NewCachedClient(mockFS, client, time.Hour)
// Populate cache
_, err = c.ReadDir("/dir_reval")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
// Update mock file (simulate external change)
mockFS.AddFile("/dir_reval/f2.txt", []byte("2"))
// Run revalidation manually
c.revalidateReadDir(context.Background())
// Verify cache is updated
// Reset stats
c.hits.Store(0)
c.misses.Store(0)
infos2, err := c.ReadDir("/dir_reval")
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(infos2) != 2 {
t.Errorf("expected 2 files after revalidation, got %d", len(infos2))
}
if c.hits.Load() != 1 {
t.Errorf("expected 1 hit, got %d", c.hits.Load())
}
}
+106
View File
@@ -0,0 +1,106 @@
package filesystem
import (
"encoding/json"
"os"
"time"
)
type FS interface {
ReadDir(path string) ([]os.FileInfo, error)
Read(path string) ([]byte, error)
Stat(path string) (os.FileInfo, error)
}
type FileInfo interface {
os.FileInfo
ContentType() string
Path() string
}
// File implements FileInfo
type File struct {
name string
size int64
mode os.FileMode
modTime time.Time
isDir bool
contentType string
path string
}
func (f File) Name() string { return f.name }
func (f File) Size() int64 { return f.size }
func (f File) Mode() os.FileMode { return f.mode }
func (f File) ModTime() time.Time { return f.modTime }
func (f File) IsDir() bool { return f.isDir }
func (f File) Sys() any { return nil }
func (f File) ContentType() string { return f.contentType }
func (f File) Path() string { return f.path }
type serializableFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
Mode os.FileMode `json:"mode"`
ModTime time.Time `json:"modTime"`
IsDir bool `json:"isDir"`
ContentType string `json:"contentType"`
Path string `json:"path"`
}
// MarshalJSON implements json.Marshaler
func (f File) MarshalJSON() ([]byte, error) {
// Create a local shadow struct with exported fields for the JSON package
return json.Marshal(serializableFile{
Name: f.name,
Size: f.size,
Mode: f.mode,
ModTime: f.modTime,
IsDir: f.isDir,
ContentType: f.contentType,
Path: f.path,
})
}
// UnmarshalJSON implements json.Unmarshaler
func (f *File) UnmarshalJSON(data []byte) error {
// Define a shadow struct to capture the incoming JSON
shadow := serializableFile{}
if err := json.Unmarshal(data, &shadow); err != nil {
return err
}
// Transfer values to the actual struct
f.name = shadow.Name
f.size = shadow.Size
f.mode = shadow.Mode
f.modTime = shadow.ModTime
f.isDir = shadow.IsDir
f.contentType = shadow.ContentType
f.path = shadow.Path
return nil
}
func ParseToFile(file os.FileInfo) File {
if fi, ok := file.(FileInfo); ok {
return File{
name: fi.Name(),
size: fi.Size(),
mode: fi.Mode(),
modTime: fi.ModTime(),
isDir: fi.IsDir(),
contentType: fi.ContentType(),
path: fi.Path(),
}
} else {
// Fallback if underlying impl returns generic os.FileInfo
return File{
name: file.Name(),
size: file.Size(),
mode: file.Mode(),
modTime: file.ModTime(),
isDir: file.IsDir(),
}
}
}
+109
View File
@@ -0,0 +1,109 @@
package filesystem
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/valkey-io/valkey-go"
)
const keyRead = "webdav:read:"
type readFile struct {
Age time.Time `json:"age"`
Content []byte `json:"content"`
}
func (c *CachedClient) Read(path string) ([]byte, error) {
key := keyRead + path
ctx := context.Background()
// Try cache
cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes()
if err == nil && len(cached) > 0 {
c.readHits.Add(1)
c.hits.Add(1)
slog.Debug("cache hit", "key", key)
var file readFile
err := json.Unmarshal(cached, &file)
if err != nil {
slog.Error("could not Unmarshal cache", "key", key, "err", err)
} else {
return file.Content, nil
}
}
// Cache miss
c.misses.Add(1)
slog.Debug("cache miss", "key", key)
data, err := c.impl.Read(path)
if err != nil {
return nil, err
}
file := readFile{
Age: time.Now(),
Content: data,
}
cached, err = json.Marshal(file)
if err != nil {
slog.Error("could not marshal file for cache", "key", key, "err", err)
return data, nil
}
// Cache
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(cached)).Ex(c.ttl).Build())
return data, nil
}
func (c *CachedClient) revalidateRead(ctx context.Context) {
startTime := time.Now()
c.scanAndProcess(ctx, keyRead+"*", func(key string) error {
path := strings.TrimPrefix(key, keyRead)
cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes()
if err != nil {
// Key might be gone or error fetching
return nil
}
var cachedFile readFile
err = json.Unmarshal(cached, &cachedFile)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return err
}
info, err := c.impl.Stat(path)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return nil
}
if info.ModTime().After(cachedFile.Age) {
data, err := c.impl.Read(path)
if err != nil {
return err
}
cachedFile.Age = time.Now()
cachedFile.Content = data
newCached, err := json.Marshal(cachedFile)
if err != nil {
return err
}
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(newCached)).Ex(c.ttl).Build())
} else {
c.cache.Do(ctx, c.cache.B().Expire().Key(key).Seconds(int64(c.ttl/time.Second)).Build())
}
return nil
})
slog.Debug("revalidation of read complete", "duration", time.Since(startTime))
}
+79
View File
@@ -0,0 +1,79 @@
package filesystem
import (
"context"
"encoding/json"
"log/slog"
"os"
"strings"
"time"
)
const keyReadDir = "webdav:readdir:"
func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) {
key := keyReadDir + path
ctx := context.Background()
// Try cache
val, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).ToString()
if err == nil && val != "" {
var cached []File
if err := json.Unmarshal([]byte(val), &cached); err == nil {
c.readDirHits.Add(1)
c.hits.Add(1)
slog.Debug("cache hit", "key", key)
infos := make([]os.FileInfo, len(cached))
for i, f := range cached {
infos[i] = f
}
return infos, nil
}
}
// Cache miss
c.misses.Add(1)
slog.Debug("cache miss", "key", key)
infos, err := c.impl.ReadDir(path)
if err != nil {
return nil, err
}
// Serialize and cache
cached := make([]File, 0, len(infos))
for _, info := range infos {
cached = append(cached, ParseToFile(info))
}
bytes, err := json.Marshal(cached)
if err == nil {
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return infos, nil
}
func (c *CachedClient) revalidateReadDir(ctx context.Context) {
startTime := time.Now()
c.scanAndProcess(ctx, keyReadDir+"*", func(key string) error {
path := strings.TrimPrefix(key, keyReadDir)
infos, err := c.impl.ReadDir(path)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return err
}
// Serialize and cache
cached := make([]File, 0, len(infos))
for _, info := range infos {
cached = append(cached, ParseToFile(info))
}
bytes, err := json.Marshal(cached)
if err == nil {
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return nil
})
slog.Debug("revalidation of readDir complete", "duration", time.Since(startTime))
}
+67
View File
@@ -0,0 +1,67 @@
package filesystem
import (
"context"
"encoding/json"
"log/slog"
"os"
"strings"
"time"
)
func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) {
key := keyStat + path
ctx := context.Background()
// Try cache
val, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).ToString()
if err == nil && val != "" {
var f File
if err := json.Unmarshal([]byte(val), &f); err == nil {
c.hits.Add(1)
slog.Debug("cache hit", "key", key)
return f, nil
}
}
// Cache miss
c.misses.Add(1)
slog.Debug("cache miss", "key", key)
fileInfo, err := c.impl.Stat(path)
if err != nil {
return
}
// Serialize and cache
// Type assert to ensure we capture ContentType and Path
info = ParseToFile(fileInfo)
bytes, err := json.Marshal(info)
if err == nil {
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return
}
func (c *CachedClient) revalidateStat(ctx context.Context) {
startTime := time.Now()
c.scanAndProcess(ctx, keyStat+"*", func(key string) error {
path := strings.TrimPrefix(key, keyStat)
info, err := c.impl.Stat(path)
if err != nil {
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
return err
}
// Serialize and cache
f := ParseToFile(info)
bytes, err := json.Marshal(f)
if err == nil {
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
}
return nil
})
slog.Debug("revalidation of Stat complete", "duration", time.Since(startTime))
}
+34
View File
@@ -0,0 +1,34 @@
package templer
import (
"context"
"log/slog"
"net/http"
"strings"
)
type ctxKey int
const (
pathKey ctxKey = iota
)
func Middleware(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, pathKey, r.URL.Path)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
// Path returns the current path without a leading slash
func Path(ctx context.Context) string {
path, ok := ctx.Value(pathKey).(string)
if !ok {
slog.Error("could not extract request path from context")
return ""
}
return strings.TrimPrefix(path, "/")
}