statesync

package
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Jun 18, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

README

statesync

Generic Redis-backed state sync for multiple gateway (or other) replicas. Uses per-entity keys with SETEX (per-record TTL), MGET (batched), and SCAN (enumeration). Keys are namespaced via a hash tag (aibrix:{namespace}:e:{id}). No PUB/SUB.

  • Periodic sync: Each replica runs Pull first (load from Redis), then Push (delta or full) every sync period.
  • Optional write-through: After local updates you may call Put/Delete directly. The default gateway wiring uses periodic delta push only.
  • Deletions/tombstones: If a Syncable implements TombstoneSupport, deletions are written as tombstone payloads so peers can remove the local entry on the next pull; otherwise DEL is used.
  • Jitter and backoff: Random initial startup delay (up to half syncPeriod); per-cycle jitter is roughly ±10% of syncPeriod (see syncLoop in redissync.go); exponential backoff (capped at 1 min) on errors.
  • Lifecycle: Register all Syncables before Start(); registrations after Start() are silently ignored.

Per-record TTL: Each entity is stored as its own key with SETEX and a default TTL of 2 minutes. A record expires if it is not rewritten within that window. The TTL of one record does not affect others.

Redis key format: aibrix:{namespace}:e:{entityId}{namespace} is the hash tag. Namespace must be non-empty.

Prefix cache (gateway default wiring)

With AIBRIX_STATESYNC_ENABLED=true, cmd/plugins/main.go:

  1. Calls table.EnableDeltaSync() to activate dirty tracking on PrefixHashTable (no-op cost when disabled).
  2. Registers PrefixHashTableSyncable with the manager.
  3. Relies on periodic delta push + pullPut is not called on every AddPrefix.

This gives eventual consistency with staleness bounded by the sync interval and per-key TTL. For faster cross-replica visibility you can call syncManager.Put(ctx, "prefixcache", blockIDStr, data) on the *statesync.RedisSync using bytes from EncodeBlockForSync after each AddPrefix (higher Redis write load trade-off).

Delta sync and LRU eviction

EnableDeltaSync() must be called on PrefixHashTable before it is registered with *statesync.RedisSync; without it, dirty tracking is inactive and GetDeltaForSync always returns an empty delta. If a block is marked dirty then evicted locally before the next successful push, GetDeltaForSync skips it silently. The corresponding Redis key remains until the per-entity TTL expires. Strong delete propagation is not guaranteed for evicted entries.

Diagrams

Technical Flow
sequenceDiagram
    participant SyncLoop
    participant Redis
    participant Syncable
    Note over SyncLoop: Pull first, then Push every syncPeriod
    SyncLoop->>Redis: SCAN pattern (keys for namespace)
    Redis-->>SyncLoop: keys[]
    SyncLoop->>Redis: MGET keys (batched)
    Redis-->>SyncLoop: values[]
    SyncLoop->>Syncable: ApplyRemote(id, bytes) for each
    SyncLoop->>Syncable: GetDelta or GetSnapshot
    Syncable-->>SyncLoop: updated, deleted
    SyncLoop->>Redis: SETEX key val TTL per entity, DEL deleted
    SyncLoop->>Syncable: OptionalHooks (if implemented)

Adopting statesync in a new component

Changes live only in the package that owns the state. The statesync package stays generic and never imports component types.

1. Implement syncable.Syncable

Depend on github.com/vllm-project/aibrix/pkg/utils/syncable and implement:

Method Purpose
Namespace() string Stable name for this state (e.g. "mytracker"). Used in the Redis key.
GetSnapshot(ctx) (map[string][]byte, error) Return current local state as entity id → serialized bytes.
ApplyRemote(ctx, id string, data []byte) error Apply one entity from Redis into local state (merge or overwrite).

Optional interfaces:

Interface Purpose
DeltaSyncable Add GetDelta / ClearDirty to push only changed entities instead of the full snapshot.
OptionalHooks OnSyncStart / OnSyncEnd called around each Pull for that Syncable (not around Push).
StalePolicy IsStale — if true, the sync layer deletes the remote key and skips applying.
TombstoneSupport MakeTombstone / IsTombstone / DeleteLocal — propagate deletes as payloads instead of DEL.
2. Add serialization
  • Encode: turn your struct into []byte (e.g. JSON). Use a stable format all replicas can decode.
  • Decode: in ApplyRemote, unmarshal data and merge into or replace local state.
3. Expose a constructor returning syncable.Syncable

Return an adapter that holds a pointer to your state and implements the interface. Callers pass it to (*statesync.RedisSync).Register(...).

4. Wire in the process entry point
rs := statesync.New(redisClient,
    statesync.WithSyncPeriod(30*time.Second), // optional; default 10s
    statesync.WithOpTimeout(15*time.Second),  // optional; default 30s
    statesync.WithKeyPrefix("myapp"),         // optional; default "aibrix"
)
// Register all Syncables before Start.
rs.Register(mypkg.NewMyStateSyncable(myState))
rs.Start()

// On shutdown:
rs.Stop() // blocks until the sync loop exits or stop-wait timeout (default 90s)

Available statesync.New options:

Option Default Description
WithSyncPeriod(d) 10s Interval between sync cycles (jitter applied).
WithOpTimeout(d) 30s Per-operation context timeout for Pull/Push.
WithKeyPrefix(s) "aibrix" Prefix for all Redis keys.
WithRecordTTL(d) 2m Per-entity SETEX TTL; values ≤0 are ignored.
WithStopWaitTimeout(d) 90s Max time Stop() waits for the background loop to exit.
WithSetexChunkSize(n) 25 Pipeline chunk size for SETEX writes.
WithMGetBatchSize(n) 200 Batch size for MGET during Pull.
Redis client

The Redis client is provided by the caller (e.g. utils.GetRedisClient()). Configure TLS and auth in the client options for production (see pkg/utils/redis.go).


Example: a new component "TokenTracker"

A component that keeps tokenID → lastUsedTime and should sync across gateway replicas.

Step 1 – Implement Syncable in the component package
// pkg/plugins/gateway/algorithms/vtc/token_tracker_sync.go

import (
    "context"
    "encoding/json"
    "strconv"
    "time"

    "github.com/vllm-project/aibrix/pkg/utils/syncable"
)

const tokenTrackerNamespace = "token_tracker"

// TokenTrackerSyncable adapts TokenTracker for statesync.
type TokenTrackerSyncable struct {
    Tracker *TokenTracker
}

func NewTokenTrackerSyncable(t *TokenTracker) syncable.Syncable {
    return &TokenTrackerSyncable{Tracker: t}
}

func (s *TokenTrackerSyncable) Namespace() string { return tokenTrackerNamespace }

func (s *TokenTrackerSyncable) GetSnapshot(ctx context.Context) (map[string][]byte, error) {
    s.Tracker.mu.RLock()
    defer s.Tracker.mu.RUnlock()
    out := make(map[string][]byte, len(s.Tracker.entries))
    for id, t := range s.Tracker.entries {
        b, _ := json.Marshal(t.UnixNano())
        out[strconv.FormatInt(id, 10)] = b
    }
    return out, nil
}

func (s *TokenTrackerSyncable) ApplyRemote(ctx context.Context, id string, data []byte) error {
    var nano int64
    if err := json.Unmarshal(data, &nano); err != nil {
        return err
    }
    parsed, _ := strconv.ParseInt(id, 10, 64)
    remote := time.Unix(0, nano)
    s.Tracker.mu.Lock()
    defer s.Tracker.mu.Unlock()
    if existing, ok := s.Tracker.entries[parsed]; !ok || remote.After(existing) {
        s.Tracker.entries[parsed] = remote
    }
    return nil
}
Step 2 – Wire in the entry point
rs := statesync.New(redisClient, statesync.WithSyncPeriod(30*time.Second))
rs.Register(prefixcacheindexer.NewPrefixHashTableSyncable(prefixHashTable))
rs.Register(vtc.NewTokenTrackerSyncable(tokenTracker))
rs.Start()
defer rs.Stop()
Step 3 – Optional write-through

If you want other replicas to see a change before the next periodic push:

func (t *TokenTracker) RecordUse(ctx context.Context, id int64, rs *statesync.RedisSync) {
    t.mu.Lock()
    t.entries[id] = time.Now()
    t.mu.Unlock()
    if rs != nil {
        data, _ := json.Marshal(time.Now().UnixNano())
        _ = rs.Put(ctx, tokenTrackerNamespace, strconv.FormatInt(id, 10), data)
    }
}

Reference implementation

See pkg/utils/prefixcacheindexer/sync.go:

  • PrefixHashTableSyncable and NewPrefixHashTableSyncable(table) — full DeltaSyncable implementation.
  • GetSnapshotForSync / GetDeltaForSync / ClearDirtyForSync / ApplyRemoteForSync / EncodeBlockForSync on PrefixHashTable.
  • Remember to call table.EnableDeltaSync() before registering if you want delta push (not required for snapshot-only sync).

Documentation

Overview

Package statesync provides a generic Redis-backed state sync layer for use across multiple gateway (or other) replicas. State is stored as one Redis key per entity (aibrix:{namespace}:e:{entityId}) with per-record TTL so each record expires independently. Uses SET/SETEX, GET, DEL, and SCAN (or ISCAN). No PUB/SUB.

Usage:

  1. Create a Manager and register Syncables (e.g. prefixcacheindexer.NewPrefixHashTableSyncable(table)).
  2. Call Start() to run periodic pull/push from Redis.
  3. Optional write-through: after local state changes, call Put(ctx, namespace, id, data) so peers see updates before the next push (see package README; prefix cache uses delta + periodic push by default).
  4. On shutdown, call Stop().

Example: prefixcacheindexer.NewPrefixHashTableSyncable(table) implements syncable.Syncable; EncodeBlockForSync supports optional per-block Put for write-through.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Option

type Option func(*RedisSync)

Option configures RedisSync.

func WithKeyPrefix

func WithKeyPrefix(prefix string) Option

WithKeyPrefix sets the Redis key prefix (default "aibrix").

func WithMGetBatchSize

func WithMGetBatchSize(n int) Option

WithMGetBatchSize sets the batch size for MGET during Pull; if <=0, default is used.

func WithOpTimeout

func WithOpTimeout(d time.Duration) Option

WithOpTimeout sets the per-operation context timeout for Pull/Push (default 30s).

func WithRecordTTL

func WithRecordTTL(d time.Duration) Option

WithRecordTTL sets the per-entity SETEX TTL (default 2 minutes). Values <= 0 are ignored.

func WithSetexChunkSize

func WithSetexChunkSize(n int) Option

WithSetexChunkSize sets the pipeline chunk size for SETEX writes; if <=0, default is used.

func WithStopWaitTimeout

func WithStopWaitTimeout(d time.Duration) Option

WithStopWaitTimeout sets how long Stop() waits for the background sync loop to exit after closing the stop channel (default 90s). Values <= 0 use the default.

func WithSyncPeriod

func WithSyncPeriod(d time.Duration) Option

WithSyncPeriod sets the interval for periodic sync (default 10s).

type RedisSync

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

RedisSync stores one Redis key per entity with per-record TTL. Key format aibrix:{namespace}:e:{entityId} so {namespace} is the hash tag for same-shard routing.

func New

func New(client *redis.Client, opts ...Option) *RedisSync

New creates a RedisSync that stores one string key per entity (SETEX/MGET/SCAN).

func (*RedisSync) Delete

func (r *RedisSync) Delete(ctx context.Context, namespace, id string) error

Delete removes one entity from Redis. Uses DEL.

func (*RedisSync) Pull

func (r *RedisSync) Pull(ctx context.Context, s syncable.Syncable) error

Pull loads all entity keys for the namespace via SCAN (use ISCAN—replace client.Scan with client.Do(ctx, "ISCAN", ...) if needed), then MGET in batches and ApplyRemote for each. Each record has its own TTL; expired keys are not returned by Redis.

func (*RedisSync) Push

func (r *RedisSync) Push(ctx context.Context, s syncable.Syncable) error

Push writes Syncable state to Redis. If s implements syncable.DeltaSyncable, only changed entities (delta) are written; otherwise the full snapshot. Each record is written with SETEX so it has its own per-entity TTL.

func (*RedisSync) Put

func (r *RedisSync) Put(ctx context.Context, namespace, id string, data []byte) error

Put writes one entity to Redis (write-through). Uses SETEX so the record has per-entity TTL.

func (*RedisSync) Register

func (r *RedisSync) Register(s syncable.Syncable)

Register adds a Syncable for periodic sync. Must be called before Start; Register after Start is a no-op and is not applied.

func (*RedisSync) Start

func (r *RedisSync) Start()

Start starts the background goroutine: optional initial sync (with small random delay), then periodic Pull-first then optional Push, with jitter and backoff on errors. Register must be called before Start.

func (*RedisSync) StartWithContext

func (r *RedisSync) StartWithContext(ctx context.Context)

StartWithContext starts the sync loop and binds it to the provided context. When ctx is canceled, Stop() is invoked.

func (*RedisSync) Stop

func (r *RedisSync) Stop()

Stop signals the background sync loop to exit and blocks until it finishes or stopWaitTimeout elapses (default 90s). Safe to call more than once; each call waits for the loop goroutine if Start was used.

Jump to

Keyboard shortcuts

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