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 }