Documentation
¶
Overview ¶
Package kahora is a high-performance, sharded, in-memory cache for Go.
kahora targets a specific pain point: Go's built-in map does not shrink after entries are deleted or expire, leading to unbounded memory growth in long-running services with high TTL turnover. kahora reconstructs shard maps gradually in the background — one shard per tick — to reclaim memory without spikes.
Design ¶
The cache is sharded into N independent partitions (default 256). Each shard has its own RWMutex, its own map, and its own atomic counters. Operations on different shards never contend. Sharding uses maphash.Comparable for zero-allocation key hashing.
All time measurements use a monotonic clock derived from time.Since, immune to NTP adjustments and wall clock jumps.
Shrink ¶
Shrink runs on a single background goroutine, processing one shard per tick via round-robin. The full cycle duration is configurable (default 60s).
Each shrink uses a three-phase approach to minimise lock hold time:
- Snapshot live entries under write lock (brief).
- Build a new map from the snapshot without holding any lock.
- Delta-merge keys mutated during phase 2 and swap atomically (brief).
A "dirty" set tracks keys written or deleted during phase 2, ensuring no mutation is lost even under heavy concurrent load.
TTL ¶
TTL is optional. When enabled, expired entries are removed lazily on Get, or proactively by the active expiry sweep if WithActiveExpiry is set.
Limits ¶
kahora limits entry count, not bytes. For byte-aware caching, wrap kahora or use a different library — keeping the API simple is a deliberate choice. The maxEntries limit is enforced per-shard via atomic counters and may be exceeded slightly under concurrent load.
Metrics ¶
kahora exposes a MetricsRecorder interface — bring your own backend. A DefaultRecorder is provided with thread-safe counters and a Snapshot method for direct inspection. If no recorder is set, metrics are discarded at zero cost (the no-op implementation is inlined by the compiler).
Quick start ¶
c, err := kahora.New[string, []byte](
kahora.WithShardCount(kahora.ShardCountM),
kahora.WithTTL(5 * time.Minute),
kahora.WithActiveExpiry(30 * time.Second),
kahora.WithMaxEntries(10_000_000),
)
if err != nil {
log.Fatal(err)
}
defer c.Close()
c.Set("user:42", payload)
if v, ok := c.Get("user:42"); ok {
use(v)
}
When to use kahora ¶
kahora is designed for read-heavy in-memory caches with millions of entries and high TTL turnover, where Go's native map memory behaviour becomes a problem. It is not a distributed cache, not a byte-aware cache, and not a replacement for Redis. For sub-million entry counts, the standard library sync.Map or a simple map+mutex will likely be simpler and equally fast.
Index ¶
- Variables
- type Cache
- type DefaultRecorder
- func (r *DefaultRecorder) RecordActiveEviction(_ int)
- func (r *DefaultRecorder) RecordCapacityExceeded(_ int)
- func (r *DefaultRecorder) RecordDelete(_ int)
- func (r *DefaultRecorder) RecordHit(shard int)
- func (r *DefaultRecorder) RecordLazyEviction(_ int)
- func (r *DefaultRecorder) RecordMiss(shard int)
- func (r *DefaultRecorder) RecordSet(shard int)
- func (r *DefaultRecorder) RecordShrink(shard, before, after int)
- func (r *DefaultRecorder) Snapshot() Snapshot
- type MetricsRecorder
- type Option
- func WithActiveExpiry(interval time.Duration) Option
- func WithMaxEntries(n int) Option
- func WithMetricsRecorder(r MetricsRecorder) Option
- func WithShardCount(n ShardCount) Option
- func WithShrinkCycleInterval(d time.Duration) Option
- func WithShrinkMinEntries(n int) Option
- func WithTTL(ttl time.Duration) Option
- type ShardCount
- type ShardSnapshot
- type Snapshot
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrCapacityExceeded = errors.New("kahora: capacity exceeded")
ErrCapacityExceeded is returned by Set when the cache has reached its maxEntries limit. The limit is per-shard and approximate — see WithMaxEntries.
var ErrClosed = errors.New("kahora: cache is closed")
ErrClosed is returned by Set when called on a closed cache. Get and Delete remain safe after Close but no further writes are accepted.
Functions ¶
This section is empty.
Types ¶
type Cache ¶
type Cache[K comparable, V any] struct { // contains filtered or unexported fields }
Cache is a generic, sharded, in-memory cache. Safe for concurrent use by multiple goroutines.
K must be comparable. V can be any type. Zero value is not usable — always create via New.
func New ¶
func New[K comparable, V any](opts ...Option) (*Cache[K, V], error)
New creates a new Cache with the given options. Returns an error if any option is invalid or options are logically inconsistent.
New starts a background goroutine for shrink and active expiry (if enabled). Always call Close when the cache is no longer needed.
Example ¶
ExampleNew demonstrates basic cache creation, Set, and Get.
package main
import (
"fmt"
"github.com/BawNer/kahora"
)
func main() {
c, err := kahora.New[string, string]()
if err != nil {
panic(err)
}
defer c.Close()
c.Set("greeting", "hello")
v, ok := c.Get("greeting")
if ok {
fmt.Println(v)
}
}
Output: hello
Example (WithCapacity) ¶
ExampleNew_withCapacity demonstrates capping the cache to a maximum number of entries. Set returns ErrCapacityExceeded once the limit is reached.
package main
import (
"errors"
"fmt"
"github.com/BawNer/kahora"
)
func main() {
c, err := kahora.New[int, int](
kahora.WithShardCount(kahora.ShardCountXS),
kahora.WithMaxEntries(16),
)
if err != nil {
panic(err)
}
defer c.Close()
for i := range 100 {
if err := c.Set(i, i); errors.Is(err, kahora.ErrCapacityExceeded) {
fmt.Println("hit capacity limit")
break
}
}
}
Output: hit capacity limit
Example (WithTTL) ¶
ExampleNew_withTTL demonstrates configuring a cache with a TTL and active background expiry.
package main
import (
"fmt"
"time"
"github.com/BawNer/kahora"
)
func main() {
c, err := kahora.New[string, int](
kahora.WithTTL(time.Minute),
kahora.WithActiveExpiry(10*time.Second),
)
if err != nil {
panic(err)
}
defer c.Close()
c.Set("counter", 42)
if v, ok := c.Get("counter"); ok {
fmt.Println(v)
}
}
Output: 42
func (*Cache[K, V]) Close ¶
func (c *Cache[K, V]) Close()
Close stops the background goroutine and releases resources. After Close, Set returns ErrClosed. Get and Delete remain safe to call but operate on a static snapshot — no further eviction or shrink occurs.
Close is idempotent — safe to call multiple times.
Example ¶
ExampleCache_Close demonstrates the cache lifecycle. After Close, Set returns ErrClosed but Get remains usable against the static snapshot.
package main
import (
"errors"
"fmt"
"github.com/BawNer/kahora"
)
func main() {
c, err := kahora.New[string, string]()
if err != nil {
panic(err)
}
c.Set("k", "v")
c.Close()
err = c.Set("k2", "v2")
if errors.Is(err, kahora.ErrClosed) {
fmt.Println("cache is closed")
}
}
Output: cache is closed
func (*Cache[K, V]) Delete ¶
func (c *Cache[K, V]) Delete(key K)
Delete removes key from the cache. No-op if key does not exist or has already expired.
Example ¶
ExampleCache_Delete demonstrates explicit removal of an entry.
package main
import (
"fmt"
"github.com/BawNer/kahora"
)
func main() {
c, err := kahora.New[string, string]()
if err != nil {
panic(err)
}
defer c.Close()
c.Set("temp", "value")
c.Delete("temp")
if _, ok := c.Get("temp"); !ok {
fmt.Println("gone")
}
}
Output: gone
type DefaultRecorder ¶
type DefaultRecorder struct {
// contains filtered or unexported fields
}
DefaultRecorder is a thread-safe, zero-dependency MetricsRecorder. Use NewRecorder to create one, then pass it to New via WithMetricsRecorder. Call Snapshot at any time to read current state.
func NewRecorder ¶
func NewRecorder(shardCount ShardCount) *DefaultRecorder
NewRecorder creates a DefaultRecorder sized for the given shard count. Pass the same ShardCount you use in New — or use ShardCountM (256) if default.
Example ¶
ExampleNewRecorder demonstrates using the built-in DefaultRecorder to observe cache activity without integrating an external metrics backend.
package main
import (
"fmt"
"github.com/BawNer/kahora"
)
func main() {
r := kahora.NewRecorder(kahora.ShardCountM)
c, err := kahora.New[string, string](
kahora.WithMetricsRecorder(r),
)
if err != nil {
panic(err)
}
defer c.Close()
c.Set("k", "v")
c.Get("k") // hit
c.Get("missing") // miss
snap := r.Snapshot()
fmt.Printf("hits=%d misses=%d sets=%d\n", snap.Hits, snap.Misses, snap.Sets)
}
Output: hits=1 misses=1 sets=1
func (*DefaultRecorder) RecordActiveEviction ¶
func (r *DefaultRecorder) RecordActiveEviction(_ int)
func (*DefaultRecorder) RecordCapacityExceeded ¶
func (r *DefaultRecorder) RecordCapacityExceeded(_ int)
func (*DefaultRecorder) RecordDelete ¶
func (r *DefaultRecorder) RecordDelete(_ int)
func (*DefaultRecorder) RecordHit ¶
func (r *DefaultRecorder) RecordHit(shard int)
func (*DefaultRecorder) RecordLazyEviction ¶
func (r *DefaultRecorder) RecordLazyEviction(_ int)
func (*DefaultRecorder) RecordMiss ¶
func (r *DefaultRecorder) RecordMiss(shard int)
func (*DefaultRecorder) RecordSet ¶
func (r *DefaultRecorder) RecordSet(shard int)
func (*DefaultRecorder) RecordShrink ¶
func (r *DefaultRecorder) RecordShrink(shard, before, after int)
func (*DefaultRecorder) Snapshot ¶
func (r *DefaultRecorder) Snapshot() Snapshot
Snapshot returns a point-in-time copy of all metrics. Safe to call concurrently. Non-blocking.
type MetricsRecorder ¶
type MetricsRecorder interface {
// RecordHit is called when Get returns a live entry.
RecordHit(shard int)
// RecordMiss is called when Get returns nothing —
// either key not found or entry expired (lazy eviction).
RecordMiss(shard int)
// RecordSet is called when Set writes a new or existing entry.
RecordSet(shard int)
// RecordDelete is called when Delete explicitly removes an entry.
RecordDelete(shard int)
// RecordLazyEviction is called when Get finds an expired entry and removes it.
// High rate here means active expiry is too infrequent or disabled.
RecordLazyEviction(shard int)
// RecordActiveEviction is called when the background sweep removes an expired entry.
// Requires WithActiveExpiry to be set.
RecordActiveEviction(shard int)
// RecordShrink is called when a shard completes map reconstruction.
// before and after are the live entry counts before and after shrink.
// Lets you observe how much memory pressure was relieved per cycle.
RecordShrink(shard, before, after int)
// RecordCapacityExceeded is called when Set is rejected because
// the shard has reached its share of maxEntries.
RecordCapacityExceeded(shard int)
}
MetricsRecorder is the interface for recording cache metrics. Implement this to plug in your own metrics backend (Prometheus, StatsD, etc).
All methods are called on the hot path — implementations must be non-blocking and must not allocate. If you need to batch or buffer, do it inside your implementation.
Shard index is provided where relevant — allows per-shard observability without kahora itself aggregating anything. Aggregation is your job.
type Option ¶
type Option func(*options) error
Option is a functional option for Cache.
func WithActiveExpiry ¶
WithActiveExpiry enables a background goroutine that proactively sweeps shards and deletes expired entries. Without this, expiry is lazy — stale entries are only evicted on Get. Requires WithTTL to be set.
func WithMaxEntries ¶
WithMaxEntries sets a best-effort cap on total live entries across all shards. The limit is enforced per-shard via atomic counters and may be exceeded slightly under concurrent load. This is intentional — avoiding a global lock on the hot path is worth the approximation. 0 means unlimited.
func WithMetricsRecorder ¶
func WithMetricsRecorder(r MetricsRecorder) Option
WithMetricsRecorder attaches a custom metrics recorder. If not set, a nop recorder is used — zero overhead.
func WithShardCount ¶
func WithShardCount(n ShardCount) Option
WithShardCount sets the number of shards. Use ShardCount* constants (XS/S/M/L/XL) or a custom positive value. Default: ShardCountM (256).
func WithShrinkCycleInterval ¶
WithShrinkCycleInterval sets the duration of one full shrink cycle across all shards. Internally: tick = cycleInterval / shardCount. One shard per tick, round-robin. Higher value = less frequent reconstruction, lower CPU/alloc pressure. Default: 60s (tick ~234ms for 256 shards).
func WithShrinkMinEntries ¶
WithShrinkMinEntries sets the minimum number of live entries required for a shard to be eligible for reconstruction. Shards below this threshold are skipped — already small enough. 0 means always reconstruct when the cycle reaches this shard. Default: 0.
type ShardCount ¶
type ShardCount int
ShardCount defines the number of shards used by the cache. More shards — less lock contention, more memory overhead per shard.
const ( ShardCountXS ShardCount = 16 ShardCountS ShardCount = 64 ShardCountM ShardCount = 256 // default ShardCountL ShardCount = 1024 ShardCountXL ShardCount = 4096 )
type ShardSnapshot ¶
type ShardSnapshot struct {
Index int
Hits int64
Misses int64
Sets int64
ShrinkCount int64
LastShrinkBefore int64
LastShrinkAfter int64
}
ShardSnapshot is a point-in-time snapshot of a single shard's metrics.
type Snapshot ¶
type Snapshot struct {
// Global
Hits int64
Misses int64
Sets int64
Deletes int64
LazyEvictions int64
ActiveEvictions int64
CapacityExceeded int64
// Per-shard — use to observe distribution uniformity and shrink activity.
Shards []ShardSnapshot
}
Snapshot is a point-in-time view of all cache metrics. Global counters are independent atomics — not derived from shards — so they are always consistent with what was actually recorded.