kv

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: MIT Imports: 15 Imported by: 0

README

kv

Key-value adapter for Arandu: cache, sessions, rate limiting and distributed locks.

It speaks RESP, which is one protocol and four products — Dragonfly, Redis, Valkey and KeyDB all answer it, and switching between them is a connection string. That is why the package is called kv and not redis: the name would tie the identity to a product that is not even the recommended one.

client := kv.Connect(kv.Options{Address: cfg.KV.Address, Prefix: "acme"})

k := kernel.New(cfg).
    Register(kv.NewModule(client))

sessions := security.NewSessionStore(cfg.AppKey, cfg.SessionTTL, true, kv.NewSessionBackend(client))
limiter  := kv.NewLimiter(client)
locks    := kv.NewLocker(client)
cache    := kv.NewCache(client, "invoice")

Every cache key is prefixed by tenant, and the tenant comes from the Grant. Sessions, rate limits and locks are not: a session id is what identifies the tenant, a rate limit applies before there is one, and a scheduler lock covers the whole instance.

Nothing here depends on RedisJSON, RediSearch or advanced Lua. The moment something does, Dragonfly stops being a drop-in replacement and four products become one.

MIT. See LICENSE.md.

Documentation

Overview

Package kv is the key-value adapter: cache, sessions, rate limiting and locks.

It speaks RESP, which is one protocol and four products: Dragonfly, Redis, Valkey and KeyDB all answer it, and switching between them is a connection string. That is why the package is called kv and not redis -- the name would tie the identity to a product that is not even the recommended one.

Dragonfly is the recommended default: multi-threaded, and compatible enough that adopting it costs nothing. The price of that compatibility is a restriction this package accepts everywhere: nothing here may depend on RedisJSON, RediSearch or advanced Lua. The moment something does, Dragonfly stops being a drop-in replacement and four products become one.

Every key is prefixed by tenant, and the tenant comes from the Grant (RULE 14). A cache shared across tenants is a data leak with a fast path.

Index

Constants

This section is empty.

Variables

View Source
var ErrLocked = errors.New("kv: the lock is held")

ErrLocked is returned when the lock is held by someone else.

View Source
var ErrNoTTL = errTTL{}

ErrNoTTL is returned by Put when no expiry was given.

View Source
var ErrNoTenant = errors.New("kv: the operation needs a tenant, and the Grant carries none")

ErrNoTenant is returned when a tenant-scoped operation gets no tenant.

It is an error rather than a global fallback, and that is RULE 14 with teeth: a cache key without a tenant is one request away from serving one customer's data to another.

View Source
var ErrNotFound = errors.New("kv: not found")

ErrNotFound is returned when a key is absent.

It is distinct from an empty value on purpose: "not cached" and "cached as empty" lead to different code, and conflating them is how a cache stampede starts.

Functions

This section is empty.

Types

type Cache

type Cache struct {
	// contains filtered or unexported fields
}

Cache is a tenant-scoped cache.

Every method takes a Grant, and the tenant comes from it. That is not ceremony: a cache key shared across tenants is a data leak with a fast path, and it is the kind that survives review because the query underneath was correct.

func NewCache

func NewCache(c *Client, namespace string) *Cache

NewCache returns a cache under a namespace: "user", "invoice-total".

func (*Cache) Forget

func (c *Cache) Forget(ctx context.Context, g security.Grant, id string) error

Forget removes a key. Removing what is not there is not an error: the caller wanted the key gone, and it is.

func (*Cache) Get

func (c *Cache) Get(ctx context.Context, g security.Grant, id string, v any) error

Get reads a value into v.

It returns ErrNotFound when the key is absent, which the caller is expected to treat as "compute it", not as an error to propagate.

func (*Cache) Put

func (c *Cache) Put(ctx context.Context, g security.Grant, id string, v any, ttl time.Duration) error

Put stores a value with a time to live.

The TTL is required rather than optional. A cache entry with no expiry is a second copy of the truth, and the day it diverges nobody knows it exists.

func (*Cache) Remember

func (c *Cache) Remember(ctx context.Context, g security.Grant, id string, ttl time.Duration, v any, compute func() (any, error)) error

Remember returns the cached value, computing and storing it on a miss.

This is the shape that belongs in a service, and having it here is what keeps the get-check-compute-put sequence from being written slightly differently in every module.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the connection.

It wraps the driver rather than exposing it, for the same reason data.DB wraps *sql.DB: what goes through the wrapper carries the tenant prefix, and what bypasses it does not.

func Connect

func Connect(opts Options) *Client

Connect opens the client. It does not talk to the server: use Ping for that, which is what the module health check does.

func (*Client) Close

func (c *Client) Close() error

Close releases the pool.

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) error

Ping verifies the connection. It feeds the module health check.

type Limiter

type Limiter struct {
	// contains filtered or unexported fields
}

Limiter is the distributed rate limiter.

The in-memory one in the core counts per process, which means N replicas allow N times the limit -- and the endpoint that matters most, login, is exactly the one where that gap is worth exploiting.

It implements middleware.Limiter, so swapping it in is one line.

func NewLimiter

func NewLimiter(c *Client) *Limiter

NewLimiter returns the limiter.

func (*Limiter) Allow

func (l *Limiter) Allow(key string, limit int, window time.Duration) (remaining int, retryAfter time.Duration, ok bool)

Allow counts one hit against the key.

The window is fixed rather than sliding: INCR plus EXPIRE in one round trip, no Lua, no sorted set. A sliding window would be more precise and would need either a script or a ZSET per key -- and precision is not what a rate limit is for. What it is for is stopping a flood, and a fixed window stops it.

The known cost is the boundary: a client can send `limit` requests at the end of one window and `limit` at the start of the next. For login throttling, that is two bursts instead of one, which is not the attack anyone is worried about.

type Lock

type Lock struct {
	// contains filtered or unexported fields
}

Lock is a held lock. Release it with Unlock, and only the holder can.

func (*Lock) Unlock

func (l *Lock) Unlock(ctx context.Context) error

Unlock releases the lock, and only if this holder still owns it.

The compare-and-delete goes through WATCH/MULTI/EXEC rather than through a Lua script. The script is the canonical form and would be one line shorter; the transaction is plain RESP, and this package does not depend on Lua at all -- which is what keeps Dragonfly a drop-in replacement (doc 11).

type Locker

type Locker struct {
	// contains filtered or unexported fields
}

Locker is a distributed lock.

It exists for the scheduler: with N replicas, a task scheduled every minute runs N times a minute unless exactly one of them wins a lock first. Same for the outbox relay, which would otherwise publish every event N times.

This is not a consensus lock. It is correct while the store is up and one node answers, and it fails the way every such lock fails: a network partition long enough to outlive the TTL can let two holders exist. The mitigation is the same one the scheduler needs anyway -- tasks are idempotent, because at-least-once is what a distributed scheduler delivers.

func NewLocker

func NewLocker(c *Client) *Locker

NewLocker returns the locker.

func (*Locker) Acquire

func (l *Locker) Acquire(ctx context.Context, name string, ttl time.Duration) (*Lock, error)

Acquire takes the lock, or returns ErrLocked.

The TTL is required and is the deadlock protection: a process that dies holding the lock releases it when the TTL expires, and there is no other way out. Size it above the longest run of the work it guards, or a second worker starts while the first is still going.

func (*Locker) Run

func (l *Locker) Run(ctx context.Context, name string, ttl time.Duration, fn func(context.Context) error) error

Run acquires the lock, runs fn, and releases it.

This is the shape the scheduler and the relay use, and having it here is what stops the acquire-defer-release sequence from being written slightly wrong in each of them. A lock that is already held returns ErrLocked and fn does not run -- which for a scheduled task means "another replica is doing it", not an error to report.

type Module

type Module struct {
	// contains filtered or unexported fields
}

Module reports the connection on the health check.

It registers no routes and owns no tables. It exists so that "the key-value store is down" shows up on /_arandu/health next to the database, rather than as a class of request failures somebody has to correlate by hand.

func NewModule

func NewModule(c *Client) *Module

NewModule returns the module.

func (*Module) Close

func (m *Module) Close(context.Context) error

Close releases the pool on shutdown.

func (*Module) Health

func (m *Module) Health(ctx context.Context) error

Health pings the store.

func (*Module) Name

func (*Module) Name() string

Name is the module identifier.

func (*Module) Routes

func (*Module) Routes(*httpx.Router)

Routes registers nothing.

type Options

type Options struct {
	// Address is host:port. It is one address, not a cluster: RESP sharding
	// belongs to the deployment, and Dragonfly exists precisely so a single node
	// covers what a Redis cluster used to.
	Address string
	// Password is optional, and comes from configuration -- never from a literal.
	Password string
	// Database is the numbered database. Zero is right for almost everything;
	// separating environments belongs to separate instances, not to db 1.
	Database int
	// Prefix namespaces every key of this application, so two applications can
	// share one server without one flushing the other's cache.
	Prefix string
	// DialTimeout bounds the connect. Unbounded, a key-value store that is down
	// turns into requests that hang rather than requests that fail.
	DialTimeout time.Duration
	// ReadTimeout bounds each command.
	ReadTimeout time.Duration
}

Options is what it takes to connect.

type SessionBackend

type SessionBackend struct {
	// contains filtered or unexported fields
}

SessionBackend is the distributed session store.

The core ships the in-memory one, which is correct for one instance and wrong for two: behind a load balancer, half the requests land on the replica that never saw the login. This is the same interface, over RESP.

It implements security.SessionBackend, so swapping it in is one line in cmd/app/main.go and nothing else changes.

func NewSessionBackend

func NewSessionBackend(c *Client) *SessionBackend

NewSessionBackend returns the store.

func (*SessionBackend) Delete

func (b *SessionBackend) Delete(ctx context.Context, id string) error

Delete removes the session.

This is what the in-memory backend cannot do across replicas: a logout, or a password change, invalidates the session everywhere rather than only on the instance that handled the request.

func (*SessionBackend) Get

Get returns the subject behind a session id.

func (*SessionBackend) Put

Put stores the subject with the session's time to live.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL