fe9266f53f
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.
125 lines
4.5 KiB
Go
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
|
|
}
|
|
}
|