feat(scope): add namespace isolation with quota enforcement
ScopedStore wraps Store and auto-prefixes groups with a namespace to prevent key collisions across tenants. QuotaConfig enforces per-namespace MaxKeys and MaxGroups limits (zero = unlimited). Upserts and expired keys are excluded from quota counts. New Store methods: CountAll(prefix) and Groups(prefix) for cross-group queries. All 93 tests pass with race detector, coverage 94.7%. Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
22342f0395
commit
175fd6bf83
5 changed files with 824 additions and 11 deletions
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## What This Is
|
||||
|
||||
SQLite key-value store wrapper with TTL support. Module: `forge.lthn.ai/core/go-store`
|
||||
SQLite key-value store wrapper with TTL support and namespace isolation. Module: `forge.lthn.ai/core/go-store`
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -29,6 +29,18 @@ all, _ := st.GetAll("group") // excludes expired
|
|||
n, _ := st.Count("group") // excludes expired
|
||||
out, _ := st.Render(tmpl, "group") // excludes expired
|
||||
removed, _ := st.PurgeExpired() // manual purge
|
||||
total, _ := st.CountAll("prefix:") // count keys matching prefix (excludes expired)
|
||||
groups, _ := st.Groups("prefix:") // distinct group names matching prefix
|
||||
|
||||
// Namespace isolation (auto-prefixes groups with "tenant:")
|
||||
sc, _ := store.NewScoped(st, "tenant")
|
||||
sc.Set("config", "key", "val") // stored as "tenant:config" in underlying store
|
||||
sc.Get("config", "key") // reads from "tenant:config"
|
||||
|
||||
// With quota enforcement
|
||||
quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}
|
||||
sq, _ := store.NewScopedWithQuota(st, "tenant", quota)
|
||||
sq.Set("g", "k", "v") // returns ErrQuotaExceeded if limits hit
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
|
|
|||
20
TODO.md
20
TODO.md
|
|
@ -29,7 +29,7 @@ Scoped store wrapper that auto-prefixes groups with a namespace to prevent key c
|
|||
|
||||
### 2.1 ScopedStore Wrapper
|
||||
|
||||
- [ ] **Create `scope.go`** — A lightweight wrapper around `*Store` that auto-prefixes all group names:
|
||||
- [x] **Create `scope.go`** — A lightweight wrapper around `*Store` that auto-prefixes all group names:
|
||||
- `type ScopedStore struct { store *Store; namespace string }` — holds reference to underlying store and namespace prefix
|
||||
- `func NewScoped(store *Store, namespace string) *ScopedStore` — constructor. Validates namespace is non-empty, alphanumeric + hyphens only.
|
||||
- `func (s *ScopedStore) prefix(group string) string` — returns `namespace + ":" + group`
|
||||
|
|
@ -42,21 +42,21 @@ Scoped store wrapper that auto-prefixes groups with a namespace to prevent key c
|
|||
|
||||
### 2.2 Quota Enforcement
|
||||
|
||||
- [ ] **Add `QuotaConfig` to ScopedStore** — Optional quota limits per namespace:
|
||||
- [x] **Add `QuotaConfig` to ScopedStore** — Optional quota limits per namespace:
|
||||
- `type QuotaConfig struct { MaxKeys int; MaxGroups int }` — zero means unlimited
|
||||
- `func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) *ScopedStore`
|
||||
- `var ErrQuotaExceeded = errors.New("store: quota exceeded")`
|
||||
- [ ] **Enforce on Set()** — Before inserting, check `Count()` across all groups with the namespace prefix. If `MaxKeys > 0` and current count >= MaxKeys, return `ErrQuotaExceeded`. Only check on new keys (UPSERT existing keys doesn't increase count).
|
||||
- [ ] **Enforce on group creation** — Track distinct groups with the namespace prefix. If `MaxGroups > 0` and adding a new group would exceed the limit, return `ErrQuotaExceeded`.
|
||||
- [ ] **Add `CountAll() (int, error)` to Store** — Returns total key count across ALL groups matching a prefix. SQL: `SELECT COUNT(*) FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)` with `namespace + ":%"`.
|
||||
- [ ] **Add `Groups() ([]string, error)` to Store** — Returns distinct group names. SQL: `SELECT DISTINCT grp FROM kv WHERE (expires_at IS NULL OR expires_at > ?)`. Useful for quota checks and admin tooling.
|
||||
- [x] **Enforce on Set()** — Before inserting, check `Count()` across all groups with the namespace prefix. If `MaxKeys > 0` and current count >= MaxKeys, return `ErrQuotaExceeded`. Only check on new keys (UPSERT existing keys doesn't increase count).
|
||||
- [x] **Enforce on group creation** — Track distinct groups with the namespace prefix. If `MaxGroups > 0` and adding a new group would exceed the limit, return `ErrQuotaExceeded`.
|
||||
- [x] **Add `CountAll() (int, error)` to Store** — Returns total key count across ALL groups matching a prefix. SQL: `SELECT COUNT(*) FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)` with `namespace + ":%"`.
|
||||
- [x] **Add `Groups() ([]string, error)` to Store** — Returns distinct group names. SQL: `SELECT DISTINCT grp FROM kv WHERE (expires_at IS NULL OR expires_at > ?)`. Useful for quota checks and admin tooling.
|
||||
|
||||
### 2.3 Tests
|
||||
|
||||
- [ ] **ScopedStore basic tests** — Set/Get/Delete through ScopedStore, verify underlying store has prefixed groups, two namespaces don't collide, GetAll returns only scoped group's keys
|
||||
- [ ] **Quota tests** — (a) MaxKeys=5, insert 5 keys → OK, insert 6th → ErrQuotaExceeded, (b) UPSERT existing key doesn't count towards quota, (c) Delete + re-insert stays within quota, (d) MaxGroups=3, create 3 groups → OK, 4th → ErrQuotaExceeded, (e) zero quota = unlimited, (f) TTL-expired keys don't count towards quota
|
||||
- [ ] **CountAll/Groups tests** — (a) CountAll with mixed namespaces, (b) Groups returns distinct list, (c) expired keys excluded from both
|
||||
- [ ] **Existing tests still pass** — No changes to Store API, backward compatible
|
||||
- [x] **ScopedStore basic tests** — Set/Get/Delete through ScopedStore, verify underlying store has prefixed groups, two namespaces don't collide, GetAll returns only scoped group's keys
|
||||
- [x] **Quota tests** — (a) MaxKeys=5, insert 5 keys → OK, insert 6th → ErrQuotaExceeded, (b) UPSERT existing key doesn't count towards quota, (c) Delete + re-insert stays within quota, (d) MaxGroups=3, create 3 groups → OK, 4th → ErrQuotaExceeded, (e) zero quota = unlimited, (f) TTL-expired keys don't count towards quota
|
||||
- [x] **CountAll/Groups tests** — (a) CountAll with mixed namespaces, (b) Groups returns distinct list, (c) expired keys excluded from both
|
||||
- [x] **Existing tests still pass** — No changes to Store API, backward compatible. Coverage: 90.9% → 94.7%.
|
||||
|
||||
## Phase 3: Event Hooks
|
||||
|
||||
|
|
|
|||
157
scope.go
Normal file
157
scope.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
// validNamespace matches alphanumeric characters and hyphens (non-empty).
|
||||
var validNamespace = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
|
||||
|
||||
// QuotaConfig defines optional limits for a ScopedStore namespace.
|
||||
// Zero values mean unlimited.
|
||||
type QuotaConfig struct {
|
||||
MaxKeys int // maximum total keys across all groups in the namespace
|
||||
MaxGroups int // maximum distinct groups in the namespace
|
||||
}
|
||||
|
||||
// ScopedStore wraps a *Store and auto-prefixes all group names with a
|
||||
// namespace to prevent key collisions across tenants.
|
||||
type ScopedStore struct {
|
||||
store *Store
|
||||
namespace string
|
||||
quota QuotaConfig
|
||||
}
|
||||
|
||||
// NewScoped creates a ScopedStore that prefixes all groups with the given
|
||||
// namespace. The namespace must be non-empty and contain only alphanumeric
|
||||
// characters and hyphens.
|
||||
func NewScoped(store *Store, namespace string) (*ScopedStore, error) {
|
||||
if !validNamespace.MatchString(namespace) {
|
||||
return nil, fmt.Errorf("store.NewScoped: namespace %q is invalid (must be non-empty, alphanumeric + hyphens)", namespace)
|
||||
}
|
||||
return &ScopedStore{store: store, namespace: namespace}, nil
|
||||
}
|
||||
|
||||
// NewScopedWithQuota creates a ScopedStore with quota enforcement. Quotas are
|
||||
// checked on Set and SetWithTTL before inserting new keys or creating new
|
||||
// groups.
|
||||
func NewScopedWithQuota(store *Store, namespace string, quota QuotaConfig) (*ScopedStore, error) {
|
||||
s, err := NewScoped(store, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.quota = quota
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// prefix returns the namespaced group name.
|
||||
func (s *ScopedStore) prefix(group string) string {
|
||||
return s.namespace + ":" + group
|
||||
}
|
||||
|
||||
// Namespace returns the namespace string for this scoped store.
|
||||
func (s *ScopedStore) Namespace() string {
|
||||
return s.namespace
|
||||
}
|
||||
|
||||
// Get retrieves a value by group and key within the namespace.
|
||||
func (s *ScopedStore) Get(group, key string) (string, error) {
|
||||
return s.store.Get(s.prefix(group), key)
|
||||
}
|
||||
|
||||
// Set stores a value by group and key within the namespace. If quotas are
|
||||
// configured, they are checked before inserting new keys or groups.
|
||||
func (s *ScopedStore) Set(group, key, value string) error {
|
||||
if err := s.checkQuota(group, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.Set(s.prefix(group), key, value)
|
||||
}
|
||||
|
||||
// SetWithTTL stores a value with a time-to-live within the namespace. Quota
|
||||
// checks are applied for new keys and groups.
|
||||
func (s *ScopedStore) SetWithTTL(group, key, value string, ttl time.Duration) error {
|
||||
if err := s.checkQuota(group, key); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.SetWithTTL(s.prefix(group), key, value, ttl)
|
||||
}
|
||||
|
||||
// Delete removes a single key from a group within the namespace.
|
||||
func (s *ScopedStore) Delete(group, key string) error {
|
||||
return s.store.Delete(s.prefix(group), key)
|
||||
}
|
||||
|
||||
// DeleteGroup removes all keys in a group within the namespace.
|
||||
func (s *ScopedStore) DeleteGroup(group string) error {
|
||||
return s.store.DeleteGroup(s.prefix(group))
|
||||
}
|
||||
|
||||
// GetAll returns all non-expired key-value pairs in a group within the
|
||||
// namespace.
|
||||
func (s *ScopedStore) GetAll(group string) (map[string]string, error) {
|
||||
return s.store.GetAll(s.prefix(group))
|
||||
}
|
||||
|
||||
// Count returns the number of non-expired keys in a group within the namespace.
|
||||
func (s *ScopedStore) Count(group string) (int, error) {
|
||||
return s.store.Count(s.prefix(group))
|
||||
}
|
||||
|
||||
// Render loads all non-expired key-value pairs from a namespaced group and
|
||||
// renders a Go template.
|
||||
func (s *ScopedStore) Render(tmplStr, group string) (string, error) {
|
||||
return s.store.Render(tmplStr, s.prefix(group))
|
||||
}
|
||||
|
||||
// checkQuota verifies that inserting key into group would not exceed the
|
||||
// namespace's quota limits. It returns nil if no quota is set or the operation
|
||||
// is within bounds. Existing keys (upserts) are not counted as new.
|
||||
func (s *ScopedStore) checkQuota(group, key string) error {
|
||||
if s.quota.MaxKeys == 0 && s.quota.MaxGroups == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
prefixedGroup := s.prefix(group)
|
||||
nsPrefix := s.namespace + ":"
|
||||
|
||||
// Check if this is an upsert (key already exists) — upserts never exceed quota.
|
||||
_, err := s.store.Get(prefixedGroup, key)
|
||||
if err == nil {
|
||||
// Key exists — this is an upsert, no quota check needed.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check MaxKeys quota.
|
||||
if s.quota.MaxKeys > 0 {
|
||||
count, err := s.store.CountAll(nsPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.ScopedStore: quota check: %w", err)
|
||||
}
|
||||
if count >= s.quota.MaxKeys {
|
||||
return fmt.Errorf("store.ScopedStore: key limit (%d): %w", s.quota.MaxKeys, ErrQuotaExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
// Check MaxGroups quota — only if this would create a new group.
|
||||
if s.quota.MaxGroups > 0 {
|
||||
groupCount, err := s.store.Count(prefixedGroup)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.ScopedStore: quota check: %w", err)
|
||||
}
|
||||
if groupCount == 0 {
|
||||
// This group is new — check if adding it would exceed the group limit.
|
||||
groups, err := s.store.Groups(nsPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.ScopedStore: quota check: %w", err)
|
||||
}
|
||||
if len(groups) >= s.quota.MaxGroups {
|
||||
return fmt.Errorf("store.ScopedStore: group limit (%d): %w", s.quota.MaxGroups, ErrQuotaExceeded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
584
scope_test.go
Normal file
584
scope_test.go
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewScoped — constructor validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewScoped_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, err := NewScoped(s, "tenant-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sc)
|
||||
assert.Equal(t, "tenant-1", sc.Namespace())
|
||||
}
|
||||
|
||||
func TestNewScoped_Good_AlphanumericHyphens(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
valid := []string{"abc", "ABC", "123", "a-b-c", "tenant-42", "A1-B2"}
|
||||
for _, ns := range valid {
|
||||
sc, err := NewScoped(s, ns)
|
||||
require.NoError(t, err, "namespace %q should be valid", ns)
|
||||
require.NotNil(t, sc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewScoped_Bad_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_, err := NewScoped(s, "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid")
|
||||
}
|
||||
|
||||
func TestNewScoped_Bad_InvalidChars(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
invalid := []string{"foo.bar", "foo:bar", "foo bar", "foo/bar", "foo_bar", "tenant!", "@ns"}
|
||||
for _, ns := range invalid {
|
||||
_, err := NewScoped(s, ns)
|
||||
require.Error(t, err, "namespace %q should be invalid", ns)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScopedStore — basic CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestScopedStore_Good_SetGet(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.Set("config", "theme", "dark"))
|
||||
|
||||
val, err := sc.Get("config", "theme")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dark", val)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_PrefixedInUnderlyingStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.Set("config", "key", "val"))
|
||||
|
||||
// The underlying store should have the prefixed group name.
|
||||
val, err := s.Get("tenant-a:config", "key")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "val", val)
|
||||
|
||||
// Direct access without prefix should fail.
|
||||
_, err = s.Get("config", "key")
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_NamespaceIsolation(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
a, _ := NewScoped(s, "tenant-a")
|
||||
b, _ := NewScoped(s, "tenant-b")
|
||||
|
||||
require.NoError(t, a.Set("config", "colour", "blue"))
|
||||
require.NoError(t, b.Set("config", "colour", "red"))
|
||||
|
||||
va, err := a.Get("config", "colour")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "blue", va)
|
||||
|
||||
vb, err := b.Get("config", "colour")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "red", vb)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Delete(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.Set("g", "k", "v"))
|
||||
require.NoError(t, sc.Delete("g", "k"))
|
||||
|
||||
_, err := sc.Get("g", "k")
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_DeleteGroup(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.Set("g", "a", "1"))
|
||||
require.NoError(t, sc.Set("g", "b", "2"))
|
||||
require.NoError(t, sc.DeleteGroup("g"))
|
||||
|
||||
n, err := sc.Count("g")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_GetAll(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
a, _ := NewScoped(s, "tenant-a")
|
||||
b, _ := NewScoped(s, "tenant-b")
|
||||
|
||||
require.NoError(t, a.Set("items", "x", "1"))
|
||||
require.NoError(t, a.Set("items", "y", "2"))
|
||||
require.NoError(t, b.Set("items", "z", "3"))
|
||||
|
||||
all, err := a.GetAll("items")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"x": "1", "y": "2"}, all)
|
||||
|
||||
allB, err := b.GetAll("items")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"z": "3"}, allB)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Count(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.Set("g", "a", "1"))
|
||||
require.NoError(t, sc.Set("g", "b", "2"))
|
||||
|
||||
n, err := sc.Count("g")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_SetWithTTL(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.SetWithTTL("g", "k", "v", time.Hour))
|
||||
|
||||
val, err := sc.Get("g", "k")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v", val)
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_SetWithTTL_Expires(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.SetWithTTL("g", "k", "v", 1*time.Millisecond))
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
_, err := sc.Get("g", "k")
|
||||
assert.True(t, errors.Is(err, ErrNotFound))
|
||||
}
|
||||
|
||||
func TestScopedStore_Good_Render(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScoped(s, "tenant-a")
|
||||
require.NoError(t, sc.Set("user", "name", "Alice"))
|
||||
|
||||
out, err := sc.Render("Hello {{ .name }}", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Hello Alice", out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quota enforcement — MaxKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestQuota_Good_MaxKeys(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, err := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 5})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Insert 5 keys across different groups — should be fine.
|
||||
for i := 0; i < 5; i++ {
|
||||
require.NoError(t, sc.Set("g", keyName(i), "v"))
|
||||
}
|
||||
|
||||
// 6th key should fail.
|
||||
err = sc.Set("g", "overflow", "v")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded), "expected ErrQuotaExceeded, got: %v", err)
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxKeys_AcrossGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
|
||||
|
||||
require.NoError(t, sc.Set("g1", "a", "1"))
|
||||
require.NoError(t, sc.Set("g2", "b", "2"))
|
||||
require.NoError(t, sc.Set("g3", "c", "3"))
|
||||
|
||||
// Total is now 3 — any new key should fail regardless of group.
|
||||
err := sc.Set("g4", "d", "4")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_UpsertDoesNotCount(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
|
||||
|
||||
require.NoError(t, sc.Set("g", "a", "1"))
|
||||
require.NoError(t, sc.Set("g", "b", "2"))
|
||||
require.NoError(t, sc.Set("g", "c", "3"))
|
||||
|
||||
// Upserting existing key should succeed.
|
||||
require.NoError(t, sc.Set("g", "a", "updated"))
|
||||
|
||||
val, err := sc.Get("g", "a")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "updated", val)
|
||||
}
|
||||
|
||||
func TestQuota_Good_DeleteAndReInsert(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
|
||||
|
||||
require.NoError(t, sc.Set("g", "a", "1"))
|
||||
require.NoError(t, sc.Set("g", "b", "2"))
|
||||
require.NoError(t, sc.Set("g", "c", "3"))
|
||||
|
||||
// Delete one key, then insert a new one — should work.
|
||||
require.NoError(t, sc.Delete("g", "c"))
|
||||
require.NoError(t, sc.Set("g", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_ZeroMeansUnlimited(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 0, MaxGroups: 0})
|
||||
|
||||
// Should be able to insert many keys and groups without error.
|
||||
for i := 0; i < 100; i++ {
|
||||
require.NoError(t, sc.Set("g", keyName(i), "v"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuota_Good_ExpiredKeysExcluded(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 3})
|
||||
|
||||
// Insert 3 keys, 2 with short TTL.
|
||||
require.NoError(t, sc.SetWithTTL("g", "temp1", "v", 1*time.Millisecond))
|
||||
require.NoError(t, sc.SetWithTTL("g", "temp2", "v", 1*time.Millisecond))
|
||||
require.NoError(t, sc.Set("g", "permanent", "v"))
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// After expiry, only 1 key counts — should be able to insert 2 more.
|
||||
require.NoError(t, sc.Set("g", "new1", "v"))
|
||||
require.NoError(t, sc.Set("g", "new2", "v"))
|
||||
|
||||
// Now at 3 — next should fail.
|
||||
err := sc.Set("g", "new3", "v")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_SetWithTTL_Enforced(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 2})
|
||||
|
||||
require.NoError(t, sc.SetWithTTL("g", "a", "1", time.Hour))
|
||||
require.NoError(t, sc.SetWithTTL("g", "b", "2", time.Hour))
|
||||
|
||||
err := sc.SetWithTTL("g", "c", "3", time.Hour)
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quota enforcement — MaxGroups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestQuota_Good_MaxGroups(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 3})
|
||||
|
||||
require.NoError(t, sc.Set("g1", "k", "v"))
|
||||
require.NoError(t, sc.Set("g2", "k", "v"))
|
||||
require.NoError(t, sc.Set("g3", "k", "v"))
|
||||
|
||||
// 4th group should fail.
|
||||
err := sc.Set("g4", "k", "v")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ExistingGroupOK(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 2})
|
||||
|
||||
require.NoError(t, sc.Set("g1", "a", "1"))
|
||||
require.NoError(t, sc.Set("g2", "b", "2"))
|
||||
|
||||
// Adding more keys to existing groups should be fine.
|
||||
require.NoError(t, sc.Set("g1", "c", "3"))
|
||||
require.NoError(t, sc.Set("g2", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_DeleteAndRecreate(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 2})
|
||||
|
||||
require.NoError(t, sc.Set("g1", "k", "v"))
|
||||
require.NoError(t, sc.Set("g2", "k", "v"))
|
||||
|
||||
// Delete a group, then create a new one.
|
||||
require.NoError(t, sc.DeleteGroup("g1"))
|
||||
require.NoError(t, sc.Set("g3", "k", "v"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ZeroUnlimited(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 0})
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
require.NoError(t, sc.Set(keyName(i), "k", "v"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuota_Good_MaxGroups_ExpiredGroupExcluded(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxGroups: 2})
|
||||
|
||||
// Create 2 groups, one with only TTL keys.
|
||||
require.NoError(t, sc.SetWithTTL("g1", "k", "v", 1*time.Millisecond))
|
||||
require.NoError(t, sc.Set("g2", "k", "v"))
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// g1's only key has expired, so group count should be 1 — we can create a new one.
|
||||
require.NoError(t, sc.Set("g3", "k", "v"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_BothLimits(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
sc, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 10, MaxGroups: 2})
|
||||
|
||||
require.NoError(t, sc.Set("g1", "a", "1"))
|
||||
require.NoError(t, sc.Set("g2", "b", "2"))
|
||||
|
||||
// Group limit hit.
|
||||
err := sc.Set("g3", "c", "3")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
|
||||
// But adding to existing groups is fine (within key limit).
|
||||
require.NoError(t, sc.Set("g1", "d", "4"))
|
||||
}
|
||||
|
||||
func TestQuota_Good_DoesNotAffectOtherNamespaces(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
a, _ := NewScopedWithQuota(s, "tenant-a", QuotaConfig{MaxKeys: 2})
|
||||
b, _ := NewScopedWithQuota(s, "tenant-b", QuotaConfig{MaxKeys: 2})
|
||||
|
||||
require.NoError(t, a.Set("g", "a1", "v"))
|
||||
require.NoError(t, a.Set("g", "a2", "v"))
|
||||
require.NoError(t, b.Set("g", "b1", "v"))
|
||||
require.NoError(t, b.Set("g", "b2", "v"))
|
||||
|
||||
// a is at limit — but b's keys don't count against a.
|
||||
err := a.Set("g", "a3", "v")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
|
||||
// b is also at limit independently.
|
||||
err = b.Set("g", "b3", "v")
|
||||
assert.True(t, errors.Is(err, ErrQuotaExceeded))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CountAll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCountAll_Good_WithPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, s.Set("ns-a:g1", "k1", "v"))
|
||||
require.NoError(t, s.Set("ns-a:g1", "k2", "v"))
|
||||
require.NoError(t, s.Set("ns-a:g2", "k1", "v"))
|
||||
require.NoError(t, s.Set("ns-b:g1", "k1", "v"))
|
||||
|
||||
n, err := s.CountAll("ns-a:")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, n)
|
||||
|
||||
n, err = s.CountAll("ns-b:")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_EmptyPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, s.Set("g1", "k1", "v"))
|
||||
require.NoError(t, s.Set("g2", "k2", "v"))
|
||||
|
||||
n, err := s.CountAll("")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Good_ExcludesExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, s.Set("ns:g", "permanent", "v"))
|
||||
require.NoError(t, s.SetWithTTL("ns:g", "temp", "v", 1*time.Millisecond))
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
n, err := s.CountAll("ns:")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, n, "expired keys should not be counted")
|
||||
}
|
||||
|
||||
func TestCountAll_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
n, err := s.CountAll("nonexistent:")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestCountAll_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
_, err := s.CountAll("")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Groups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestGroups_Good_WithPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, s.Set("ns-a:g1", "k", "v"))
|
||||
require.NoError(t, s.Set("ns-a:g2", "k", "v"))
|
||||
require.NoError(t, s.Set("ns-a:g2", "k2", "v")) // duplicate group
|
||||
require.NoError(t, s.Set("ns-b:g1", "k", "v"))
|
||||
|
||||
groups, err := s.Groups("ns-a:")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups, 2)
|
||||
assert.Contains(t, groups, "ns-a:g1")
|
||||
assert.Contains(t, groups, "ns-a:g2")
|
||||
}
|
||||
|
||||
func TestGroups_Good_EmptyPrefix(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, s.Set("g1", "k", "v"))
|
||||
require.NoError(t, s.Set("g2", "k", "v"))
|
||||
require.NoError(t, s.Set("g3", "k", "v"))
|
||||
|
||||
groups, err := s.Groups("")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups, 3)
|
||||
}
|
||||
|
||||
func TestGroups_Good_Distinct(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
// Multiple keys in the same group should produce one entry.
|
||||
require.NoError(t, s.Set("g1", "a", "v"))
|
||||
require.NoError(t, s.Set("g1", "b", "v"))
|
||||
require.NoError(t, s.Set("g1", "c", "v"))
|
||||
|
||||
groups, err := s.Groups("")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups, 1)
|
||||
assert.Equal(t, "g1", groups[0])
|
||||
}
|
||||
|
||||
func TestGroups_Good_ExcludesExpired(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, s.Set("ns:g1", "permanent", "v"))
|
||||
require.NoError(t, s.SetWithTTL("ns:g2", "temp", "v", 1*time.Millisecond))
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
groups, err := s.Groups("ns:")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, groups, 1, "group with only expired keys should be excluded")
|
||||
assert.Equal(t, "ns:g1", groups[0])
|
||||
}
|
||||
|
||||
func TestGroups_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
groups, err := s.Groups("nonexistent:")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, groups)
|
||||
}
|
||||
|
||||
func TestGroups_Bad_ClosedStore(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
s.Close()
|
||||
|
||||
_, err := s.Groups("")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func keyName(i int) string {
|
||||
return "key-" + string(rune('a'+i%26))
|
||||
}
|
||||
60
store.go
60
store.go
|
|
@ -16,6 +16,9 @@ import (
|
|||
// ErrNotFound is returned when a key does not exist in the store.
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// ErrQuotaExceeded is returned when a namespace quota limit is reached.
|
||||
var ErrQuotaExceeded = errors.New("store: quota exceeded")
|
||||
|
||||
// Store is a group-namespaced key-value store backed by SQLite.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
|
|
@ -215,6 +218,63 @@ func (s *Store) Render(tmplStr, group string) (string, error) {
|
|||
return b.String(), nil
|
||||
}
|
||||
|
||||
// CountAll returns the total number of non-expired keys across all groups whose
|
||||
// name starts with the given prefix. Pass an empty string to count everything.
|
||||
func (s *Store) CountAll(prefix string) (int, error) {
|
||||
var n int
|
||||
var err error
|
||||
if prefix == "" {
|
||||
err = s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM kv WHERE (expires_at IS NULL OR expires_at > ?)",
|
||||
time.Now().UnixMilli(),
|
||||
).Scan(&n)
|
||||
} else {
|
||||
err = s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
prefix+"%", time.Now().UnixMilli(),
|
||||
).Scan(&n)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store.CountAll: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Groups returns the distinct group names of all non-expired keys. If prefix is
|
||||
// non-empty, only groups starting with that prefix are returned.
|
||||
func (s *Store) Groups(prefix string) ([]string, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if prefix == "" {
|
||||
rows, err = s.db.Query(
|
||||
"SELECT DISTINCT grp FROM kv WHERE (expires_at IS NULL OR expires_at > ?)",
|
||||
time.Now().UnixMilli(),
|
||||
)
|
||||
} else {
|
||||
rows, err = s.db.Query(
|
||||
"SELECT DISTINCT grp FROM kv WHERE grp LIKE ? AND (expires_at IS NULL OR expires_at > ?)",
|
||||
prefix+"%", time.Now().UnixMilli(),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.Groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []string
|
||||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
return nil, fmt.Errorf("store.Groups: scan: %w", err)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store.Groups: rows: %w", err)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// PurgeExpired deletes all expired keys across all groups. Returns the number
|
||||
// of rows removed.
|
||||
func (s *Store) PurgeExpired() (int64, error) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue