feat: add independent platform modules and multi-provider storage

This commit is contained in:
2026-09-23 14:51:14 +08:00
parent f91034477b
commit 208a9c3fbd
57 changed files with 4890 additions and 48 deletions

View File

@@ -17,12 +17,13 @@ import (
)
type IdempotencyConfig struct {
TTL time.Duration `json:"ttl"`
MaxEntries int `json:"maxEntries"`
MaxBytes int `json:"maxBytes"`
MaxRequestBytes int64 `json:"maxRequestBytes"`
MaxResponseBytes int `json:"maxResponseBytes"`
MaxInFlight int `json:"maxInFlight"`
Store IdempotencyStore `json:"-"` // Optional shared store; nil keeps local behavior.
TTL time.Duration `json:"ttl"`
MaxEntries int `json:"maxEntries"`
MaxBytes int `json:"maxBytes"`
MaxRequestBytes int64 `json:"maxRequestBytes"`
MaxResponseBytes int `json:"maxResponseBytes"`
MaxInFlight int `json:"maxInFlight"`
// Scope must return a server-verified tenant/user identity. Register auth
// before this middleware. Client-provided identity is not authorization.
Scope func(*httpx.Context) (string, error) `json:"-"`
@@ -53,6 +54,9 @@ func NewIdempotency(cfg IdempotencyConfig) (*Idempotency, error) {
if cfg.TTL == 0 {
cfg.TTL = 10 * time.Minute
}
if cfg.Store != nil && cfg.TTL > 30*24*time.Hour {
return nil, errors.New("middleware: shared idempotency TTL exceeds 30d")
}
if cfg.MaxEntries == 0 {
cfg.MaxEntries = 256
}
@@ -125,6 +129,9 @@ func (i *Idempotency) Middleware() httpx.Middleware {
copy(fingerprint[:], hash.Sum(nil))
namespace, _ := json.Marshal([]string{scope, key})
id := sha256.Sum256(namespace)
if i.config.Store != nil {
return i.shared(c, next, id, fingerprint)
}
i.mu.Lock()
now := i.now()
for k, v := range i.items {

View File

@@ -13,6 +13,7 @@ import (
)
type RateLimitConfig struct {
Backend RateLimitBackend `json:"-"`
Rate float64 `json:"rate"` // Tokens per second, per key.
Burst int `json:"burst"`
MaxKeys int `json:"maxKeys"`
@@ -64,7 +65,17 @@ func (l *RateLimiter) Middleware() httpx.Middleware {
if key == "" || len(key) > 1024 {
return apierrors.BadRequest("INVALID_RATE_KEY", "invalid rate limit key")
}
delay, full := l.allow(key)
var delay time.Duration
var full bool
if l.config.Backend != nil {
var err error
delay, err = l.config.Backend.Take(c.Context(), key)
if err != nil {
return apierrors.New(503, "RATE_LIMIT_UNAVAILABLE", "rate limiter unavailable").WithCause(err)
}
} else {
delay, full = l.allow(key)
}
if full {
return apierrors.New(503, "RATE_LIMIT_CAPACITY", "rate limiter capacity reached")
}

178
middleware/redis/store.go Normal file
View File

@@ -0,0 +1,178 @@
// Package redis supplies atomic shared middleware state in one Redis hash slot.
// Use a dedicated namespace and a noeviction Redis deployment for correctness.
package redis
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/middleware"
goredis "github.com/redis/go-redis/v9"
"strings"
"time"
)
type IdempotencyConfig struct {
MaxEntries int `json:"maxEntries"`
MaxRecordBytes int `json:"maxRecordBytes"`
}
type IdempotencyStore struct {
client goredis.Scripter
keys []string
cfg IdempotencyConfig
signature string
}
func namespace(prefix, kind string) (string, error) {
if prefix == "" || len(prefix) > 256 || strings.ContainsAny(prefix, "{}\r\n") {
return "", errors.New("middleware/redis: nonempty bounded namespace without braces required")
}
sum := sha256.Sum256([]byte(prefix))
return "goOmni:{" + hex.EncodeToString(sum[:]) + "}:" + kind, nil
}
func NewIdempotencyStore(client goredis.Scripter, prefix string, cfg IdempotencyConfig) (*IdempotencyStore, error) {
if client == nil || cfg.MaxEntries < 0 || cfg.MaxRecordBytes < 0 {
return nil, errors.New("middleware/redis: invalid configuration")
}
if cfg.MaxEntries == 0 {
cfg.MaxEntries = 128
}
if cfg.MaxRecordBytes == 0 {
cfg.MaxRecordBytes = 128 << 10
}
if cfg.MaxEntries > 100000 || cfg.MaxRecordBytes < 512 || cfg.MaxRecordBytes > 1<<20 {
return nil, errors.New("middleware/redis: capacity outside supported bounds")
}
base, err := namespace(prefix, "idempotency")
if err != nil {
return nil, err
}
return &IdempotencyStore{client: client, keys: []string{base + ":records", base + ":expiry", base + ":config"}, cfg: cfg, signature: fmt.Sprintf("%d:%d", cfg.MaxEntries, cfg.MaxRecordBytes)}, nil
}
var claim = goredis.NewScript(`
local config=redis.call('GET',KEYS[3])
if config and config~=ARGV[5] then return redis.error_reply('configuration mismatch') end
if not config then redis.call('SET',KEYS[3],ARGV[5]) end
local clock=redis.call('TIME');local now=clock[1]*1000+math.floor(clock[2]/1000)
local expired=redis.call('ZRANGEBYSCORE',KEYS[2],'-inf',now,'LIMIT',0,128)
for _,key in ipairs(expired) do redis.call('HDEL',KEYS[1],key);redis.call('ZREM',KEYS[2],key) end
local expiry=redis.call('ZSCORE',KEYS[2],ARGV[1]);if expiry and tonumber(expiry)<=now then redis.call('HDEL',KEYS[1],ARGV[1]);redis.call('ZREM',KEYS[2],ARGV[1]) end
local value=redis.call('HGET',KEYS[1],ARGV[1]);if value then return {0,value} end
if redis.call('HLEN',KEYS[1])>=tonumber(ARGV[4]) then return redis.error_reply('idempotency capacity reached') end
value=cjson.encode({fingerprint=ARGV[2],owner=ARGV[3],state='running'})
redis.call('HSET',KEYS[1],ARGV[1],value);return {1,value}
`)
func (s *IdempotencyStore) Claim(ctx context.Context, key, fp, owner string) (middleware.IdempotencyRecord, bool, error) {
if len(key) != 64 || len(fp) != 64 || len(owner) != 32 {
return middleware.IdempotencyRecord{}, false, errors.New("middleware/redis: invalid claim")
}
result, err := claim.Run(ctx, s.client, s.keys, key, fp, owner, s.cfg.MaxEntries, s.signature).Slice()
if err != nil {
return middleware.IdempotencyRecord{}, false, err
}
if len(result) != 2 {
return middleware.IdempotencyRecord{}, false, errors.New("middleware/redis: invalid claim result")
}
value, ok := result[1].(string)
if !ok || len(value) > s.cfg.MaxRecordBytes {
return middleware.IdempotencyRecord{}, false, errors.New("middleware/redis: invalid record")
}
var record middleware.IdempotencyRecord
err = json.Unmarshal([]byte(value), &record)
return record, result[0] == int64(1), err
}
var complete = goredis.NewScript(`
local current=redis.call('HGET',KEYS[1],ARGV[1]);if not current then return redis.error_reply('claim missing') end
local value=cjson.decode(current)
if value.owner~=ARGV[2] or value.state~='running' then return redis.error_reply('claim owner/state changed') end
local response=ARGV[3]
if response~='' then value.state='completed';value.response=cjson.decode(response) else value.state='failed' end
local encoded=cjson.encode(value)
if string.len(encoded)>tonumber(ARGV[5]) then value.state='failed';value.response=nil;encoded=cjson.encode(value) end
local clock=redis.call('TIME');local now=clock[1]*1000+math.floor(clock[2]/1000)
redis.call('HSET',KEYS[1],ARGV[1],encoded);redis.call('ZADD',KEYS[2],now+tonumber(ARGV[4]),ARGV[1]);return 1
`)
func (s *IdempotencyStore) Complete(ctx context.Context, key, owner string, response *middleware.StoredResponse, ttl time.Duration) error {
if ttl <= 0 || ttl > 30*24*time.Hour {
return errors.New("middleware/redis: TTL must be positive and <=30d")
}
var encoded []byte
var err error
if response != nil {
encoded, err = json.Marshal(response)
if err != nil {
return err
}
if len(encoded) > s.cfg.MaxRecordBytes {
encoded = nil
}
}
return complete.Run(ctx, s.client, s.keys, key, owner, string(encoded), max(1, ttl.Milliseconds()), s.cfg.MaxRecordBytes).Err()
}
// Resolve removes only a matching non-replayable claim after the operator has
// reconciled the business outcome. Never invoke automatically on a timeout.
func (s *IdempotencyStore) Resolve(ctx context.Context, key, owner string) error {
return goredis.NewScript(`local v=redis.call('HGET',KEYS[1],ARGV[1]);if not v then return 0 end;local r=cjson.decode(v);if r.owner~=ARGV[2] or r.state=='completed' then return redis.error_reply('claim cannot be resolved') end;redis.call('HDEL',KEYS[1],ARGV[1]);redis.call('ZREM',KEYS[2],ARGV[1]);return 1`).Run(ctx, s.client, s.keys, key, owner).Err()
}
type limiter struct {
client goredis.Scripter
keys []string
cfg middleware.RateLimitConfig
signature string
}
func NewRateLimiter(client goredis.Scripter, prefix string, cfg middleware.RateLimitConfig) (*middleware.RateLimiter, error) {
if client == nil {
return nil, errors.New("middleware/redis: client required")
}
if cfg.MaxKeys == 0 {
cfg.MaxKeys = 10000
}
if cfg.IdleTTL == 0 {
cfg.IdleTTL = 24 * time.Hour
}
if _, err := middleware.NewRateLimiter(cfg); err != nil {
return nil, err
}
base, err := namespace(prefix, "rate")
if err != nil {
return nil, err
}
backend := &limiter{client: client, keys: []string{base + ":buckets", base + ":seen", base + ":config"}, cfg: cfg, signature: fmt.Sprintf("%g:%d:%d:%d", cfg.Rate, cfg.Burst, cfg.MaxKeys, cfg.IdleTTL.Milliseconds())}
cfg.Backend = backend
return middleware.NewRateLimiter(cfg)
}
var take = goredis.NewScript(`
local config=redis.call('GET',KEYS[3]);if config and config~=ARGV[6] then return redis.error_reply('configuration mismatch') end
if not config then redis.call('SET',KEYS[3],ARGV[6]) end
local clock=redis.call('TIME');local now=clock[1]*1000+math.floor(clock[2]/1000)
local expired=redis.call('ZRANGEBYSCORE',KEYS[2],'-inf',now-tonumber(ARGV[5]),'LIMIT',0,128)
for _,key in ipairs(expired) do redis.call('HDEL',KEYS[1],key);redis.call('ZREM',KEYS[2],key) end
local value=redis.call('HGET',KEYS[1],ARGV[1]);local tokens=tonumber(ARGV[3])
if value then local b=cjson.decode(value);tokens=math.min(tokens,b.tokens+math.max(0,now-b.time)*tonumber(ARGV[2])/1000)
elseif redis.call('HLEN',KEYS[1])>=tonumber(ARGV[4]) then return -1 end
local delay=0;if tokens>=1 then tokens=tokens-1 else delay=math.ceil((1-tokens)*1000/tonumber(ARGV[2])) end
redis.call('HSET',KEYS[1],ARGV[1],cjson.encode({tokens=tokens,time=now}));redis.call('ZADD',KEYS[2],now,ARGV[1]);return delay
`)
func (l *limiter) Take(ctx context.Context, key string) (time.Duration, error) {
sum := sha256.Sum256([]byte(key))
delay, err := take.Run(ctx, l.client, l.keys, hex.EncodeToString(sum[:]), l.cfg.Rate, l.cfg.Burst, l.cfg.MaxKeys, l.cfg.IdleTTL.Milliseconds(), l.signature).Int64()
if err != nil {
return 0, err
}
if delay < 0 {
return 0, middleware.ErrLimitCapacity
}
return time.Duration(delay) * time.Millisecond, nil
}

View File

@@ -0,0 +1,145 @@
package redis
import (
"context"
"fmt"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
goredis "github.com/redis/go-redis/v9"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestSharedRedisIntegration(t *testing.T) {
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
if address == "" {
t.Skip("requires isolated Redis")
}
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 8, MaxRetries: -1, ContextTimeoutEnabled: true, ReadTimeout: time.Second, WriteTimeout: time.Second})
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
prefix := fmt.Sprint("shared:", time.Now().UnixNano())
s, err := NewIdempotencyStore(client, prefix, IdempotencyConfig{MaxEntries: 256})
if err != nil {
t.Fatal(err)
}
defer client.Del(context.Background(), s.keys...)
key, fp := strings.Repeat("a", 64), strings.Repeat("b", 64)
var claims atomic.Int32
var wg sync.WaitGroup
owners := make(chan string, 32)
for i := 0; i < 32; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
owner := fmt.Sprintf("%032x", i)
_, ok, err := s.Claim(ctx, key, fp, owner)
if err != nil {
t.Error(err)
}
if ok {
claims.Add(1)
owners <- owner
}
}(i)
}
wg.Wait()
if claims.Load() != 1 {
t.Fatal(claims.Load())
}
owner := <-owners
if err = s.Complete(ctx, key, "wrong", nil, time.Minute); err == nil {
t.Fatal("foreign completion accepted")
}
if err = s.Complete(ctx, key, owner, &middleware.StoredResponse{Status: 201, Headers: http.Header{}, Body: []byte{0, 1, 255}}, time.Minute); err != nil {
t.Fatal(err)
}
record, ok, err := s.Claim(ctx, key, fp, strings.Repeat("c", 32))
if err != nil || ok || record.State != "completed" || len(record.Response.Body) != 3 {
t.Fatal(record, ok, err)
}
// Two independently constructed apps use the same backend, so only one runs.
var executions atomic.Int32
makeApp := func() *omni.App {
backend, _ := NewIdempotencyStore(client, prefix, IdempotencyConfig{MaxEntries: 256})
idem, err := middleware.NewIdempotency(middleware.IdempotencyConfig{Store: backend, Scope: func(*httpx.Context) (string, error) { return "verifiedTenant", nil }})
if err != nil {
t.Fatal(err)
}
a := omni.New(omni.WithLogger(false))
a.POST("/submit", func(c *httpx.Context) error { executions.Add(1); return c.OK("saved") }, idem.Middleware())
return a
}
a, b := makeApp(), makeApp()
request := func(a *omni.App, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/submit", strings.NewReader(body))
r.Header.Set("Idempotency-Key", "stableKey")
w := httptest.NewRecorder()
a.ServeHTTP(w, r)
return w
}
first, second := request(a, `{}`), request(b, `{}`)
if first.Code != 200 || second.Code != 200 || first.Body.String() != second.Body.String() || second.Header().Get("Idempotency-Replayed") != "true" || executions.Load() != 1 {
t.Fatal(first.Code, second.Code, second.Body.String(), executions.Load())
}
if request(b, `{"changed":true}`).Code != 409 {
t.Fatal("conflict accepted")
}
// Shared limits do not multiply when a second replica is added.
limitPrefix := prefix + "limit"
cfg := middleware.RateLimitConfig{Rate: .01, Burst: 2, Key: func(*httpx.Context) string { return "tenant" }}
r1, _ := NewRateLimiter(client, limitPrefix, cfg)
r2, _ := NewRateLimiter(client, limitPrefix, cfg)
base, _ := namespace(limitPrefix, "rate")
defer client.Del(context.Background(), base+":buckets", base+":seen", base+":config")
limited := func(l *middleware.RateLimiter) int {
a := omni.New(omni.WithLogger(false))
a.GET("/", func(c *httpx.Context) error { return c.OK("ok") }, l.Middleware())
w := httptest.NewRecorder()
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
return w.Code
}
if limited(r1) != 200 || limited(r2) != 200 || limited(r1) != 429 {
t.Fatal("limits multiplied across instances")
}
// Redis failure must fail closed before executing a new request.
client.Close()
if status := limited(r2); status != 503 {
t.Fatal(status)
}
}
func TestExpiredTargetBeyondCleanupBatch(t *testing.T) {
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
if address == "" {
t.Skip("requires isolated Redis")
}
client := goredis.NewClient(&goredis.Options{Addr: address})
defer client.Close()
s, _ := NewIdempotencyStore(client, fmt.Sprint("expiry:", time.Now().UnixNano()), IdempotencyConfig{MaxEntries: 256})
defer client.Del(context.Background(), s.keys...)
ctx := context.Background()
owner := strings.Repeat("1", 32)
fp := strings.Repeat("2", 64)
for i := 0; i < 140; i++ {
key := fmt.Sprintf("%064x", i)
if _, ok, err := s.Claim(ctx, key, fp, owner); err != nil || !ok {
t.Fatal(err)
}
if err := s.Complete(ctx, key, owner, nil, time.Minute); err != nil {
t.Fatal(err)
}
client.ZAdd(ctx, s.keys[1], goredis.Z{Score: 0, Member: key})
}
if _, ok, err := s.Claim(ctx, fmt.Sprintf("%064x", 139), fp, owner); err != nil || !ok {
t.Fatal("expired target retained beyond TTL", ok, err)
}
}

View File

@@ -0,0 +1,102 @@
package middleware
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"net/http"
"strings"
"time"
)
type StoredResponse struct {
Status int `json:"status"`
Headers http.Header `json:"headers"`
Body []byte `json:"body"`
}
type IdempotencyRecord struct {
Fingerprint string `json:"fingerprint"`
Owner string `json:"owner"`
State string `json:"state"`
Response *StoredResponse `json:"response,omitempty"`
}
// IdempotencyStore must atomically claim absent keys, compare owners when
// finishing and retain in-flight claims until explicit resolution. Only completed
// or failed records expire; an uncertain outcome must never become a new claim.
type IdempotencyStore interface {
Claim(context.Context, string, string, string) (IdempotencyRecord, bool, error)
Complete(context.Context, string, string, *StoredResponse, time.Duration) error
}
func (i *Idempotency) shared(c *httpx.Context, next httpx.Handler, id, fingerprint [32]byte) error {
key, fp := hex.EncodeToString(id[:]), hex.EncodeToString(fingerprint[:])
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
return err
}
owner := hex.EncodeToString(token)
record, claimed, err := i.config.Store.Claim(c.Context(), key, fp, owner)
if err != nil {
return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "idempotency store unavailable").WithCause(err)
}
if !claimed {
if record.Fingerprint != fp {
return apierrors.New(409, "IDEMPOTENCY_CONFLICT", "key was used with a different request")
}
if record.State != "completed" || record.Response == nil {
return apierrors.New(409, "IDEMPOTENCY_UNAVAILABLE", "request is in progress or outcome requires reconciliation")
}
response := record.Response
headers := response.Headers.Clone()
headers.Set("Idempotency-Replayed", "true")
return c.WriteResponse(response.Status, headers, response.Body)
}
completed := false
finish := func(response *StoredResponse) error {
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Context()), time.Second)
defer cancel()
return i.config.Store.Complete(ctx, key, owner, response, i.config.TTL)
}
defer func() {
if !completed {
if err := finish(nil); err != nil {
logx.ErrorContext(c.Context(), "idempotency.persistFailed", "error", err)
}
}
}()
buffer := &boundedResponse{headers: c.Raw().Header().Clone(), limit: i.config.MaxResponseBytes}
child := c.Clone(c.Context(), buffer)
err = next(child)
if ctxErr := child.Context().Err(); ctxErr != nil {
return ctxErr
}
if err != nil {
child.WriteError(err)
}
if buffer.err != nil {
return apierrors.Internal(buffer.err)
}
if buffer.status == 0 {
buffer.status = 200
}
var response *StoredResponse
if buffer.status < 500 && buffer.headers.Get("Set-Cookie") == "" && !strings.Contains(strings.ToLower(buffer.headers.Get("Cache-Control")), "no-store") {
response = &StoredResponse{Status: buffer.status, Headers: buffer.headers.Clone(), Body: append([]byte(nil), buffer.body.Bytes()...)}
}
if err = finish(response); err != nil {
return apierrors.New(503, "IDEMPOTENCY_UNAVAILABLE", "outcome requires reconciliation").WithCause(err)
}
completed = true
return c.WriteResponse(buffer.status, buffer.headers, buffer.body.Bytes())
}
var ErrLimitCapacity = errors.New("middleware: shared rate limiter capacity")
type RateLimitBackend interface {
Take(context.Context, string) (time.Duration, error)
}