Files
ITWorx-Pulse-Public/internal/discovery/jobs.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

167 lines
4.3 KiB
Go

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
}