137 lines
4.6 KiB
Go
137 lines
4.6 KiB
Go
// Package observe contains observer-pattern primitives shared across the SCM
|
|
// and Tracker observation lanes. The pieces here are deliberately
|
|
// provider-agnostic: a polling goroutine supervisor, a lazy credential gate,
|
|
// and a bounded FIFO cache helper. Provider-specific normalization,
|
|
// persistence, and lifecycle reactions live in the sibling packages
|
|
// (observe/scm, future observe/tracker).
|
|
package observe
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
)
|
|
|
|
// StartPollLoop launches a goroutine that calls poll immediately, then on every
|
|
// tick interval until ctx is done. The returned channel closes when the
|
|
// goroutine exits; callers wait on it during shutdown.
|
|
//
|
|
// The immediate first poll inside the goroutine (rather than before the ticker
|
|
// loop) keeps daemon startup non-blocking: callers see Start return after the
|
|
// goroutine is launched, not after the first network call.
|
|
//
|
|
// poll errors other than context.Canceled are logged via logger with name as a
|
|
// prefix, e.g. name="scm observer" -> "scm observer: initial poll failed".
|
|
func StartPollLoop(ctx context.Context, tick time.Duration, poll func(context.Context) error, logger *slog.Logger, name string) <-chan struct{} {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
if err := poll(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
|
logger.Error(name+": initial poll failed", "err", err)
|
|
}
|
|
t := time.NewTicker(tick)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := poll(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
|
logger.Error(name+": poll failed", "err", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return done
|
|
}
|
|
|
|
// CredentialProbe checks whether the observer's provider has usable credentials.
|
|
// Implementations return (false, nil) for a transient failure the observer
|
|
// should retry on the next tick, (false, non-nil) for an error the caller
|
|
// should surface, and (true, nil) when credentials are available.
|
|
type CredentialProbe func(ctx context.Context) (available bool, err error)
|
|
|
|
// CheckCredentialsOnce runs probe at most once. The caller owns checked/disabled
|
|
// state via pointer so the observer struct keeps a single source of truth.
|
|
//
|
|
// State transitions:
|
|
// - probe == nil → *checked = true, returns (true, nil). No gate.
|
|
// - probe returns err → state unchanged, returns (false, nil). Retried next tick.
|
|
// - probe returns false → *checked = true, *disabled = true, returns (false, nil). Observer stays disabled.
|
|
// - probe returns true → *checked = true, returns (true, nil). Subsequent calls bypass the probe.
|
|
//
|
|
// Subsequent calls after the probe has run return (!*disabled, nil) so the
|
|
// disabled verdict is honoured on every poll, not just the first one.
|
|
//
|
|
// A context-cancellation before the probe returns (false, ctx.Err()).
|
|
func CheckCredentialsOnce(ctx context.Context, probe CredentialProbe, checked, disabled *bool, logger *slog.Logger, name string) (bool, error) {
|
|
if *checked {
|
|
return !*disabled, nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
if probe == nil {
|
|
*checked = true
|
|
return true, nil
|
|
}
|
|
available, err := probe(ctx)
|
|
if err != nil {
|
|
logger.Warn(name+" credentials check failed; will retry", "err", err)
|
|
return false, nil
|
|
}
|
|
*checked = true
|
|
if !available {
|
|
*disabled = true
|
|
logger.Warn(name + " disabled: provider credentials unavailable")
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// CacheSet writes value to m[key] and tracks insertion order in *order for
|
|
// bounded FIFO eviction. If the bucket already had key, order is left
|
|
// unchanged; otherwise key is appended. When len(*order) exceeds maxEntries,
|
|
// the oldest keys are evicted from both order and m.
|
|
//
|
|
// maxEntries <= 0 disables eviction; callers that want bounded behavior must
|
|
// pass a positive value. The generic shape lets the same helper serve
|
|
// string-, time-, and bool-valued caches without per-type duplication.
|
|
func CacheSet[V any](m map[string]V, order *[]string, maxEntries int, key string, value V) {
|
|
if _, ok := m[key]; !ok {
|
|
*order = append(*order, key)
|
|
}
|
|
m[key] = value
|
|
if maxEntries <= 0 {
|
|
return
|
|
}
|
|
for len(*order) > maxEntries {
|
|
evict := (*order)[0]
|
|
*order = (*order)[1:]
|
|
delete(m, evict)
|
|
}
|
|
}
|
|
|
|
// CacheDelete removes key from m and the matching slot from *order. It is a
|
|
// no-op when key is absent.
|
|
func CacheDelete[V any](m map[string]V, order *[]string, key string) {
|
|
if _, ok := m[key]; !ok {
|
|
return
|
|
}
|
|
delete(m, key)
|
|
dst := (*order)[:0]
|
|
for _, cachedKey := range *order {
|
|
if cachedKey != key {
|
|
dst = append(dst, cachedKey)
|
|
}
|
|
}
|
|
*order = dst
|
|
}
|