owpengram-server/internal/store/postgres/dispatch_outbox.go
2026-09-01 12:06:31 +03:00

337 lines
11 KiB
Go
Raw Permalink 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 postgres
import (
"context"
"errors"
"fmt"
"sort"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/postgres/sqlcgen"
)
// defaultDispatchLease 是 'dispatching' 行被判定租约过期、可被重新 claim 的默认时长。
// 与 docs/message-module.md 的 outbox 背压参数对应;生产由 config 注入覆盖。
const (
defaultDispatchLease = 30 * time.Second
defaultDispatchPoisonCleanupBatch = 256
maxDispatchPoisonCleanupBatch = 1000
)
var errInvalidDispatchOutboxExclusionPair = errors.New("dispatch outbox exclusion requires both raw auth key and session id")
// enqueueDispatch is the only production write boundary for dispatch_outbox.
// A zero pair means no originating session is excluded; a non-zero pair identifies
// one exact physical raw-auth/session tuple. A half pair is never meaningful because
// session IDs are not globally unique and must fail the surrounding transaction.
func enqueueDispatch(ctx context.Context, q *sqlcgen.Queries, arg sqlcgen.EnqueueDispatchParams) error {
hasAuthKey := arg.ExcludeAuthKeyID != 0
hasSession := arg.ExcludeSessionID != 0
if hasAuthKey != hasSession {
return errInvalidDispatchOutboxExclusionPair
}
return q.EnqueueDispatch(ctx, arg)
}
// DispatchOutboxStore 用 PostgreSQL 实现 transactional outbox。
type DispatchOutboxStore struct {
db sqlcgen.DBTX
q *sqlcgen.Queries
leaseSeconds int32
}
// DispatchOutboxOption 调整 DispatchOutboxStore 的 claim 行为。
type DispatchOutboxOption func(*DispatchOutboxStore)
// WithLeaseTimeout 设置租约超时;<=0 时保持默认。
func WithLeaseTimeout(d time.Duration) DispatchOutboxOption {
return func(s *DispatchOutboxStore) {
if d > 0 {
s.leaseSeconds = int32(d / time.Second)
if s.leaseSeconds < 1 {
s.leaseSeconds = 1
}
}
}
}
// NewDispatchOutboxStore 基于 pgx 连接池(或事务)创建 DispatchOutboxStore。
func NewDispatchOutboxStore(db sqlcgen.DBTX, opts ...DispatchOutboxOption) *DispatchOutboxStore {
s := &DispatchOutboxStore{
db: db,
q: sqlcgen.New(db),
leaseSeconds: int32(defaultDispatchLease / time.Second),
}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
return s
}
func (s *DispatchOutboxStore) ClaimPending(ctx context.Context, limit int) ([]store.DispatchOutboxItem, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
rows, err := s.q.ClaimDispatchOutbox(ctx, sqlcgen.ClaimDispatchOutboxParams{
LeaseSeconds: s.leaseSeconds,
LimitCount: int32(limit),
})
if err != nil {
return nil, fmt.Errorf("claim dispatch outbox: %w", err)
}
return dispatchItemsFromClaimRows(rows), nil
}
// ClaimPendingShards 只领取固定 logical shard 集合中的用户 head 事件。
// shardCount 是稳定哈希空间shardIDs 是当前 worker 独占的子集worker 数变化只改变
// shard→worker 的运行时归属,不改变 user→shard从而避免同一用户被并行领取。
func (s *DispatchOutboxStore) ClaimPendingShards(ctx context.Context, shardCount int, shardIDs []int, limit int) ([]store.DispatchOutboxItem, error) {
if shardCount <= 0 || len(shardIDs) == 0 {
return nil, nil
}
if shardCount != store.DispatchOutboxLogicalShards {
return nil, fmt.Errorf("claim dispatch outbox shards: shard count %d, want stable %d", shardCount, store.DispatchOutboxLogicalShards)
}
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
ids := make([]int16, 0, len(shardIDs))
seen := make(map[int]struct{}, len(shardIDs))
for _, id := range shardIDs {
if id < 0 || id >= shardCount {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, int16(id))
}
if len(ids) == 0 {
return nil, nil
}
rows, err := s.q.ClaimDispatchOutboxShards(ctx, sqlcgen.ClaimDispatchOutboxShardsParams{
LeaseSeconds: s.leaseSeconds,
LimitCount: int32(limit),
ShardIds: ids,
})
if err != nil {
return nil, fmt.Errorf("claim dispatch outbox shards: %w", err)
}
out := make([]store.DispatchOutboxItem, 0, len(rows))
for _, row := range rows {
out = append(out, store.DispatchOutboxItem{
ID: row.ID,
TargetUserID: row.TargetUserID,
Pts: int(row.Pts),
EventType: domain.UpdateEventType(row.EventType),
ExcludeAuthKeyID: authKeyIDFromInt64(row.ExcludeAuthKeyID),
ExcludeSessionID: row.ExcludeSessionID,
Attempts: int(row.Attempts),
})
}
return out, nil
}
func dispatchItemsFromClaimRows(rows []sqlcgen.ClaimDispatchOutboxRow) []store.DispatchOutboxItem {
out := make([]store.DispatchOutboxItem, 0, len(rows))
for _, row := range rows {
out = append(out, store.DispatchOutboxItem{
ID: row.ID,
TargetUserID: row.TargetUserID,
Pts: int(row.Pts),
EventType: domain.UpdateEventType(row.EventType),
ExcludeAuthKeyID: authKeyIDFromInt64(row.ExcludeAuthKeyID),
ExcludeSessionID: row.ExcludeSessionID,
Attempts: int(row.Attempts),
})
}
return out
}
// MarkDeliveredBatch 一次性删除一批已投递的 outbox 行(方案 A投递成功即删取代逐条 MarkDelivered。
func (s *DispatchOutboxStore) MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error {
if len(items) == 0 {
return nil
}
targetUserIDs := make([]int64, len(items))
ids := make([]int64, len(items))
expectedAttempts := make([]int32, len(items))
for i, it := range items {
targetUserIDs[i] = it.TargetUserID
ids[i] = it.ID
expectedAttempts[i] = int32(it.Attempts)
}
rows, err := s.withExclusiveLaneFences(ctx, targetUserIDs, func(db sqlcgen.DBTX) (int64, error) {
return sqlcgen.New(db).MarkDispatchDeliveredBatch(ctx, sqlcgen.MarkDispatchDeliveredBatchParams{
TargetUserIds: targetUserIDs,
Ids: ids,
ExpectedAttempts: expectedAttempts,
})
})
if err != nil {
return fmt.Errorf("mark dispatch delivered batch: %w", err)
}
if rows != int64(len(items)) {
return fmt.Errorf("mark dispatch delivered batch: %w: updated %d of %d", store.ErrDispatchLeaseLost, rows, len(items))
}
return nil
}
func (s *DispatchOutboxStore) MarkDelivered(ctx context.Context, item store.DispatchOutboxItem) error {
rows, err := s.withExclusiveLaneFences(ctx, []int64{item.TargetUserID}, func(db sqlcgen.DBTX) (int64, error) {
return sqlcgen.New(db).MarkDispatchDelivered(ctx, sqlcgen.MarkDispatchDeliveredParams{
TargetUserID: item.TargetUserID,
ID: item.ID,
ExpectedAttempts: int32(item.Attempts),
})
})
if err != nil {
return fmt.Errorf("mark dispatch delivered: %w", err)
}
if rows != 1 {
return fmt.Errorf("mark dispatch delivered: %w", store.ErrDispatchLeaseLost)
}
return nil
}
// withExclusiveLaneFences serializes the empty-lane transition with producers'
// shared append fences. The DELETE runs as a later READ COMMITTED statement, so
// it sees every producer that committed before the exclusive fence was granted.
func (s *DispatchOutboxStore) withExclusiveLaneFences(
ctx context.Context,
userIDs []int64,
work func(sqlcgen.DBTX) (int64, error),
) (int64, error) {
beginner, ok := s.db.(txBeginner)
if !ok {
return 0, fmt.Errorf("dispatch lane transition requires transaction-capable database")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin dispatch lane transition: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := lockDispatchOutboxLanesExclusive(ctx, tx, userIDs); err != nil {
return 0, err
}
rows, err := work(tx)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit dispatch lane transition: %w", err)
}
committed = true
return rows, nil
}
func lockDispatchOutboxLanesExclusive(ctx context.Context, db sqlcgen.DBTX, userIDs []int64) error {
unique := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
unique = append(unique, userID)
}
if len(unique) == 0 {
return nil
}
sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] })
if _, err := db.Exec(ctx, `
SELECT pg_advisory_xact_lock(dispatch_outbox_lane_advisory_key(streams.target_user_id))
FROM unnest($1::bigint[]) AS streams(target_user_id)
ORDER BY streams.target_user_id`, unique); err != nil {
return fmt.Errorf("lock dispatch outbox lane transition: %w", err)
}
return nil
}
func (s *DispatchOutboxStore) MarkFailed(ctx context.Context, item store.DispatchOutboxItem, lastError string) error {
rows, err := s.q.MarkDispatchFailed(ctx, sqlcgen.MarkDispatchFailedParams{
TargetUserID: item.TargetUserID,
ID: item.ID,
LastError: lastError,
ExpectedAttempts: int32(item.Attempts),
})
if err != nil {
return fmt.Errorf("mark dispatch failed: %w", err)
}
if rows != 1 {
return fmt.Errorf("mark dispatch failed: %w", store.ErrDispatchLeaseLost)
}
return nil
}
func (s *DispatchOutboxStore) DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) {
if olderThan <= 0 {
olderThan = time.Minute
}
if limit <= 0 {
limit = defaultDispatchPoisonCleanupBatch
}
if limit > maxDispatchPoisonCleanupBatch {
limit = maxDispatchPoisonCleanupBatch
}
olderThanSeconds := int32(olderThan / time.Second)
rows, err := s.db.Query(ctx, `
SELECT h.target_user_id
FROM dispatch_outbox_user_heads h
WHERE h.status = 'failed'
AND h.updated_at < now() - make_interval(secs => $1::int)
ORDER BY h.updated_at ASC, h.target_user_id ASC, h.head_id ASC
LIMIT $2`, olderThanSeconds, int32(limit))
if err != nil {
return 0, fmt.Errorf("list failed dispatch outbox lanes: %w", err)
}
userIDs := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
rows.Close()
return 0, fmt.Errorf("scan failed dispatch outbox lane: %w", err)
}
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, fmt.Errorf("iterate failed dispatch outbox lanes: %w", err)
}
rows.Close()
if len(userIDs) == 0 {
return 0, nil
}
deleted, err := s.withExclusiveLaneFences(ctx, userIDs, func(db sqlcgen.DBTX) (int64, error) {
count, deleteErr := sqlcgen.New(db).DeleteFailedDispatchOutbox(ctx, sqlcgen.DeleteFailedDispatchOutboxParams{
OlderThanSeconds: olderThanSeconds,
LimitCount: int32(limit),
TargetUserIds: userIDs,
})
return int64(count), deleteErr
})
if err != nil {
return 0, fmt.Errorf("delete failed dispatch outbox: %w", err)
}
return int(deleted), nil
}