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.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user