diff --git a/backend/plugins/infra/cache_memory/cache.go b/backend/plugins/infra/cache_memory/cache.go new file mode 100644 index 00000000..b617541f --- /dev/null +++ b/backend/plugins/infra/cache_memory/cache.go @@ -0,0 +1,101 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache_memory + +import ( + "context" + "encoding/json" + "time" + + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/cache/ram" +) + +type ramEntry struct { + data []byte + expireAt time.Time +} + +type memoryCacheService struct { + ramCache *ram.Cache[string, ramEntry] + events *core.EventBus +} + +func newMemoryCacheService(capacity int, events *core.EventBus) (*memoryCacheService, error) { + ramCache, err := ram.New[string, ramEntry](ram.Options{ + MaximumSize: capacity, + }) + if err != nil { + return nil, err + } + + return &memoryCacheService{ + ramCache: ramCache, + events: events, + }, nil +} + +func (s *memoryCacheService) Get(ctx context.Context, key string, target any) error { + if entry, ok := s.ramCache.GetIfPresent(key); ok { + if entry.expireAt.IsZero() || time.Now().Before(entry.expireAt) { + return json.Unmarshal(entry.data, target) + } + // Expired + s.ramCache.Invalidate(key) + } + return contracts.ErrCacheMiss +} + +func (s *memoryCacheService) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + + var expireAt time.Time + if ttl > 0 { + expireAt = time.Now().Add(ttl) + } + + s.ramCache.Set(key, ramEntry{ + data: data, + expireAt: expireAt, + }) + return nil +} + +func (s *memoryCacheService) Delete(ctx context.Context, key string) error { + s.ramCache.Invalidate(key) + if s.events != nil { + _ = s.events.Emit(ctx, "cache:invalidate", key) + } + return nil +} + +func (s *memoryCacheService) Invalidate(ctx context.Context, key string) error { + return s.Delete(ctx, key) +} + +func (s *memoryCacheService) GetOrSet(ctx context.Context, key string, target any, ttl time.Duration, loader func() (any, error)) error { + err := s.Get(ctx, key, target) + if err == nil { + return nil + } + + val, err := loader() + if err != nil { + return err + } + + if setErr := s.Set(ctx, key, val, ttl); setErr != nil { + return setErr + } + + data, err := json.Marshal(val) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} diff --git a/backend/plugins/infra/cache_memory/plugin.go b/backend/plugins/infra/cache_memory/plugin.go new file mode 100644 index 00000000..1242226c --- /dev/null +++ b/backend/plugins/infra/cache_memory/plugin.go @@ -0,0 +1,66 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package cache_memory provides the in-memory cache infrastructure plugin for Cordis. +package cache_memory + +import ( + "Wavelet/core" + "Wavelet/core/contracts" +) + +const defaultRAMCapacity = 10000 + +// Option configures the cache_memory plugin. +type Option func(*Plugin) + +// WithCapacity sets the maximum capacity for the in-memory cache. +func WithCapacity(capacity int) Option { + return func(p *Plugin) { + p.capacity = capacity + } +} + +// Plugin implements core.Plugin to provide contracts.CacheService using in-memory storage. +type Plugin struct { + capacity int +} + +// New creates a new in-memory cache infrastructure plugin. +func New(opts ...Option) *Plugin { + p := &Plugin{ + capacity: defaultRAMCapacity, + } + for _, opt := range opts { + if opt != nil { + opt(p) + } + } + return p +} + +// Name returns the unique identifier of the cache_memory plugin. +func (p *Plugin) Name() string { + return "cache_memory" +} + +// Manifest returns the plugin metadata. +func (p *Plugin) Manifest() core.Manifest { + return core.Manifest{ + Name: "cache_memory", + Version: "1.0.0", + Description: "Zero-dependency pure in-memory cache infrastructure plugin", + Author: "Wavelet Team", + } +} + +// Apply mounts the in-memory cache service into the Context. +func (p *Plugin) Apply(ctx *core.Context) error { + svc, err := newMemoryCacheService(p.capacity, ctx.Events()) + if err != nil { + return err + } + + core.Provide[contracts.CacheService](ctx, svc) + return nil +} diff --git a/backend/plugins/infra/cache_memory/plugin_test.go b/backend/plugins/infra/cache_memory/plugin_test.go new file mode 100644 index 00000000..e8f27a1e --- /dev/null +++ b/backend/plugins/infra/cache_memory/plugin_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cache_memory_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/infra/cache_memory" +) + +func TestCacheMemoryPlugin(t *testing.T) { + ctx := core.NewContext(context.Background()) + p := cache_memory.New(cache_memory.WithCapacity(100)) + + assert.Equal(t, "cache_memory", p.Name()) + assert.Equal(t, "1.0.0", p.Manifest().Version) + require.NoError(t, p.Apply(ctx)) + + cacheSvc, err := core.Inject[contracts.CacheService](ctx) + require.NoError(t, err) + require.NotNil(t, cacheSvc) + + reqCtx := context.Background() + + // 1. Get non-existent + var target string + err = cacheSvc.Get(reqCtx, "non_existent", &target) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 2. Set and Get + err = cacheSvc.Set(reqCtx, "user:100", "Alice", 10*time.Minute) + require.NoError(t, err) + + var name string + err = cacheSvc.Get(reqCtx, "user:100", &name) + require.NoError(t, err) + assert.Equal(t, "Alice", name) + + // 3. GetOrSet + var count int + var loaded int + loader := func() (any, error) { + loaded++ + return 42, nil + } + err = cacheSvc.GetOrSet(reqCtx, "counter", &count, 10*time.Minute, loader) + require.NoError(t, err) + assert.Equal(t, 42, count) + assert.Equal(t, 1, loaded) + + // Second GetOrSet hits cache + var count2 int + err = cacheSvc.GetOrSet(reqCtx, "counter", &count2, 10*time.Minute, loader) + require.NoError(t, err) + assert.Equal(t, 42, count2) + assert.Equal(t, 1, loaded) + + // 4. Delete & Invalidate + require.NoError(t, cacheSvc.Delete(reqCtx, "user:100")) + err = cacheSvc.Get(reqCtx, "user:100", &name) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + require.NoError(t, cacheSvc.Invalidate(reqCtx, "counter")) + err = cacheSvc.Get(reqCtx, "counter", &count) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) + + // 5. Expiration + err = cacheSvc.Set(reqCtx, "temp_key", "val", 50*time.Millisecond) + require.NoError(t, err) + time.Sleep(70 * time.Millisecond) + var tempVal string + err = cacheSvc.Get(reqCtx, "temp_key", &tempVal) + assert.ErrorIs(t, err, contracts.ErrCacheMiss) +}