// 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 }