Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/TecharoHq/Anubis/llms.txt

Use this file to discover all available pages before exploring further.

Anubis uses the store.Interface abstraction for all persistence operations. This allows you to implement custom storage backends for Redis, databases, cloud storage, or any key-value system.

Store Interface

The core storage interface defines four methods:
type Interface interface {
	// Delete removes a value from the store by key.
	Delete(ctx context.Context, key string) error

	// Get returns the value of a key assuming that value exists and has not expired.
	Get(ctx context.Context, key string) ([]byte, error)

	// Set puts a value into the store that expires according to its expiry.
	Set(ctx context.Context, key string, value []byte, expiry time.Duration) error

	// IsPersistent returns true if this storage backend persists data across
	// service restarts (e.g., bbolt, valkey). Returns false for volatile storage
	// like in-memory backends.
	IsPersistent() bool
}
Source: lib/store/interface.go:31-45

Standard Errors

Implementations should return these sentinel errors:
var (
	// ErrNotFound is returned when the store implementation cannot find the value
	// for a given key.
	ErrNotFound = errors.New("store: key not found")

	// ErrCantDecode is returned when a store adaptor cannot decode the store format
	// to a value used by the code.
	ErrCantDecode = errors.New("store: can't decode value")

	// ErrCantEncode is returned when a store adaptor cannot encode the value into
	// the format that the store uses.
	ErrCantEncode = errors.New("store: can't encode value")

	// ErrBadConfig is returned when a store adaptor's configuration is invalid.
	ErrBadConfig = errors.New("store: configuration is invalid")
)
Source: lib/store/interface.go:11-26

Implementation Example: In-Memory Store

The memory backend demonstrates a minimal implementation:
package memory

import (
	"context"
	"fmt"
	"time"

	"github.com/TecharoHQ/anubis/decaymap"
	"github.com/TecharoHQ/anubis/lib/store"
)

type impl struct {
	store *decaymap.Impl[string, []byte]
}

func (i *impl) Delete(_ context.Context, key string) error {
	if !i.store.Delete(key) {
		return fmt.Errorf("%w: %q", store.ErrNotFound, key)
	}
	return nil
}

func (i *impl) Get(_ context.Context, key string) ([]byte, error) {
	result, ok := i.store.Get(key)
	if !ok {
		return nil, fmt.Errorf("%w: %q", store.ErrNotFound, key)
	}
	return result, nil
}

func (i *impl) Set(_ context.Context, key string, value []byte, expiry time.Duration) error {
	i.store.Set(key, value, expiry)
	return nil
}

func (i *impl) IsPersistent() bool {
	return false
}

func New(ctx context.Context) store.Interface {
	result := &impl{
		store: decaymap.New[string, []byte](),
	}
	go result.cleanupThread(ctx)
	return result
}
Source: lib/store/memory/memory.go:25-78

Implementation Example: Valkey/Redis

The Valkey backend shows integration with external systems:
package valkey

import (
	"context"
	"time"

	"github.com/TecharoHQ/anubis/lib/store"
	valkey "github.com/redis/go-redis/v9"
)

type Store struct {
	client redisClient
}

var _ store.Interface = (*Store)(nil)

func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
	cmd := s.client.Get(ctx, key)
	if err := cmd.Err(); err != nil {
		if err == valkey.Nil {
			return nil, store.ErrNotFound
		}
		return nil, err
	}
	return cmd.Bytes()
}

func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
	return s.client.Set(ctx, key, value, expiry).Err()
}

func (s *Store) Delete(ctx context.Context, key string) error {
	res := s.client.Del(ctx, key)
	if err := res.Err(); err != nil {
		return err
	}
	if n, _ := res.Result(); n == 0 {
		return store.ErrNotFound
	}
	return nil
}

func (s *Store) IsPersistent() bool {
	return true
}
Source: lib/store/valkey/valkey.go

Implementation Example: bbolt

The bbolt backend demonstrates persistent on-disk storage with expiry management:
// Store implements store.Interface backed by bbolt.
//
// In essence, bbolt is a hierarchical key/value store with a twist: every value
// needs to belong to a bucket. Each value in the store is given its own bucket 
// with two keys:
//
// 1. data - The raw data, usually in JSON
// 2. expiry - The expiry time formatted as a time.RFC3339Nano timestamp string
type Store struct {
	bdb *bbolt.DB
}

func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
	var result []byte

	if err := s.bdb.View(func(tx *bbolt.Tx) error {
		itemBucket := tx.Bucket([]byte(key))
		if itemBucket == nil {
			return fmt.Errorf("%w: %q", store.ErrNotFound, key)
		}

		expiryStr := itemBucket.Get([]byte("expiry"))
		if expiryStr == nil {
			return fmt.Errorf("[unexpected] %w: %q (expiry is nil)", store.ErrNotFound, key)
		}

		expiry, err := time.Parse(time.RFC3339Nano, string(expiryStr))
		if err != nil {
			return fmt.Errorf("[unexpected] %w: %w", store.ErrCantDecode, err)
		}

		if time.Now().After(expiry) {
			go s.Delete(context.Background(), key)
			return fmt.Errorf("%w: %q", store.ErrNotFound, key)
		}

		dataStr := itemBucket.Get([]byte("data"))
		if dataStr == nil {
			return fmt.Errorf("[unexpected] %w: %q (data is nil)", store.ErrNotFound, key)
		}

		result = make([]byte, len(dataStr))
		copy(result, dataStr)
		return nil
	); err != nil {
		return nil, err
	}

	return result, nil
}

func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
	expires := time.Now().Add(expiry)

	return s.bdb.Update(func(tx *bbolt.Tx) error {
		valueBkt, err := tx.CreateBucketIfNotExists([]byte(key))
		if err != nil {
			return fmt.Errorf("%w: %w: %q (create bucket)", store.ErrCantEncode, err, key)
		}

		if err := valueBkt.Put([]byte("expiry"), []byte(expires.Format(time.RFC3339Nano))); err != nil {
			return fmt.Errorf("%w: %q (expiry)", store.ErrCantEncode, key)
		}

		if err := valueBkt.Put([]byte("data"), value); err != nil {
			return fmt.Errorf("%w: %q (data)", store.ErrCantEncode, key)
		}

		return nil
	)
}
Source: lib/store/bbolt/bbolt.go:60-122

Factory Pattern

Store backends use a factory pattern for registration:
type Factory interface {
	Build(ctx context.Context, config json.RawMessage) (Interface, error)
	Valid(config json.RawMessage) error
}

func Register(name string, impl Factory) {
	regLock.Lock()
	defer regLock.Unlock()
	registry[name] = impl
}
Source: lib/store/registry.go:15-25

Example Factory

type factory struct{}

func (factory) Build(ctx context.Context, _ json.RawMessage) (store.Interface, error) {
	return New(ctx), nil
}

func (factory) Valid(json.RawMessage) error { 
	return nil 
}

func init() {
	store.Register("memory", factory{)
}
Source: lib/store/memory/memory.go:13-23

JSON Type Wrapper

Anubis provides a generic JSON wrapper for type-safe operations:
type JSON[T any] struct {
	Underlying Interface
	Prefix     string
}

func (j *JSON[T]) Get(ctx context.Context, key string) (T, error) {
	if j.Prefix != "" {
		key = j.Prefix + key
	}

	data, err := j.Underlying.Get(ctx, key)
	if err != nil {
		return z[T](), err
	}

	var result T
	if err := json.Unmarshal(data, &result); err != nil {
		return z[T](), fmt.Errorf("%w: %w", ErrCantDecode, err)
	}

	return result, nil
}

func (j *JSON[T]) Set(ctx context.Context, key string, value T, expiry time.Duration) error {
	if j.Prefix != "" {
		key = j.Prefix + key
	}

	data, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("%w: %w", ErrCantEncode, err)
	}

	return j.Underlying.Set(ctx, key, data, expiry)
}
Source: lib/store/interface.go:49-95

Configuration

Reference your store backend in anubis.yaml:
store:
  backend: mycustom
  parameters:
    connection_string: "postgresql://..."
    pool_size: 10

Cleanup Threads

Implement background cleanup for expired keys:
func (s *Store) cleanupThread(ctx context.Context) {
	t := time.NewTicker(time.Hour)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			if err := s.cleanup(ctx); err != nil {
				slog.Error("error during cleanup", "err", err)
			}
		}
	}
}
Source: lib/store/bbolt/bbolt.go:156-170

Best Practices

  1. Return standard errors: Use store.ErrNotFound, store.ErrCantDecode, etc.
  2. Handle expiry: Implement automatic cleanup or lazy deletion of expired keys
  3. Context awareness: Honor context.Context for cancellation and timeouts
  4. IsPersistent accuracy: Return false for in-memory stores, true for persistent backends
  5. Thread safety: Ensure concurrent access is safe
  6. Configuration validation: Implement Factory.Valid() to catch config errors early

Available Backends

Query registered stores at runtime:
methods := store.Methods()  // Returns []string of backend names
factory, ok := store.Get("bbolt")  // Get a specific factory
Source: lib/store/registry.go:34-42