Files
felhom-controller/controller/internal/report/trigger.go
T
admin fe9266f53f feat(report): v0.139.0 — immediate out-of-cycle hub report on user actions (Direction 1)
New report.Trigger (buffered-1 chan + worker; quiet 2s, min spacing 15s,
trailing-edge coalescing) generalizes the v0.70.0 geo out-of-band push.
One canonical fire closure in main.go; wired: geo save/sync + app
deploy/remove/delete (api reportPushNow), escrow recovery-code claim,
notification-prefs save, app-email toggle, offsite config + per-app
toggle, customer claim (web SetReportTrigger seam, nil-safe, fired only
after a successful local commit). 15-min hub-report cycle untouched as
the reconciliation backbone; hub.enabled=false stays a strict no-op.
Tests: trigger engine (2 red-proofs), seam fires-after-commit-only,
nil-seam no-ops.
2026-07-16 19:31:19 +02:00

125 lines
4.5 KiB
Go

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