first commit
This commit is contained in:
432
framework/kvstore/kvstore.go
Normal file
432
framework/kvstore/kvstore.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package kvstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClosed = errors.New("kvstore is closed")
|
||||
ErrEmptyKey = errors.New("key cannot be empty")
|
||||
ErrNotFound = errors.New("key not found")
|
||||
ErrInvalidTTL = errors.New("ttl cannot be negative")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCleanupInterval = 30 * time.Second
|
||||
noExpirationUnixNanos = int64(0)
|
||||
)
|
||||
|
||||
// Config controls in-memory KV store behavior.
|
||||
type Config struct {
|
||||
// CleanupInterval controls how often expired entries are removed.
|
||||
// If <= 0, defaults to 30s.
|
||||
CleanupInterval time.Duration
|
||||
// DefaultTTL applies when Set is used.
|
||||
// A zero value means entries do not expire by default.
|
||||
DefaultTTL time.Duration
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
value any
|
||||
writtenAt int64 // unix nanos, 0 means not written yet
|
||||
expiresAt int64 // unix nanos, 0 means no expiration
|
||||
}
|
||||
|
||||
// Store is an in-memory KV store with optional TTL support.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]entry
|
||||
|
||||
defaultTTL time.Duration
|
||||
cleanupInterval time.Duration
|
||||
|
||||
closed atomic.Bool
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
cleanupWg sync.WaitGroup
|
||||
|
||||
delegate SyncDelegate
|
||||
decoders map[string]TypeDecoder
|
||||
decoderMu sync.RWMutex
|
||||
}
|
||||
|
||||
// SyncDelegate is notified of all mutations, enabling cross-node replication.
|
||||
// All calls happen synchronously after the local mutation has succeeded.
|
||||
// writtenAt / deletedAt are absolute Unix nanosecond timestamps used by remote
|
||||
// nodes for last-write-wins conflict resolution.
|
||||
// expiresAt is an absolute Unix nanosecond timestamp; 0 means no expiration.
|
||||
type SyncDelegate interface {
|
||||
OnSet(key string, valueJSON []byte, writtenAt int64, expiresAt int64)
|
||||
OnDelete(key string, deletedAt int64)
|
||||
}
|
||||
|
||||
// TypeDecoder reconstructs a concrete value from its JSON representation.
|
||||
// Register decoders by key prefix via RegisterDecoder.
|
||||
type TypeDecoder func(data []byte) (any, error)
|
||||
|
||||
// SetDelegate plugs in the cluster sync implementation.
|
||||
func (s *Store) SetDelegate(d SyncDelegate) {
|
||||
s.delegate = d
|
||||
}
|
||||
|
||||
// RegisterDecoder registers a decoder for keys matching the given prefix.
|
||||
// Used by the receiving side to reconstruct concrete types from gossip payloads.
|
||||
func (s *Store) RegisterDecoder(keyPrefix string, decoder TypeDecoder) {
|
||||
s.decoderMu.Lock()
|
||||
s.decoders[keyPrefix] = decoder
|
||||
s.decoderMu.Unlock()
|
||||
}
|
||||
|
||||
// New creates a new in-memory KV store.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DefaultTTL < 0 {
|
||||
return nil, ErrInvalidTTL
|
||||
}
|
||||
|
||||
cleanupInterval := cfg.CleanupInterval
|
||||
if cleanupInterval <= 0 {
|
||||
cleanupInterval = defaultCleanupInterval
|
||||
}
|
||||
|
||||
s := &Store{
|
||||
data: make(map[string]entry),
|
||||
defaultTTL: cfg.DefaultTTL,
|
||||
cleanupInterval: cleanupInterval,
|
||||
stopCh: make(chan struct{}),
|
||||
decoders: make(map[string]TypeDecoder),
|
||||
}
|
||||
|
||||
s.cleanupWg.Add(1)
|
||||
go s.cleanupLoop()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Set stores a value using the store's default TTL.
|
||||
func (s *Store) Set(key string, value any) error {
|
||||
return s.SetWithTTL(key, value, s.defaultTTL)
|
||||
}
|
||||
|
||||
// SetWithTTL stores a value with an explicit TTL.
|
||||
// ttl=0 means no expiration.
|
||||
func (s *Store) SetWithTTL(key string, value any, ttl time.Duration) error {
|
||||
if err := s.validateMutable(key, ttl); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
var expiresAt int64
|
||||
if ttl > 0 {
|
||||
expiresAt = now + int64(ttl)
|
||||
}
|
||||
|
||||
var valueJSON []byte
|
||||
var err error
|
||||
|
||||
if s.delegate != nil {
|
||||
valueJSON, err = sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.data[key] = entry{
|
||||
value: value,
|
||||
writtenAt: now,
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.delegate != nil {
|
||||
s.delegate.OnSet(key, valueJSON, now, expiresAt)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetNXWithTTL atomically sets a value with TTL only if the key does not exist.
|
||||
// Returns true if the key was set, false if the key already existed.
|
||||
// ttl=0 means no expiration.
|
||||
func (s *Store) SetNXWithTTL(key string, value any, ttl time.Duration) (bool, error) {
|
||||
if err := s.validateMutable(key, ttl); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
var expiresAt int64
|
||||
if ttl > 0 {
|
||||
expiresAt = now + int64(ttl)
|
||||
}
|
||||
|
||||
var valueJSON []byte
|
||||
var err error
|
||||
|
||||
if s.delegate != nil {
|
||||
valueJSON, err = sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
// Check if key exists and is not expired
|
||||
if existing, ok := s.data[key]; ok {
|
||||
if !isExpired(existing, now) {
|
||||
s.mu.Unlock()
|
||||
return false, nil // Key already exists
|
||||
}
|
||||
// Key exists but is expired, allow overwrite
|
||||
}
|
||||
|
||||
// Key doesn't exist or is expired, set it
|
||||
s.data[key] = entry{
|
||||
value: value,
|
||||
writtenAt: now,
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.delegate != nil {
|
||||
s.delegate.OnSet(key, valueJSON, now, expiresAt)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetRemote applies a remotely-gossiped entry without triggering OnSet.
|
||||
// writtenAt and expiresAt must be absolute Unix nanosecond timestamps.
|
||||
// If the local entry was written more recently than writtenAt the update is
|
||||
// silently skipped (last-write-wins by wall clock on the writing node).
|
||||
func (s *Store) SetRemote(key string, valueJSON []byte, writtenAt int64, expiresAt int64) error {
|
||||
if key == "" {
|
||||
return ErrEmptyKey
|
||||
}
|
||||
if s.closed.Load() {
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
value := s.decodeValue(key, valueJSON)
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.data[key]; ok && existing.writtenAt > writtenAt {
|
||||
s.mu.Unlock()
|
||||
return nil // stale gossip — local entry is newer
|
||||
}
|
||||
s.data[key] = entry{value: value, writtenAt: writtenAt, expiresAt: expiresAt}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a value by key.
|
||||
func (s *Store) Get(key string) (any, error) {
|
||||
if key == "" {
|
||||
return nil, ErrEmptyKey
|
||||
}
|
||||
if s.closed.Load() {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
s.mu.RLock()
|
||||
e, ok := s.data[key]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if isExpired(e, now) {
|
||||
s.mu.Lock()
|
||||
if latest, exists := s.data[key]; exists && isExpired(latest, time.Now().UnixNano()) {
|
||||
delete(s.data, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return e.value, nil
|
||||
}
|
||||
|
||||
// GetAndDelete retrieves and deletes a key atomically.
|
||||
func (s *Store) GetAndDelete(key string) (any, error) {
|
||||
if key == "" {
|
||||
return nil, ErrEmptyKey
|
||||
}
|
||||
if s.closed.Load() {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
s.mu.Lock()
|
||||
e, ok := s.data[key]
|
||||
if ok {
|
||||
delete(s.data, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !ok || isExpired(e, now) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if s.delegate != nil {
|
||||
s.delegate.OnDelete(key, now)
|
||||
}
|
||||
return e.value, nil
|
||||
}
|
||||
|
||||
// Delete removes a key.
|
||||
func (s *Store) Delete(key string) (bool, error) {
|
||||
if key == "" {
|
||||
return false, ErrEmptyKey
|
||||
}
|
||||
if s.closed.Load() {
|
||||
return false, ErrClosed
|
||||
}
|
||||
|
||||
deletedAt := time.Now().UnixNano()
|
||||
|
||||
s.mu.Lock()
|
||||
_, ok := s.data[key]
|
||||
if ok {
|
||||
delete(s.data, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if s.delegate != nil {
|
||||
s.delegate.OnDelete(key, deletedAt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteRemote applies a remotely-gossiped delete without triggering OnDelete.
|
||||
// deletedAt is the absolute Unix nanosecond timestamp when the delete was issued.
|
||||
// The delete is skipped if the local entry was written after the delete intent
|
||||
// (last-write-wins).
|
||||
func (s *Store) DeleteRemote(key string, deletedAt int64) error {
|
||||
if key == "" {
|
||||
return ErrEmptyKey
|
||||
}
|
||||
if s.closed.Load() {
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.data[key]; ok && existing.writtenAt > deletedAt {
|
||||
s.mu.Unlock()
|
||||
return nil // entry was written after the delete intent — write wins
|
||||
}
|
||||
delete(s.data, key)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Len returns the number of currently non-expired keys.
|
||||
func (s *Store) Len() int {
|
||||
if s.closed.Load() {
|
||||
return 0
|
||||
}
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
total := 0
|
||||
|
||||
s.mu.RLock()
|
||||
for _, v := range s.data {
|
||||
if isExpired(v, now) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
// Close stops background cleanup and prevents further operations.
|
||||
func (s *Store) Close() error {
|
||||
s.stopOnce.Do(func() {
|
||||
s.closed.Store(true)
|
||||
close(s.stopCh)
|
||||
})
|
||||
s.cleanupWg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) cleanupLoop() {
|
||||
defer s.cleanupWg.Done()
|
||||
|
||||
ticker := time.NewTicker(s.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.cleanupExpired()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) cleanupExpired() {
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
s.mu.Lock()
|
||||
for k, v := range s.data {
|
||||
if isExpired(v, now) {
|
||||
delete(s.data, k)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) validateMutable(key string, ttl time.Duration) error {
|
||||
if key == "" {
|
||||
return ErrEmptyKey
|
||||
}
|
||||
if ttl < 0 {
|
||||
return ErrInvalidTTL
|
||||
}
|
||||
if s.closed.Load() {
|
||||
return ErrClosed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeValue uses the registered decoder for the key's prefix, falling back
|
||||
// to raw []byte if no decoder matches.
|
||||
func (s *Store) decodeValue(key string, valueJSON []byte) any {
|
||||
s.decoderMu.RLock()
|
||||
|
||||
var bestPrefix string
|
||||
var bestDecode TypeDecoder
|
||||
for prefix, decode := range s.decoders {
|
||||
if strings.HasPrefix(key, prefix) && len(prefix) > len(bestPrefix) {
|
||||
bestPrefix = prefix
|
||||
bestDecode = decode
|
||||
}
|
||||
}
|
||||
|
||||
s.decoderMu.RUnlock()
|
||||
|
||||
if bestDecode != nil {
|
||||
if v, err := bestDecode(valueJSON); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return valueJSON
|
||||
}
|
||||
|
||||
func isExpired(e entry, nowUnixNano int64) bool {
|
||||
return e.expiresAt != noExpirationUnixNanos && nowUnixNano >= e.expiresAt
|
||||
}
|
||||
Reference in New Issue
Block a user