Statedb
In-memory state database for Go
Install / Use
/learn @cilium/StatedbREADME
:memo: StateDB 
StateDB is an in-memory database for Go. The database is built on top of Persistent Adaptive Radix Trees.
StateDB is/supports:
-
In-memory. Objects and indexes are stored in main memory and not on disk. This makes it easy to store and index any Go data type.
-
Multi-Version Concurrency Control (MVCC). Both objects and indexes are immutable and objects are versioned. A read transaction has access to an immutable snapshot of the data.
-
Cross-table write transactions. Write transactions lock the requested tables and allow modifying objects in multiple tables as a single atomic action. Transactions can be aborted to throw away the changes.
-
Multiple indexes. A table may have one or more indexers for objects, with each indexer returning zero or more keys. Indexes can be unique or non-unique. A non-unique index is a concatenation of the primary and secondary keys.
-
Watch channels. Changes to the database can be watched at fine-granularity via Go channels that close when a relevant part of the database changes. This is implemented by having a Go channel at each of the radix tree nodes. This enables watching an individual object for changes, a key prefix, or the whole table.
Warning! Immutable data! Read this!
To support lockless readers and transactionality StateDB relies on both the indexes
and the objects themselves being immutable. Since in Go you cannot declare fields
const we cannot stop mutation of public fields in objects. This means that care
must be taken with objects stored in StateDB and not mutate objects that have been
inserted into it. This means both the fields directly in the object and everything
referenced from it, e.g. a map field must not be modified, but must be cloned first!
StateDB has a check in Insert() to validate that if an object is a pointer then it
cannot be replaced with the same pointer, but that at least a shallow clone has been
made. This of course doesn't extend to references within the object.
For "very important objects", please consider storing an interface type instead that contains getter methods and a safe way of mutating the object, e.g. via the builder pattern or a constructor function.
Also prefer persistent/immutable data structures within the object to avoid expensive
copying on mutation. The part package comes with persistent Map[K]V and Set[T].
Example
Here's a quick example to show how using StateDB looks like.
// Define an object to store in the database.
type MyObject struct {
ID uint32
Foo string
}
// Define header for a formatted table (db/show command)
func (o *MyObject) TableHeader() []string {
return []string{"ID", "Foo"}
}
// Define how to show the object in a formatted table
func (o *MyObject) TableRow() []string {
return []string{strconv.FormatUint(uint64(o.ID), 10), o.Foo}
}
// Define how to index and query the object.
var IDIndex = statedb.Index[*MyObject, uint32]{
Name: "id",
FromObject: func(obj *MyObject) index.KeySet {
return index.NewKeySet(index.Uint64(obj.ID))
},
FromKey: func(id uint32) index.Key {
return index.Uint32(id)
},
Unique: true,
}
// Create the database and the table.
func example() {
db := statedb.New()
myObjects, err := statedb.NewTable(
db,
"my-objects",
IDIndex,
)
if err != nil { ... }
wtxn := db.WriteTxn(myObjects)
// Insert some objects
myObjects.Insert(wtxn, &MyObject{1, "a"})
myObjects.Insert(wtxn, &MyObject{2, "b"})
myObjects.Insert(wtxn, &MyObject{3, "c"})
// Modify an object
if obj, _, found := myObjects.Get(wtxn, IDIndex.Query(1)); found {
objCopy := *obj
objCopy.Foo = "d"
myObjects.Insert(wtxn, &objCopy)
}
// Delete an object
if obj, _, found := myObjects.Get(wtxn, IDIndex.Query(2)); found {
myObjects.Delete(wtxn, obj)
}
if feelingLucky {
// Commit the changes.
wtxn.Commit()
} else {
// Throw away the changes.
wtxn.Abort()
}
// Query the objects with a snapshot of the database.
txn := db.ReadTxn()
if obj, _, found := myObjects.Get(txn, IDIndex.Query(1)); found {
...
}
// Iterate over all objects
for obj := range myObjects.All() {
...
}
// Iterate with revision
for obj, revision := range myObjects.All() {
...
}
// Iterate all objects and then wait until something changes.
objs, watch := myObjects.AllWatch(txn)
for obj := range objs { ... }
<-watch
// Grab a new snapshot to read the new changes.
txn = db.ReadTxn()
// Iterate objects with ID >= 2
objs, watch = myObjects.LowerBoundWatch(txn, IDIndex.Query(2))
for obj := range objs { ... }
// Iterate objects where ID is between 0x1000_0000 and 0x1fff_ffff
objs, watch = myObjects.PrefixWatch(txn, IDIndex.Query(0x1000_0000))
for obj := range objs { ... }
}
Read on for a more detailed guide or check out the Go package docs.
Guide to StateDB
StateDB can be used directly as a normal library, or as a Hive Cell.
For example usage as part of Hive, see reconciler/example. Here we show a standalone example.
We start by defining the data type we want to store in the database. There are
no constraints on the type and it may be a primitive type like an int or a
struct type, or a pointer. Since each index stores a copy of the object one should
use a pointer if the object is large.
import (
"github.com/cilium/statedb"
"github.com/cilium/statedb/index"
"github.com/cilium/statedb/part"
)
type ID = uint64
type Tag = string
type MyObject struct {
ID ID // Identifier
Tags part.Set[Tag] // Set of tags
}
// Define header for a formatted table (db/show command)
func (o *MyObject) TableHeader() []string {
return []string{"ID", "Foo"}
}
// Define how to show the object in a formatted table
func (o *MyObject) TableRow() []string {
return []string{strconv.FormatUint(uint64(o.ID), 10), o.Foo}
}
Indexes
With the object defined, we can describe how it should be indexed. Indexes are
constant values and can be defined as global variables alongside the object type.
Indexes take two type parameters, your object type and the key type: Index[MyObject, ID].
Additionally you define two operations: FromObject that takes your object and returns
a set of StateDB keys (zero or many), and FromKey that takes the key type of your choosing and
converts it to a StateDB key.
// IDIndex is the primary index for MyObject indexing the 'ID' field.
var IDIndex = statedb.Index[*MyObject, ID]{
Name: "id",
FromObject: func(obj *MyObject) index.KeySet {
return index.NewKeySet(index.Uint64(obj.ID))
}
FromKey: func(id ID) index.Key {
return index.Uint64(id)
}
// Above is equal to just:
// FromKey: index.Uint64,
Unique: true, // IDs are unique.
}
The index.Key seen above is just a []byte. The index package contains many functions
for converting into the index.Key type, for example index.Uint64 and so on.
A single object can also map to multiple keys (multi-index). Let's construct an index for tags.
var TagsIndex = statedb.Index[*MyObject, Tag]{
Name: "tags",
FromObject: func(o *MyObject) index.KeySet {
// index.Set turns the part.Set[string] into a set of keys
// (set of byte slices)
return index.Set(o.Tags)
}
FromKey: index.String,
// Many objects may have the same tag, so we mark this as
// non-unique.
Unique: false,
}
With the indexes now defined, we can construct a table.
Setting up a table
func NewMyObjectTable(db *statedb.DB) (statedb.RWTable[*MyObject], error) {
return statedb.NewTable[*MyObject](
db,
"my-objects",
IDIndex, // IDIndex is the primary index
TagsIndex, // TagsIndex is a secondary index
// ... more secondary indexes can be passed in here
)
}
The NewTable function takes the database, the name of the table, a primary
index and zero or more secondary indexes. The table name must match the regular
expression "^[a-z][a-z0-9_\-]{0,30}$".
NewTable returns a RWTable, which is an interface for both reading and
writing to a table. An RWTable is a superset of Table, an interface
that contains methods just for reading. This provides a simple form of
type-level access control to the table. NewTable may return an error if
the name or indexers are malformed, for example if IDIndex is not unique
(primary index has to be), or if the indexers have overlapping names.
Additionally, it may return an error if another table with the same name
is already registered with the database.
Inserting
With the table defined, we can now create the database and start writing and reading to the table.
db := statedb.New()
myObjects, err := NewMyObjectTable(db)
if err != nil { return err }
To insert objects into a table, we'll need to create a WriteTxn. This locks
the target table(s) allowing for an atomic transaction change.
// Create a write transaction against the 'myObjects' table, locking
// it for writing.
// Note that the returned 'wtxn' holds internal state and it is not
// safe to use concurrently (e.g. you must not have multiple goroutines
// using the same WriteTxn in parallel).
wtxn := db.WriteTxn(myObjects)
// We can defer an Abort() of the transaction in case we encounter
// issues and want to forget our writes. This is a good practice
// to safe-guard against forgotten call to Commit(). Worry not though,
// StateDB has a finalizer on WriteTxn to catch forgotten Abort/Commit.
defer wtxn.Abort()
// Insert an object into the table. This will be visible to readers
// only when we commit.
obj := &MyObject{ID: 42, Tags: part.NewStringSet("hello")}
oldObj, hadO
Related Skills
feishu-drive
350.1k|
things-mac
350.1kManage Things 3 via the `things` CLI on macOS (add/update projects+todos via URL scheme; read/search/list from the local Things database)
clawhub
350.1kUse the ClawHub CLI to search, install, update, and publish agent skills from clawhub.com
postkit
PostgreSQL-native identity, configuration, metering, and job queues. SQL functions that work with any language or driver
