- Add parser_map for market data refresh configuration propagation (cli/worker/api) - Implement refresh status model with SQLite persistence (status_store.go) - Add scheduler refresh status headless output to CLI operator - Extend backfill scheduler with status tracking (start/complete/fail) - Add socket events for scheduler refresh status (start/complete/fail) - Update proto definitions for refresh status - Add generated PB files for client
307 lines
9.1 KiB
Go
307 lines
9.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
|
|
// ExecuteWithResult runs the job and returns the import result if available.
|
|
//
|
|
// Three-value contract:
|
|
// - (res, true, nil) → handler ran and returned result (has data)
|
|
// - (Result{}, false, nil) → no WithResultHandler registered (legacy fallback)
|
|
// - (Result{}, false, err) → handler ran but returned an error (DO NOT re-execute)
|
|
//
|
|
// The caller MUST distinguish the error case (false, err) from the
|
|
// no-handler case (false, nil). Re-executing the job on error loses
|
|
// the first error and may cause duplicate provider/import side-effects.
|
|
ExecuteWithResult(ctx context.Context, job jobs.Job) (jobs.Result, bool, 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
|
|
}
|
|
|
|
// RefreshEvidence carries actual import results from a single scheduler tick.
|
|
// This struct separates "we ran" from "we got data" so that status evidence
|
|
// is never fabricated from scheduler dispatch counts.
|
|
type RefreshEvidence struct {
|
|
// HasBars is true when at least one item carried a non-empty import summary.
|
|
HasBars bool
|
|
// ImportedBars is the total number of bars actually imported across all
|
|
// successful items. Can be zero if the provider returned no new data.
|
|
ImportedBars int
|
|
// MissingBars is the count of bars that were expected but not present
|
|
// (e.g., provider returned fewer bars than expected).
|
|
MissingBars int
|
|
// GapBars is the count of bars with missing intermediate timestamps.
|
|
GapBars int
|
|
// DuplicateBars is the count of bars rejected as duplicates.
|
|
DuplicateBars int
|
|
// ProviderDelayDays is the estimated delay in days reported by the provider.
|
|
ProviderDelayDays int
|
|
}
|
|
|
|
// 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"`
|
|
// ImportSummary carries actual import bar count for this item when
|
|
// status is "succeeded". Empty means we don't know the actual count.
|
|
ImportSummary *RefreshEvidence `json:"import_summary,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,
|
|
}
|
|
|
|
// Call ExecuteWithResult. The three-value contract:
|
|
// (res, true, nil) → handler ran with result
|
|
// (Result{}, false, nil) → no WithResultHandler (legacy)
|
|
// (Result{}, false, err) → handler error (DO NOT re-execute)
|
|
itemRes := ItemResult{
|
|
Schedule: s.Name,
|
|
Symbol: symbol,
|
|
Provider: s.Provider,
|
|
Timeframe: s.Timeframe,
|
|
}
|
|
|
|
res, hasResult, execErr := opts.Executor.ExecuteWithResult(ctx, job)
|
|
if hasResult {
|
|
// Executor returned evidence: success with data.
|
|
itemRes.Status = "succeeded"
|
|
itemRes.Readiness = "ready"
|
|
itemRes.Freshness = "fresh"
|
|
itemRes.ImportSummary = &RefreshEvidence{
|
|
HasBars: res.Bars > 0,
|
|
ImportedBars: res.Bars,
|
|
MissingBars: res.MissingBars,
|
|
GapBars: res.GapBars,
|
|
DuplicateBars: res.DuplicateBars,
|
|
ProviderDelayDays: res.ProviderDelay,
|
|
}
|
|
} else if execErr != nil {
|
|
// Handler ran but returned an error.
|
|
// DO NOT re-execute via Execute() — this preserves the
|
|
// original error and avoids duplicate provider/import side effects.
|
|
itemRes.Status = "failed"
|
|
itemRes.Readiness = "error"
|
|
itemRes.Freshness = "stale"
|
|
itemRes.Error = execErr.Error()
|
|
} else {
|
|
// No WithResultHandler registered: fall back to Execute.
|
|
execErr2 := opts.Executor.Execute(ctx, job)
|
|
if execErr2 != nil {
|
|
itemRes.Status = "failed"
|
|
itemRes.Readiness = "error"
|
|
itemRes.Freshness = "stale"
|
|
itemRes.Error = execErr2.Error()
|
|
} else {
|
|
itemRes.Status = "succeeded"
|
|
itemRes.Readiness = "ready"
|
|
itemRes.Freshness = "fresh"
|
|
// No ImportSummary — ran but no evidence.
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|