alt/services/worker/internal/scheduler/runner.go
toki bdbfe8228e feat: scheduled market data refresh - parser_map, refresh status model, CLI output, scheduler backfill, socket handlers
- 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
2026-06-22 18:46:45 +09:00

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
}