alt/services/worker/internal/scheduler/runner.go

244 lines
6.1 KiB
Go

package scheduler
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"sync"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/services/worker/internal/jobs"
)
// Executor represents the job execution engine.
type Executor interface {
Execute(ctx context.Context, job jobs.Job) error
}
// TickOptions provides parameters for executing a tick.
type TickOptions struct {
Now time.Time
Capabilities map[market.Provider]market.ProviderCapability
Executor Executor
}
// TickResult represents the outcome of a single scheduler tick.
type TickResult struct {
Items []ItemResult
}
// ItemResult represents the outcome of a single symbol import within a schedule.
type ItemResult struct {
Schedule string `json:"schedule"`
Symbol string `json:"symbol"`
Status string `json:"status"` // "succeeded", "failed", "skipped"
Provider string `json:"provider"`
Timeframe string `json:"timeframe"`
Readiness string `json:"readiness"`
Freshness string `json:"freshness"`
Error string `json:"error,omitempty"`
}
var (
activeJobs = make(map[string]bool)
activeJobsMu sync.Mutex
)
// resetActiveJobs clears the active running schedules (for testing).
func resetActiveJobs() {
activeJobsMu.Lock()
defer activeJobsMu.Unlock()
activeJobs = make(map[string]bool)
}
func currentWindow(now time.Time, loc *time.Location, cadence time.Duration) time.Time {
next := nextWindow(now, loc, cadence)
return next.Add(-cadence)
}
// RunTick triggers the periodic import task scheduler.
func RunTick(ctx context.Context, cfg Config, opts TickOptions) (TickResult, error) {
now := opts.Now
if now.IsZero() {
now = time.Now()
}
validation := ValidateConfig(cfg, ValidationOptions{
Now: now,
Capabilities: opts.Capabilities,
})
var results []ItemResult
var resultsMu sync.Mutex
var wg sync.WaitGroup
for i, sched := range cfg.Schedules {
valItem := validation.Items[i]
if valItem.Status != "valid" {
continue
}
wg.Add(1)
go func(s ScheduleConfig) {
defer wg.Done()
loc, err := time.LoadLocation(s.Timezone)
if err != nil {
return
}
cadence, _ := parseWindow(s.Cadence)
backfill, _ := parseWindow(s.BackfillWindow)
windowTime := currentWindow(now, loc, cadence)
lockKey := fmt.Sprintf("%s:%s", s.Name, windowTime.Format(time.RFC3339))
activeJobsMu.Lock()
if activeJobs[lockKey] {
activeJobsMu.Unlock()
resultsMu.Lock()
for _, sym := range s.Selector.Symbols {
results = append(results, ItemResult{
Schedule: s.Name,
Symbol: sym,
Status: "skipped",
Provider: s.Provider,
Timeframe: s.Timeframe,
Readiness: "stale",
Freshness: "stale",
Error: "duplicate window running",
})
}
resultsMu.Unlock()
return
}
activeJobs[lockKey] = true
activeJobsMu.Unlock()
defer func() {
activeJobsMu.Lock()
delete(activeJobs, lockKey)
activeJobsMu.Unlock()
}()
sem := make(chan struct{}, s.ParallelismLimit)
var schedWg sync.WaitGroup
for _, sym := range s.Selector.Symbols {
schedWg.Add(1)
sem <- struct{}{}
go func(symbol string) {
defer func() {
<-sem
schedWg.Done()
}()
fromTime := windowTime.Add(-backfill)
payload := jobs.DailyBarImportPayload{
Provider: s.Provider,
SelectorKind: s.Selector.Kind,
Market: s.Selector.Market,
Venue: s.Selector.Venue,
Name: s.Selector.Name,
Symbols: []string{symbol},
From: fromTime.Format("20060102"),
To: windowTime.Format("20060102"),
Timeframe: s.Timeframe,
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
resultsMu.Lock()
results = append(results, ItemResult{
Schedule: s.Name,
Symbol: symbol,
Status: "failed",
Provider: s.Provider,
Timeframe: s.Timeframe,
Readiness: "error",
Freshness: "stale",
Error: fmt.Sprintf("marshal payload: %v", err),
})
resultsMu.Unlock()
return
}
jobID := fmt.Sprintf("sched-%s-%s-%d", s.Name, symbol, windowTime.Unix())
job := jobs.Job{
ID: jobID,
Kind: jobs.KindImportDailyBars,
Payload: payloadJSON,
}
execErr := opts.Executor.Execute(ctx, job)
itemRes := ItemResult{
Schedule: s.Name,
Symbol: symbol,
Provider: s.Provider,
Timeframe: s.Timeframe,
}
if execErr != nil {
itemRes.Status = "failed"
itemRes.Readiness = "error"
itemRes.Freshness = "stale"
itemRes.Error = execErr.Error()
} else {
itemRes.Status = "succeeded"
itemRes.Readiness = "ready"
itemRes.Freshness = "fresh"
}
resultsMu.Lock()
results = append(results, itemRes)
resultsMu.Unlock()
}(sym)
}
schedWg.Wait()
}(sched)
}
wg.Wait()
sort.Slice(results, func(i, j int) bool {
if results[i].Schedule != results[j].Schedule {
return results[i].Schedule < results[j].Schedule
}
return results[i].Symbol < results[j].Symbol
})
return TickResult{Items: results}, nil
}
// WriteTickText renders stable tick result lines.
func WriteTickText(w io.Writer, result TickResult) error {
for _, item := range result.Items {
if item.Error != "" {
if _, err := fmt.Fprintf(w, "schedule=%s symbol=%s status=%s provider=%s timeframe=%s readiness=%s freshness=%s error=%q\n",
item.Schedule, item.Symbol, item.Status, item.Provider, item.Timeframe, item.Readiness, item.Freshness, item.Error); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(w, "schedule=%s symbol=%s status=%s provider=%s timeframe=%s readiness=%s freshness=%s\n",
item.Schedule, item.Symbol, item.Status, item.Provider, item.Timeframe, item.Readiness, item.Freshness); err != nil {
return err
}
}
}
return nil
}
// WriteTickJSONL renders one JSON object per tick item.
func WriteTickJSONL(w io.Writer, result TickResult) error {
enc := json.NewEncoder(w)
for _, item := range result.Items {
if err := enc.Encode(item); err != nil {
return err
}
}
return nil
}