This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SnapshotFunc func(context.Context) ([]Event, error)
|
||||
|
||||
// Event is one discovered change. SourceID and DedupKey together with OccurredAt
|
||||
// form the deduplication identity that persistent stores rely on, so a repeated
|
||||
// discovery pass re-emits the same event without creating a second row.
|
||||
// EntityID and Severity are optional: they carry the inventory entity the change
|
||||
// belongs to and how loud it is, and default to "no entity" and "info" so older
|
||||
// producers keep working unchanged.
|
||||
type Event struct {
|
||||
SourceID, DedupKey, Type, Summary string
|
||||
EntityID, Severity string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
type JobRun struct {
|
||||
Key, Status, ErrorCode string
|
||||
Attempts int
|
||||
StartedAt, CompletedAt time.Time
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Claim(context.Context, string, time.Time) (bool, error)
|
||||
Finish(context.Context, JobRun) error
|
||||
Emit(context.Context, Event) (bool, error)
|
||||
}
|
||||
type AuditFunc func(context.Context, string, string) error
|
||||
type AuthorizeFunc func(context.Context, string) bool
|
||||
|
||||
type Runner struct {
|
||||
Store Store
|
||||
MaxAttempts int
|
||||
BaseRetry time.Duration
|
||||
Sleep func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
func (r Runner) Run(ctx context.Context, jobKey string, discover SnapshotFunc) error {
|
||||
if r.Store == nil || discover == nil || jobKey == "" {
|
||||
return errors.New("discovery runner requires store, key, and discover function")
|
||||
}
|
||||
max := r.MaxAttempts
|
||||
if max == 0 {
|
||||
max = 3
|
||||
}
|
||||
if max < 1 || max > 5 {
|
||||
return errors.New("discovery attempts must be between 1 and 5")
|
||||
}
|
||||
base := r.BaseRetry
|
||||
if base == 0 {
|
||||
base = 100 * time.Millisecond
|
||||
}
|
||||
sleeper := r.Sleep
|
||||
if sleeper == nil {
|
||||
sleeper = func(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
claimed, err := r.Store.Claim(ctx, jobKey, time.Now().UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim discovery job: %w", err)
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
run := JobRun{Key: jobKey, Status: "running", StartedAt: time.Now().UTC()}
|
||||
var last error
|
||||
for attempt := 1; attempt <= max; attempt++ {
|
||||
run.Attempts = attempt
|
||||
events, runErr := discover(ctx)
|
||||
if runErr == nil {
|
||||
for _, event := range events {
|
||||
if _, err := r.Store.Emit(ctx, event); err != nil {
|
||||
runErr = fmt.Errorf("emit discovery event: %w", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if runErr == nil {
|
||||
run.Status = "succeeded"
|
||||
run.CompletedAt = time.Now().UTC()
|
||||
if err := r.Store.Finish(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
last = runErr
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if attempt < max {
|
||||
if err := sleeper(ctx, base*time.Duration(1<<(attempt-1))); err != nil {
|
||||
last = err
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
run.Status = "failed"
|
||||
run.ErrorCode = "DISCOVERY_FAILED"
|
||||
run.CompletedAt = time.Now().UTC()
|
||||
if err := r.Store.Finish(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func (r Runner) RunManual(ctx context.Context, actor string, authorize AuthorizeFunc, audit AuditFunc, jobKey string, discover SnapshotFunc) error {
|
||||
if authorize == nil || !authorize(ctx, actor) {
|
||||
return errors.New("manual discovery is unauthorized")
|
||||
}
|
||||
if audit != nil {
|
||||
if err := audit(ctx, actor, "discovery.manual.run"); err != nil {
|
||||
return fmt.Errorf("audit manual discovery: %w", err)
|
||||
}
|
||||
}
|
||||
return r.Run(ctx, jobKey, discover)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
claimed map[string]bool
|
||||
Runs []JobRun
|
||||
Events map[string]Event
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{claimed: make(map[string]bool), Events: make(map[string]Event)}
|
||||
}
|
||||
func (s *MemoryStore) Claim(_ context.Context, key string, _ time.Time) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.claimed[key] {
|
||||
return false, nil
|
||||
}
|
||||
s.claimed[key] = true
|
||||
return true, nil
|
||||
}
|
||||
func (s *MemoryStore) Finish(_ context.Context, run JobRun) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Runs = append(s.Runs, run)
|
||||
return nil
|
||||
}
|
||||
func (s *MemoryStore) Emit(_ context.Context, event Event) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.Events[event.SourceID+"\x00"+event.DedupKey]; ok {
|
||||
return false, nil
|
||||
}
|
||||
s.Events[event.SourceID+"\x00"+event.DedupKey] = event
|
||||
return true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user