kvdecorator

package module
v1.0.1-0...-3068072 Latest Latest
Warning

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

Go to latest
Published: Feb 14, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

README

KVDecorator

Go Reference License

A transparent fallback cache for go-redis. When your Redis goes down, KVDecorator automatically routes supported commands to an in-memory cache — zero code changes required in your business logic.

How It Works

                          ┌─────────────────────────────────────────────────┐
                          │              FallbackHook (redis.Hook)         │
                          │                                                 │
                          │  ┌──────────────┐       ┌──────────────────┐   │
  rdb.Get / rdb.Set       │  │  ProcessHook │       │  CircuitBreaker  │   │
 ─────────────────────▶   │  │              │       │                  │   │
   (any redis.Cmder)      │  │  breaker.Is  │◀──────│  TCP probe loop  │   │
                          │  │  Down()?     │       │  (background)    │   │
                          │  └──┬───────┬───┘       └────────┬─────────┘   │
                          │     │       │                     │             │
                          │  breaker    │ breaker           TCP dial        │
                          │  = open     │ = closed         every Ns        │
                          │     │       │                     │             │
                          │     ▼       ▼                     ▼             │
                          │ ┌───────┐ ┌──────────┐    ┌─────────────┐      │
                          │ │ Local │ │  Remote  │    │    Redis    │      │
                          │ │ Cache │ │  Redis   │───▶│   Server    │      │
                          │ │  (map)│ │          │    │  :6379      │      │
                          │ └───────┘ └─────┬────┘    └─────────────┘      │
                          │     ▲           │                              │
                          │     │  dual-write on success                   │
                          │     └───────────┘                              │
                          └─────────────────────────────────────────────────┘

Normal — Commands go to Redis. Write operations (SET/DEL/MSET) are dual-written to the local cache as backup.

Degraded — TCP probe detects Redis is unreachable. The breaker opens, and all supported commands are served from the local cache. No request ever blocks on a dead connection.

Recovery — TCP probe detects Redis is back. The breaker closes, and traffic automatically routes back to Redis.

Components
  1. CircuitBreaker — A background goroutine probes the Redis address via net.DialTimeout("tcp", ...) at a configurable interval. After N consecutive failures, the breaker opens. No request-path latency is added.
  2. FallbackHook — Implements redis.Hook. Inspects the breaker state on every command and routes accordingly.
  3. LocalCache — A sync.RWMutex-protected map[string]Item with lazy expiration on read and periodic background cleanup.

Installation

go get github.com/FastSchnell/KVDecorator

Requires Go 1.21+ and go-redis v9.

Quick Start

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/FastSchnell/KVDecorator"
	"github.com/redis/go-redis/v9"
)

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

	hook := kvdecorator.NewFallbackHook("localhost:6379")
	defer hook.Close()
	rdb.AddHook(hook)

	ctx := context.Background()

	// Business code is unchanged — fallback is transparent
	rdb.Set(ctx, "user:1:name", "Alice", 10*time.Minute)
	val, err := rdb.Get(ctx, "user:1:name").Result()
	fmt.Println(val, err) // "Alice" <nil>  — from Redis or local cache
}

Configuration

hook := kvdecorator.NewFallbackHook("localhost:6379",
	kvdecorator.WithHookProbeInterval(2*time.Second),   // TCP probe interval (default: 1s)
	kvdecorator.WithHookDialTimeout(time.Second),        // TCP dial timeout  (default: 500ms)
	kvdecorator.WithHookThreshold(5),                    // consecutive failures to trip (default: 3)
	kvdecorator.WithHookCleanupInterval(30*time.Second), // expired-key cleanup interval (default: 10s)
)
Option Default Description
WithHookProbeInterval 1s How often the background goroutine probes Redis via TCP
WithHookDialTimeout 500ms Timeout for each TCP probe dial
WithHookThreshold 3 Consecutive probe failures required to open the breaker
WithHookCleanupInterval 10s Interval for the background goroutine that purges expired keys

Supported Commands in Degraded Mode

Command Local Behavior
GET Returns cached value or redis.Nil
SET Stores in local cache, supports EX / PX / EXAT / PXAT
DEL Removes from local cache, returns delete count
MGET Returns multiple cached values
MSET Stores multiple key-value pairs
EXISTS Returns count of existing keys
EXPIRE Updates TTL on an existing key
TTL Returns remaining TTL
PING Returns PONG

Unsupported commands (e.g. LPUSH, ZADD, HSET) return kvdecorator.ErrDegraded.

Observability

// Check breaker state programmatically
if hook.IsDown() {
	log.Warn("Redis is unreachable, serving from local cache")
}

Dev Without Redis

The same code works whether Redis is running or not — no if branches, no mock clients:

// Exact same code in dev and production
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
hook := kvdecorator.NewFallbackHook("localhost:6379")
defer hook.Close()
rdb.AddHook(hook)

rdb.Set(ctx, "session:abc", token, 30*time.Minute)
rdb.Get(ctx, "session:abc")
Environment Redis What happens
Dev laptop not installed NewFallbackHook probes on init, breaker trips immediately, all commands run against in-memory cache
CI / test not running same — tests pass without a Redis dependency
Production running commands go to Redis, local cache stays warm as backup

No code changes between environments. Just start (or don't start) Redis.

Design Decisions

  • TCP probe, not request-path detection — The circuit breaker runs independently. It never adds latency to real commands, and it detects recovery even when there is no traffic.
  • Dual-write on success — During normal operation, SET/DEL/MSET results are mirrored to the local cache. This ensures the local cache is warm when a failover happens.
  • redis.Hook integration — No wrapper client, no custom interface. Just AddHook() on any existing redis.Client, redis.ClusterClient, or redis.Ring.
  • No external dependencies — Only depends on go-redis/v9 and the Go standard library.

Testing

go test -race -v ./...

25 tests covering:

  • LocalCache: set/get, TTL expiration, delete, exists, mget/mset, expire, flush, concurrent access
  • CircuitBreaker: healthy server, dead server, recovery cycle
  • FallbackHook: all supported commands, backup logic, degraded routing, pipeline handling

License

KVDecorator is available under the Apache License, Version 2.0.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrDegraded = fmt.Errorf("kvdecorator: service degraded, command not supported locally")

ErrDegraded is returned for commands not supported in local fallback mode.

Functions

This section is empty.

Types

type BreakerOption

type BreakerOption func(*CircuitBreaker)

BreakerOption configures the CircuitBreaker.

func WithDialTimeout

func WithDialTimeout(d time.Duration) BreakerOption

WithDialTimeout sets the TCP dial timeout. Default: 500ms.

func WithProbeInterval

func WithProbeInterval(d time.Duration) BreakerOption

WithProbeInterval sets the interval between TCP probes. Default: 1s.

func WithThreshold

func WithThreshold(n int64) BreakerOption

WithThreshold sets the number of consecutive failures to trip. Default: 3.

type CircuitBreaker

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

CircuitBreaker monitors a remote service via TCP probes and switches to a degraded (open) state when the service is unreachable.

func NewCircuitBreaker

func NewCircuitBreaker(addr string, opts ...BreakerOption) *CircuitBreaker

NewCircuitBreaker creates a new CircuitBreaker for the given address.

func (*CircuitBreaker) IsDown

func (cb *CircuitBreaker) IsDown() bool

IsDown returns true if the remote service is considered unreachable.

func (*CircuitBreaker) Start

func (cb *CircuitBreaker) Start()

Start runs an initial synchronous probe to determine the current state of the remote service, then launches the background probe goroutine. If Redis is unreachable, the breaker trips before Start returns.

func (*CircuitBreaker) Stop

func (cb *CircuitBreaker) Stop()

Stop terminates the background TCP probe goroutine.

type FallbackHook

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

FallbackHook implements redis.Hook. It intercepts all commands and routes them to a local in-memory cache when the remote Redis is unreachable.

func NewFallbackHook

func NewFallbackHook(addr string, opts ...HookOption) *FallbackHook

NewFallbackHook creates a FallbackHook for the given Redis address. The addr should match the Redis server address (e.g. "localhost:6379"). Use AddHook on your redis.Client to install it:

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
hook := kvdecorator.NewFallbackHook("localhost:6379")
defer hook.Close()
rdb.AddHook(hook)

func (*FallbackHook) Close

func (h *FallbackHook) Close()

Close stops the circuit breaker probe and the local cache cleanup goroutine.

func (*FallbackHook) DialHook

func (h *FallbackHook) DialHook(next redis.DialHook) redis.DialHook

DialHook passes through to the next hook.

func (*FallbackHook) IsDown

func (h *FallbackHook) IsDown() bool

IsDown reports whether the remote Redis is currently considered unreachable.

func (*FallbackHook) ProcessHook

func (h *FallbackHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook

ProcessHook intercepts each command. If the breaker is open, the command is handled locally. Otherwise, it is forwarded to Redis and the result is backed up in the local cache.

func (*FallbackHook) ProcessPipelineHook

func (h *FallbackHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook

ProcessPipelineHook intercepts pipeline commands with the same logic.

type HookOption

type HookOption func(*hookConfig)

HookOption configures the FallbackHook.

func WithHookCleanupInterval

func WithHookCleanupInterval(d time.Duration) HookOption

WithHookCleanupInterval sets the local cache cleanup interval. Default: 10s.

func WithHookDialTimeout

func WithHookDialTimeout(d time.Duration) HookOption

WithHookDialTimeout sets the TCP dial timeout. Default: 500ms.

func WithHookProbeInterval

func WithHookProbeInterval(d time.Duration) HookOption

WithHookProbeInterval sets the TCP probe interval. Default: 1s.

func WithHookThreshold

func WithHookThreshold(n int64) HookOption

WithHookThreshold sets the consecutive failure threshold. Default: 3.

type Item

type Item struct {
	Value      string
	Expiration int64 // UnixNano timestamp; 0 means no expiration
}

Item represents a cached value with optional expiration.

func (Item) Expired

func (item Item) Expired() bool

Expired returns true if the item has expired.

type LocalCache

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

LocalCache is a concurrent-safe in-memory key-value cache with TTL support.

func NewLocalCache

func NewLocalCache(cleanupInterval time.Duration) *LocalCache

NewLocalCache creates a new LocalCache and starts the background cleanup goroutine. cleanupInterval controls how often expired items are removed.

func (*LocalCache) Close

func (c *LocalCache) Close()

Close stops the background cleanup goroutine.

func (*LocalCache) Delete

func (c *LocalCache) Delete(keys ...string) int64

Delete removes one or more keys. Returns the number of keys that were present.

func (*LocalCache) Exists

func (c *LocalCache) Exists(keys ...string) int64

Exists returns the number of specified keys that exist (and are not expired).

func (*LocalCache) Expire

func (c *LocalCache) Expire(key string, ttl time.Duration) bool

Expire sets a TTL on an existing key. Returns true if the key exists.

func (*LocalCache) Flush

func (c *LocalCache) Flush()

Flush removes all items from the cache.

func (*LocalCache) Get

func (c *LocalCache) Get(key string) (string, bool)

Get retrieves a value by key. Returns the value and true if found and not expired.

func (*LocalCache) MGet

func (c *LocalCache) MGet(keys ...string) []interface{}

MGet returns the values for the given keys. Missing or expired keys have nil entries.

func (*LocalCache) MSet

func (c *LocalCache) MSet(pairs map[string]string)

MSet stores multiple key-value pairs. keys and values must have the same length.

func (*LocalCache) Set

func (c *LocalCache) Set(key, value string, ttl time.Duration)

Set stores a key-value pair with an optional TTL. Zero TTL means no expiration.

func (*LocalCache) TTL

func (c *LocalCache) TTL(key string) time.Duration

TTL returns the remaining TTL for a key. Returns -2 if the key does not exist, -1 if the key has no expiration.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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