164 lines
5.3 KiB
Go
164 lines
5.3 KiB
Go
// Package outboxpartition provides the shared tenant scheduling rules used by
|
|
// owner-service outbox workers. It does not read or publish business facts;
|
|
// repositories retain ownership of indexed claims and delivery state.
|
|
package outboxpartition
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"hyapp/pkg/appcode"
|
|
)
|
|
|
|
const DefaultDiscoveryRefreshInterval = 5 * time.Minute
|
|
|
|
// Partitions resolves the App partitions visible to one outbox lane and rotates
|
|
// the first partition across claims. A configured allowlist is immutable for the
|
|
// process lifetime; an empty allowlist falls back to low-frequency DB discovery.
|
|
type Partitions struct {
|
|
configured []string
|
|
refreshInterval time.Duration
|
|
|
|
mu sync.Mutex
|
|
cached []string
|
|
hasCache bool
|
|
nextRefresh time.Time
|
|
cursor atomic.Uint64
|
|
}
|
|
|
|
// New creates a partition scheduler. Operators can stage a backlog safely by
|
|
// configuring only the Apps currently allowed to publish; an empty list keeps
|
|
// automatic discovery as the compatibility default for existing deployments.
|
|
func New(configured []string, refreshInterval time.Duration) *Partitions {
|
|
if refreshInterval <= 0 {
|
|
refreshInterval = DefaultDiscoveryRefreshInterval
|
|
}
|
|
return &Partitions{
|
|
configured: Normalize(configured, false),
|
|
refreshInterval: refreshInterval,
|
|
}
|
|
}
|
|
|
|
// Resolve returns configured Apps without touching MySQL. In discovery mode it
|
|
// refreshes at most once per interval across all local worker goroutines. A
|
|
// failed refresh returns the last successful list alongside the error so the
|
|
// caller can log the stale condition without stopping already-known partitions.
|
|
func (p *Partitions) Resolve(ctx context.Context, discover func(context.Context) ([]string, error)) ([]string, error) {
|
|
if p == nil {
|
|
return nil, nil
|
|
}
|
|
if len(p.configured) > 0 {
|
|
return append([]string(nil), p.configured...), nil
|
|
}
|
|
if discover == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
now := time.Now().UTC()
|
|
if p.hasCache && now.Before(p.nextRefresh) {
|
|
return append([]string(nil), p.cached...), nil
|
|
}
|
|
|
|
discovered, err := discover(ctx)
|
|
if err != nil {
|
|
// Move the next attempt out by the normal interval: a transient database
|
|
// error must not turn every hot poll into a DISTINCT query stampede.
|
|
p.nextRefresh = now.Add(p.refreshInterval)
|
|
if p.hasCache {
|
|
return append([]string(nil), p.cached...), err
|
|
}
|
|
return nil, err
|
|
}
|
|
p.cached = Normalize(discovered, true)
|
|
p.hasCache = true
|
|
p.nextRefresh = now.Add(p.refreshInterval)
|
|
return append([]string(nil), p.cached...), nil
|
|
}
|
|
|
|
// Order rotates the first App atomically while preserving the remaining cyclic
|
|
// order. Multiple worker goroutines therefore share one fair cursor instead of
|
|
// independently preferring the first configured tenant.
|
|
func (p *Partitions) Order(appCodes []string) []string {
|
|
if len(appCodes) == 0 {
|
|
return nil
|
|
}
|
|
if p == nil {
|
|
return append([]string(nil), appCodes...)
|
|
}
|
|
start := int((p.cursor.Add(1) - 1) % uint64(len(appCodes)))
|
|
ordered := make([]string, 0, len(appCodes))
|
|
for offset := 0; offset < len(appCodes); offset++ {
|
|
ordered = append(ordered, appCodes[(start+offset)%len(appCodes)])
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
// Normalize canonicalizes tenant keys, removes blanks and duplicates, and can
|
|
// sort DB-discovered keys for deterministic rotation. Explicit configuration
|
|
// preserves operator order so staged rollouts remain readable and predictable.
|
|
func Normalize(values []string, sorted bool) []string {
|
|
normalized := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
value = appcode.Normalize(value)
|
|
if _, exists := seen[value]; exists {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
normalized = append(normalized, value)
|
|
}
|
|
if sorted {
|
|
sort.Strings(normalized)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// ClaimFair gives every App one bounded first-pass quota, then lets non-empty
|
|
// partitions use capacity left by empty queues. Each callback remains App-scoped
|
|
// and is expected to perform its own indexed SKIP LOCKED transaction.
|
|
func ClaimFair[T any](ctx context.Context, orderedApps []string, limit int, claim func(context.Context, string, int) ([]T, error)) ([]T, error) {
|
|
if limit <= 0 || len(orderedApps) == 0 || claim == nil {
|
|
return nil, nil
|
|
}
|
|
quota := (limit + len(orderedApps) - 1) / len(orderedApps)
|
|
claimed := make([]T, 0, limit)
|
|
for pass := 0; pass < 2 && len(claimed) < limit; pass++ {
|
|
for _, app := range orderedApps {
|
|
remaining := limit - len(claimed)
|
|
if remaining <= 0 {
|
|
break
|
|
}
|
|
appLimit := remaining
|
|
if pass == 0 && appLimit > quota {
|
|
appLimit = quota
|
|
}
|
|
records, err := claim(ctx, app, appLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(records) > appLimit {
|
|
// Silently truncating would strand already-claimed rows until lease
|
|
// expiry. Treat repository limit violations as explicit worker errors.
|
|
return nil, fmt.Errorf("outbox claim for app %q returned %d records above limit %d", app, len(records), appLimit)
|
|
}
|
|
claimed = append(claimed, records...)
|
|
}
|
|
if pass == 0 && len(claimed) == 0 {
|
|
// A globally empty first pass must stop here; a second identical pass
|
|
// only doubles indexed polling queries and cannot discover new work.
|
|
break
|
|
}
|
|
}
|
|
return claimed, nil
|
|
}
|