cache

package
v0.20.0 Latest Latest
Warning

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

Go to latest
Published: Aug 31, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package cache is the cache: a Repository over a Store, the stores, the locks, the tags and the rate limiter.

It is split in two. Repository is the thing an application calls; Store is the thing a backend implements. The split is what lets the in-process ArrayStore and the RESP store in hesape/redis be the same cache from the call site, and it is what makes cachetest possible -- one contract suite that every Store passes.

The Grant is not decoration

Every Repository method takes an auth.Grant and the tenant comes out of it. 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. The key is cache:<tenant>:<namespace>:<key>, and a Grant carrying no tenant -- or a tenant that could be read as a separator -- is an error, not a global bucket.

Locks and rate limits are the exceptions: a scheduler lock covers the whole instance, and rate limiting happens before anybody has authenticated, so neither has a Grant to take a tenant from. Locks and RateLimiter therefore take a name and a key, not a Grant.

Every entry expires

Put requires a ttl. Forever is here, and it is written down as a century rather than as an absence, because an entry with no expiry is a second copy of the truth and the day it diverges nobody knows it exists. Reach for Forever rarely.

A ttl that has already passed is not an entry: Put and PutMany forget the keys, and Add reports that it wrote nothing. Increment is the exception, and its doc comment says why.

The stores

ArrayStore is in-process and is the default. FileStore is on disk, shared between the processes of one machine. DatabaseStore is a table, and is the only one whose locks survive the cache being emptied. NullStore keeps nothing. MemoizedStore remembers, for one request, what another store already answered. FailoverStore tries several in turn.

The RESP store -- Dragonfly, Redis, Valkey and KeyDB, which are one product to this collection -- is hesape/redis, a separate module, and it arrives through CacheManager.Extend, so that the driver ships in the binaries that use it and in no others.

Index

Constants

View Source
const LockTable = "cache_locks"

LockTable is the table it keeps locks in.

Two tables and not one because flushing the locks must not flush the cache, and a store whose locks live in the cache table refuses to do it at all -- see DatabaseStore.HasSeparateLockStore.

View Source
const Table = "cache"

Table is the table DatabaseStore keeps entries in.

Variables

View Source
var ErrAllStoresFailed = errors.New("cache: every store in the failover set failed")

ErrAllStoresFailed is returned when every store in a failover set refused.

It is only reached when the set is empty: with stores in it, the last one's own error is returned instead, because "the cache is down" is less use than "the connection was refused".

View Source
var ErrLockTimeout = errors.New("cache: waiting for the lock timed out")

ErrLockTimeout is returned by Lock.Block when the wait ran out before the lock came free.

It is distinct from ErrLocked, which says "not now"; this one says "not within the time you were willing to wait", and a caller that retries on the first should not retry on the second.

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

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

View Source
var ErrNoTTL = errors.New("cache: an entry needs a time to live, or it is a second copy of the truth that nobody knows exists")

ErrNoTTL is returned when something that must expire was given no expiry.

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

ErrNoTenant is returned when a Grant carries no tenant, or one that cannot be part of a key.

An error and not a fallback bucket: a cache key without a tenant is one request away from serving one customer's data to another.

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

ErrNotFound is returned when a key is absent.

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

View Source
var ErrUnsupported = errors.New("cache: this store does not support the operation")

ErrUnsupported is returned when a call needs something of the store that this store does not implement: flushing locks, reading a lock's owner.

It is an error rather than a panic because the wiring that chose the store is usually not the code making the call.

Functions

func Flexible

func Flexible[T any](ctx context.Context, r *Repository, g auth.Grant, key string, fresh, stale time.Duration, compute func(context.Context) (T, error)) (T, error)

Flexible returns the cached value, and refreshes it in the background once it has gone stale.

fresh is how long the value is served without a thought; stale is how long it is still served while a refresh runs behind it. A reader arriving after fresh has passed gets the old value immediately and pays nothing; one arriving after stale has passed computes.

This is the answer to a cache stampede on a value that is expensive and not exact -- a dashboard total, a count of things. It is not the answer for a value that must be right, because between fresh and stale it is by construction not.

The refresh takes a lock so that N readers arriving in the stale window produce one recomputation and not N; a store that cannot hold a lock refreshes without one, which is worse but is not wrong. It runs on a context detached from the caller's, so the request that triggered it can return.

func Get

func Get[T any](ctx context.Context, r *Repository, g auth.Grant, key string) (T, error)

Get reads a value.

It returns ErrNotFound when the key is absent, which the caller is expected to treat as "compute it", not as an error to propagate -- and usually the caller should be Remember instead.

It is a function and not a method because a method cannot take a type parameter in Go. Every generic entry point in this package has the repository as its first argument for that reason, and it is the same reason slices.SortFunc is not a method.

func GetMultiple

func GetMultiple[T any](ctx context.Context, r *Repository, g auth.Grant, keys []string) (map[string]T, error)

GetMultiple is Many under another name.

func Many

func Many[T any](ctx context.Context, r *Repository, g auth.Grant, keys ...string) (map[string]T, error)

Many reads several values in one call.

Every key asked for is in the result, including the part that surprises people: a miss comes back as the zero value of T.

That means Many cannot tell a miss from a cached zero. Get can, and is what to reach for when the difference matters.

func Pull

func Pull[T any](ctx context.Context, r *Repository, g auth.Grant, key string) (T, error)

Pull reads a value and forgets it.

It is the one-shot read: a flash value, a token that may be spent once. The forget happens after a successful read, so a miss leaves nothing to delete and a decode failure leaves the entry for whoever can read it.

func Remember

func Remember[T any](ctx context.Context, r *Repository, g auth.Grant, key string, ttl time.Duration, compute func(context.Context) (T, error)) (T, 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.

A store that is unreachable is not an outage: anything other than a missing tenant falls through and computes, and a failure to store is not a failure to answer. The cost of that choice is load, and the alternative is a cache being down taking the application with it.

A missing tenant is the exception, and it is deliberate: it is a bug in the caller, not a cache miss, and computing anyway would hide it until the day the value is wrong.

A computation that came back with nothing is cached, and it is deliberate: a miss is ErrNotFound and a stored nil is a value, so a callback returning nil twice runs once rather than twice. Keeping "not cached" apart from "cached as nothing" is the whole reason ErrNotFound exists -- see Has. What it buys is negative caching, which is usually the answer worth caching: "this customer has no plan" costs a query to produce and is asked on every request.

The cost is that a callback returning the zero value for a reason it expects to change soon is remembered for the whole ttl. Return an error instead when that is a failure, because an error is not cached.

There is no stampede protection here: N requests missing the same key all compute. Add is the primitive to build it on when something needs it, and it is not built in because the fix has a cost -- everybody waiting on one computation -- that only some callers want to pay.

func RememberForever

func RememberForever[T any](ctx context.Context, r *Repository, g auth.Grant, key string, compute func(context.Context) (T, error)) (T, error)

RememberForever returns the cached value, computing and storing it with no expiry on a miss.

See Forever for what "no expiry" means here, and for why it is worth a second thought before reaching for it.

func Sear

func Sear[T any](ctx context.Context, r *Repository, g auth.Grant, key string, compute func(context.Context) (T, error)) (T, error)

Sear is RememberForever, under its shorter name.

Types

type ArrayStore

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

ArrayStore is the in-process cache: a map, a mutex, and expiry.

It is the default store, and it is the right one for development, for tests, and for a single instance that only caches what it can recompute. It is the wrong one for anything else, and the reason is not performance: with N replicas there are N caches, so a Forget on one replica leaves the other N-1 serving the old value until the ttl runs out. When that matters, the store is the RESP one in hesape/redis -- a separate module, registered through CacheManager.Extend -- and nothing else in the application changes.

It is safe for concurrent use, and it holds a copy of every value it is given: a caller that reuses a buffer after a Put cannot rewrite what is cached, and a caller that mutates what Get returned cannot either.

func NewArrayStore

func NewArrayStore() *ArrayStore

NewArrayStore returns an empty in-process store.

func (*ArrayStore) AcquireLock

func (s *ArrayStore) AcquireLock(_ context.Context, key, token string, ttl time.Duration) (bool, error)

AcquireLock takes the lock if it is free.

func (*ArrayStore) Add

func (s *ArrayStore) Add(_ context.Context, key string, value []byte, ttl time.Duration) (bool, error)

Add stores value if the key is free, and reports whether it did.

func (*ArrayStore) All

func (s *ArrayStore) All() map[string]Entry

All returns every live entry and its expiry.

Expired entries are dropped on the way out rather than returned with a past deadline, so what All shows is what Get would find.

It is a snapshot: the map and every value in it are copies, so a caller that ranges over the result while another goroutine writes cannot see a torn read and cannot rewrite the cache by assigning into what it was given.

func (*ArrayStore) CurrentOwner

func (s *ArrayStore) CurrentOwner(_ context.Context, key string) (string, error)

CurrentOwner returns the token holding the lock, or the empty string.

A lock that expired is free, and free is the empty string rather than the token of whoever held it last -- otherwise IsOwnedBy would keep saying yes to a holder that lost the lock.

func (*ArrayStore) Decrement

func (s *ArrayStore) Decrement(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Decrement subtracts delta from the counter under key and returns the new value. It is Increment with the sign turned round.

func (*ArrayStore) Flush

func (s *ArrayStore) Flush(_ context.Context, prefix string) error

Flush removes every key beginning with prefix.

func (*ArrayStore) FlushLocks

func (s *ArrayStore) FlushLocks(_ context.Context) error

FlushLocks releases every lock this store holds.

It never refuses: locks live in their own map, precisely so that a Repository.Flush emptying one tenant's namespace cannot release the lock the scheduler is holding.

func (*ArrayStore) ForceReleaseLock

func (s *ArrayStore) ForceReleaseLock(_ context.Context, key string) error

ForceReleaseLock releases a lock whoever holds it.

It is the recovery hatch, not a tool: a caller that uses it routinely has two holders running at once and does not know it.

func (*ArrayStore) Forever

func (s *ArrayStore) Forever(ctx context.Context, key string, value []byte) error

Forever stores a value with no expiry the caller has to think about.

A Store here is promised a positive ttl, so "never" is the longest deadline the package is willing to write down -- see Repository.Forever for what that is and why it is a number and not a special case.

func (*ArrayStore) Forget

func (s *ArrayStore) Forget(_ context.Context, key string) error

Forget removes a key, present or not.

func (*ArrayStore) Get

func (s *ArrayStore) Get(_ context.Context, key string) ([]byte, error)

Get returns a copy of the stored bytes, or ErrNotFound.

func (*ArrayStore) GetPrefix

func (s *ArrayStore) GetPrefix() string

GetPrefix returns the prefix this store puts in front of every key.

It is the empty string: the store is a map in this process, nothing else is sharing it, and the prefixing that matters -- tenant and namespace -- happens in Repository, where it can be got right once.

func (*ArrayStore) HasSeparateLockStore

func (s *ArrayStore) HasSeparateLockStore() bool

HasSeparateLockStore reports whether locks live apart from entries, and the answer is always yes: see FlushLocks.

func (*ArrayStore) Increment

func (s *ArrayStore) Increment(_ context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Increment adds delta to the counter under key and returns the new value.

The expiry belongs to the counter and not to the increment: a key that is already there keeps the deadline it was created with, which is what makes a fixed window fixed.

func (*ArrayStore) Lock

func (s *ArrayStore) Lock(name string, ttl time.Duration, owner string) *Lock

Lock returns a handle on a named lock. It does not touch the store.

Pass an empty owner to have one minted at Acquire; pass one to restore a handle on a lock this process already took, which is what RestoreLock does.

func (*ArrayStore) Many

func (s *ArrayStore) Many(_ context.Context, keys []string) (map[string][]byte, error)

Many returns the stored bytes for several keys at once.

Keys that are absent or expired are present in the result with a nil value, so the caller can tell "I asked for six and got six" without holding the slice it asked with.

func (*ArrayStore) Put

func (s *ArrayStore) Put(_ context.Context, key string, value []byte, ttl time.Duration) error

Put stores value for ttl.

func (*ArrayStore) PutMany

func (s *ArrayStore) PutMany(_ context.Context, values map[string][]byte, ttl time.Duration) error

PutMany stores several values under one ttl.

It is a loop rather than a transaction: a store that is a map has nothing to roll back to.

func (*ArrayStore) ReleaseLock

func (s *ArrayStore) ReleaseLock(_ context.Context, key, token string) error

ReleaseLock releases the lock only if token still holds it.

func (*ArrayStore) RestoreLock

func (s *ArrayStore) RestoreLock(name, owner string) *Lock

RestoreLock returns a handle on a lock owner already holds.

It is what lets one process take a lock and another part of it release the same lock: the owner string is the whole handle.

func (*ArrayStore) Touch

func (s *ArrayStore) Touch(_ context.Context, key string, ttl time.Duration) (bool, error)

Touch gives a live entry a new expiry and reports whether there was one.

An absent or expired key is false and is not created: touching what is not there would turn a cache miss into a cache entry holding nothing.

type CacheManager

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

CacheManager is every cache store an application has, by name.

It resolves a store the first time it is asked for and keeps it, so Store("redis") called in forty places is one connection and not forty.

It is not a container. It holds a Config that was built at wiring time, and the thing it hands back is an ordinary *Repository that a caller could equally have built itself. What it buys is the one place that knows what "the file store" means, so a second module cannot decide it means a different directory.

The drivers it builds are array, file, database, null and failover. Redis is not one of them: the RESP store is hesape/redis, a separate module, so that its driver ships only in the binaries that use it. Register it with Extend, which is what Extend is for.

Memo is not a driver either. It is a method, because a memoized store is per request and a configuration is not.

A CacheManager is safe for concurrent use.

func NewCacheManager

func NewCacheManager(config Config) *CacheManager

NewCacheManager returns the manager over a configuration.

func (*CacheManager) Build

func (m *CacheManager) Build(config StoreConfig) (*Repository, error)

Build makes a repository out of one store's configuration.

It is exported because a store that exists for one job, built from a configuration nobody wants to name, is a real thing. A configuration carrying no name is called "ondemand".

func (*CacheManager) Driver

func (m *CacheManager) Driver(driver string) (*Repository, error)

Driver is Store under another name.

func (*CacheManager) Extend

func (m *CacheManager) Extend(driver string, creator Creator) *CacheManager

Extend registers a driver this package does not know.

It is how the RESP store in hesape/redis arrives -- that store is a separate module, so registering it here is what keeps its driver out of binaries that do not use it -- and how anything else does: the creator is called with the manager and the configuration, and returns the finished repository.

It replaces a creator of the same name rather than refusing, so an application can override a built-in driver by registering one with its name.

func (*CacheManager) ForgetDriver

func (m *CacheManager) ForgetDriver(names ...string) *CacheManager

ForgetDriver drops the named stores, so the next Store rebuilds them.

No names means the default one.

func (*CacheManager) GetDefaultDriver

func (m *CacheManager) GetDefaultDriver() string

GetDefaultDriver is the store that is used when nobody names one.

An application that configured no default gets "null", which caches nothing and breaks nothing.

func (*CacheManager) Memo

func (m *CacheManager) Memo(driver string) (*Repository, error)

Memo returns a repository that remembers, for as long as it is held, what the named store already answered.

The repository is kept like any other, so every caller asking for the same memo store shares one map -- which is the point: four parts of one request asking for the same feature flag make one round trip.

Hold it for a request or a job and let it go. One held for the life of the process never forgets anything: see MemoizedStore.

Its events are off, because the store underneath already fired them and a memoized hit is not a second cache hit.

func (*CacheManager) Purge

func (m *CacheManager) Purge(name string)

Purge drops one store, so the next Store rebuilds it. It is ForgetDriver for a single name. An empty name means the default one.

func (*CacheManager) RefreshEventDispatcher

func (m *CacheManager) RefreshEventDispatcher() *CacheManager

RefreshEventDispatcher re-sets the dispatcher on every store already built.

It is what an application calls after the event bus exists: the stores resolved during boot were built without one, and this is what gives it to them.

func (*CacheManager) Repository

func (m *CacheManager) Repository(store Store, config StoreConfig) *Repository

Repository wraps a store in a repository, with the events wired unless the configuration turned them off.

It is exported because a driver registered with Extend has to be able to build the same kind of repository the built-in ones do, and doing it by hand means getting the event wiring right by hand.

func (*CacheManager) Resolve

func (m *CacheManager) Resolve(name string) (*Repository, error)

Resolve builds the named store, without keeping it.

A name that is not in the configuration is an error naming it, because the alternative is a cache that silently does nothing.

func (*CacheManager) SetDefaultDriver

func (m *CacheManager) SetDefaultDriver(name string) *CacheManager

SetDefaultDriver sets it.

It does not forget the store that was the default: call ForgetDriver for that.

func (*CacheManager) SetEventDispatcher

func (m *CacheManager) SetEventDispatcher(d Dispatcher) *CacheManager

SetEventDispatcher sets the dispatcher new repositories are built with.

It does not touch the repositories already built: RefreshEventDispatcher does that, and keeping the two apart is what lets an application wire the bus after the cache without every store having been built with a nil one.

func (*CacheManager) Store

func (m *CacheManager) Store(name string) (*Repository, error)

Store returns the named store, wrapped in a repository, building it the first time.

An empty name means the default. The repository is kept, so two callers asking for the same store get the same one -- which matters for the memo store, whose whole value is that it is shared for the length of a request.

type CanFlushLocks

type CanFlushLocks interface {
	// FlushLocks removes every lock the store holds, held or not.
	FlushLocks(ctx context.Context) error
}

CanFlushLocks is the optional third half of a Store: emptying the lock space wholesale.

It is a separate interface because a backend that keeps its locks in the same key space as its entries cannot flush the first without flushing the second, and must not pretend it can.

type Config

type Config struct {
	// Default is the store Store and Driver return when nobody names one. An
	// empty one means "null".
	Default string

	// Prefix goes in front of every key a store writes, under the tenant and
	// the namespace. It is the one for sharing a backend with another
	// application -- not for separating tenants, which is Repository's job and
	// is not optional.
	Prefix string

	// Stores is every store by name.
	Stores map[string]StoreConfig
}

Config is every cache store an application has, and which of them is the default.

It is a typed struct and not a map of strings because a build configuration that is checked by the compiler is a misconfiguration found at build time rather than at the first cache miss.

type Connection

type Connection interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

Connection is the slice of a database connection DatabaseStore needs.

It is the shape of *sql.DB and *sql.Tx, and it is declared here rather than imported because hesape/database is being written in parallel -- a store that could not compile until it landed would block on it. When it lands, its connection satisfies this.

The placeholders are the ? of MySQL and SQLite; a connection to Postgres is expected to rebind them, which is the arrangement the outbox in hesape/events already runs on.

type Creator

type Creator func(m *CacheManager, config StoreConfig) (*Repository, error)

Creator builds a repository for a driver the manager does not know.

It takes the manager so a creator can reach the other stores -- which is what the failover driver does -- and returns the finished repository, because a driver that needs a store this package has never heard of also decides how to wrap it.

type CurrentOwner

type CurrentOwner interface {
	// CurrentOwner returns the token written into the store for key, or the
	// empty string when the lock is free or has expired.
	CurrentOwner(ctx context.Context, key string) (string, error)
}

CurrentOwner is the optional read half of Locking: who holds a lock now.

It is what Lock.IsOwnedBy, Lock.IsOwnedByCurrentProcess and Lock.ForceRelease are built on.

It is a third interface rather than a method on Locking, because Locking is implemented outside this module -- hesape/redis is one -- and widening an interface an adapter already satisfies breaks it at the next build.

type DatabaseStore

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

DatabaseStore is the cache in a table.

It is the store for an application that already has a database and does not want a second piece of infrastructure.

It is the slowest store here and it is the only one that is shared, durable and transactional at the same time. Reach for it when the alternative is no cache at all, or when the locks are what is wanted: a database lock is the only one in this package that survives the cache being emptied, because it lives in its own table.

The tables

The cache table has key (primary), value and expiration. The lock table has key (primary), owner and expiration. Both are what `aru make:cache-table` writes.

The expiration is unix milliseconds, and that is why the column is a BIGINT: Store.Put is given a duration, and a store whose resolution is a second cannot honour a ttl shorter than one -- it would either expire the entry on arrival or keep it for most of a second longer than it was told.

func NewDatabaseStore

func NewDatabaseStore(connection Connection, table, prefix, lockTable string) *DatabaseStore

NewDatabaseStore returns a store over a table.

An empty lockTable means "cache_locks". A lock table separate from the cache table is what keeps FlushLocks possible -- see HasSeparateLockStore.

func (*DatabaseStore) AcquireLock

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

AcquireLock takes the lock if it is free.

It answers DatabaseLock::acquire(): insert, and when the row is already there, update it if it is this owner's or has expired. Both are one statement, so two processes racing cannot both win.

func (*DatabaseStore) Add

func (s *DatabaseStore) Add(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)

Add stores value only if the key is absent, and reports whether it did.

It answers DatabaseStore::add(): read first, which clears an expired row on the way past, then insert -- and a primary key violation means somebody else got there in between, which is a false and not an error.

func (*DatabaseStore) CurrentOwner

func (s *DatabaseStore) CurrentOwner(ctx context.Context, key string) (string, error)

CurrentOwner returns the token holding the lock, or the empty string.

It answers DatabaseLock::getCurrentOwner(). A row past its expiration is a lock nobody holds, and says so rather than naming whoever held it last.

func (*DatabaseStore) Decrement

func (s *DatabaseStore) Decrement(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Decrement subtracts delta from the counter under key.

func (*DatabaseStore) Flush

func (s *DatabaseStore) Flush(ctx context.Context, prefix string) error

Flush removes every entry whose key begins with prefix.

This store holds every tenant's cache, so it deletes by prefix. An empty prefix empties the table.

func (*DatabaseStore) FlushLocks

func (s *DatabaseStore) FlushLocks(ctx context.Context) error

FlushLocks releases every lock this store holds.

It answers DatabaseStore::flushLocks(), including the refusal: a store keeping its locks in the cache table cannot empty the first without emptying the second, and says so instead of doing it.

func (*DatabaseStore) Forever

func (s *DatabaseStore) Forever(ctx context.Context, key string, value []byte) error

Forever stores a value with no expiry the caller has to think about.

It answers DatabaseStore::forever(), which writes ten years. Here it is a century, which is what everything else in this package writes: see Repository.Forever.

func (*DatabaseStore) Forget

func (s *DatabaseStore) Forget(ctx context.Context, key string) error

Forget removes a key, present or not.

It answers DatabaseStore::forget(), including the companion entry: a flexible value keeps its timestamp under a second key, and leaving that behind would have the next reader age a value that is no longer there.

func (*DatabaseStore) ForgetIfExpired

func (s *DatabaseStore) ForgetIfExpired(ctx context.Context, key string) error

ForgetIfExpired removes a key, and only if it has expired.

It answers DatabaseStore::forgetIfExpired(). It is what a read does with a row it found expired, and the expiration in the WHERE is what stops it from removing an entry that somebody rewrote in between.

func (*DatabaseStore) Get

func (s *DatabaseStore) Get(ctx context.Context, key string) ([]byte, error)

Get returns the stored bytes, or ErrNotFound.

An expired row is deleted on the way out and reported as a miss, which is what DatabaseStore::many() does with the expired half of its result.

func (*DatabaseStore) GetConnection

func (s *DatabaseStore) GetConnection() Connection

GetConnection returns the connection the entries are read and written on.

func (*DatabaseStore) GetConnectionName

func (s *DatabaseStore) GetConnectionName() string

GetConnectionName is the name of the connection the locks are managed on.

It answers DatabaseLock::getConnectionName(). A connection that does not know its own name -- a bare *sql.DB is one -- answers the empty string rather than making one up.

func (*DatabaseStore) GetLockConnection

func (s *DatabaseStore) GetLockConnection() Connection

GetLockConnection returns the connection the locks are managed on, and nil when there is none.

func (*DatabaseStore) GetPrefix

func (s *DatabaseStore) GetPrefix() string

GetPrefix is what goes in front of every key this store writes.

func (*DatabaseStore) HasSeparateLockStore

func (s *DatabaseStore) HasSeparateLockStore() bool

HasSeparateLockStore reports whether the locks live in another table.

func (*DatabaseStore) Increment

func (s *DatabaseStore) Increment(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Increment adds delta to the counter under key and returns the new value.

The counter keeps the deadline it was created with, which is what makes a fixed window fixed.

It is not atomic across connections: an exact count would need a row lock, which this interface cannot ask for without a transaction, and a counter that has to be exact belongs in a store that counts atomically. What it does instead is refuse to widen the race: the update carries the value it read in its WHERE, so a lost update is a lost count and never a wrong one.

func (*DatabaseStore) Lock

func (s *DatabaseStore) Lock(name string, ttl time.Duration, owner string) *Lock

Lock returns a handle on a named lock. It does not touch the store.

func (*DatabaseStore) Many

func (s *DatabaseStore) Many(ctx context.Context, keys []string) (map[string][]byte, error)

Many returns the stored bytes for several keys at once.

It answers DatabaseStore::many(): every key asked for is in the result, and the misses carry nil. It is one statement, which is the reason to call it rather than Get in a loop.

func (*DatabaseStore) PruneExpiredLocks

func (s *DatabaseStore) PruneExpiredLocks(ctx context.Context) error

PruneExpiredLocks deletes the locks that are past their expiration.

AcquireLock runs it two times in a hundred, so the table stays bounded without any one caller paying for it often. Call it directly from a scheduled task to make it certain.

func (*DatabaseStore) Put

func (s *DatabaseStore) Put(ctx context.Context, key string, value []byte, ttl time.Duration) error

Put stores value for ttl, replacing whatever was there.

It is an update, and an insert when the update matched nothing. An upsert is a different statement on every dialect; this is one pair that every database this package supports understands, and the outcome -- last writer wins -- is the same.

func (*DatabaseStore) PutMany

func (s *DatabaseStore) PutMany(ctx context.Context, values map[string][]byte, ttl time.Duration) error

PutMany stores several values under one ttl.

It answers DatabaseStore::putMany(). It is a loop over Put rather than one multi-row upsert, for the reason Put is a pair: the upsert is a different statement on every grammar.

func (*DatabaseStore) ReleaseLock

func (s *DatabaseStore) ReleaseLock(ctx context.Context, key, token string) error

ReleaseLock releases the lock only if token still holds it.

It answers DatabaseLock::release(), where the ownership check is in the WHERE rather than in a read before it: a check made in the application is a check that was true a moment ago.

func (*DatabaseStore) RestoreLock

func (s *DatabaseStore) RestoreLock(name, owner string) *Lock

RestoreLock returns a handle on a lock owner already holds.

func (*DatabaseStore) SetConnection

func (s *DatabaseStore) SetConnection(c Connection) *DatabaseStore

SetConnection sets that connection and returns the store.

func (*DatabaseStore) SetLockConnection

func (s *DatabaseStore) SetLockConnection(c Connection) *DatabaseStore

SetLockConnection sets that connection and returns the store.

It answers DatabaseStore::setLockConnection(). It is worth setting to a connection of its own: a lock taken on the same connection as the work it guards is a lock inside the transaction the work is in, and a rollback takes it with it.

func (*DatabaseStore) SetPrefix

func (s *DatabaseStore) SetPrefix(prefix string)

SetPrefix sets it. It answers DatabaseStore::setPrefix().

func (*DatabaseStore) Touch

func (s *DatabaseStore) Touch(ctx context.Context, key string, ttl time.Duration) (bool, error)

Touch gives a live entry a new expiry and reports whether there was one.

type Dispatcher

type Dispatcher interface {
	// Dispatch delivers one event to whoever is listening.
	//
	// It returns nothing: a listener that fails must not fail the cache
	// operation that fired it, and a Repository that had to decide what to do
	// with a listener's error would have to decide it eight times.
	Dispatch(event any)
}

Dispatcher is the one method the cache needs of an event dispatcher.

It is one method, and it is declared here rather than imported because a cache that had to import the event bus to fire a CacheHit would drag the bus into every binary that caches anything -- and because hesape/events is the transactional outbox, which is a different thing with a different guarantee.

A dispatcher runs on the calling goroutine, so a listener that blocks blocks the cache. Anything slow belongs on a queue, which is where the listener should put it.

type Entry

type Entry struct {
	// Value is the stored bytes. It is a copy; mutating it changes nothing.
	Value []byte

	// ExpiresAt is when the entry stops being live.
	ExpiresAt time.Time
}

Entry is one stored value and when it stops being one.

It exists only for All: nothing in the ordinary path of the cache needs to see an expiry.

type FailoverStore

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

FailoverStore tries each store in turn and answers with the first one that worked.

It is for the cache that must not be a single point of failure: put the fast store first and the durable one after it, and a cache that goes down becomes a slower cache instead of an outage.

What it is not is replication. A write goes to the first store that accepts it and to no other, so after a failover the second store answers with whatever it happened to have -- which for a value written while the first store was up is nothing. The guarantee is availability of the cache, never agreement between the copies, and a caller that needs the second is not describing a cache.

It fires CacheFailedOver when a store refuses an operation, once per store per spell of failure: a cache that has been down for an hour produces one event and not one per request, which is what makes the event worth listening to.

func NewFailoverStore

func NewFailoverStore(d Dispatcher, names []string, stores ...Store) *FailoverStore

NewFailoverStore returns a store over an ordered set of others.

It answers FailoverStore::__construct(). The order is the priority: the first store is asked first, always, so a store that has come back is used again without anything having to notice that it did.

names are what goes into CacheFailedOver, one per store and in the same order. A shorter list leaves the extra stores unnamed rather than refusing to build, because a store that cannot be named is still a store that can answer.

func (*FailoverStore) AcquireLock

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

AcquireLock takes the lock in the first store that could hold it.

A lock held in one store means nothing in another, so a failover between acquiring and releasing leaves the lock to expire where it was taken -- and lets a second holder take it where it was not. A lock that must be exact needs a store that is not a failover set.

func (*FailoverStore) Add

func (s *FailoverStore) Add(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)

Add stores value in the first store that accepted it, if the key was absent there.

func (*FailoverStore) CurrentOwner

func (s *FailoverStore) CurrentOwner(ctx context.Context, key string) (string, error)

CurrentOwner asks the first store that can say who holds the lock.

func (*FailoverStore) Decrement

func (s *FailoverStore) Decrement(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Decrement counts down in the first store that accepted it.

func (*FailoverStore) Flush

func (s *FailoverStore) Flush(ctx context.Context, prefix string) error

Flush empties the prefix in the first store that accepted it.

func (*FailoverStore) FlushStaleTags

func (s *FailoverStore) FlushStaleTags(ctx context.Context) error

FlushStaleTags removes the tag entries nothing points at any more.

It answers FailoverStore::flushStaleTags(): the first store that knows how is asked, and the rest are left alone. A store that cannot prune tags has none to prune -- the tag generations in this package are ordinary entries with a ttl, and the only backend that keeps a set beside them is the RESP one.

func (*FailoverStore) Forever

func (s *FailoverStore) Forever(ctx context.Context, key string, value []byte) error

Forever stores a value with no expiry in the first store that accepted it.

func (*FailoverStore) Forget

func (s *FailoverStore) Forget(ctx context.Context, key string) error

Forget removes a key from the first store that accepted the removal.

It is not removed from the others, which is the sharpest edge on this type: a value invalidated while the first store is up is still in the second, and a failover after that serves it. Give entries a ttl short enough that the stale window is one you can defend.

func (*FailoverStore) Get

func (s *FailoverStore) Get(ctx context.Context, key string) ([]byte, error)

Get returns the stored bytes from the first store that answered.

A miss is an answer: ErrNotFound from the first store is the result, and the second store is not asked. Falling through on a miss would make every miss cost a round trip to every store, and would answer with whatever stale value the second one still had.

func (*FailoverStore) GetPrefix

func (s *FailoverStore) GetPrefix() string

GetPrefix is the prefix of the first store that has one.

func (*FailoverStore) Increment

func (s *FailoverStore) Increment(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Increment counts in the first store that accepted it.

The counter lives in one store, not in all of them, so a failover in the middle of a window restarts the count. That is the trade the whole type is: availability over agreement.

func (*FailoverStore) Lock

func (s *FailoverStore) Lock(name string, ttl time.Duration, owner string) *Lock

Lock returns a handle on a named lock. It does not touch any store.

func (*FailoverStore) Many

func (s *FailoverStore) Many(ctx context.Context, keys []string) (map[string][]byte, error)

Many returns the stored bytes for several keys from the first store that answered.

func (*FailoverStore) Put

func (s *FailoverStore) Put(ctx context.Context, key string, value []byte, ttl time.Duration) error

Put stores value in the first store that accepted it.

func (*FailoverStore) PutMany

func (s *FailoverStore) PutMany(ctx context.Context, values map[string][]byte, ttl time.Duration) error

PutMany stores the values in the first store that accepted them.

func (*FailoverStore) ReleaseLock

func (s *FailoverStore) ReleaseLock(ctx context.Context, key, token string) error

ReleaseLock releases the lock in the first store that could hold it.

func (*FailoverStore) RestoreLock

func (s *FailoverStore) RestoreLock(name, owner string) *Lock

RestoreLock returns a handle on a lock owner already holds.

func (*FailoverStore) Touch

func (s *FailoverStore) Touch(ctx context.Context, key string, ttl time.Duration) (bool, error)

Touch gives an entry a new expiry in the first store that accepted it.

type FileStore

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

FileStore is the cache on disk.

It is the store for a single machine that wants its cache to survive a restart, and for several processes on that machine to share one -- which is exactly what ArrayStore cannot do. It is still not the store for N replicas: a Forget on one machine leaves the others serving the old value until the ttl runs out, and when that matters the store is the RESP one in hesape/redis, a separate module registered through CacheManager.Extend.

The layout

One file per entry, at directory/xx/yy/<sha1 of the key>, where xx and yy are the first four characters of that hash. The two levels are there because a hundred thousand files in one directory is a directory nothing can list.

The file begins with ten digits of unix expiry, then three digits of millisecond, eight hex digits of key length, then the key, then the value.

The key is stored because Store.Flush takes a prefix -- one tenant's slice of one namespace -- and a hashed path cannot be matched against one. Without it, a cache:clear for one customer would empty the cache for all of them, which is the outage this package refuses to make possible. The millisecond is there because a store whose resolution is a second cannot honour a ttl shorter than one, and every test that pins the behaviour of a cache is written in fractions of a second.

func NewFileStore

func NewFileStore(files Filesystem, directory string, permission fs.FileMode) *FileStore

NewFileStore returns a store over a directory.

Pass LocalFilesystem{} for files unless something is standing in for the disk. A permission of zero leaves whatever the umask produced.

func (*FileStore) AcquireLock

func (s *FileStore) AcquireLock(_ context.Context, key, token string, ttl time.Duration) (bool, error)

AcquireLock takes the lock if it is free.

The lock is one more entry, in the lock directory when there is one, and the exclusive create is what makes it a lock at all.

func (*FileStore) Add

func (s *FileStore) Add(_ context.Context, key string, value []byte, ttl time.Duration) (bool, error)

Add stores value only if the key is absent, and reports whether it did.

It is atomic across processes: the entry is written to a temporary file and linked into place, and exactly one of N racers gets the link.

An entry that is there but expired is replaced.

func (*FileStore) CurrentOwner

func (s *FileStore) CurrentOwner(_ context.Context, key string) (string, error)

CurrentOwner returns the token holding the lock, or the empty string.

func (*FileStore) Decrement

func (s *FileStore) Decrement(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Decrement subtracts delta from the counter under key. It is Increment with the sign turned round.

func (*FileStore) Flush

func (s *FileStore) Flush(_ context.Context, prefix string) error

Flush removes every entry whose key begins with prefix.

This store holds every tenant's cache, so it reads each entry's key and removes the ones in the prefix -- which is what the key in the file header is for. An empty prefix removes everything.

func (*FileStore) FlushLocks

func (s *FileStore) FlushLocks(_ context.Context) error

FlushLocks releases every lock this store holds.

A store keeping its locks in the cache directory cannot empty the first without emptying the second, and says so instead of doing it. Call SetLockDirectory to make it possible.

func (*FileStore) Forever

func (s *FileStore) Forever(ctx context.Context, key string, value []byte) error

Forever stores a value with no expiry the caller has to think about.

The expiry is written a century out rather than left off: see Repository.Forever.

func (*FileStore) Forget

func (s *FileStore) Forget(_ context.Context, key string) error

Forget removes a key, present or not.

The companion entry goes with it: a flexible value keeps its timestamp under a second key, and leaving that behind would have the next reader age a value that is no longer there.

func (*FileStore) Get

func (s *FileStore) Get(_ context.Context, key string) ([]byte, error)

Get returns the stored bytes, or ErrNotFound.

An expired entry is deleted on the way out rather than left to accumulate, which is what FileStore::getPayload() does and is the only cleanup this store has.

func (*FileStore) GetDirectory

func (s *FileStore) GetDirectory() string

GetDirectory is the working directory of the cache.

func (*FileStore) GetFilesystem

func (s *FileStore) GetFilesystem() Filesystem

GetFilesystem returns the file system this store writes through.

func (*FileStore) GetPrefix

func (s *FileStore) GetPrefix() string

GetPrefix is the empty string: the prefixing that matters -- tenant and namespace -- happens in Repository, where it can be got right once.

func (*FileStore) HasSeparateLockStore

func (s *FileStore) HasSeparateLockStore() bool

HasSeparateLockStore reports whether locks live apart from entries: a lock directory was set, and it is not the cache directory.

func (*FileStore) Increment

func (s *FileStore) Increment(_ context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Increment adds delta to the counter under key and returns the new value.

The part that is easy to miss: the counter keeps the deadline it was created with. The remaining time is written back unchanged, because refreshing it instead would make a fixed window one that never closes.

func (*FileStore) Lock

func (s *FileStore) Lock(name string, ttl time.Duration, owner string) *Lock

Lock returns a handle on a named lock. It does not touch the store.

func (*FileStore) Many

func (s *FileStore) Many(ctx context.Context, keys []string) (map[string][]byte, error)

Many returns the stored bytes for several keys at once.

func (*FileStore) Path

func (s *FileStore) Path(key string) string

Path is the file a key is stored in.

It is exported because something eventually has to go and look.

func (*FileStore) Put

func (s *FileStore) Put(_ context.Context, key string, value []byte, ttl time.Duration) error

Put stores value for ttl, replacing whatever was there.

func (*FileStore) PutMany

func (s *FileStore) PutMany(ctx context.Context, values map[string][]byte, ttl time.Duration) error

PutMany stores several values under one ttl.

func (*FileStore) ReleaseLock

func (s *FileStore) ReleaseLock(ctx context.Context, key, token string) error

ReleaseLock releases the lock only if token still holds it.

func (*FileStore) RestoreLock

func (s *FileStore) RestoreLock(name, owner string) *Lock

RestoreLock returns a handle on a lock owner already holds.

func (*FileStore) SetDirectory

func (s *FileStore) SetDirectory(directory string) *FileStore

SetDirectory sets the working directory of the cache and returns the store.

It mutates rather than derives: a store is built at wiring time and read afterwards, and the deriving that Repository does is for the object an application passes around, which this is not.

func (*FileStore) SetLockDirectory

func (s *FileStore) SetLockDirectory(directory string) *FileStore

SetLockDirectory sets where locks are kept and returns the store.

Point it somewhere other than the cache directory and FlushLocks becomes possible: see HasSeparateLockStore.

func (*FileStore) Touch

func (s *FileStore) Touch(_ context.Context, key string, ttl time.Duration) (bool, error)

Touch gives a live entry a new expiry and reports whether there was one. An absent key is false and is not created.

type Filesystem

type Filesystem interface {
	// Get returns the contents of a file.
	Get(path string) ([]byte, error)

	// Put writes contents to a file, creating or replacing it. The write is
	// atomic as far as a reader is concerned: nobody sees half of it.
	Put(path string, contents []byte, mode fs.FileMode) error

	// PutIfAbsent writes contents only if the file does not exist, and reports
	// whether it did.
	PutIfAbsent(path string, contents []byte, mode fs.FileMode) (bool, error)

	// Exists reports whether a path is there.
	Exists(path string) bool

	// Delete removes a file. Removing what is not there is not an error.
	Delete(path string) error

	// MakeDirectory creates a directory and its parents.
	MakeDirectory(path string, mode fs.FileMode) error

	// DeleteDirectory removes a directory and everything under it.
	DeleteDirectory(path string) error

	// IsDirectory reports whether a path is a directory.
	IsDirectory(path string) bool

	// Files returns the paths of the files directly inside a directory, and
	// nothing from its subdirectories.
	Files(path string) ([]string, error)

	// Directories returns the paths of the directories directly inside a
	// directory.
	Directories(path string) ([]string, error)

	// Chmod sets a path's permissions.
	Chmod(path string, mode fs.FileMode) error
}

Filesystem is the slice of a file system FileStore needs.

It is the handful of calls FileStore makes, and it is declared here rather than imported so that this store does not depend on hesape/filesystem to compile. The Filesystem of that module satisfies this interface.

PutIfAbsent is the one method that is not an ordinary file operation: it is "create it only if nobody else did", the file system offers it atomically, and FileStore.Add is nothing but that.

type Limit

type Limit struct {
	// Key identifies the caller being limited: an IP, a session id, an account.
	// It is not a tenant: rate limiting happens before authentication on the
	// routes where it matters most, and there is no Grant yet.
	Key string

	// MaxAttempts is how many are allowed within Decay.
	MaxAttempts int

	// Decay is the length of the window.
	Decay time.Duration
}

Limit is how many attempts are allowed, over what period, against what key.

It is a value and it is built by composition -- PerMinute(5).By(ip) -- so a route declares its limit once, as data, and the same value is what the middleware counts against and what the error message is written from.

It lives here, next to the RateLimiter that counts against it, and not in a ratelimiting subpackage: a subpackage is a real boundary, and one struct on the far side of one is an import for no gain.

It is three fields, and it carries no callbacks

It carried two: one that decided, from the status a handler wrote, whether an attempt counted, and one that wrote the refusal itself. Nothing in the collection read either of them. A field that is documented as behaviour and is never read is worse than an absent one -- writing a limit that counts only failures compiled, ran, and counted everything, and the reason was invisible to whoever wired it.

They do not come back as working code either, because each already has one spelling. The refusal is written by the Refuse passed to the throttle middleware, which is one place for every refusal a request layer makes; a second one carried on the limit would be two. And giving an attempt back once the work is known to have succeeded is Release, which is a call the caller makes when it knows the answer, not a callback the counter invokes.

func None

func None() Limit

None is the limit that allows everything.

There is no separate Unlimited or GlobalLimit type: they would be two more ways to spell the same value.

It is for the branch that has to return a limit and has nothing to limit -- a named limiter that decides, per caller, that this one is exempt. A route with no limit on it is written by not putting one there.

func PerDay

func PerDay(max int) Limit

PerDay is a limit over one day.

func PerHour

func PerHour(max int) Limit

PerHour is a limit over one hour.

func PerMinute

func PerMinute(max int) Limit

PerMinute is a limit over one minute.

func PerMinutes

func PerMinutes(decayMinutes, max int) Limit

PerMinutes is a limit over several minutes.

Mind the argument order: the window comes first and the count second, which is the opposite of every other constructor here.

func PerSecond

func PerSecond(max int) Limit

PerSecond, PerMinute, PerHour and PerDay build the limits anybody writes.

They are four functions and not one with a unit argument, because the call site reads as the sentence somebody says out loud: "five per minute".

A window several units long is PerMinutes, so the common call stays one argument long.

func (Limit) By

func (l Limit) By(key string) Limit

By returns the same limit against a key.

func (Limit) FallbackKey

func (l Limit) FallbackKey() string

FallbackKey is a key for this limit that no other limit in the same set can collide with.

It answers Limit::fallbackKey(). It exists for one situation, and Limiter is the only caller: a named limiter that returns several limits which all ended up with the same key -- "sixty a minute and a thousand an hour, both by IP" -- would have the two counting in the same counter, and the hourly one would be spent in a minute. Appending the shape of the limit separates them.

type LimitResolver

type LimitResolver func(ctx context.Context) []Limit

LimitResolver decides, per caller, what the limit is.

It receives the context, which is where the request is, and it returns a set rather than one limit: "sixty a minute and a thousand an hour" is two limits against the same caller, and both are counted. A resolver with nothing to limit returns None, or nothing at all.

type LocalFilesystem

type LocalFilesystem struct{}

LocalFilesystem is the Filesystem over the machine's own disk.

It is the default FileStore is built with, and it is the whole of what this package knows about files: everything else here goes through the interface.

func (LocalFilesystem) Chmod

func (LocalFilesystem) Chmod(path string, mode fs.FileMode) error

Chmod sets a path's permissions.

func (LocalFilesystem) Delete

func (LocalFilesystem) Delete(path string) error

Delete removes a file. Removing what is not there is not an error.

func (LocalFilesystem) DeleteDirectory

func (LocalFilesystem) DeleteDirectory(path string) error

DeleteDirectory removes a directory and everything under it.

func (LocalFilesystem) Directories

func (LocalFilesystem) Directories(path string) ([]string, error)

Directories returns the paths of the directories directly inside a directory.

func (LocalFilesystem) Exists

func (LocalFilesystem) Exists(path string) bool

Exists reports whether a path is there.

func (LocalFilesystem) Files

func (LocalFilesystem) Files(path string) ([]string, error)

Files returns the paths of the files directly inside a directory.

func (LocalFilesystem) Get

func (LocalFilesystem) Get(path string) ([]byte, error)

Get returns the contents of a file.

func (LocalFilesystem) IsDirectory

func (LocalFilesystem) IsDirectory(path string) bool

IsDirectory reports whether a path is a directory.

func (LocalFilesystem) MakeDirectory

func (LocalFilesystem) MakeDirectory(path string, mode fs.FileMode) error

MakeDirectory creates a directory and its parents.

func (LocalFilesystem) Put

func (LocalFilesystem) Put(path string, contents []byte, mode fs.FileMode) error

Put writes contents to a file through a temporary one, then renames it.

The rename is what makes it atomic: a reader arriving mid-write sees the old file or the new one, never half of the new one.

func (LocalFilesystem) PutIfAbsent

func (LocalFilesystem) PutIfAbsent(path string, contents []byte, mode fs.FileMode) (bool, error)

PutIfAbsent writes contents only if the file does not exist.

It writes a temporary file and hard-links it into place, because the link is the atomic step: exactly one of N processes racing for the same path gets it, and the losers are told the file exists rather than overwriting each other.

type Lock

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

Lock is one named lock, held or not.

A Lock is not safe for concurrent use, and it does not need to be: it is the handle one goroutine holds while it does the work the lock protects.

func (*Lock) Acquire

func (lk *Lock) Acquire(ctx context.Context) error

Acquire takes the lock, or returns ErrLocked.

It answers Lock::acquire(), which returns a bool. This returns an error because the two failures are different and a bool folds them together: a lock somebody else holds is ErrLocked and is ordinary, and a store that did not answer is the store's error and is not.

func (*Lock) BetweenBlockedAttemptsSleepFor

func (lk *Lock) BetweenBlockedAttemptsSleepFor(d time.Duration) *Lock

BetweenBlockedAttemptsSleepFor sets how long Block waits between attempts.

It returns the lock, so it can be written in one line:

lock.BetweenBlockedAttemptsSleepFor(50 * time.Millisecond).Block(ctx, time.Second, fn)

func (*Lock) Block

func (lk *Lock) Block(ctx context.Context, wait time.Duration, fn func(context.Context) error) error

Block waits up to wait for the lock, then runs fn and releases it.

A wait that runs out is ErrLockTimeout; a cancelled context is the context's error. Pass a nil fn to wait for the lock and keep it.

It polls. The interval is a quarter second unless BetweenBlockedAttemptsSleepFor says otherwise, and it is a poll rather than a subscription because a lock that can be waited on properly is a feature of one backend and this has to work on all of them.

func (*Lock) ForceRelease

func (lk *Lock) ForceRelease(ctx context.Context) error

ForceRelease releases the lock whoever holds it.

It answers the forceRelease() of CacheLock and ArrayLock. It is the recovery hatch, not a tool: a caller reaching for it routinely has two holders running at once and does not know it. A store that cannot say who holds a lock cannot take it away from them, and returns ErrUnsupported.

func (*Lock) Get

func (lk *Lock) Get(ctx context.Context, fn func(context.Context) error) (bool, error)

Get takes the lock, runs fn if it got it, and releases it.

It answers Lock::get(), including the shape of the answer: the bool says whether the lock was taken, and fn ran exactly when it is true. A lock somebody else holds is (false, nil) and not an error -- "another replica is doing it" is the expected outcome, not a fault.

Pass a nil fn to acquire without running anything, which is what $lock->get() with no callback does.

func (*Lock) Held

func (lk *Lock) Held() bool

Held reports whether this handle currently believes it holds the lock.

It answers from the token and does not ask the store, so it says "the lock was taken and not released here", not "the lock has not expired". Nothing can answer the second question usefully: it would be true at the moment of the answer and false at the moment the caller acted on it. Ask IsOwnedByCurrentProcess when the store's opinion is what is wanted.

func (*Lock) IsOwnedBy

func (lk *Lock) IsOwnedBy(ctx context.Context, owner string) (bool, error)

IsOwnedBy reports whether owner holds the lock right now.

It answers Lock::isOwnedBy(). A store that cannot say who holds a lock returns ErrUnsupported rather than guessing.

The answer is true at the moment it is given and may be false at the moment it is acted on. Nothing can fix that, which is why Release checks ownership in the store rather than trusting a check made here.

func (*Lock) IsOwnedByCurrentProcess

func (lk *Lock) IsOwnedByCurrentProcess(ctx context.Context) (bool, error)

IsOwnedByCurrentProcess reports whether the store still says this handle holds the lock.

It answers Lock::isOwnedByCurrentProcess(). Unlike Held it asks the store, so it says "not expired and not taken by anybody else", which is the question worth asking before doing something that assumed the lock was held.

func (*Lock) Name

func (lk *Lock) Name() string

Name returns the lock's name. It answers the $name every Lock subclass reads.

func (*Lock) Owner

func (lk *Lock) Owner() string

Owner returns the token written into the store for this lock.

It answers Lock::owner(). It is empty until Acquire, because that is when the token is minted; hand it to Locks.RestoreLock to release the lock from somewhere else.

func (*Lock) Release

func (lk *Lock) Release(ctx context.Context) error

Release gives the lock back, and only if this holder still owns it.

It answers Lock::release(). Releasing a lock that was never acquired, or that has already expired, is not an error: the caller wanted it gone and it is.

func (*Lock) Run

func (lk *Lock) Run(ctx context.Context, fn func(context.Context) error) error

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

This is the shape the scheduler, the relay and an isolated command 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.

The release runs on a context that is not cancelled with the caller's, so a request that is abandoned while fn is finishing still gives the lock back instead of leaving it to expire.

It is Get with the refusal surfaced as ErrLocked instead of a false, which is what a caller that has nothing else to do wants.

type Locking

type Locking interface {
	// AcquireLock stores token under key for ttl if the key is free, and
	// reports whether it took it. It is Store.Add with a name that says what it
	// is for.
	AcquireLock(ctx context.Context, key, token string, ttl time.Duration) (bool, 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 must
	// not delete anything.
	ReleaseLock(ctx context.Context, key, token string) error
}

Locking is the optional half of a Store: the two calls a distributed lock needs.

It is a second interface rather than four more methods on Store, because a backend can be a perfectly good cache without being able to hold a lock, and because the lock is what the scheduler and the outbox relay depend on -- they take a Locking, and cannot be handed a Store that merely happens to compile.

The token is what makes the release safe: without it, a holder whose lock already expired would release the lock a different holder now owns.

type Locks

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

Locks issues distributed locks over a store.

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, and for a console command that must not run twice.

This is the only kind of lock in the collection: one issuer, one handle, one key per name. Nothing here declares an interface for it, and that is not an omission -- Go satisfies an interface by shape, so a caller that wants the lock behind one declares it where it is used, and a declaration here would be a second name for the same thing with nothing keeping the two in step.

It 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 partition longer than the ttl can let two holders exist. The mitigation is the one a scheduler needs anyway -- tasks are idempotent, because at-least-once is what a distributed scheduler delivers.

func NewLocks

func NewLocks(s Locking) *Locks

NewLocks returns the issuer.

It takes a Locking and not a Store, so a backend that cannot hold a lock cannot be wired in as one that can.

func (*Locks) Lock

func (l *Locks) Lock(name string, ttl time.Duration) *Lock

Lock names a lock. It does not touch the store: Acquire does.

The ttl is required and it 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 (*Locks) RestoreLock

func (l *Locks) RestoreLock(name, owner string) *Lock

RestoreLock returns a handle on a lock owner already holds.

It answers the restoreLock() of HasCacheLock. It is how one process takes a lock and another gives it back: the owner string is the whole handle, so a job that acquires a lock can hand its owner to the worker that releases it.

The returned handle believes it holds the lock. Ask IsOwnedByCurrentProcess if it matters whether that is still true.

type MemoizedStore

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

MemoizedStore remembers, for the life of one request, what the store underneath already answered.

The problem it solves is the one nobody notices until they count: a request that asks the cache for the same feature flag in the controller, in the policy and twice in the view makes four round trips for one value. This makes it one, and the other three are a map lookup.

It is a per-request object, not a second cache. Build one at the top of a request or a job, throw it away at the bottom. One kept alive for the life of the process is a cache with no expiry at all: nothing here ever forgets what it read, which is exactly what makes it safe for the length of one request and wrong for anything longer.

Every write forgets what it remembered about that key first, so a caller that writes and then reads sees what it wrote.

It wraps a Store rather than a Repository: a Repository method takes an auth.Grant and a Store has none to give it, so a Store that delegated to a Repository could not be written. What is memoized is the bytes under a fully built key.

func NewMemoizedStore

func NewMemoizedStore(name string, store Store) *MemoizedStore

NewMemoizedStore returns a store that remembers what it read.

The name is the name of the store underneath. It goes into the events, so a listener can tell which cache a hit came from.

func (*MemoizedStore) AcquireLock

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

AcquireLock takes the lock in the store underneath.

It answers MemoizedStore::lock(), including the refusal: a store that cannot hold a lock is asked for one and says so. Nothing about a lock is memoized -- remembering that a lock was free would be a lock that is always free.

func (*MemoizedStore) Add

func (s *MemoizedStore) Add(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)

Add forgets what it remembered about the key and adds it through.

func (*MemoizedStore) CurrentOwner

func (s *MemoizedStore) CurrentOwner(ctx context.Context, key string) (string, error)

CurrentOwner asks the store underneath who holds the lock.

func (*MemoizedStore) Decrement

func (s *MemoizedStore) Decrement(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Decrement forgets what it remembered about the counter and decrements it through. It answers MemoizedStore::decrement().

func (*MemoizedStore) Flush

func (s *MemoizedStore) Flush(ctx context.Context, prefix string) error

Flush forgets everything it remembered and flushes the store underneath.

It answers MemoizedStore::flush(). Everything, and not only the prefix being flushed: a memo that kept part of what a flush removed would serve it after it was gone, and a request-scoped map is cheap to rebuild.

func (*MemoizedStore) Forever

func (s *MemoizedStore) Forever(ctx context.Context, key string, value []byte) error

Forever forgets what it remembered about the key and writes it through.

func (*MemoizedStore) Forget

func (s *MemoizedStore) Forget(ctx context.Context, key string) error

Forget forgets what it remembered about the key and removes it.

func (*MemoizedStore) Get

func (s *MemoizedStore) Get(ctx context.Context, key string) ([]byte, error)

Get returns the stored bytes, asking the store underneath at most once per key.

It answers MemoizedStore::get(). The second call for a key that was not there is still a miss and still costs nothing, which is the half people forget: a cache miss asked for four times is four round trips, and they are the expensive ones.

func (*MemoizedStore) GetPrefix

func (s *MemoizedStore) GetPrefix() string

GetPrefix is the prefix of the store underneath.

It answers MemoizedStore::getPrefix(), which forwards for the same reason: the memo is keyed on what the store underneath would be asked, so the two have to agree on what that is.

func (*MemoizedStore) GetStore

func (s *MemoizedStore) GetStore() Store

GetStore returns the store underneath.

func (*MemoizedStore) Increment

func (s *MemoizedStore) Increment(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

Increment forgets what it remembered about the counter and increments it through.

It answers MemoizedStore::increment(). A memoized counter would be a counter that stopped counting, which is why the forget comes first.

func (*MemoizedStore) Many

func (s *MemoizedStore) Many(ctx context.Context, keys []string) (map[string][]byte, error)

Many returns the stored bytes for several keys, asking the store underneath only for the ones it has not seen.

func (*MemoizedStore) Name

func (s *MemoizedStore) Name() string

Name is the name of the store underneath.

func (*MemoizedStore) Put

func (s *MemoizedStore) Put(ctx context.Context, key string, value []byte, ttl time.Duration) error

Put forgets what it remembered about the key and writes it through.

func (*MemoizedStore) PutMany

func (s *MemoizedStore) PutMany(ctx context.Context, values map[string][]byte, ttl time.Duration) error

PutMany forgets them all and writes them through.

func (*MemoizedStore) ReleaseLock

func (s *MemoizedStore) ReleaseLock(ctx context.Context, key, token string) error

ReleaseLock releases the lock in the store underneath.

func (*MemoizedStore) Touch

func (s *MemoizedStore) Touch(ctx context.Context, key string, ttl time.Duration) (bool, error)

Touch forgets what it remembered about the key and touches it through.

type NamedConnection

type NamedConnection interface {
	Connection

	// GetName returns the connection's name in the configuration.
	GetName() string
}

NamedConnection is the optional half of Connection: one that knows what it is called.

It is a second interface rather than a method on Connection because *sql.DB has no name and would stop satisfying it.

type NoLock

type NoLock struct{ *Lock }

NoLock is the lock that is always free.

Acquire says yes, release says yes, and nothing is written anywhere. It is what the null store hands out, and it is correct there for the same reason the null store is correct -- caching is off, so there is nothing to serialize access to.

It is wrong everywhere else, and loudly: a scheduler holding a NoLock runs its task on every replica at once. If a lock matters, the store has to be one that can hold it.

func NewNoLock

func NewNoLock(name string, ttl time.Duration, owner string) *NoLock

NewNoLock returns a lock that everybody gets.

The ttl is not a deadlock protection here, because there is no lock to dead: it is carried so a handle taken from a NoLock reads the same as one taken from a real store.

type NullStore

type NullStore struct{}

NullStore is the store that keeps nothing.

It is what CACHE_STORE=null wires, and it is the honest way to turn caching off: every read is a miss and every write goes nowhere, so the application takes exactly the path it takes on a cold cache, every time. That is worth more in a test than a mock, because it is the same code the production wiring runs.

Put, Forever, Increment and Touch return no error. A caller checks the error of every write, and a null store that reported a failure on each of them would take the application down rather than turning the cache off. What a reader sees is unchanged: ErrNotFound, always.

It holds a lock as NoLock does: it says yes to everybody. See NoLock for why that is the right answer here and the wrong one anywhere else.

func NewNullStore

func NewNullStore() *NullStore

NewNullStore returns the store that keeps nothing.

func (*NullStore) AcquireLock

func (s *NullStore) AcquireLock(context.Context, string, string, time.Duration) (bool, error)

AcquireLock always succeeds. It is NoLock's acquire: see Lock for what that costs.

func (*NullStore) Add

Add never adds, and says so. It answers the add() a null store does not have: nothing is there, and nothing was put there either.

func (*NullStore) CurrentOwner

func (s *NullStore) CurrentOwner(context.Context, string) (string, error)

CurrentOwner is nobody, because no lock was ever written down.

It answers NoLock::getCurrentOwner(), which returns the asking handle's own owner -- so isOwnedByCurrentProcess is true. Here the store cannot know who is asking, and an empty owner is the closest honest answer: a lock nothing wrote down is a lock nobody holds.

func (*NullStore) Decrement

Decrement counts nothing and returns zero. It answers NullStore::decrement().

func (*NullStore) Flush

func (s *NullStore) Flush(context.Context, string) error

Flush removes nothing, successfully. It answers NullStore::flush(), which returns true.

func (*NullStore) Forever

func (s *NullStore) Forever(context.Context, string, []byte) error

Forever discards the value. It answers NullStore::forever().

func (*NullStore) Forget

func (s *NullStore) Forget(context.Context, string) error

Forget removes nothing, successfully. It answers NullStore::forget(), which returns true.

func (*NullStore) Get

func (s *NullStore) Get(context.Context, string) ([]byte, error)

Get is always a miss. It answers NullStore::get(), which returns null.

func (*NullStore) GetPrefix

func (s *NullStore) GetPrefix() string

GetPrefix is the empty string. It answers NullStore::getPrefix().

func (*NullStore) Increment

Increment counts nothing and returns zero. It answers NullStore::increment(), which returns false.

func (*NullStore) Lock

func (s *NullStore) Lock(name string, ttl time.Duration, owner string) *Lock

Lock returns a handle that will take the lock, because NullStore gives it to everybody.

func (*NullStore) Many

func (s *NullStore) Many(_ context.Context, keys []string) (map[string][]byte, error)

Many is a miss for every key.

func (*NullStore) Put

Put discards the value. It answers NullStore::put().

func (*NullStore) PutMany

func (s *NullStore) PutMany(context.Context, map[string][]byte, time.Duration) error

PutMany discards them all. It answers the putMany() of RetrievesMultipleKeys.

func (*NullStore) ReleaseLock

func (s *NullStore) ReleaseLock(context.Context, string, string) error

ReleaseLock always succeeds, having released nothing.

func (*NullStore) RestoreLock

func (s *NullStore) RestoreLock(name, owner string) *Lock

RestoreLock returns a handle on a lock owner already holds.

func (*NullStore) Touch

Touch reports that there was nothing to touch. It answers NullStore::touch().

type RateLimiter

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

RateLimiter counts attempts against limits.

It replaces two implementations that disagreed: an in-memory one in the HTTP middleware, which counted per process -- so N replicas allowed N times the limit, on the one endpoint where that gap is worth exploiting -- and a second one in the kv adapter. There is one now, and which store it counts in is wiring.

It does not decide what happens when the store is unreachable. It reports the error and the caller chooses: the HTTP throttle middleware fails open, because a rate limiter that is down must not become an outage, and a sign-in throttle may well choose the opposite. That decision was previously buried inside the limiter, where the middleware could not see it and could not change it.

No method here takes a Grant, and none should

Every Repository method in this package takes one, and takes the tenant out of it. This type is the exception on purpose, and making it consistent with its neighbour would break the routes it exists to protect: a rate limit runs before authentication precisely where it matters most -- the sign-in form, the password reset, the public API key check -- and at that point in the request nobody has been identified, so there is no Grant to take a tenant from. A signature that demanded one would be a signature no caller on those routes could satisfy, and the limit would come off the routes rather than the requirement coming off the signature.

What a caller wants a tenant in the key for, it already has: Limit.Key is a string, so a limit that really is per tenant is written by putting the tenant in the key it is built with.

func NewRateLimiter

func NewRateLimiter(s Store) *RateLimiter

NewRateLimiter returns the limiter.

func (*RateLimiter) Attempt

func (rl *RateLimiter) Attempt(ctx context.Context, l Limit) (Result, error)

Attempt counts one attempt and says whether it fits.

This is the call a throttle makes, and it counts before it answers on purpose. A limiter that answered first and counted afterwards would let everything that arrived in between through, and the endpoint where that matters -- sign-in -- is the one where the budget has to be taken before the password is checked, not after.

func (*RateLimiter) Attempts

func (rl *RateLimiter) Attempts(ctx context.Context, l Limit) (int, error)

Attempts is how many have been counted in the current window.

A window nobody has attempted anything in is zero and not an error: a counter that is not there is a counter at zero.

func (*RateLimiter) AvailableIn

func (rl *RateLimiter) AvailableIn(l Limit) time.Duration

AvailableIn is how long until the current window rolls.

It is arithmetic and not a round trip, which is what the bucketed key buys: the window a counter belongs to is in its name, so when it ends is known without asking the store how long the key has left.

func (*RateLimiter) CleanRateLimiterKey

func (rl *RateLimiter) CleanRateLimiterKey(key string) string

CleanRateLimiterKey folds a key down to the characters a counter can be named by.

It answers RateLimiter::cleanRateLimiterKey(), which is preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key)) -- two steps that look like escaping and are not. What they do is fold an accented letter to its base one: htmlentities turns "é" into "&eacute;" and the pattern keeps the "e". A rate limit on a name spelled with and without its accent is one limit, which is the point.

The details are worth stating, because they are the kind that get lost:

  • "&" folds to "a", because its entity is "&amp;". So do "<", ">" and the double quote, to "l", "g" and "q". They are not escaped, they are replaced by a letter.
  • The apostrophe does not fold. It has no named entity, only the numeric "&#039;", and the pattern only matches letters -- so it survives as "&#039;", six characters where there was one.
  • Neither do "&sup2;", "&frac12;" and the other entities with a digit in the name, for the same reason.
  • A character with no entity in the table -- anything past Latin-1 that is not one of the symbols HTML 4.01 names -- is left exactly as it was.

It is not a security boundary and it is not an escape. It is a fold, and the only thing it protects is one counter from being two.

func (*RateLimiter) Clear

func (rl *RateLimiter) Clear(ctx context.Context, l Limit) error

Clear forgets the attempts in the current window.

func (*RateLimiter) For

func (rl *RateLimiter) For(name string, resolver LimitResolver) *RateLimiter

For registers a named limiter and returns the rate limiter.

It answers RateLimiter::for(). It is what a route refers to by name, so the limit for "login" is written once, in the wiring, rather than repeated at every route that needs it.

func (*RateLimiter) Hit

func (rl *RateLimiter) Hit(ctx context.Context, l Limit) (int, error)

Hit counts one attempt and returns how many there have been in this window.

It is the primitive Attempt is built on, and it is exported because a caller that already knows it is going to refuse -- a sign-in that failed, an upload that was rejected -- wants to count without asking a question it has already answered.

func (*RateLimiter) Limiter

func (rl *RateLimiter) Limiter(name string) LimitResolver

Limiter returns the named limiter, or nil.

It answers RateLimiter::limiter(), including the part of the body that is the reason it wraps rather than returns the closure: two limits that resolved to the same key would count in the same counter, and the longer window would be spent by the shorter one. The duplicates are given FallbackKey instead, which separates them by shape.

func (*RateLimiter) Release

func (rl *RateLimiter) Release(ctx context.Context, l Limit) error

Release gives one attempt back.

It is what a success does to a failure counter: five wrong passwords lock the account for a minute, and the right one on the second try should not leave four attempts standing against somebody who is who they say they are. Use Clear to give all of them back.

A counter that has already expired stays absent rather than being recreated at minus one, and a counter driven below zero is clamped back -- both are the same rule, which is that a window nobody has attempted anything in holds nothing.

func (*RateLimiter) Remaining

func (rl *RateLimiter) Remaining(ctx context.Context, l Limit) (int, error)

Remaining is how many attempts are left in this window, never below zero.

func (*RateLimiter) ResetAttempts

func (rl *RateLimiter) ResetAttempts(ctx context.Context, l Limit) error

ResetAttempts forgets the attempts in the current window.

It forgets all of it: the window is in the counter's key, so there is no second thing to forget.

func (*RateLimiter) RetriesLeft

func (rl *RateLimiter) RetriesLeft(ctx context.Context, l Limit) (int, error)

RetriesLeft is Remaining.

func (*RateLimiter) TooManyAttempts

func (rl *RateLimiter) TooManyAttempts(ctx context.Context, l Limit) (bool, error)

TooManyAttempts reports whether the budget for this window is spent.

It asks without counting, which is the difference from Attempt: use this to decide whether to offer something, and Attempt to actually spend one.

There is no separate lockout timer to expire: the window is in the counter's key, so a counter that outlived its window is not the counter being asked about.

type Repository

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

Repository is the cache an application calls.

It is the tenant-scoped, JSON-encoding, expiry-enforcing layer over a Store -- the only one of the two that an application ever holds. Every method takes an auth.Grant, and the tenant comes from it: not from the request, not from a field somebody filled in.

It does not run a policy. Holding a Grant is already proof that one ran: Grant has no exported fields and cannot be written as a literal, so a caller with one has been past Authorize or has asked for a SystemGrant by name. What the Repository takes from it is the tenant, which is the part a cache key cannot be built without.

func New

func New(s Store) *Repository

New returns a repository over a store, under the default namespace.

Use Namespace to separate caches of different kinds: "user", "invoice-total". Two namespaces over one store cannot collide, and clearing one leaves the other alone.

func (*Repository) Add

func (r *Repository) Add(ctx context.Context, g auth.Grant, key string, value any, ttl time.Duration) (bool, error)

Add stores a value only if the key is absent, and reports whether it did.

It is the atomic primitive underneath "only one of them may do this": the first caller gets true, everybody else gets false, and no two callers get true. A Get followed by a Put is the same code with a race in the middle.

A ttl that has already passed adds nothing and reports false -- an entry that is expired before it is written was not added.

The trap that leaves behind is worth knowing: a lock built on Add with a ttl that is computed and comes out zero never acquires, and never says why. The caller is holding the ttl, and the false is the answer to the question it asked.

func (*Repository) Array

func (r *Repository) Array(ctx context.Context, g auth.Grant, key string) ([]any, error)

Array reads a value that must be a list.

This is the list only. A cached object is read with Get[map[string]any] and a cached struct with Get[T], both of which say more about the value than a bare list does.

func (*Repository) Boolean

func (r *Repository) Boolean(ctx context.Context, g auth.Grant, key string) (bool, error)

Boolean reads a value that must be a boolean.

Strictly a boolean: 1 and "true" are refused, because a cache that quietly agreed that 1 means true is a cache that quietly agrees that 2 does.

func (*Repository) Clear

func (r *Repository) Clear(ctx context.Context, g auth.Grant) error

Clear is Flush under another name.

func (*Repository) Decrement

func (r *Repository) Decrement(ctx context.Context, g auth.Grant, key string, delta int64, ttl time.Duration) (int64, error)

Decrement subtracts delta from a counter and returns the new value. It is Increment with the sign turned round.

func (*Repository) Delete

func (r *Repository) Delete(ctx context.Context, g auth.Grant, key string) error

Delete is Forget under another name.

func (*Repository) DeleteMultiple

func (r *Repository) DeleteMultiple(ctx context.Context, g auth.Grant, keys ...string) error

DeleteMultiple removes several keys.

It stops at the first error, so the caller gets the error itself rather than a bool that says something went wrong without saying what.

func (*Repository) Float

func (r *Repository) Float(ctx context.Context, g auth.Grant, key string) (float64, error)

Float reads a value that must be a float.

A numeric string is accepted and so is a whole number: 42 is 42.0.

func (*Repository) Flush

func (r *Repository) Flush(ctx context.Context, g auth.Grant) error

Flush removes every entry of this tenant in this namespace.

It is not "empty the cache". A cache:clear that emptied the store would clear every other tenant on the way past, and in a SaaS that is an outage caused by a support request.

func (*Repository) FlushLocks

func (r *Repository) FlushLocks(ctx context.Context) error

FlushLocks releases every lock the store holds.

A store that cannot flush its locks returns ErrUnsupported rather than pretending. Ask SupportsFlushingLocks first if the answer matters.

It takes no Grant because a lock has no tenant: a scheduler lock covers the whole instance.

func (*Repository) Forever

func (r *Repository) Forever(ctx context.Context, g auth.Grant, key string, value any) error

Forever stores a value with no expiry the caller has to think about.

"No expiry" is written down as a century, because a Store is promised a positive ttl and a special case for zero is a thing every backend would get subtly different.

Reach for it rarely. An entry that never expires is a second copy of the truth, and the day it diverges from the first nothing notices; Put with a ttl you can defend is almost always what was meant.

func (*Repository) Forget

func (r *Repository) Forget(ctx context.Context, g auth.Grant, key string) error

Forget removes a key. Removing what is not there is not an error.

func (*Repository) GetDefaultCacheTime

func (r *Repository) GetDefaultCacheTime() time.Duration

GetDefaultCacheTime is how long an item is stored for when nobody says.

Nothing on this type takes an optional ttl -- Put requires one -- so its one job is to be carried into the TaggedCache that Tags builds.

func (*Repository) GetEventDispatcher

func (r *Repository) GetEventDispatcher() Dispatcher

GetEventDispatcher returns the dispatcher this repository fires into, or nil.

func (*Repository) GetName

func (r *Repository) GetName() string

GetName is the name this cache is known by: the name of the store the manager built this repository for. It is what goes into every event, so a listener can tell which cache a hit came from.

A repository built with New rather than by a CacheManager has no store name, and answers with its namespace instead: that is the thing that distinguishes one repository over a store from another, and an empty name in an event is worth less than an imperfect one.

func (*Repository) GetStore

func (r *Repository) GetStore() Store

GetStore returns the store underneath.

It is the hatch for a caller that needs something of the backend the Repository does not offer -- and reaching through it skips the tenant prefix, so whatever is done there is done to every tenant at once.

func (*Repository) Has

func (r *Repository) Has(ctx context.Context, g auth.Grant, key string) (bool, error)

Has reports whether a key is present and unexpired.

It is a read, and it fires the same events a read fires. A hit rate computed from CacheHit and CacheMissed counts these, which is one more reason not to ask before reading.

Whoever is about to read the value should call Get instead: asking and then reading is two round trips and a race, and the answer to "is it there" is already in Get's error.

A key cached as null is present: ErrNotFound is what reports an absence, so "not cached" and "cached as nothing" stay different -- which is the whole reason ErrNotFound exists.

func (*Repository) Increment

func (r *Repository) Increment(ctx context.Context, g auth.Grant, key string, delta int64, ttl time.Duration) (int64, error)

Increment adds delta to a counter and returns the new value.

The ttl is the counter's whole life: it is set when the counter is created and is not refreshed by later increments, so a counter created at the top of an hour is gone an hour later however busy it was.

An absent counter starts at zero.

A ttl that has already passed is ErrNoTTL, and this is the one of the three writes that keeps its error rather than reinterpreting the ttl. Forgetting the key would make Increment(k, 1, 0) delete a counter while reporting that it raised one, and treating it as forever would mint exactly the immortal counter this signature takes a ttl to prevent.

func (*Repository) Integer

func (r *Repository) Integer(ctx context.Context, g auth.Grant, key string) (int, error)

Integer reads a value that must be an integer.

A numeric string is accepted: "42" is 42. A number with a fractional part is not, and neither is anything else.

func (*Repository) ItemKey

func (r *Repository) ItemKey(ctx context.Context, g auth.Grant, key string) (string, error)

ItemKey formats the key an item is really stored under.

It is not the identity: the tenant, the namespace and -- on a tagged repository -- the tag generation are all in front of the caller's key, and this is where a caller finds out what that came to.

Something eventually has to look in the store and find out where an entry went, and this is the one honest way to ask. It takes a context because a tagged repository reads, and sometimes mints, the tag generations before it knows the answer.

func (*Repository) Missing

func (r *Repository) Missing(ctx context.Context, g auth.Grant, key string) (bool, error)

Missing reports whether a key is absent. It is the negation of Has and nothing else -- it exists because "if !has" reads worse than "if missing" at the call site.

func (*Repository) Namespace

func (r *Repository) Namespace(name string) *Repository

Namespace returns a repository over the same store, under another namespace.

It derives rather than mutates, so a repository handed to two modules cannot have its namespace changed underneath one of them.

The name is limited to what a tenant is limited to -- lowercase letters, digits, - and _ -- for exactly the same reason: it is a segment of a key, and a segment carrying a colon can name another namespace's entry. An invalid name is reported by every call the repository makes, not by this constructor: this is wiring, and wiring that panics takes the process down at boot for a typo.

func (*Repository) Put

func (r *Repository) Put(ctx context.Context, g auth.Grant, key string, value any, ttl time.Duration) error

Put stores a value for ttl, replacing whatever was there.

A ttl that has already passed forgets the key rather than writing it, because an entry that expires the moment it is written and an entry that is not there are the same entry, and the shorter of the two ways to say it is the one the caller wrote. Refusing the write instead would leave the previous value in place: the caller would have told the cache to stop serving something and the cache would go on serving it.

The ttl is required rather than optional, because an entry with no expiry is a second copy of the truth that nobody knows exists. Call Forever when that is what you mean, so it is written down at the call site.

func (*Repository) PutMany

func (r *Repository) PutMany(ctx context.Context, g auth.Grant, values map[string]any, ttl time.Duration) error

PutMany stores several values under one ttl.

It is a loop, and it is honest about being one: it is not atomic, and a failure part-way through leaves the entries it already wrote in place. That is the right failure for a cache -- the alternative would be a transaction across a store that may not have one -- and it is why the method exists here rather than being written slightly differently in each module.

A ttl that has already passed forgets the keys, which is the batch spelling of what Put does.

func (*Repository) Set

func (r *Repository) Set(ctx context.Context, g auth.Grant, key string, value any, ttl time.Duration) error

Set is Put under another name.

func (*Repository) SetDefaultCacheTime

func (r *Repository) SetDefaultCacheTime(ttl time.Duration) *Repository

SetDefaultCacheTime returns a repository with another default.

It derives rather than mutates, for the reason Namespace derives: a repository handed to two modules must not change underneath one of them.

func (*Repository) SetEventDispatcher

func (r *Repository) SetEventDispatcher(d Dispatcher) *Repository

SetEventDispatcher returns a repository that fires its events into d.

It derives and returns a new repository rather than mutating this one, for the reason SetStore and SetDefaultCacheTime derive: a repository handed to two modules must not change underneath one of them.

func (*Repository) SetMultiple

func (r *Repository) SetMultiple(ctx context.Context, g auth.Grant, values map[string]any, ttl time.Duration) error

SetMultiple is PutMany under another name.

func (*Repository) SetName

func (r *Repository) SetName(name string) *Repository

SetName returns a repository known by another name. It is the name a CacheManager stamps on the repositories it builds, and it is a method rather than a constructor argument because a repository is derived from another one rather than rebuilt.

func (*Repository) SetStore

func (r *Repository) SetStore(s Store) *Repository

SetStore returns a repository over another store.

It derives rather than mutates, for the reason SetDefaultCacheTime derives.

func (*Repository) String

func (r *Repository) String(ctx context.Context, g auth.Grant, key string) (string, error)

String reads a value that must be a string.

A value that is stored but is not a string is an error naming the key and what was there; a key that is absent is ErrNotFound, so a caller can still tell "wrong type" from "not cached".

func (*Repository) SupportsFlushingLocks

func (r *Repository) SupportsFlushingLocks() bool

SupportsFlushingLocks reports whether FlushLocks will work.

func (*Repository) SupportsTags

func (r *Repository) SupportsTags() bool

SupportsTags reports whether this store can carry tags.

Every Store in this package satisfies Taggable, so the answer is yes for all of them; a store registered from elsewhere answers for itself.

func (*Repository) Tags

func (r *Repository) Tags(names ...string) (*TaggedCache, error)

Tags begins a tagged operation on this repository.

The returned TaggedCache is the whole Repository surface again, with every key prefixed by the tag set's generation -- so TaggedCache.Flush is a single write per tag that orphans every entry carrying it, rather than a scan.

Every Store can tag: a tag is an ordinary entry holding a generation id, so tagging needs Get, Put and Forget and nothing a Store does not already have. The error is for the one thing that is still wrong, which is asking for no tags at all.

func (*Repository) Touch

func (r *Repository) Touch(ctx context.Context, g auth.Grant, key string, ttl time.Duration) (bool, error)

Touch gives a live entry a new expiry and reports whether there was one.

A ttl of zero means forever. An absent key is false and is not created: touching a miss would turn it into an entry holding nothing.

func (*Repository) WithoutOverlapping

func (r *Repository) WithoutOverlapping(ctx context.Context, g auth.Grant, key string, fn func(context.Context) error, lockFor, waitFor time.Duration) error

WithoutOverlapping runs fn while holding a lock, so two of them cannot run at once.

lockFor is how long the lock survives a process that dies holding it; waitFor is how long this caller is willing to queue before giving up with ErrLockTimeout.

The store must be able to hold a lock. One that cannot returns ErrUnsupported.

type Result

type Result struct {
	// OK says whether this attempt was within the limit. An attempt that was
	// not is still counted -- the flood is the thing being measured.
	OK bool

	// Attempts is how many have been counted in this window, including this
	// one.
	Attempts int

	// Remaining is how many are left, never below zero.
	Remaining int

	// RetryAfter is how long until the window rolls. It is zero when OK.
	RetryAfter time.Duration
}

Result is what one Attempt did.

It is a struct and not three return values because the three are read together: the throttle middleware writes all of them into response headers, and a signature that returned them loose would be four values with an error.

type Store

type Store interface {
	// Get returns the stored bytes, or ErrNotFound when the key is absent or
	// expired.
	//
	// ErrNotFound specifically, and not the backend's own not-found error:
	// callers branch on it to mean "compute it", and a Store that returns
	// something else makes swapping the backend change the behaviour of the
	// application.
	Get(ctx context.Context, key string) ([]byte, error)

	// Put stores value under key for ttl, replacing whatever was there.
	//
	// ttl is always positive -- Repository refuses a non-positive one before it
	// gets here -- so a Store never has to decide what "no expiry" means.
	Put(ctx context.Context, key string, value []byte, ttl time.Duration) error

	// Add stores value only if the key is absent, and reports whether it did.
	//
	// It is the atomic half of the interface and the reason Repository.Add
	// exists: a get-then-put written at the call site is a race with a nice
	// name. A Store that cannot do it atomically has not implemented it.
	Add(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)

	// Forget removes a key. Removing what is not there is not an error: the
	// caller wanted the key gone, and it is.
	Forget(ctx context.Context, key string) error

	// Increment adds delta to the integer stored under key and returns the new
	// value. An absent key starts at zero, so the first call returns delta.
	//
	// The expiry is set when the key is created and an existing expiry is left
	// alone. That is what makes a fixed window fixed: refreshing the ttl on
	// every hit would keep a counter alive for as long as traffic continues,
	// which is a window that never closes.
	//
	// The value is written as decimal text, which is also what JSON makes of an
	// integer -- so a counter written here reads back through Get[int64].
	Increment(ctx context.Context, key string, delta int64, ttl time.Duration) (int64, error)

	// Flush removes every key beginning with prefix.
	//
	// It takes a prefix and not nothing, because the only caller is
	// Repository.Flush and a repository owns one tenant's slice of one
	// namespace. A cache:clear that emptied the store would clear every other
	// tenant on the way past.
	Flush(ctx context.Context, prefix string) error
}

Store is what a cache backend implements.

It is deliberately below the application's vocabulary: it moves bytes under keys that are already built, it has never heard of a Grant or a tenant, and it does not know what a namespace is. Everything that decides WHICH key a value belongs under lives in Repository, in one place, where it can be got right once -- rather than in every backend, where the third one gets the tenant separator subtly different and two customers share an entry.

A Store is safe for concurrent use.

type StoreConfig

type StoreConfig struct {
	// Driver names the kind: "array", "file", "database", "null", "failover",
	// or anything Extend registered.
	Driver string

	// Name is what this store is called, and it is filled in by Resolve from
	// the key in Config.Stores. It is what every event reports.
	Name string

	// Prefix overrides Config.Prefix for this store.
	Prefix string

	// NoEvents turns the events off for this store. The memo and failover
	// drivers set it, and it is negative so that the zero value keeps the
	// events on.
	NoEvents bool

	// Path is the directory the file driver writes in.
	Path string

	// LockPath is the directory the file driver keeps locks in. Setting it is
	// what makes FlushLocks possible.
	LockPath string

	// Permission is the mode the file driver gives what it creates; zero leaves
	// whatever the umask produced.
	Permission fs.FileMode

	// Connection is the database driver's connection, already resolved: there
	// is nothing here to look a name up in, so the connection itself is what
	// the configuration carries.
	Connection Connection

	// LockConnection is the connection the database driver manages locks on.
	LockConnection Connection

	// Table is the database driver's table.
	Table string

	// LockTable is the database driver's lock table; empty means "cache_locks".
	LockTable string

	// Stores is the failover driver's ordered set of store names.
	Stores []string
}

StoreConfig is one store's configuration.

It holds the union of what the drivers need, and each driver reads its own fields. That is one struct rather than one per driver because the configuration of a cache is read by a person choosing between them, and a person choosing between them wants one page.

type TagSet

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

TagSet is the set of tags a TaggedCache writes under.

Each tag is one ordinary cache entry holding a generation id, and the set's namespace is those ids joined. Every key a tagged repository builds carries a digest of that namespace, so changing one id orphans every entry carrying that tag at once -- one write, however many millions of entries. Nothing is scanned and nothing is deleted; the orphans expire on their own ttl.

The generations are tenant-scoped like everything else: two tenants tagging with "invoices" have separate generations, so one flushing its tag cannot orphan the other's entries.

func NewTagSet

func NewTagSet(s Store, names []string) *TagSet

NewTagSet returns the set. It answers TagSet::__construct().

It does not touch the store: the generations are read, and minted if they are not there, the first time a key has to be built.

func (*TagSet) Flush

func (t *TagSet) Flush(ctx context.Context, g auth.Grant) error

Flush removes every tag's generation. It answers TagSet::flush().

func (*TagSet) FlushTag

func (t *TagSet) FlushTag(ctx context.Context, g auth.Grant, name string) error

FlushTag removes a tag's generation entirely.

It answers TagSet::flushTag(). The difference from ResetTag is what is left behind: Reset writes a new generation, Flush writes none, and the next read mints one. Both orphan the same entries.

func (*TagSet) GetNames

func (t *TagSet) GetNames() []string

GetNames returns the tag names in the set. It answers TagSet::getNames().

func (*TagSet) GetNamespace

func (t *TagSet) GetNamespace(ctx context.Context, g auth.Grant) (string, error)

GetNamespace is the set's generations joined, and it changes whenever any tag in the set is reset or flushed.

func (*TagSet) Reset

func (t *TagSet) Reset(ctx context.Context, g auth.Grant) error

Reset gives every tag in the set a new generation.

It answers TagSet::reset(). It is what TaggedCache.Flush is built on.

func (*TagSet) ResetTag

func (t *TagSet) ResetTag(ctx context.Context, g auth.Grant, name string) (string, error)

ResetTag gives a tag a new generation and returns it.

Everything cached under the old generation is now unreachable, which is what flushing a tag is.

The generation is time plus randomness: two processes resetting the same tag in the same instant must not agree on the new id, or one of them keeps serving what the other flushed.

func (*TagSet) TagID

func (t *TagSet) TagID(ctx context.Context, g auth.Grant, name string) (string, error)

TagID returns a tag's current generation, minting one if there is none.

A tag nobody has used yet is not an error: the first read creates the generation, so tagging works on a cold cache.

func (*TagSet) TagKey

func (t *TagSet) TagKey(g auth.Grant, name string) (string, error)

TagKey is the cache key one tag's generation is stored under.

It answers TagSet::tagKey(), which is 'tag:'.$name.':key'. The tenant is in it here, because a generation shared between tenants would let one of them flush the other's entries.

type Taggable

type Taggable interface {
	Store
}

Taggable is the optional tag half of a Store.

Every Store in this package satisfies it for free: a tag is an ordinary entry holding a generation id, so tagging needs Get, Put and Forget and nothing a Store does not already have. The interface exists so Repository.SupportsTags has something to ask.

type TaggedCache

type TaggedCache struct {
	*Repository
}

TaggedCache is a Repository whose every key carries a tag generation.

It is the whole Repository surface again -- Put, Get, Remember, Forget and the rest are the methods of the embedded Repository, and they behave identically. What changes is where the entries land and what Flush does.

The one thing to know before reaching for it: a tagged entry can only be reached through the same tags. Cache.Tags("a").Put and Cache.Put write two different entries, and so do Tags("a", "b") and Tags("b", "a") -- order is part of the namespace.

func (*TaggedCache) Clear

func (t *TaggedCache) Clear(ctx context.Context, g auth.Grant) error

Clear is Flush, as it is on Repository.

func (*TaggedCache) Flush

func (t *TaggedCache) Flush(ctx context.Context, g auth.Grant) error

Flush orphans every entry carrying these tags.

It resets the generations rather than deleting entries: one write per tag, whatever the entry count, and the orphaned entries go when their own ttl does.

That is the trade the tag mechanism is: flushing is O(tags) instead of O(entries), and the price is that flushed entries occupy the store until they expire. Forever entries under a flushed tag occupy it for a century, which is the strongest argument this package has against Forever.

It fires CacheFlushing and then CacheFlushed, and both carry the tags: a listener told that a cache was flushed without being told which tags were flushed has been told something false, because the rest of the store is untouched.

func (*TaggedCache) GetTags

func (t *TaggedCache) GetTags() *TagSet

GetTags returns the tag set.

func (*TaggedCache) TaggedItemKey

func (t *TaggedCache) TaggedItemKey(ctx context.Context, g auth.Grant, key string) (string, error)

TaggedItemKey is the key an item is really stored under.

It is exported because something eventually has to look in the store and find out where the entry went.

Directories

Path Synopsis
Package cachetest is the contract suite every cache store passes.
Package cachetest is the contract suite every cache store passes.
Package console holds the cache commands.
Package console holds the cache commands.
Package events holds every event a cache Repository fires.
Package events holds every event a cache Repository fires.
Package ratelimiting is reserved, and holds no code.
Package ratelimiting is reserved, and holds no code.

Jump to

Keyboard shortcuts

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