Back to Case StudyCase Study · Open Source Systems

ProcessCache: In-Process LRU Cache for Go

A bounded, thread-safe Go cache package that keeps Redis out of simple hot paths while preserving exact global LRU order, O(1) prefix-scoped eviction, TTL expiration, typed reads, stats, custom sizing, and clean shutdown.

GoLRUMemory ManagementOpen Source
O(1)
global eviction
O(1)
prefix eviction
0
runtime deps

Repository Shape

process-cache
GitHub
pkg.go.dev
Package docs

Problem & Goal

ProcessCache started from a practical backend need: keep repeated reads inside one Go process without adding Redis, Memcached, a database cache table, an HTTP sidecar, or another operational dependency. The package is intentionally local. It optimizes for fast in-process reads, bounded memory, exact LRU behavior, and a public API that can be installed from the module root.

  • Expose a clean package import: github.com/tonmoytalukder/process-cache.
  • Keep runtime dependencies at zero.
  • Bound total memory with an approximate global byte cap.
  • Support optional prefix quotas such as username: or session:.
  • Keep both global and prefix eviction O(1), not scan-based.
  • Remove expired entries lazily on reads and with a background sweeper.
  • Provide typed reads, stats snapshots, testable clocks, custom sizing, and idempotent shutdown.

Package Layout

process-cache/
process-cache/ processcache.go              # public facade: Cache, constructors, options, GetAs internal/    processcache/       memory.go            # map, LRU lists, eviction, expiration, stats       config.go            # defaults, options, validation       sizer.go             # approximate default sizing       clock.go             # RealClock       types.go             # Config, Stats, sentinel errors    pkg/testclock/           # deterministic clock for tests test/    unit/processcache/       # public behavior tests    integration/processcache/    e2e/consumer/            # external consumer module cmd/example/                 # runnable example cmd/bench/                   # manual benchmark runner scripts/                     # test, race, bench, docker-test docs/                        # algorithm and design notes

The root file is deliberately a facade. Consumers import the module root, while implementation details stay under internal/processcache. That keeps the exported surface small without forcing users to import a nested internal-looking package. The published API is also browsable from pkg.go.dev for teams that want package documentation before installing it.

Architecture Overview

The algorithm combines one hash map with linked lists. The map gives average O(1) lookup by key. The global doubly-linked list gives O(1) access to the least-recently-used item. For every configured prefix, ProcessCache also keeps a dedicated prefix list, so prefix eviction is O(1) instead of scanning the global list.

Flow· Core structures
Caller
  |
  v
processcache.Cache interface
  |
  v
MemoryCache
  |
  +-- items: map[string]*item
  |      key -> item pointer for average O(1) lookup
  |
  +-- globalLRU: doubly linked list
  |      front = most recently used, back = least recently used
  |
  +-- typeLRUs: map[prefix]*list.List
  |      one LRU list per configured prefix
  |
  +-- typeSizes / typeLimits
  |      approximate byte accounting per prefix
  |
  +-- sizer, clock, stats, cleanup goroutine
internal/processcache/memory.go
type MemoryCache struct {    mu sync.Mutex    items     map[string]*item    globalLRU *list.List    maxSize     int64    currentSize int64    typeSizes  map[string]int64    typeLimits map[string]TypeLimit    typeLRUs   map[string]*list.List    prefixes   []string    sizer Sizer    clock Clock    cleanupInterval time.Duration    cleanupDisabled bool    stopCleanup     chan struct{}    cleanupDone     chan struct{}    closeOnce       sync.Once    metrics bool    stats   statsCounter}type item struct {    key        string    value      any    size       int64    prefix     string    expiration time.Time    hasExpiry  bool    globalElem *list.Element    typeElem   *list.Element}

Get Algorithm

Flow· Get(key)
Get(key)
  |
  v
Lock cache mutex
  |
  v
items[key] exists?
  |
  +-- no --> unlock, record miss, return nil false
  |
  v
Expired according to clock.Now()?
  |
  +-- yes --> remove from map, global LRU, prefix LRU
  |           record expiration and miss
  |           unlock, return nil false
  |
  v
Move item to front of global LRU
  |
  v
If item has configured prefix, move it to front of prefix LRU
  |
  v
Copy value, unlock, record hit, return value true

A successful read mutates recency state, so the implementation uses one mutex rather than pretending reads are read-only. That is an intentional tradeoff: exact LRU ordering is more valuable here than an approximate lock-free policy.

Set Algorithm

Flow· Set(key, value, ttl)
Set(key, value, ttl)
  |
  +-- empty key? --------------------> reject false
  |
  v
size = sizer.SizeOf(key, value)
  |
  +-- size > global max? ------------> reject false
  |
  v
prefix = first configured prefix matching key
  |
  +-- size > prefix max? ------------> reject false
  |
  v
Lock cache mutex
  |
  v
Remove existing item with same key, if present
  |
  v
While prefix budget would overflow:
  |       evict tail of prefix LRU
  |
  v
While global budget would overflow:
  |       evict tail of global LRU
  |
  v
Push item to front of global LRU
  |
  v
If prefix configured, push item to front of that prefix LRU
  |
  v
Update map, size counters, stats; unlock; return true
public usage
cache, err := processcache.NewMemoryCache(    processcache.WithMaxSize(100*processcache.MB),    processcache.WithCleanupInterval(5*time.Minute),    processcache.WithTypeLimit("username:", 1*processcache.MB),    processcache.WithTypeLimit("session:", 50*processcache.MB),)if err != nil {    panic(err)}defer cache.Close()cache.Set("username:tonmoy", true, 5*time.Minute)exists, ok := processcache.GetAs[bool](cache, "username:tonmoy")

Why Prefix Eviction Is O(1)

The earlier draft of this design used one global LRU list and scanned backward to find the oldest item for a prefix. The real package improves that: configured prefixes each have their own doubly-linked list. An item with a prefix stores two list nodes, one in the global list and one in the prefix list.

Flow· Two coordinated LRU indexes
item("session:123")
  |
  +-- globalElem --> globalLRU: newest <-> ... <-> oldest
  |
  +-- typeElem   --> typeLRUs["session:"]: newest session <-> ... <-> oldest session

Global pressure:
  evict globalLRU.Back() in O(1)

session: pressure:
  evict typeLRUs["session:"].Back() in O(1)
Pressure sourceEviction targetComplexityWhy
Global max sizeglobalLRU.Back()O(1)The global list tail is the oldest item overall.
Prefix max sizetypeLRUs[prefix].Back()O(1)The prefix list tail is the oldest item in that namespace.
Expired entriesMap scan during sweeperO(n) per sweepCleanup must inspect expiration timestamps across all entries.
Prefix matchingConfigured prefix listO(p)p is the number of configured prefixes; overlapping prefixes are rejected.

Expiration, Stats, and Shutdown

Expiration is enforced in two places. Get and Exists remove expired items lazily so an expired value is never returned. A background sweeper periodically scans for expired items that nobody reads again. Close stops that sweeper and waits for it to exit; calling Close more than once is safe.

FeatureBehavior
Lazy expirationGet and Exists remove expired items before returning.
Background cleanupA ticker scans all items at the configured interval.
CloseStops the sweeper, waits on cleanupDone, remains idempotent with sync.Once.
StatsReturns copied TypeSizes and TypeLimits so callers cannot mutate internals.
Metrics toggleAtomic counters can be disabled for lower bookkeeping overhead.
typed read and stats
value, ok := processcache.GetAs[string](cache, "profile:42")if !ok {    // miss, nil cache, nil value, or type mismatch}stats := cache.Stats()fmt.Println(stats.Hits, stats.Misses, stats.CurrentSize)

Complexity Summary

OperationAverage costReason
Get hitO(1)Map lookup plus list moves.
Get missO(1)Map lookup.
Set without evictionO(1) averageMap insert plus list inserts.
Global evictionO(1)Remove global LRU tail.
Prefix evictionO(1)Remove configured prefix LRU tail.
DeleteO(1) averageMap lookup plus list removals.
Background cleanupO(n) per sweepScans all tracked items.
Prefix resolutionO(p)Checks configured prefixes; p should stay small.

Key Design Decisions

In-process cache instead of Redis

Why: For data local to one Go process, a local memory cache avoids network latency, connection management, and an extra runtime service. The package is explicit that it is not a distributed cache.

Alternative: Redis or Memcached when multiple processes need shared cache state, persistence, cross-service invalidation, or centralized memory limits.

Exact LRU with one mutex

Why: Every successful Get changes recency order, so exact LRU is a write. One mutex keeps the map, global list, prefix lists, and size counters consistent.

Alternative: Approximate lock-sharded LRU would improve contention under extreme read load but makes behavior harder to reason about.

Per-prefix LRU lists

Why: A prefix quota needs to evict the oldest item within that prefix. A dedicated list turns that from a global scan into an O(1) tail removal.

Alternative: Single global list with prefix scan: less bookkeeping, but O(n) worst-case eviction for configured prefixes.

Custom Sizer and Clock

Why: Approximate byte accounting and time-dependent TTL behavior must be testable. WithSizer supports richer payload sizing; WithClock makes expiration deterministic in tests.

Alternative: Hard-coded size estimation and time.Now calls would be simpler but harder to validate.

Idempotent Close

Why: A background cleanup goroutine needs a clean shutdown path. sync.Once plus cleanupDone prevents double-close panics and test leaks.

Alternative: No Close method: simpler API, but long-running goroutines remain harder to control in tests and services.

When To Use ProcessCache

ProcessCache is built for cache data that belongs to one Go process, can be recomputed or fetched again, and does not need to survive deploys or restarts. The strongest fit is a service, CLI, worker, or internal tool where Redis would add more operational surface area than the hot path deserves.

  • Use it when hot reads should stay inside the process and avoid repeated database, filesystem, or API calls.
  • Use it when an unbounded map would be risky and you want approximate byte limits with exact LRU eviction.
  • Use it when separate key families need fairness, such as username:, session:, profile:, token:, or api-response:.
  • Use it when cached values can expire naturally through TTLs and be refreshed from a source of truth.
  • Use it when you want zero runtime dependencies in CLIs, workers, batch jobs, HTTP services, prototypes, or internal tools.
  • Use it when services can accept a process-local cache abstraction and treat nil as cache disabled.
install
go get github.com/tonmoytalukder/process-cacheimport processcache "github.com/tonmoytalukder/process-cache"

Practical Use Cases

Use caseWhat gets cachedWhy ProcessCache fits
Username, slug, or email availabilityShort-lived boolean lookups such as username:tonmoyAvoids repeated validation queries during signup or profile editing while TTL keeps results fresh.
Session and token metadataToken introspection, session flags, permission snapshotsKeeps frequently checked auth metadata local to a process with bounded memory and expiration.
Reference dataFeature flags, plan limits, country lists, category treesCaches rarely changing records after loading from a database or API without adding infrastructure.
Expensive computation resultsParsed payloads, validation results, rendered fragments, deterministic function outputsStores values that are safe to recompute after eviction, restart, or TTL expiration.
External API response shieldingBriefly reusable responses from rate-limited or latency-sensitive servicesReduces repeated calls while keeping the cache local and disposable.
Background workersSmall lookup results reused across many jobs in the same worker processAvoids running a shared cache service just to reuse local job context.

Tradeoffs & When Not To Use It

  • Use Redis or Memcached when many processes need the same cache state.
  • Use a distributed cache when cache data must survive process restarts, support cross-service invalidation, or participate in atomic distributed operations.
  • Approximate size accounting is good enough for bounded local caching, but not a replacement for process memory observability.
  • Exact LRU is simpler to reason about, but the single mutex is not designed for extremely high-contention cache workloads.
  • Prefix quotas give predictable namespace fairness, but overlapping prefixes are rejected so accounting stays deterministic.