244 lines
6.1 KiB
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
|
|
}
|