redisstore

package
v0.1.0-alpha.21 Latest Latest
Warning

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

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package redisstore implements GoBeyond's shared L2 cache tier on top of Redis (ElastiCache Serverless in the reference deployment): a cache.Store/cache.Leaser/cache.TagBumpPublisher backed by one Redis endpoint, shared across every instance behind a deploy.

Write-behind, not write-through

Set never talks to Redis synchronously: it hands the record to a bounded worker pool and returns immediately, so a slow or unreachable L2 never adds latency to the request that produced the value. The tradeoff is that Set cannot report ErrStaleWrite - the compare-and-set that would produce it happens later, on a worker, against whatever tag versions are current at that point, not the ones current when Set was called. Losing a queued write (timeout, dropped-because-full, transport error) is treated the same as never having cached the entry: correctness rests on the version check every Get performs, not on Set succeeding.

Tag versions are the fence, not the TTL

Every entry is written with a Lua script that re-checks its tags' versions immediately before the SET (write-time CAS), and every Get re-checks them again against the current counters (read-time invalidation), mirroring cache.Store's contract that a tag bump must win over a not-yet-expired TTL. Both checks share one decision function, casAllows, so the Lua script and the Go read path can never disagree about what counts as stale.

Namespacing

Options.Namespace scopes tag-version keys and the tag-bump pub/sub channel to one deploy inside a possibly shared Redis instance; entry keys are not touched here because callers (cache.RouteKey / cache.DataKey) already namespace them with a deploy prefix before Get/Set ever see them.

Index

Constants

View Source
const (
	// EnvEndpoint is the ElastiCache Serverless endpoint host. Its absence
	// is the signal FromEnv uses to report "no cache configured".
	EnvEndpoint = "GOBEYOND_CACHE_ENDPOINT"
	// EnvPort is the endpoint's port; DefaultPort applies when unset.
	EnvPort = "GOBEYOND_CACHE_PORT"
	// EnvKeyPrefix seeds Options.Namespace.
	EnvKeyPrefix = "GOBEYOND_CACHE_KEY_PREFIX"
	// EnvUsername and EnvPassword seed Options.Username / Options.Password
	// for Redis AUTH. Either or both may be unset.
	EnvUsername = "GOBEYOND_CACHE_USERNAME"
	EnvPassword = "GOBEYOND_CACHE_PASSWORD"
	// EnvTLS force-disables TLS for local development against a plaintext
	// Redis. It is read only when Options.DisableTLS was not already set to
	// true explicitly, and only a value that parses as boolean false (e.g.
	// "false", "0") disables TLS; any other value, including unset, leaves
	// the secure default in place.
	EnvTLS = "GOBEYOND_CACHE_TLS"
)

Environment variables FromEnv reads. Names and defaulting behavior mirror what infra/opentofu/compute.tf injects into the task's environment when a cache is provisioned, so the runtime can wire a Store from the deployment without any deploy-specific code.

View Source
const (
	DefaultDialTimeout = 2 * time.Second
	DefaultReadTimeout = 1 * time.Second
)

Defaults for the owned redis.UniversalClient's network timeouts. ElastiCache Serverless sits behind a VPC hop, so both are a little more generous than go-redis's own defaults.

View Source
const (
	DefaultWriteWorkers = 4
	DefaultWriteQueue   = 256
	DefaultWriteTimeout = 2 * time.Second
)

Defaults for the write-behind pool. They favor bounding worst-case memory and Redis load over never dropping a write: a dropped write just means the entry stays a cache miss, which every reader already handles.

View Source
const DefaultPort = "6379"

DefaultPort is the port FromEnv assumes when EnvPort is unset.

Variables

This section is empty.

Functions

This section is empty.

Types

type Options

type Options struct {
	// Addr is the "host:port" of a single Redis endpoint. Required unless
	// Client is set. ElastiCache Serverless exposes one endpoint that
	// transparently scales, so this package targets a single address rather
	// than a cluster topology.
	Addr string
	// Namespace scopes tag-version keys and the tag-bump channel to one
	// deploy; see the package doc. Entry keys are not namespaced here - the
	// caller already did that (cache.RouteKey / cache.DataKey).
	Namespace string
	// Username and Password authenticate to Redis via AUTH. Both are
	// optional; ElastiCache Serverless with an auth token uses Password
	// only ("default" user).
	Username, Password string
	// TLS overrides the TLS config used to dial Redis. When nil and
	// DisableTLS is false, New builds one itself with ServerName set to
	// Addr's host, matching ElastiCache Serverless's requirement that
	// clients speak TLS with a verifiable certificate.
	TLS *tls.Config
	// DisableTLS connects in plaintext, for local development against a
	// Redis started without TLS. It must never be set in a deployment that
	// talks to ElastiCache Serverless, which requires TLS.
	DisableTLS bool
	// Client injects a pre-built redis.UniversalClient (e.g. a cluster
	// client, or a client shared with other packages) and takes ownership
	// away from Store: Close will not close it. When set, Addr, Username,
	// Password, TLS, and DisableTLS are ignored - the client is already
	// fully configured.
	Client redis.UniversalClient
	// WriteWorkers and WriteQueue bound the write-behind pool: WriteWorkers
	// goroutines drain a channel buffered to WriteQueue. DefaultWriteWorkers
	// / DefaultWriteQueue apply when either is <= 0.
	WriteWorkers int
	WriteQueue   int
	// WriteTimeout bounds one queued write's Redis round trip, applied to a
	// context detached from the caller's (see the package doc's write-behind
	// section). DefaultWriteTimeout applies when <= 0.
	WriteTimeout time.Duration
	// DialTimeout and ReadTimeout configure the owned client's network
	// timeouts; ignored when Client is set. Defaults above apply when <= 0.
	DialTimeout time.Duration
	ReadTimeout time.Duration
	// Logger receives write-behind failures and other best-effort-operation
	// warnings. slog.Default() applies when nil.
	Logger *slog.Logger
	// Clock overrides time.Now, for tests.
	Clock func() time.Time
}

Options configures a Store. The zero value is not valid on its own: New requires either Addr (to build an owned client) or Client (an injected one).

type Stats

type Stats struct {
	// Enqueued counts writes accepted onto the queue.
	Enqueued int64
	// Dropped counts writes discarded because the queue was full.
	Dropped int64
	// Persisted counts writes whose CAS matched and were stored.
	Persisted int64
	// Rejected counts writes whose CAS did not match: some tag was bumped
	// between the version read and the write running.
	Rejected int64
	// Failed counts writes that errored talking to Redis.
	Failed int64
}

Stats snapshots the write-behind pool's lifetime counters. It exists so tests (and, eventually, metrics) can observe pool behavior that Set's fire-and-forget return value hides on purpose.

type Store

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

Store is GoBeyond's shared L2 cache.Store, backed by one Redis endpoint. See the package doc for the write-behind and tag-versioning design; Store itself is safe for concurrent use.

func FromEnv

func FromEnv(opts Options) (*Store, bool, error)

FromEnv builds a Store from the environment a GoBeyond deployment injects (see the Env constants). It returns (nil, false, nil) when EnvEndpoint is unset or empty, which is not an error: the caller degrades to an L1-only cache. Fields already set on opts win over the corresponding environment variable, so a caller can override any single piece (e.g. inject a test Client, or force a different Namespace) while still picking up the rest from the environment.

func New

func New(opts Options) (*Store, error)

New creates a Store. Callers that do not have a pre-built client typically use FromEnv instead, which also decides whether a Store should exist at all for this deployment.

func (*Store) AcquireLease

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

AcquireLease reports whether the caller now holds key's lease, via a SET NX PX so at most one caller across every instance ever wins it.

func (*Store) BumpTag

func (s *Store) BumpTag(ctx context.Context, tag string) error

BumpTag increments tag's counter and best-effort publishes the new version so other instances' L1 can drop the tag's entries early. Tag counter keys are given no expiry: they are a handful of bytes each, one per tag ever used, and an expired counter would silently reset a tag to version 0 - resurrecting entries a bump was meant to invalidate rather than merely losing the pub/sub optimization a TTL'd key would only cost.

func (*Store) Close

func (s *Store) Close() error

Close stops accepting new writes, waits for in-flight and already-queued writes to drain, and - only when Store built the client itself, per Options.Client's contract - closes it. Calling Close more than once is safe.

func (*Store) Delete

func (s *Store) Delete(ctx context.Context, key string) error

Delete removes key synchronously; unlike Set it has no CAS to lose, so there is no reason to defer it to the write pool.

func (*Store) Get

func (s *Store) Get(ctx context.Context, key string) (cache.Record, bool, error)

Get returns the record stored under key, treating a tag-invalidated entry the same as a missing one (Get, not Set, is where an L2 read-time invalidation belongs - see the package doc). ExpiresAt is derived from the key's remaining Redis TTL, not from anything stored in the payload.

func (*Store) Set

func (s *Store) Set(ctx context.Context, key string, record cache.Record, ttl time.Duration) error

Set enqueues record for a write-behind, write-time-CAS write and returns without talking to Redis - see the package doc. It therefore never returns cache.ErrStaleWrite; a stale write is instead silently rejected by the CAS and counted in Stats().Rejected. The only synchronous errors are input validation (ttl must be positive; the store's own TTL bound is Redis's PX, not a client-side clamp).

func (*Store) Stats

func (s *Store) Stats() Stats

Stats snapshots the write-behind pool's lifetime counters.

func (*Store) SubscribeTagBumps

func (s *Store) SubscribeTagBumps(ctx context.Context, onBump func(tag string, version int64)) error

SubscribeTagBumps decodes BumpTag's broadcasts and invokes onBump for each one, blocking until ctx is canceled. It returns nil in that case (a canceled subscription is the normal shutdown path, not a failure); a non-nil error means the subscription itself broke. A malformed message is logged and skipped rather than propagated.

func (*Store) TagVersions

func (s *Store) TagVersions(ctx context.Context, tags []string) (map[string]int64, error)

TagVersions returns the current version of each requested tag, 0 for a tag whose counter key does not exist.

Jump to

Keyboard shortcuts

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