enumcache

enumcache stores enumeration name and ID mappings for set-once, read-many lookup in both directions, with allocation-free reads.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/enumcache"

A two-way map between small integer IDs and names, held in memory and safe for concurrent reads.

ec := enumcache.New()
ec.SetAllNameByID(enumcache.NameByID{1: "ACTIVE", 2: "SUSPENDED"})

id, err := ec.ID("ACTIVE")    // 1
name, err := ec.Name(2)       // "SUSPENDED"

What it replaces

Reference data is the least interesting part of a service and one of the most repeated. A status table with twenty rows, a country table, a channel table: the database owns the values, the code needs them in both directions, and the lookup happens on every request.

Two ways people do this badly. Query the table per request, which turns a twenty-row lookup into a network round trip. Or hard-code the constants in Go, which works until someone adds a row and the two definitions drift.

enumcache is the third: load once at startup from wherever the truth lives, then serve every lookup from memory. enumdb is the loader when the truth is a database table.

Set once, read many

The access pattern is in the design. Bulk loaders (SetAllIDByName, SetAllNameByID) exist for startup; Set and Delete handle the occasional individual change. After that it is all reads.

Operationns/opB/opallocs/op
Name (ID to name)14.800
ID (name to ID)16.400
Set (overwrite)39.200
EncodeBinaryMap55.4641
DecodeBinaryMap2337682

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/enumcache/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

Both lookups are allocation-free, so they add no garbage collection pressure when called per field per request. A database round trip for the same answer costs four to five orders of magnitude more.

The lookups return an error rather than a zero value for an unknown key, so a name that is not in the set is distinguishable from an ID of 0.

Bitmap encoding

Where the IDs are powers of two, the cache doubles as a flag-set codec. EncodeBinaryMap turns a list of names into a single integer; DecodeBinaryMap reverses it.

mask, err := ec.EncodeBinaryMap([]string{"READ", "WRITE"})
names, err := ec.DecodeBinaryMap(mask)

This is the same job enumbitmap does with plain maps, reachable here without a second lookup structure once the cache is loaded. Decoding costs four times encoding, because it builds and returns a slice of strings where encoding returns one integer.

The contract from enumbitmap carries over: IDs must be distinct single-bit values. An ID of zero or a multi-bit ID makes the round trip asymmetric.

Ordering helpers

SortNames and SortIDs return sorted slices, which matters because the underlying map has no order. Rendering a dropdown or a stable API response needs one of these; iterating the map directly gives a different order on every run.

Both build and sort a new slice per call, so they belong at the edge of a response rather than inside a loop.

Lifetime model

The cache holds entries for the lifetime of the process. There is no TTL and no background refresh: an entry stays until Set overwrites it or Delete removes it, both of which are available at runtime and take the write lock.

The bulk setters merge rather than replace: SetAllNameByID takes the write lock once and applies every entry, overwriting the IDs present in the new set and leaving any others in place. Reloading a table that has had a row removed therefore needs an explicit Delete for that ID, since a second bulk set will not drop it.

TTL-driven expiry and single-flight refresh are in sfcache.

Example

ec := enumcache.New()
ec.Set(1, "ACTIVE")

name, err := ec.Name(1)

fmt.Println(name, err)

Full source is in example_enumcache_test.go. More runnable examples are on pkg.go.dev.

Dependencies

This package reaches no external module: it uses only the Go standard library.