ghost

package module
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Nov 13, 2026 License: MIT Imports: 13 Imported by: 0

README

Ghost

Ghost is a small distributed counter cache for Go. It is designed for values that must be visible immediately, but are expected to be confirmed or cancelled shortly afterwards.

Examples include temporary reservations, quotas and in-flight limits. Confirmed state belongs in your durable store, not in Ghost.

Why it exists

A database is the right place for durable counters, but a network round trip for every temporary increment can dominate a request. Ghost keeps those increments in the application process. When several processes need the same counter, it assigns a key range to one process and sends compact batches over a persistent TCP connection.

The result is a very small local fast path and a much smaller remote protocol than a general-purpose database. Ghost does not offer database durability, transactions or queries.

Properties

  • uint64 keys and float64 deltas.
  • Independent numeric namespaces with caller-defined failure policies.
  • 64 sharded maps, expiration and deletion at zero.
  • Entry, request, frame, replication and outbound byte limits.
  • Persistent TCP connections and fixed-width binary batches.
  • Asynchronous backup with operation-ID deduplication.
  • Bucket transfer with epochs and receipts tied to the original owner.
  • Optional memberlist-based discovery and health checks.

Install

go get github.com/AdBox-LLC/go-ghost@v1.0.0

Local counters

package main

import "github.com/AdBox-LLC/go-ghost"

func main() {
	counters := ghost.New()
	counters.Add(42, 1)
	counters.Add(42, 2)

	println(counters.Get(42)) // 3

	counters.Add(42, -3)
	println(counters.Get(42)) // 0; the key is removed
}

Reservations

service := ghost.NewService(ghost.Config{
	NodeID: 1,
	Namespaces: map[ghost.Namespace]ghost.NamespaceConfig{
		1: {MaxEntries: 100_000, TTL: time.Minute, Failure: ghost.LocalFallback},
	},
})
defer service.Close()

ops := []ghost.Operation{{Key: 42, Delta: 1}}
receipts, err := service.Reserve(1, ops)
if err != nil {
	// Handle a rejected reservation.
}

// Later, confirm the value elsewhere and release the reservation.
_ = service.Settle(receipts)

LocalFallback continues locally when the owner is unavailable, so the value can temporarily be approximate. FailClosed returns an error instead. The application decides which policy is appropriate for each namespace.

Distributed operation

Start a counter listener on each process and configure matching Buckets values. StartCluster accepts a static peer map. StartClusterService adds memberlist discovery and health information on top.

service := ghost.NewService(ghost.Config{
	NodeID: ghost.NodeID("node-a"),
	Bind: "10.0.0.10:9000",
	Buckets: 256,
	Namespaces: map[ghost.Namespace]ghost.NamespaceConfig{
		1: {TTL: time.Minute, Failure: ghost.LocalFallback},
	},
})
if err := service.StartCluster(map[uint64]string{
	ghost.NodeID("node-a"): "10.0.0.10:9000",
	ghost.NodeID("node-b"): "10.0.0.11:9000",
}); err != nil {
	panic(err)
}
defer service.Close()

Keep this listener on a trusted private network. The counter protocol does not provide TLS or authentication.

For repeated distributed calls, ReserveBatch and LoadBatch accept a reusable Batch as routing scratch. A Batch must not be shared between concurrent calls.

Benchmarks

Measured on an Apple M3 Pro, darwin/arm64, Go 1.25, with local TCP peers. These are examples, not a guarantee for another machine or network.

Operation Time Allocations
Local reserve, warm counter 55.0 ns/op 0 B, 0/op
Remote batch of 32 keys 64.2 µs/op 640 B, 2/op

Run them yourself:

go test -run '^$' -bench 'BenchmarkServiceLocalReserve|BenchmarkTransportRemoteBatch' -benchmem -count=1

The local path is allocation-free after warm-up. Map growth, connection setup, bucket transfer and buffer growth are separate events, not part of that steady state. Remote calls allocate because they have request and response buffers; batching is what keeps their cost close to a single round trip.

Consistency and failure model

A primary answers before its asynchronous backup has acknowledged the update. If it fails immediately, an update that has not reached the backup can be lost. This is deliberate: waiting for every backup would put another network round trip on the hot path.

Bucket ownership changes carry an epoch. Settlement uses the receipt's original owner, so it does not accidentally decrement a new owner's value after a transfer. Replication retries are deduplicated by operation ID.

memberlist is used only for membership and health information. It does not make the counter protocol a consensus system. For values that must survive process failure, use durable storage and treat Ghost as the temporary layer in front of it.

Tests

The suite covers counter arithmetic, zero deletion, expiration, concurrent entry limits, allocation checks, real two-node reserve/load/settle, replication deduplication, rollback, stale epochs, concurrent bucket transfer, dynamic membership and backpressure.

go test ./... -race -count=1

Documentation

Overview

Package ghost is a distributed, sharded counter cache for short-lived numeric state. It has no application-specific keys or policies.

Callers assign numeric namespaces and choose whether a namespace fails closed or uses local fallback when its owner is unavailable. Local counters expire and disappear at zero. Entry, queue, frame, in-flight and outbound limits protect the process from overload.

Cluster mode assigns virtual buckets to a primary and asynchronous backup. Counter batches use persistent TCP connections and a compact binary format. Operation IDs deduplicate retries. A receipt retains its original owner so settlement releases the same counter after a bucket transfer.

Asynchronous replication is not durable: an abrupt primary failure can lose operations not yet delivered. memberlist supplies discovery and health information only; it is not a consensus protocol.

Use cases

A high-throughput service can use Ghost for temporary reservations that should be visible before an external event confirms them. One namespace may accept local fallback for approximate values, while another requires strict owner access for values that must not be approximated. Confirmed state belongs in a durable store chosen by the application.

Why not always use a database?

A durable counter read or write pays for a network round trip and storage work. A local Ghost operation is only a sharded map access. A remote Ghost operation still pays network latency, but uses a small counter-specific batch protocol and does not wait for a durable write. It is not a database replacement and does not provide the same durability.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrFull        = errors.New("ghost: entry limit")
	ErrUnavailable = errors.New("ghost: owner unavailable")
	ErrTimeout     = errors.New("ghost: request timeout")
	ErrStaleEpoch  = errors.New("ghost: stale owner epoch")
)

Functions

func Keys

func Keys[Key ~uint64](keys []Key) []uint64

Keys reinterprets a typed uint64 key slice without a conversion allocation. The input is immutable for the duration of Load.

func NodeID

func NodeID(name string) uint64

ClusterConfig is gossip membership only. Counter traffic never uses gossip. NodeID deterministically maps the configured node name to a non-zero ID.

Types

type Batch

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

Reservation identifies the exact owner of a hold. Settle uses this route, not the current owner after a rebalance. Batch is reusable routing scratch. It must not be shared concurrently.

type Cluster

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

func StartClusterService

func StartClusterService(service *Service, cfg ClusterConfig) (*Cluster, error)

func (*Cluster) Close

func (c *Cluster) Close() error

func (*Cluster) GetBroadcasts

func (c *Cluster) GetBroadcasts(int, int) [][]byte

func (*Cluster) LocalState

func (c *Cluster) LocalState(bool) []byte

func (*Cluster) MergeRemoteState

func (c *Cluster) MergeRemoteState([]byte, bool)

func (*Cluster) NodeMeta

func (c *Cluster) NodeMeta(int) []byte

func (*Cluster) NotifyJoin

func (c *Cluster) NotifyJoin(*memberlist.Node)

func (*Cluster) NotifyLeave

func (c *Cluster) NotifyLeave(*memberlist.Node)

func (*Cluster) NotifyMsg

func (c *Cluster) NotifyMsg([]byte)

func (*Cluster) NotifyUpdate

func (c *Cluster) NotifyUpdate(*memberlist.Node)

func (*Cluster) String

func (c *Cluster) String() string

type ClusterConfig

type ClusterConfig struct {
	NodeName      string
	BindAddr      string
	BindPort      int
	AdvertiseAddr string
	AdvertisePort int
	Seeds         []string
}

type Config

type Config struct {
	NodeID      uint64
	Bind        string
	Buckets     uint32
	RPCTimeout  time.Duration
	MaxInFlight int
	MaxBatch    int
	MaxOutbound int
	MaxReplay   int
	SweepEvery  time.Duration
	SweepLimit  int
	Namespaces  map[Namespace]NamespaceConfig
}

type FailurePolicy

type FailurePolicy uint8

FailurePolicy controls behavior when the ns owner is unavailable.

const (
	FailClosed FailurePolicy = iota
	LocalFallback
)

type Ghost

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

Ghost is a sharded in-process counter. A negative delta never creates a key.

func New

func New() *Ghost

func NewStore

func NewStore(maxEntries int, ttl time.Duration) *Ghost

NewStore bounds entries and expires reservations. Zero means no limit/TTL.

func (*Ghost) Add

func (g *Ghost) Add(key uint64, delta float64)

Add applies delta. A zero key and a zero delta do nothing.

func (*Ghost) Apply

func (g *Ghost) Apply(ops []Operation) error

Apply applies each individual operation. A full store returns ErrFull for new keys without changing them; existing keys remain updateable.

func (*Ghost) Get

func (g *Ghost) Get(key uint64) float64

Get is the amount still held for key.

func (*Ghost) Len

func (g *Ghost) Len() int

Len counts live entries, removing expired ones encountered during the scan.

func (*Ghost) Load

func (g *Ghost) Load(_ Namespace, keys []uint64, dst []float64) error

Load adds each counter into dst in key order. dst must be len(keys).

func (*Ghost) Sweep

func (g *Ghost) Sweep(now time.Time, maxPerShard int) int

Sweep expires at most maxPerShard entries per shard.

type Namespace

type Namespace uint8

Namespace identifies an independent counter space owned by the caller.

const DefaultNamespace Namespace = 1

DefaultNamespace is used by the simple Add/Get compatibility helpers.

type NamespaceConfig

type NamespaceConfig struct {
	MaxEntries int
	TTL        time.Duration
	Failure    FailurePolicy
}

NamespaceConfig configures storage and availability for one ns.

type Operation

type Operation struct {
	Key   uint64
	Delta float64
}

Operation is one atomic counter delta.

type Reservation

type Reservation struct {
	Operation
	Namespace Namespace
	NodeID    uint64
	Epoch     uint64
	OpID      uint64
	Fallback  bool
}

type Service

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

func NewService

func NewService(cfg Config) *Service

func (*Service) AcceptPolicy

func (s *Service) AcceptPolicy(ns Namespace, primary, backup uint64, epoch uint64, bucket uint32) bool

func (*Service) Add

func (s *Service) Add(key uint64, delta float64)

func (*Service) Addr

func (s *Service) Addr() string

Addr is the bound counter transport address after StartCluster.

func (*Service) Bucket

func (s *Service) Bucket(key uint64) uint32

Bucket returns the virtual bucket of a key for routing/policy setup.

func (*Service) Close

func (s *Service) Close() error

func (*Service) Enabled

func (s *Service) Enabled() bool

func (*Service) Get

func (s *Service) Get(key uint64) float64

func (*Service) Load

func (s *Service) Load(ns Namespace, keys []uint64, dst []float64) error

func (*Service) LoadBatch

func (s *Service) LoadBatch(ns Namespace, keys []uint64, dst []float64, scratch *Batch) error

LoadBatch is Load with caller-owned routing scratch.

func (*Service) Reserve

func (s *Service) Reserve(ns Namespace, ops []Operation) ([]Reservation, error)

Reserve returns one receipt per input operation. A FailClosed namespace returns an error instead of accepting an unconfirmed reservation.

func (*Service) ReserveBatch

func (s *Service) ReserveBatch(ns Namespace, ops []Operation, receipts []Reservation, scratch *Batch) error

ReserveBatch is ReserveInto with caller-owned routing scratch.

func (*Service) ReserveInto

func (s *Service) ReserveInto(ns Namespace, ops []Operation, receipts []Reservation) error

ReserveInto fills caller-owned receipts without allocating on a local path.

func (*Service) SetPeers

func (s *Service) SetPeers(peers map[uint64]string)

SetPeers updates static peer addresses without changing bucket ownership. Dynamic membership manages this map through Cluster.

func (*Service) SetPolicy

func (s *Service) SetPolicy(ns Namespace, primary, backup uint64, epoch uint64, bucket uint32)

func (*Service) Settle

func (s *Service) Settle(reservations []Reservation) error

Settle releases exactly the node/operation Reserve confirmed. An old owner accepts an old epoch release so a transfer never strands the hold.

func (*Service) StartCluster

func (s *Service) StartCluster(nodes map[uint64]string) error

func (*Service) Stats

func (s *Service) Stats() Stats

func (*Service) Sweep

func (s *Service) Sweep(now time.Time, maxPerShard int) int

func (*Service) Topology

func (s *Service) Topology(ns Namespace) []TopologyBucket

func (*Service) TransferBucket

func (s *Service) TransferBucket(ns Namespace, bucket uint32, target uint64, epoch uint64) error

TransferBucket snapshots a frozen route, sends all deltas that raced the snapshot, then publishes the new primary only after its acknowledgement.

type Stats

type Stats struct {
	NamespaceEntries map[Namespace]int
	Entries          int
	ReserveErrors    uint64
	LoadErrors       uint64
	RPCTimeouts      uint64
	ReplDropped      uint64
	FallbackEntries  int
	Transfers        uint64
	Swept            uint64
}

type TopologyBucket

type TopologyBucket struct {
	Primary uint64
	Backup  uint64
	Epoch   uint64
}

Jump to

Keyboard shortcuts

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