Files
ttstd-infinite-canvas/backend/internal/service/coordination.go

345 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
type localRateEntry struct {
started time.Time
count int
}
const (
minChannelConcurrencyLimit = 1
maxChannelConcurrencyLimit = maxRuntimeConcurrency
defaultChannelConcurrencyValue = 3
)
type channelSlotError struct {
scope string
limit int
err error
}
func (e channelSlotError) Error() string {
if errors.Is(e.err, context.DeadlineExceeded) {
return fmt.Sprintf("等待渠道并发槽位超时(渠道 %s,并发上限 %d)", e.scope, e.limit)
}
if errors.Is(e.err, context.Canceled) {
return fmt.Sprintf("等待渠道并发槽位已取消(渠道 %s,并发上限 %d)", e.scope, e.limit)
}
return fmt.Sprintf("获取渠道并发配额失败(渠道 %s,并发上限 %d):%v", e.scope, e.limit, e.err)
}
func (e channelSlotError) Unwrap() error { return e.err }
func ChannelSlotFailureDetails(err error) (string, string) {
var slotErr channelSlotError
if !errors.As(err, &slotErr) {
return "", ""
}
if errors.Is(slotErr, context.DeadlineExceeded) {
return "channel_concurrency_wait_timeout", slotErr.Error()
}
if errors.Is(slotErr, context.Canceled) {
return "channel_concurrency_wait_cancelled", slotErr.Error()
}
return "channel_concurrency_unavailable", slotErr.Error()
}
type runtimeCoordinator struct {
redis *redis.Client
instanceID string
localMu sync.Mutex
localRate map[string]localRateEntry
localSlots map[string]map[string]time.Time
}
var fixedWindowScript = redis.NewScript(`
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return count
`)
var acquireSlotScript = redis.NewScript(`
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then return 0 end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[5])
return 1
`)
func newRuntimeCoordinator(dialect string) (*runtimeCoordinator, error) {
coordinator := &runtimeCoordinator{instanceID: newID(), localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}
redisURL := strings.TrimSpace(os.Getenv("REDIS_URL"))
if redisURL == "" {
if dialect == "postgres" {
return coordinator, errors.New("PostgreSQL 多实例模式必须配置 REDIS_URL,用于限流、并发和熔断协调")
}
return coordinator, nil
}
options, err := redis.ParseURL(redisURL)
if err != nil {
return coordinator, fmt.Errorf("REDIS_URL 无效:%w", err)
}
coordinator.redis = redis.NewClient(options)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := coordinator.redis.Ping(ctx).Err(); err != nil {
return coordinator, fmt.Errorf("Redis 不可用:%w", err)
}
return coordinator, nil
}
func (c *runtimeCoordinator) allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
if c.redis != nil {
count, err := fixedWindowScript.Run(ctx, c.redis, []string{"canvas:rate:" + key}, window.Milliseconds()).Int64()
return count <= int64(limit), err
}
c.localMu.Lock()
defer c.localMu.Unlock()
now := time.Now()
entry := c.localRate[key]
if entry.started.IsZero() || now.Sub(entry.started) >= window {
c.localRate[key] = localRateEntry{started: now, count: 1}
return true, nil
}
if entry.count >= limit {
return false, nil
}
entry.count++
c.localRate[key] = entry
return true, nil
}
func (c *runtimeCoordinator) acquire(ctx context.Context, scope string, limit int, ttl time.Duration) (func(), bool, error) {
if c.redis == nil {
c.localMu.Lock()
now := time.Now()
slots := c.localSlots[scope]
if slots == nil {
slots = map[string]time.Time{}
c.localSlots[scope] = slots
}
for token, expiresAt := range slots {
if !expiresAt.After(now) {
delete(slots, token)
}
}
if len(slots) >= limit {
c.localMu.Unlock()
return nil, false, nil
}
token := c.instanceID + ":" + newID()
slots[token] = now.Add(ttl)
c.localMu.Unlock()
return func() {
c.localMu.Lock()
delete(c.localSlots[scope], token)
c.localMu.Unlock()
}, true, nil
}
// 有过期分数的有序集合避免实例崩溃后永久占槽,业务数据库仍保存任务与账本真相。
key := "canvas:slots:" + scope
token := c.instanceID + ":" + newID()
now := time.Now()
ok, err := acquireSlotScript.Run(ctx, c.redis, []string{key}, now.UnixMilli(), now.Add(ttl).UnixMilli(), limit, token, (ttl + time.Minute).Milliseconds()).Int()
if err != nil || ok != 1 {
return nil, false, err
}
return func() { _ = c.redis.ZRem(context.Background(), key, token).Err() }, true, nil
}
func (c *runtimeCoordinator) acquireWithWait(ctx context.Context, scope string, limit int, ttl time.Duration) (func(), error) {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
if err := ctx.Err(); err != nil {
return nil, err
}
release, acquired, err := c.acquire(ctx, scope, limit, ttl)
if err != nil {
return nil, err
}
if acquired {
return release, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}
func (c *runtimeCoordinator) circuitOpen(ctx context.Context, channelID string) (bool, error) {
if c.redis == nil || strings.TrimSpace(channelID) == "" {
return false, nil
}
count, err := c.redis.Exists(ctx, "canvas:circuit:open:"+channelID).Result()
return count > 0, err
}
func (c *runtimeCoordinator) recordChannelResult(ctx context.Context, channelID string, failed bool, failureLimit int, openDuration time.Duration) {
if c.redis == nil || strings.TrimSpace(channelID) == "" {
return
}
failureKey := "canvas:circuit:failures:" + channelID
openKey := "canvas:circuit:open:" + channelID
if !failed {
_ = c.redis.Del(ctx, failureKey, openKey).Err()
return
}
count, err := c.redis.Incr(ctx, failureKey).Result()
if err != nil {
return
}
_ = c.redis.Expire(ctx, failureKey, time.Minute).Err()
if count >= int64(failureLimit) {
_ = c.redis.Set(ctx, openKey, "1", openDuration).Err()
}
}
const routeCatalogVersionKey = "canvas:logical-model-route-catalog:version"
func (c *runtimeCoordinator) routeCatalogVersion(ctx context.Context) (int64, error) {
if c == nil || c.redis == nil {
return 0, nil
}
value, err := c.redis.Get(ctx, routeCatalogVersionKey).Int64()
if err == redis.Nil {
return 0, nil
}
return value, err
}
func (c *runtimeCoordinator) bumpRouteCatalogVersion(ctx context.Context) error {
if c == nil || c.redis == nil {
return nil
}
return c.redis.Incr(ctx, routeCatalogVersionKey).Err()
}
func routeHealthKey(key string) string { return "canvas:logical-route-health:" + key }
func (c *runtimeCoordinator) routeBlockedUntil(ctx context.Context, key string) (time.Time, error) {
if c == nil || c.redis == nil {
return time.Time{}, nil
}
value, err := c.redis.Get(ctx, routeHealthKey(key)).Int64()
if err == redis.Nil {
return time.Time{}, nil
}
if err != nil {
return time.Time{}, err
}
return time.UnixMilli(value), nil
}
func (c *runtimeCoordinator) blockRoute(ctx context.Context, key string, until time.Time) error {
if c == nil || c.redis == nil {
return nil
}
ttl := time.Until(until)
if ttl <= 0 {
return c.redis.Del(ctx, routeHealthKey(key)).Err()
}
return c.redis.Set(ctx, routeHealthKey(key), until.UnixMilli(), ttl).Err()
}
func envInt(key string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv(key)))
if err != nil || value <= 0 {
return fallback
}
return value
}
func defaultChannelConcurrencyLimit() int {
return effectiveChannelConcurrencyLimit(envInt("CANVAS_CHANNEL_CONCURRENCY", defaultChannelConcurrencyValue))
}
func effectiveChannelConcurrencyLimit(configured int) int {
if configured < minChannelConcurrencyLimit || configured > maxChannelConcurrencyLimit {
return defaultChannelConcurrencyValue
}
return configured
}
func (s *Service) AcquireChannelSlot(ctx context.Context, channelID string, fallbackScope string, ttl time.Duration) (func(), int, error) {
setting, err := s.runtimeConcurrencySetting()
limit := defaultChannelConcurrencyLimit()
if err != nil {
return nil, limit, channelSlotError{scope: firstNonEmpty(strings.TrimSpace(channelID), strings.TrimSpace(fallbackScope), "unknown"), limit: limit, err: fmt.Errorf("读取全局并发配置失败:%w", err)}
}
limit = setting.ChannelConcurrency
scope := strings.TrimSpace(channelID)
if scope != "" {
channel, err := s.repo.SystemChannel(scope)
if err != nil {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: fmt.Errorf("读取渠道并发配置失败:%w", err)}
}
if channel.ConcurrencyLimit > 0 {
if channel.ConcurrencyLimit < minChannelConcurrencyLimit || channel.ConcurrencyLimit > maxChannelConcurrencyLimit {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: errors.New("渠道并发配置超出 1-999 范围")}
}
limit = channel.ConcurrencyLimit
}
} else {
scope = strings.TrimSpace(fallbackScope)
}
if scope == "" {
return nil, limit, channelSlotError{scope: "unknown", limit: limit, err: errors.New("渠道并发范围为空")}
}
if s.coordinator == nil {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: errors.New("运行时协调器未初始化")}
}
release, err := s.coordinator.acquireWithWait(ctx, "channel:"+scope, limit, ttl)
if err != nil {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: err}
}
return release, limit, nil
}
func (s *Service) ValidateRuntime() error {
if s.pluginRuntimeErr != nil {
return s.pluginRuntimeErr
}
return s.runtimeErr
}
func (s *Service) AllowRequest(ctx context.Context, key string, limit int, window time.Duration) (bool, error) {
if s.coordinator == nil {
return false, errors.New("运行时协调器未初始化")
}
return s.coordinator.allow(ctx, key, limit, window)
}
func (s *Service) AcquireCustomRelaySlot(ctx context.Context, userID string, limit int, ttl time.Duration) (func(), bool, error) {
if s.coordinator == nil {
return nil, false, errors.New("运行时协调器未初始化")
}
return s.coordinator.acquire(ctx, "custom-relay:"+userID, limit, ttl)
}
func (s *Service) RecordChannelResult(ctx context.Context, channelID string, failed bool) error {
policy, err := s.RuntimePolicy()
if err != nil {
return err
}
if s.coordinator == nil {
return errors.New("运行时协调器未初始化")
}
s.coordinator.recordChannelResult(ctx, channelID, failed, policy.Request.ChannelCircuitFailureCount, time.Duration(policy.Request.ChannelCircuitOpenSeconds)*time.Second)
return nil
}