kv

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Aug 19, 2026 License: MIT Imports: 18 Imported by: 0

README

Arandu

arandu-io/kv

Cache, sessions, rate limiting and locks for Arandu, over RESP.

Build Status Go Reference Latest Version License

About

kv is Arandu's adapter over RESP: a cache, a distributed rate limiter, a distributed lock, and a session backend that lets a signed-in visitor land on any replica. It is called kv and not redis because RESP is one protocol answered by four products — Dragonfly, Redis, Valkey and KeyDB — and switching between them is a connection string, not a code change.

Moving into hesape

This repository is the previous address. Its content now lives inside arandu-io/hesape, at hesape/redis — its own module there too, since it is the one place that carries the RESP driver. See ADR-0048 for the reasoning: hesape collects the framework's components in one place, and a developer looking for the cache, the lock or the session backend finds it inside that collection instead of needing to know it lives in a repository of its own.

This module is not being deleted. It stays published, and the Go module proxy keeps serving every version already tagged. New work happens in hesape; this address is for whatever already depends on it.

What it delivers

One dependency: redis/go-redis/v9 v9.22.0. Speaking RESP means Dragonfly, Redis, Valkey and KeyDB all work through the same driver, with a restriction that keeps them interchangeable: nothing here may depend on RedisJSON, RediSearch or advanced Lua scripting.

CacheGet, Put, Forget, Remember, every one of them behind a security.Grant. Every cache key is prefixed by the tenant the Grant carries — (*Client).key(tenant, namespace, id), in kv/kv.go:115 — because a cache key shared across tenants is a data leak with a fast path.

Limiter, Locker and sessions. Limiter.Allow is the distributed rate limiter that keeps N replicas from allowing N times the intended limit. Locker.Acquire / Run is the distributed lock the scheduler and the outbox relay use so N replicas do not run the same task N times. SessionBackend holds a signed-in session across replicas instead of pinning a visitor to the instance that logged them in. These three are deliberately not tenant-scoped — a scheduler lock or a rate limit covers the whole instance, not one tenant's slice of it.

816 lines of production code, 685 of test.

Installation

go get github.com/arandu-io/hesape/redis

This module's own path, github.com/arandu-io/kv, still resolves for anything already pinned to it.

Learning Arandu

The API reference is generated from the doc comments and lives on pkg.go.dev. Every exported symbol carries one, and that is deliberate: it is the documentation that cannot drift from the code, because it sits in the same file.

The CLI documents itself. aru help lists every command, and each one explains what it writes and what to do with it. aru doctor explains what it found and what breaks, not which rule was violated.

A guide and a website do not exist yet, and that is a decision rather than a gap: a guide written against an API that still moves is work done twice, and the second time is worse — there is wrong documentation published. The site is the next phase, and it will be an Arandu application.

Contributing

See CONTRIBUTING.md. Before opening a pull request, the three commands at the top of that file have to pass, and CI runs exactly them.

Security Vulnerabilities

Please review our security policy on how to report a vulnerability. Never open a public issue for one.

License

Open-sourced software licensed under the MIT license.

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. 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: 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) AcquireLock added in v0.5.0

func (c *Client) AcquireLock(ctx context.Context, key, token string, ttl time.Duration) (bool, error)

AcquireLock stores token under key for ttl if the key is free, and reports whether it took it.

The token comes from the caller rather than from here, which is the whole difference between this and Locker.Acquire: an issuer that owns the token can hand the same lock to a second holder, and can release one it did not take. Both are what a lock over a shared store has to be able to say.

A key somebody else holds is (false, nil) and not an error. Asking for a lock and being told no is the answer, not a fault.

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.

func (*Client) ReleaseLock added in v0.5.0

func (c *Client) ReleaseLock(ctx context.Context, key, token string) error

ReleaseLock removes key only if it still holds token.

Releasing a lock that expired, or that somebody else now holds, is not an error and deletes nothing -- which is the reason the token exists. A holder whose lock expired mid-work would otherwise delete the lock its successor is standing on.

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(*http.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
	// TLS encrypts the connection. Nil leaves it in the clear, which is the
	// default because a client that demanded TLS would refuse every server that
	// does not offer it. Turning it on is the operator's decision, and on any
	// network the process does not own it is the only correct one: without it
	// the password, the session ids and every cached value cross the wire as
	// plain text, and so does the length the server declares for each reply.
	//
	// It is crypto/tls's own type rather than a handful of narrower fields,
	// because the narrower fields do not reach the case that needs them. A
	// managed endpoint presents a certificate a public authority signed, and for
	// that a zero &tls.Config{} is the whole configuration -- the host half of
	// Address becomes the name the certificate has to carry. A self-hosted
	// server does neither: it presents a certificate signed by an authority of
	// its own and, by default, demands one from the client in return. A private
	// root and a client certificate are what tls.Config already says, and a
	// second vocabulary for them here would say less of it.
	//
	// The value is used as given, not copied, so it must not be mutated after
	// Connect returns.
	TLS *tls.Config
}

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 bootstrap/app.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) DeleteSubject added in v0.4.0

func (b *SessionBackend) DeleteSubject(ctx context.Context, tenant, subjectID, keepID string) error

DeleteSubject removes every session of one subject of one tenant except keepID.

The index is allowed to name sessions that are already gone -- a logout deletes by id and does not know whose id it was -- so the ids are deleted without asking whether they are still there. Deleting a key that expired yesterday costs one round trip and is not an error; keeping the index exact would cost a read on every logout. What it may not do is name them forever, which is why index scores them by expiry and drops the dead ones on write.

An empty subject id is refused rather than treated as a subject. It names every session nobody has signed in on -- the guests -- and security.MemoryBackend refuses the identical call. Two backends that disagree about a bulk sign-out diverge only where this one runs, which is production.

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