Files
kandev/apps/backend/internal/orchestrator/watcher_throttle.go
T

188 lines
7.3 KiB
Go

package orchestrator
import (
"context"
"expvar"
"strings"
"go.uber.org/zap"
)
// WatcherTaskCounter exposes the count of open watcher-created tasks for a
// single watch, identified by the task-metadata key the integration writes
// (e.g. "sentry_issue_watch_id") and the watch id. Implemented by the task
// repository. Keyed by metadata key rather than an integration name so the
// repository stays ignorant of which integrations exist — each integration
// owns its key (see WatcherSource.WatchMetadataKey). Kept as a narrow
// interface so the orchestrator's throttle gate can be tested without SQLite.
type WatcherTaskCounter interface {
CountOpenWatcherCreatedTasks(ctx context.Context, metadataKey, watchID string) (int, error)
}
// SetWatcherTaskCounter wires the open-task counter used by the watcher
// throttle gate. Safe to call once during boot. When unset, the gate falls
// open (no throttling) — the orchestrator must remain usable in tests and
// during early init before the task repository is plumbed through.
func (s *Service) SetWatcherTaskCounter(c WatcherTaskCounter) {
s.watcherTaskCount = c
}
// Watcher throttle metrics, exposed via stdlib's /debug/vars handler. Counter
// labels match the office routing metrics format ("k=v;k=v") so a downstream
// Prometheus translator can read both with the same parser. Saturation is
// recorded as a counter of state-transition events (cap_reached /
// cap_cleared); a live "currently saturated" gauge would drift across
// restarts so we stick with deltas.
var (
watcherDispatchAttemptedTotal = expvar.NewMap("watcher_dispatch_attempted_total")
watcherDispatchDeferredTotal = expvar.NewMap("watcher_dispatch_deferred_total")
watcherCapTransitionsTotal = expvar.NewMap("watcher_cap_transitions_total")
)
// watcherMetricLabel builds a "k1=v1;k2=v2" label string for an expvar map
// key. Kept package-local so it doesn't clash with the office package's
// metricLabel helper.
func watcherMetricLabel(pairs ...string) string {
if len(pairs)%2 != 0 {
return ""
}
parts := make([]string, 0, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
parts = append(parts, pairs[i]+"="+pairs[i+1])
}
return strings.Join(parts, ";")
}
// watcherSlotKey is the (integration, watchID) compound key used to scope
// the pending counter and the saturation state map. Linear and Jira watches
// can share id strings; the integration prefix keeps them separate.
func watcherSlotKey(integration, watchID string) string {
return integration + "|" + watchID
}
// acquireWatcherSlot is the synchronous slot acquisition that gates the
// `go coordinator.Dispatch(...)` spawn. It is the front-pressure valve for
// the Linear, Jira, and Sentry watchers: with a per-watch cap, an unexpectedly
// broad filter cannot fan out into dozens of concurrent tasks on a single poll
// tick.
//
// Returns (release, ok). When ok is true the caller MUST invoke release()
// exactly once (typically via `defer release()` in the dispatch goroutine)
// to drop the in-process pending count. When ok is false the caller MUST
// drop the event — no goroutine, no dedup row — so the next poll tick can
// retry.
//
// metadataKey is the task-metadata key the integration records the watch id
// under (from WatcherSource.WatchMetadataKey); the counter uses it to tally
// open tasks. integration stays for metrics labels and the pending-slot key.
//
// Bypass paths (all return release=no-op, ok=true):
//
// - cap is nil (uncapped watch)
// - cap is <= 0 (defensive: API rejects these, but a stale row should not
// freeze the gate)
// - watchID is empty (event has no watch — treat as unthrottled)
// - metadataKey is empty (integration exposes no key — treat as unthrottled)
// - watcherTaskCount is unset (early boot / tests)
// - the count query errors (fail-open: a transient DB blip must not
// silently stall the watcher; one-event overshoot is acceptable)
//
// Throttling path: when count(db) + pending(in-process) >= cap, the gate
// defers. Pending is incremented atomically with the read under watcherMu
// so a burst of events arriving in the same poll tick cannot collectively
// read the same stale DB count and all pass.
func (s *Service) acquireWatcherSlot(ctx context.Context, integration, metadataKey, watchID string, maxInflight *int) (func(), bool) {
watcherDispatchAttemptedTotal.Add(watcherMetricLabel("integration", integration), 1)
noop := func() {}
if maxInflight == nil || *maxInflight <= 0 || watchID == "" || metadataKey == "" {
return noop, true
}
if s.watcherTaskCount == nil {
return noop, true
}
count, err := s.watcherTaskCount.CountOpenWatcherCreatedTasks(ctx, metadataKey, watchID)
if err != nil {
s.logger.Warn("watcher throttle: count failed, failing open",
zap.String("integration", integration),
zap.String("watch_id", watchID),
zap.Error(err))
return noop, true
}
key := watcherSlotKey(integration, watchID)
s.watcherMu.Lock()
defer s.watcherMu.Unlock()
if s.pendingByWatch == nil {
s.pendingByWatch = make(map[string]int)
}
if s.watcherSaturated == nil {
s.watcherSaturated = make(map[string]bool)
}
pending := s.pendingByWatch[key]
if count+pending >= *maxInflight {
s.recordSaturatedLocked(integration, watchID, key, count, pending, *maxInflight)
return noop, false
}
s.recordClearedLocked(integration, watchID, key)
s.pendingByWatch[key] = pending + 1
return s.releaseFunc(key), true
}
func (s *Service) releaseFunc(key string) func() {
return func() {
s.watcherMu.Lock()
defer s.watcherMu.Unlock()
if s.pendingByWatch == nil {
return
}
if v := s.pendingByWatch[key]; v <= 1 {
delete(s.pendingByWatch, key)
} else {
s.pendingByWatch[key] = v - 1
}
}
}
// recordSaturatedLocked logs a one-shot Warn the first time the gate sees
// (count + pending) >= cap for this watch, and bumps the deferred counter.
// Subsequent deferrals during the same saturation window are Debug-only.
// Must be called with s.watcherMu held.
func (s *Service) recordSaturatedLocked(integration, watchID, key string, count, pending, maxInflight int) {
watcherDispatchDeferredTotal.Add(watcherMetricLabel("integration", integration), 1)
if s.watcherSaturated[key] {
s.logger.Debug("watcher throttle: deferring event (cap held)",
zap.String("integration", integration),
zap.String("watch_id", watchID),
zap.Int("count", count),
zap.Int("pending", pending),
zap.Int("cap", maxInflight))
return
}
s.watcherSaturated[key] = true
watcherCapTransitionsTotal.Add(
watcherMetricLabel("integration", integration, "transition", "reached"), 1)
s.logger.Warn("watcher throttle: cap reached, deferring further events until tasks drain",
zap.String("integration", integration),
zap.String("watch_id", watchID),
zap.Int("count", count),
zap.Int("pending", pending),
zap.Int("cap", maxInflight))
}
// recordClearedLocked emits a single Warn when the gate transitions back
// from saturated → not-saturated for this watch. Cheap when not saturated:
// just a map lookup. Must be called with s.watcherMu held.
func (s *Service) recordClearedLocked(integration, watchID, key string) {
if !s.watcherSaturated[key] {
return
}
delete(s.watcherSaturated, key)
watcherCapTransitionsTotal.Add(
watcherMetricLabel("integration", integration, "transition", "cleared"), 1)
s.logger.Warn("watcher throttle: cap cleared, resuming dispatch",
zap.String("integration", integration),
zap.String("watch_id", watchID))
}