Files
ai-gateway-go/internal/outbox/publisher.go
T
superidou 5759c1862e AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。
含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:45:54 +08:00

99 lines
3.0 KiB
Go

package outbox
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
var ErrPublisherUnavailable = errors.New("outbox publisher unavailable")
type PublishResult struct {
Published bool
StreamID string
}
type RedisPublisher struct {
client *redis.Client
stream string
maxLength int64
markerTTL time.Duration
script *redis.Script
}
func NewRedisPublisher(client *redis.Client, stream string, maxLength int64, markerTTL time.Duration) *RedisPublisher {
return &RedisPublisher{client: client, stream: stream, maxLength: maxLength, markerTTL: markerTTL, script: redis.NewScript(publishScript)}
}
// Ping reports whether the downstream Redis stream is reachable. The outbox
// worker calls it before claiming events so a Redis outage never consumes the
// events' delivery budget (claiming increments attempts; dead-lettering then
// burns the whole queue for a fault that was never the events').
func (p *RedisPublisher) Ping(ctx context.Context) error {
if p == nil || p.client == nil {
return ErrPublisherUnavailable
}
if err := p.client.Ping(ctx).Err(); err != nil {
return fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
}
return nil
}
func (p *RedisPublisher) Publish(ctx context.Context, event Event) (PublishResult, error) {
if p == nil || p.client == nil {
return PublishResult{}, ErrPublisherUnavailable
}
marker := "gateway:{outbox}:published:" + event.EventID
result, err := p.script.Run(ctx, p.client, []string{marker, p.stream},
int64(p.markerTTL.Seconds()), p.maxLength, event.EventID, event.EventType, event.EventVersion,
valueOrEmpty(event.TenantID), event.AggregateType, event.AggregateID, string(event.Payload), string(event.TraceContext), event.OccurredAt.UTC().Format(time.RFC3339Nano),
).Slice()
if err != nil || len(result) != 2 {
return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
}
published, err := redisInt(result[0])
if err != nil {
return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err)
}
streamID := "duplicate"
if result[1] != nil && fmt.Sprint(result[1]) != "" {
streamID = fmt.Sprint(result[1])
}
return PublishResult{Published: published == 1, StreamID: streamID}, nil
}
func valueOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}
func redisInt(value any) (int64, error) {
switch typed := value.(type) {
case int64:
return typed, nil
case string:
return strconv.ParseInt(typed, 10, 64)
case []byte:
return strconv.ParseInt(string(typed), 10, 64)
default:
return 0, fmt.Errorf("unexpected redis integer %T", value)
}
}
const publishScript = `
if redis.call('SET', KEYS[1], '1', 'NX', 'EX', tonumber(ARGV[1])) then
local id = redis.call('XADD', KEYS[2], 'MAXLEN', '~', tonumber(ARGV[2]), '*',
'event_id', ARGV[3], 'event_type', ARGV[4], 'event_version', ARGV[5],
'tenant_id', ARGV[6], 'aggregate_type', ARGV[7], 'aggregate_id', ARGV[8],
'payload', ARGV[9], 'trace_context', ARGV[10], 'occurred_at', ARGV[11])
return {1, id}
end
return {0, ''}
`