Documentation
¶
Overview ¶
Package redis provides a typed cache boundary for go-codex pipelines, backed by any server speaking the Redis protocol.
Typed cache boundary ¶
Values enter and leave the cache through the port's codex.Codec — every read decodes AND validates, every write validates AND encodes. The cache key is declared once as a template on the port (ports.CachePattern) and expanded per item; TTL and wire format (JSON/YAML/TOML) are part of the same declaration.
var UserCache = codex.Must(ports.NewIOPort[UserQuery, User]("user-cache",
queryCodec, userCodec, ports.PortOptions{
Patterns: []ports.Pattern{
ports.CachePattern{Key: "user:{id}", TTL: 15 * time.Minute},
},
}))
Port mapping ¶
- GetAdapter — ports.IOAdapter: read-through lookup (Req → key vars → cached Resp; miss skips the item or errors per options).
- SetAdapter — ports.IOAdapter: write-through transform (writes each item, passes it through unchanged).
- DrainSetAdapter — ports.SinkAdapter: terminal write-through.
- Seed — warm-restart read of a var-free key (e.g. re-feeding a ports.LatestPort after a process restart).
Narrow client interface ¶
All constructors accept Commands — a three-method subset of the Redis command surface — never a concrete client. NewCommands adapts a go-redis redis.UniversalClient; unit tests use a hand-written fake. This is the only package file that imports go-redis.
Observer integration ¶
Every lookup and write fires stats.CacheObserver events (hit, miss, write) when the configured Observer implements it — always type-asserted, existing Observer implementations need not change. Decode validation failures additionally report per-field via stats.ReportErrors with location "payload". A nil Observer resolves from ctx (stats.ObserverFromContext).
Errors ¶
All failures are wrapped in CacheError (implements slog.LogValuer and Unwrap). A missing key surfaces as ErrCacheMiss — reachable through errors.Is on the wrapped chain.
Index ¶
- Variables
- func DrainSetAdapter[T any](client Commands, cache ports.Cache[T], keyFn func(T) map[string]string, ...) ports.SinkAdapter[T]
- func Get[T any](ctx context.Context, client Commands, cache ports.Cache[T], ...) (T, bool, error)
- func GetAdapter[Req, Resp any](client Commands, cache ports.Cache[Resp], keyFn func(Req) map[string]string, ...) ports.IOAdapter[Req, Resp]
- func GetMerged[T any](ctx context.Context, client Commands, cache ports.Cache[T], ...) (T, bool, error)
- func Seed[T any](ctx context.Context, client Commands, cache ports.Cache[T], opts SeedOptions) (T, bool, error)
- func Set[T any](ctx context.Context, client Commands, cache ports.Cache[T], ...) error
- func SetAdapter[T any](client Commands, cache ports.Cache[T], keyFn func(T) map[string]string, ...) ports.IOAdapter[T, T]
- func SetHandle[T any](ctx context.Context, client Commands, cache ports.Cache[T], v T, ...) error
- type CacheError
- type Commands
- type GetAdapterOptions
- type GetOptions
- type SeedOptions
- type SetAdapterOptions
- type SetOptions
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrCacheMiss = errors.New("redis: cache miss")
ErrCacheMiss is the sentinel for a missing cache key. Commands.Get implementations return it (wrapped or bare) when the key does not exist; GetAdapter maps it to skip-or-error per GetAdapterOptions.MissIsError. Test with errors.Is — it survives CacheError wrapping via Unwrap.
Functions ¶
func DrainSetAdapter ¶
func DrainSetAdapter[T any]( client Commands, cache ports.Cache[T], keyFn func(T) map[string]string, opts SetAdapterOptions, ) ports.SinkAdapter[T]
DrainSetAdapter returns a ports.SinkAdapter that writes every item to the cache — the terminal variant of SetAdapter for pipeline ends. Write errors go to SetAdapterOptions.OnError (dropped when nil); source stream errors are also forwarded to OnError.
sinkPort.Bind(ctx, redis.DrainSetAdapter(client, cacheHandle,
func(u User) map[string]string { return map[string]string{"id": u.ID} },
redis.SetAdapterOptions{}))
keyFn may be nil under the same conditions as SetAdapter — vars are then derived PER-ITEM from each item's own merge-capable key fields.
func Get ¶
func Get[T any](ctx context.Context, client Commands, cache ports.Cache[T], vars map[string]string, opts GetOptions) (T, bool, error)
Get looks up a single value in the cache — full codec validation (key vars AND value), no ports.IOAdapter, no gstream.Stream involved. This is the plain-function standalone entrypoint for a non-pipeline application, mirroring ports.File.Read/adapters/sql.Validate: ports.Cache is the declarative descriptor (built via ports.NewCache or a ports.CachePattern), and Get is the concrete redis implementation of a read against it — the same relationship ports.File has to its Read/Write methods, or a route handle has to adapters/nethttp.Call.
v, ok, err := redis.Get(ctx, client, userCache,
map[string]string{"id": userID}, redis.GetOptions{})
GetAdapter delegates to Get per item — calling Get directly and driving GetAdapter through a bound ports.IOPort produce identical behavior.
Returns (zero, false, nil) on a miss by default (an empty cache is not an error); set MissIsError to get a CacheError wrapping ErrCacheMiss instead. Key-build, transport, and decode failures return CacheError.
func GetAdapter ¶
func GetAdapter[Req, Resp any]( client Commands, cache ports.Cache[Resp], keyFn func(Req) map[string]string, opts GetAdapterOptions, ) ports.IOAdapter[Req, Resp]
GetAdapter returns a ports.IOAdapter that looks each Req up in the cache. The key is built from cache.Key (ports.CacheHandle) with vars extracted by keyFn; the stored bytes are decoded and codec-validated through cache.Format. Use with ports.IOPort.Bind:
cacheHandle, _ := ports.CacheHandle[User](userPort)
userPort.Bind(ctx, redis.GetAdapter(client, cacheHandle,
func(q UserQuery) map[string]string { return map[string]string{"id": q.ID} },
redis.GetAdapterOptions{}))
Hit → decoded Resp downstream + RecordCacheHit. Miss → skip (or CacheError wrapping ErrCacheMiss when MissIsError) + RecordCacheMiss. Key-build, transport, and decode failures → CacheError on Stream.Errors.
Looks up via GetMerged — when cache declares merge-capable key params (via ports.NewCacheKeyParam), the vars derived from keyFn(Req) are ADDITIONALLY merged into the decoded Resp (e.g. a key-derived id is populated onto Resp automatically). Identical to a bare Get when cache declares no merge fields.
Example ¶
package main
import (
"context"
"fmt"
"sync"
"time"
adapterredis "github.com/DaniDeer/go-codex/adapters/redis"
"github.com/DaniDeer/go-codex/codex"
"github.com/DaniDeer/go-codex/ports"
"github.com/DaniDeer/go-codex/stream"
"github.com/DaniDeer/go-codex/validate"
)
type user struct {
ID string
Name string
}
var userCodec = codex.Struct[user](
codex.RequiredField("id", codex.String().Refine(validate.NonEmptyString),
func(u user) string { return u.ID },
func(u *user, v string) { u.ID = v },
),
codex.RequiredField("name", codex.String().Refine(validate.NonEmptyString),
func(u user) string { return u.Name },
func(u *user, v string) { u.Name = v },
),
)
type userQuery struct{ ID string }
// fakeCommands is the hand-written Commands fake — an in-memory map plus
// recorded calls. No live Redis, no miniredis.
type fakeCommands struct {
mu sync.Mutex
store map[string][]byte
ttls map[string]time.Duration
getErr error
setErr error
}
func newFake() *fakeCommands {
return &fakeCommands{store: map[string][]byte{}, ttls: map[string]time.Duration{}}
}
func (f *fakeCommands) Get(_ context.Context, key string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.getErr != nil {
return nil, f.getErr
}
b, ok := f.store[key]
if !ok {
return nil, adapterredis.ErrCacheMiss
}
return b, nil
}
func (f *fakeCommands) Set(_ context.Context, key string, value []byte, ttl time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.setErr != nil {
return f.setErr
}
f.store[key] = value
f.ttls[key] = ttl
return nil
}
func (f *fakeCommands) Del(_ context.Context, keys ...string) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, k := range keys {
delete(f.store, k)
}
return nil
}
func userCache() ports.Cache[user] {
port, err := ports.NewIOPort[userQuery, user]("user-cache", codex.Struct[userQuery](
codex.RequiredField("id", codex.String(),
func(q userQuery) string { return q.ID },
func(q *userQuery, v string) { q.ID = v },
),
), userCodec, ports.PortOptions{})
if err != nil {
panic(err)
}
c, err := port.PluginCachePattern(ports.CachePattern{Key: "user:{id}", TTL: 15 * time.Minute})
if err != nil {
panic(err)
}
return c
}
func queryStream(qs ...userQuery) stream.Stream[userQuery] {
ch := make(chan userQuery, len(qs))
for _, q := range qs {
ch <- q
}
close(ch)
errCh := make(chan error)
close(errCh)
return stream.Stream[userQuery]{Values: ch, Errors: errCh}
}
func main() {
ctx := context.Background()
fake := newFake()
fake.store["user:42"] = []byte(`{"id":"42","name":"Ada"}`)
cache := userCache()
adapter := adapterredis.GetAdapter[userQuery, user](fake, cache,
func(q userQuery) map[string]string { return map[string]string{"id": q.ID} },
adapterredis.GetAdapterOptions{})
out := adapter.Transform(ctx, queryStream(userQuery{ID: "42"}, userQuery{ID: "404"}))
vals, _ := stream.Collect(ctx, out)
for _, u := range vals {
fmt.Println(u.Name)
}
}
Output: Ada
func GetMerged ¶
func GetMerged[T any](ctx context.Context, client Commands, cache ports.Cache[T], vars map[string]string, opts GetOptions) (T, bool, error)
GetMerged is the decode-merge convenience: it looks up exactly like Get, then ADDITIONALLY merges vars into the SAME returned value via codex.DecodeVars, using the merge-capable fields registered via ports.NewCacheKeyParam — mirrors ports.File.ReadMerged/ [events.ChannelHandle.DecodeMerged] for the cache boundary.
Additive — Get is unchanged; GetMerged behaves identically to a bare Get when the cache declares no merge-capable key params (ports.Cache.MergeFields is empty) or on a miss (nothing to merge into).
Example — key template "user:{id}" declared with ports.NewCacheKeyParam, so the extracted id is merged into the returned struct's own field:
user, ok, err := redis.GetMerged(ctx, client, userCache, map[string]string{"id": id}, redis.GetOptions{})
// ok && user.ID == id, no manual assignment needed.
func Seed ¶
func Seed[T any](ctx context.Context, client Commands, cache ports.Cache[T], opts SeedOptions) (T, bool, error)
Seed reads the cache handle's var-free key and returns the decoded, codec-validated value — the warm-restart read for a durable ports.LatestPort: persist updates with SetAdapter/DrainSetAdapter on the feeding stream, then Seed the first item after a restart. A thin wrapper around Get with nil vars — the one case that must run before any stream exists, so it stays a dedicated zero-vars function rather than asking every LatestPort warm-restart caller to pass an empty map.
if v, ok, err := redis.Seed(ctx, client, cacheHandle, redis.SeedOptions{}); ok && err == nil {
seeded := stream.Single(ctx, v)
// Merge seeded with the live stream before latestPort.Feed.
}
Returns (zero, false, nil) on a miss — an empty cache is not an error. Decode and transport failures return (zero, false, CacheError).
Example ¶
package main
import (
"context"
"fmt"
"sync"
"time"
adapterredis "github.com/DaniDeer/go-codex/adapters/redis"
"github.com/DaniDeer/go-codex/codex"
"github.com/DaniDeer/go-codex/ports"
"github.com/DaniDeer/go-codex/validate"
)
type user struct {
ID string
Name string
}
var userCodec = codex.Struct[user](
codex.RequiredField("id", codex.String().Refine(validate.NonEmptyString),
func(u user) string { return u.ID },
func(u *user, v string) { u.ID = v },
),
codex.RequiredField("name", codex.String().Refine(validate.NonEmptyString),
func(u user) string { return u.Name },
func(u *user, v string) { u.Name = v },
),
)
type userQuery struct{ ID string }
// fakeCommands is the hand-written Commands fake — an in-memory map plus
// recorded calls. No live Redis, no miniredis.
type fakeCommands struct {
mu sync.Mutex
store map[string][]byte
ttls map[string]time.Duration
getErr error
setErr error
}
func newFake() *fakeCommands {
return &fakeCommands{store: map[string][]byte{}, ttls: map[string]time.Duration{}}
}
func (f *fakeCommands) Get(_ context.Context, key string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.getErr != nil {
return nil, f.getErr
}
b, ok := f.store[key]
if !ok {
return nil, adapterredis.ErrCacheMiss
}
return b, nil
}
func (f *fakeCommands) Set(_ context.Context, key string, value []byte, ttl time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.setErr != nil {
return f.setErr
}
f.store[key] = value
f.ttls[key] = ttl
return nil
}
func (f *fakeCommands) Del(_ context.Context, keys ...string) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, k := range keys {
delete(f.store, k)
}
return nil
}
func userCache() ports.Cache[user] {
port, err := ports.NewIOPort[userQuery, user]("user-cache", codex.Struct[userQuery](
codex.RequiredField("id", codex.String(),
func(q userQuery) string { return q.ID },
func(q *userQuery, v string) { q.ID = v },
),
), userCodec, ports.PortOptions{})
if err != nil {
panic(err)
}
c, err := port.PluginCachePattern(ports.CachePattern{Key: "user:{id}", TTL: 15 * time.Minute})
if err != nil {
panic(err)
}
return c
}
func main() {
ctx := context.Background()
fake := newFake()
fake.store["latest-oee"] = []byte(`{"id":"line-1","name":"0.87"}`)
cache := ports.Cache[user]{Key: "latest-oee", Format: userCache().Format}
v, ok, err := adapterredis.Seed(ctx, fake, cache, adapterredis.SeedOptions{})
fmt.Println(ok, err == nil, v.Name)
}
Output: true true 0.87
func Set ¶
func Set[T any](ctx context.Context, client Commands, cache ports.Cache[T], vars map[string]string, v T, opts SetOptions) error
Set writes a single value to the cache — full codec validation (key vars AND value), no ports.IOAdapter, no gstream.Stream involved. This is the plain-function standalone entrypoint for a non-pipeline application, mirroring ports.File.Write/adapters/sql.Validate: ports.Cache is the declarative descriptor, and Set is the concrete redis implementation of a write against it.
err := redis.Set(ctx, client, userCache,
map[string]string{"id": user.ID}, user, redis.SetOptions{})
SetAdapter and DrainSetAdapter delegate to Set per item — calling Set directly and driving them through a bound port produce identical behavior. Errors are CacheError; unlike the pipeline adapters, Set returns the error directly instead of routing it past a "pass through regardless" step, since there is no downstream to protect.
func SetAdapter ¶
func SetAdapter[T any]( client Commands, cache ports.Cache[T], keyFn func(T) map[string]string, opts SetAdapterOptions, ) ports.IOAdapter[T, T]
SetAdapter returns a ports.IOAdapter that writes each item to the cache (write-through) and passes it through unchanged. Encode and write failures go to Stream.Errors as CacheError — the item is STILL passed through: a cache write failure must not drop pipeline data.
port.Bind(ctx, redis.SetAdapter(client, cacheHandle,
func(u User) map[string]string { return map[string]string{"id": u.ID} },
redis.SetAdapterOptions{}))
keyFn may be nil when cache declares merge-capable key params (via ports.NewCacheKeyParam): vars are then derived PER-ITEM from each item's own merge fields automatically via SetHandle — the same "one struct, one call" convenience [mqtt5.PublishHandle] provides. Pass a non-nil keyFn to keep building the map yourself (e.g. no merge fields declared, or vars come from a field the cached type doesn't have).
func SetHandle ¶
func SetHandle[T any](ctx context.Context, client Commands, cache ports.Cache[T], v T, opts SetOptions) error
SetHandle is the single-call convenience wrapper around Set: it derives the key vars from v automatically via codex.EncodeVars(v, ports.Cache.MergeFields()...) — one struct in, no manual vars map — mirroring [mqtt5.PublishHandle]/ports.WriteHandle's convenience for the cache boundary.
Set remains available as the lower-level escape hatch for callers that build the vars map themselves (e.g. no merge-capable key params declared, or vars come from a non-struct source).
err := redis.SetHandle(ctx, client, userCache, user, redis.SetOptions{})
// key derived from user's own ID field — no manual vars map.
Types ¶
type CacheError ¶
type CacheError struct {
// Key is the expanded cache key (e.g. "user:42"). May be empty when key
// building itself failed.
Key string
// Op is the operation: "get", "set", or "del".
Op string
// Err is the underlying error.
Err error
}
CacheError wraps any cache operation failure: key building, transport, encode/decode, or a miss surfaced as an error.
func (CacheError) Error ¶
func (e CacheError) Error() string
func (CacheError) LogValue ¶
func (e CacheError) LogValue() slog.Value
LogValue implements slog.LogValuer for structured logging.
func (CacheError) Unwrap ¶
func (e CacheError) Unwrap() error
Unwrap allows errors.Is and errors.As to reach the underlying error, including ErrCacheMiss and codex.ValidationErrors.
type Commands ¶
type Commands interface {
// Get returns the value stored at key, or an error satisfying
// errors.Is(err, ErrCacheMiss) when the key does not exist.
Get(ctx context.Context, key string) ([]byte, error)
// Set stores value at key. ttl zero means no expiry.
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
// Del removes the given keys. Deleting a missing key is not an error.
Del(ctx context.Context, keys ...string) error
}
Commands is the narrow Redis command surface the adapters use. Constructors accept this interface — never a concrete client — so unit tests run against a hand-written fake and the adapter stays decoupled from the client library.
NewCommands adapts a go-redis client. Any other implementation works as long as it honours the ErrCacheMiss contract on Get.
func NewCommands ¶
func NewCommands(c goredis.UniversalClient) Commands
NewCommands wraps a go-redis goredis.UniversalClient as Commands. *redis.Client, *redis.ClusterClient, and sentinel-failover clients all satisfy UniversalClient — one shim covers every deployment shape. This is the only place the adapter touches go-redis.
type GetAdapterOptions ¶
type GetAdapterOptions struct {
// MissIsError routes a cache miss to Stream.Errors as a [CacheError]
// wrapping [ErrCacheMiss]. Default false: a miss emits nothing for the
// item (the IOAdapter 0..N contract) — the idiomatic read-through shape
// where downstream handles only cache hits.
MissIsError bool
// Buffer sizes the output channels. Default 0 (unbuffered).
Buffer int
// Observer receives [stats.CacheObserver] hit/miss events and per-field
// validation reports. Resolved from ctx when nil.
Observer stats.Observer
}
GetAdapterOptions configures GetAdapter.
type GetOptions ¶
type GetOptions struct {
// MissIsError, when true, returns a [CacheError] wrapping [ErrCacheMiss]
// on a cache miss instead of (zero, false, nil).
MissIsError bool
// Observer receives [stats.CacheObserver] hit/miss events and per-field
// validation reports. Resolved from ctx when nil.
Observer stats.Observer
}
GetOptions configures Get.
type SeedOptions ¶
type SeedOptions struct {
// Observer receives the [stats.CacheObserver] hit/miss event.
// Resolved from ctx when nil.
Observer stats.Observer
}
SeedOptions configures Seed.
type SetAdapterOptions ¶
type SetAdapterOptions struct {
// TTL overrides the cache handle's declared TTL when non-zero.
TTL time.Duration
// Buffer sizes the output channels ([SetAdapter] only). Default 0.
Buffer int
// OnError receives each write error ([DrainSetAdapter] only — SetAdapter
// routes errors to its output Stream.Errors).
OnError func(error)
// Observer receives [stats.CacheObserver] write events. Resolved from
// ctx when nil.
Observer stats.Observer
}
SetAdapterOptions configures SetAdapter and DrainSetAdapter.