// trigger.go — the generic, debounced out-of-cycle report trigger (v0.139.0, Direction 1). // // User actions with hub-side effects (escrow claim, settings save, offsite toggle, app // deploy/remove, customer claim) fire this trigger so the hub sees the new state in seconds // instead of after the next ~15-min hub-report cycle. The scheduled cycle stays the // reconciliation backbone — the trigger is best-effort on top, and every failure degrades // to the cycle (no retry loop of its own; the Pusher owns retries). // // Semantics: COALESCE AND EVENTUALLY FIRE (trailing edge). A burst of fires collapses into // at most 1 + ceil(burst/minInterval) pushes, and the LAST push always happens after the // last fire — never a refused/lost update (the internal/sync refuse-debounce is the wrong // shape here on purpose). Shape copied from felhom.eu/hub/internal/wgsync/reconciler.go // (buffered-1 trigger channel + worker loop). package report import ( "context" "log" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" ) const ( // triggerQuietWindow is how long the worker waits after the first fire before pushing, // so a burst of related saves (multi-field form, wizard steps) coalesces into one report. triggerQuietWindow = 2 * time.Second // triggerMinInterval is the minimum spacing between out-of-cycle pushes — the hard // ceiling against report storms (pushes <= 1 + ceil(burst/minInterval) per burst). triggerMinInterval = 15 * time.Second ) // Trigger coalesces "push a report now" requests into paced calls of an opaque fire func. // Fire() is safe from any goroutine and never blocks (HTTP handlers call it); the single // worker (Run) does the waiting and the pushing. Report building stays with the caller — // the fire closure in main.go does BuildReport + Pusher.Push. type Trigger struct { fire func() error quiet time.Duration minInterval time.Duration signal chan struct{} logger *log.Logger } // NewTrigger builds a Trigger with the production pacing. fire is the full report // build+push closure; it must be safe to call repeatedly from one goroutine. func NewTrigger(fire func() error, logger *log.Logger) *Trigger { return newTriggerWithPacing(fire, triggerQuietWindow, triggerMinInterval, logger) } // newTriggerWithPacing is the test constructor — tests shrink the windows to keep runs fast. func newTriggerWithPacing(fire func() error, quiet, minInterval time.Duration, logger *log.Logger) *Trigger { return &Trigger{ fire: fire, quiet: quiet, minInterval: minInterval, signal: make(chan struct{}, 1), logger: logger, } } // Fire requests an out-of-cycle report push. Non-blocking: a pending signal already covers // this request (the eventual push carries the FULL current state either way). func (t *Trigger) Fire() { select { case t.signal <- struct{}{}: default: } } // Run is the worker loop; main.go starts it under the process context. Per signal: // wait the quiet window (coalescing the burst), drain, enforce minInterval spacing since // the last push, drain again, then fire ONCE. A fire error is logged and the loop // continues — the scheduled cycle reconciles. Exits on context cancel (a pending fire may // be dropped then; the cycle covers it). func (t *Trigger) Run(ctx context.Context) { var lastPush time.Time for { select { case <-ctx.Done(): return case <-t.signal: } // Quiet window: let the burst finish, then collapse it into one push. if !t.sleep(ctx, t.quiet) { return } t.drain() // Pacing: never push more often than minInterval. time.Since(zero) is huge, // so the first push after startup is never delayed. if remaining := t.minInterval - time.Since(lastPush); remaining > 0 { if !t.sleep(ctx, remaining) { return } t.drain() } if err := t.fire(); err != nil { logx.Warnf(t.logger, "[report] out-of-cycle push failed: %v — next cycle reconciles", err) } lastPush = time.Now() } } // drain clears a pending signal that arrived during a wait — those requests are covered // by the push about to happen (full state, not deltas). func (t *Trigger) drain() { select { case <-t.signal: default: } } // sleep waits d or until ctx is cancelled; false = cancelled (caller returns promptly, // never blocking shutdown on a pending wait). func (t *Trigger) sleep(ctx context.Context, d time.Duration) bool { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return false case <-timer.C: return true } }