alt/services/worker/internal/scheduler/backfill.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

386 lines
12 KiB
Go

package scheduler
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
// FreshnessObservation captures the current freshness state of a scheduled
// market data refresh for a given schedule, provider, selector, timeframe and
// backfill window.
type FreshnessObservation struct {
Schedule string `json:"schedule"`
Provider market.Provider `json:"provider"`
Selector string `json:"selector"`
Timeframe string `json:"timeframe"`
Timezone string `json:"timezone"`
Cadence string `json:"cadence"`
BackfillWindow string `json:"backfill_window"`
ExpectedFrom time.Time `json:"expected_from,omitempty"`
ExpectedTo time.Time `json:"expected_to,omitempty"`
LatestBarDate time.Time `json:"latest_bar_date,omitempty"`
MissingDates []time.Time `json:"missing_dates,omitempty"`
GapBuckets []GapBucket `json:"gap_buckets,omitempty"`
DuplicateCount int `json:"duplicate_count"`
ProviderDelay int `json:"provider_delay_days"`
LastError string `json:"last_error,omitempty"`
Status string `json:"status"` // "success", "error"
}
// GapBucket represents a contiguous range of missing dates.
type GapBucket struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
}
// BackfillDecision holds the computed backfill decision for a freshness
// observation.
type BackfillDecision struct {
Schedule string `json:"schedule"`
Status string `json:"status"` // "fresh", "stale", "error"
Reason string `json:"reason"` // human readable reason
BackfillFrom time.Time `json:"backfill_from,omitempty"`
BackfillTo time.Time `json:"backfill_to,omitempty"`
HasBackfill bool `json:"has_backfill"`
Retry bool `json:"retry"`
MissingCount int `json:"missing_count"`
GapCount int `json:"gap_count"`
ProviderDelay int `json:"provider_delay_days"`
LastError string `json:"last_error,omitempty"`
StaleDates []time.Time `json:"stale_dates,omitempty"`
}
// DecideBackfill computes a backfill decision from a freshness observation and
// a backfill window duration. It distinguishes the following cases:
//
// - fresh: no missing dates, no gaps, no error → no backfill needed.
// - stale/missing: missing dates exist → backfill window is computed from
// the earliest missing date.
// - stale/gap: contiguous gap buckets exist → backfill window covers the
// earliest gap.
// - stale/provider_delay: provider reports delay > 0 with no missing dates
// → marks retry=false, no backfill.
// - error: Status=="error" with LastError → preserves error state, includes
// the error text.
func DecideBackfill(obs FreshnessObservation, window time.Duration) BackfillDecision {
dec := BackfillDecision{
Schedule: obs.Schedule,
ProviderDelay: obs.ProviderDelay,
LastError: obs.LastError,
}
// Error state: preserve and report.
if obs.Status == "error" && obs.LastError != "" {
dec.Status = "error"
dec.Reason = fmt.Sprintf("previous operation failed: %s", obs.LastError)
dec.HasBackfill = false
dec.Retry = true
return dec
}
// Collect missing dates and gap buckets.
missingDates := obs.MissingDates
if missingDates == nil {
missingDates = []time.Time{}
}
gapBuckets := obs.GapBuckets
if gapBuckets == nil {
gapBuckets = []GapBucket{}
}
// Fresh case: nothing missing, no gaps, no error.
if len(missingDates) == 0 && len(gapBuckets) == 0 && obs.ProviderDelay == 0 {
dec.Status = "fresh"
dec.Reason = "all expected dates are present"
dec.HasBackfill = false
dec.Retry = false
return dec
}
// Provider delay only: missing may be due to delay, not data loss.
if obs.ProviderDelay > 0 && len(missingDates) == 0 && len(gapBuckets) == 0 {
dec.Status = "stale"
dec.Reason = fmt.Sprintf("provider delay %d day(s); no backfill needed", obs.ProviderDelay)
dec.HasBackfill = false
dec.Retry = false
dec.MissingCount = 0
dec.GapCount = 0
return dec
}
// Stale with missing dates or gaps.
dec.Status = "stale"
dec.MissingCount = len(missingDates)
dec.GapCount = len(gapBuckets)
// Compute backfill window from the earliest missing/gap date.
today := obs.ExpectedTo
if today.IsZero() {
today = time.Now()
}
// Find the earliest missing date.
var earliestMissing time.Time
hasMissing := false
if len(missingDates) > 0 {
earliestMissing = missingDates[0]
for _, d := range missingDates {
if d.Before(earliestMissing) {
earliestMissing = d
}
}
hasMissing = true
}
// Find the earliest gap start and latest gap end.
var earliestGapStart time.Time
var latestGapEnd time.Time
hasGap := len(gapBuckets) > 0
if hasGap {
earliestGapStart = gapBuckets[0].From
latestGapEnd = gapBuckets[0].To
for _, gb := range gapBuckets {
if gb.From.Before(earliestGapStart) {
earliestGapStart = gb.From
}
if gb.To.After(latestGapEnd) {
latestGapEnd = gb.To
}
}
}
// Determine the overall earliest date.
hasBackfillData := hasMissing || hasGap
if hasBackfillData {
if hasMissing && hasGap {
if earliestGapStart.Before(earliestMissing) {
earliestMissing = earliestGapStart
}
} else if hasGap {
earliestMissing = earliestGapStart
}
// Set backfill window.
dec.BackfillFrom = earliestMissing
if hasGap && latestGapEnd.After(today) {
dec.BackfillTo = latestGapEnd
} else {
dec.BackfillTo = today
}
// Bound by window: clamp backfill_from to window start.
windowStart := today.Add(-window)
if dec.BackfillFrom.Before(windowStart) {
dec.BackfillFrom = windowStart
}
dec.HasBackfill = true
dec.Retry = true
// Build reason text.
var parts []string
if hasMissing {
parts = append(parts, fmt.Sprintf("%d missing date(s)", len(missingDates)))
}
if hasGap {
parts = append(parts, fmt.Sprintf("%d gap bucket(s)", len(gapBuckets)))
}
dec.Reason = fmt.Sprintf("backfill needed: %s from %s to %s",
strings.Join(parts, ", "),
dec.BackfillFrom.Format("2006-01-02"),
dec.BackfillTo.Format("2006-01-02"),
)
// Collect all stale dates (missing + gap ranges).
dec.StaleDates = append(dec.StaleDates, missingDates...)
for _, gb := range gapBuckets {
for d := gb.From; !d.After(gb.To); d = d.Add(24 * time.Hour) {
dec.StaleDates = append(dec.StaleDates, d)
}
}
}
return dec
}
// WriteBackfillText renders stable operator-facing dry-run lines for backfill
// decisions.
//
// Format:
//
// schedule=<name> status=<fresh|stale|error> reason=<...> backfill_from=<YYYYMMDD|NONE> backfill_to=<YYYYMMDD|NONE> retry=<true|false>
func WriteBackfillText(w io.Writer, decisions []BackfillDecision) error {
for _, dec := range decisions {
backfillFrom := "NONE"
if dec.HasBackfill && !dec.BackfillFrom.IsZero() {
backfillFrom = dec.BackfillFrom.Format("20060102")
}
backfillTo := "NONE"
if dec.HasBackfill && !dec.BackfillTo.IsZero() {
backfillTo = dec.BackfillTo.Format("20060102")
}
line := fmt.Sprintf(
"schedule=%s status=%s reason=%q backfill_from=%s backfill_to=%s retry=%s\n",
dec.Schedule,
dec.Status,
dec.Reason,
backfillFrom,
backfillTo,
fmt.Sprintf("%t", dec.Retry),
)
if _, err := fmt.Fprint(w, line); err != nil {
return err
}
}
return nil
}
// WriteBackfillJSONL renders one JSON object per backfill decision.
func WriteBackfillJSONL(w io.Writer, decisions []BackfillDecision) error {
enc := json.NewEncoder(w)
for _, dec := range decisions {
obj := map[string]interface{}{
"schedule": dec.Schedule,
"status": dec.Status,
"reason": dec.Reason,
"has_backfill": dec.HasBackfill,
"retry": dec.Retry,
"missing_count": dec.MissingCount,
"gap_count": dec.GapCount,
"provider_delay": dec.ProviderDelay,
}
if !dec.BackfillFrom.IsZero() {
obj["backfill_from"] = dec.BackfillFrom.Format("2006-01-02")
}
if !dec.BackfillTo.IsZero() {
obj["backfill_to"] = dec.BackfillTo.Format("2006-01-02")
}
if dec.LastError != "" {
obj["last_error"] = dec.LastError
}
if len(dec.StaleDates) > 0 {
dates := make([]string, 0, len(dec.StaleDates))
for _, d := range dec.StaleDates {
dates = append(dates, d.Format("2006-01-02"))
}
obj["stale_dates"] = dates
}
if err := enc.Encode(obj); err != nil {
return err
}
}
return nil
}
// RefreshStatus represents the operator-facing status of a scheduled refresh.
type RefreshStatus struct {
Schedule string `json:"schedule"`
Status string `json:"status"` // "success", "stale", "error"
LastSuccess time.Time `json:"last_success"`
LastError string `json:"last_error"`
NextRun time.Time `json:"next_run"`
ImportedBarCount int `json:"imported_bar_count"`
MissingCount int `json:"missing_count"`
GapCount int `json:"gap_count"`
DuplicateCount int `json:"duplicate_count"`
ProviderDelay int `json:"provider_delay_days"`
}
// MakeRefreshStatus computes a RefreshStatus from a freshness observation, a backfill decision,
// and external inputs (lastSuccess, nextRun, importedBarCount).
func MakeRefreshStatus(obs FreshnessObservation, dec BackfillDecision, lastSuccess time.Time, nextRun time.Time, importedBarCount int) RefreshStatus {
status := "success"
if obs.Status == "error" || dec.Status == "error" {
status = "error"
} else if dec.Status == "stale" {
status = "stale"
}
lastErr := obs.LastError
if lastErr == "" {
lastErr = dec.LastError
}
return RefreshStatus{
Schedule: obs.Schedule,
Status: status,
LastSuccess: lastSuccess,
LastError: lastErr,
NextRun: nextRun,
ImportedBarCount: importedBarCount,
MissingCount: dec.MissingCount,
GapCount: dec.GapCount,
DuplicateCount: obs.DuplicateCount,
ProviderDelay: obs.ProviderDelay,
}
}
// WriteRefreshStatusText renders stable operator-facing text lines for refresh statuses.
// Format:
//
// schedule=<name> status=<success|stale|error> last_success=<RFC3339|NONE> last_error=<...> next_run=<RFC3339|NONE> imported_bar_count=<int> ...
func WriteRefreshStatusText(w io.Writer, statuses []RefreshStatus) error {
for _, s := range statuses {
lastSuccess := "NONE"
if !s.LastSuccess.IsZero() {
lastSuccess = s.LastSuccess.Format(time.RFC3339)
}
nextRun := "NONE"
if !s.NextRun.IsZero() {
nextRun = s.NextRun.Format(time.RFC3339)
}
line := fmt.Sprintf(
"schedule=%s status=%s last_success=%s last_error=%q next_run=%s imported_bar_count=%d missing_count=%d gap_count=%d duplicate_count=%d provider_delay=%d\n",
s.Schedule,
s.Status,
lastSuccess,
s.LastError,
nextRun,
s.ImportedBarCount,
s.MissingCount,
s.GapCount,
s.DuplicateCount,
s.ProviderDelay,
)
if _, err := fmt.Fprint(w, line); err != nil {
return err
}
}
return nil
}
// WriteRefreshStatusJSONL renders one JSON object per refresh status.
func WriteRefreshStatusJSONL(w io.Writer, statuses []RefreshStatus) error {
enc := json.NewEncoder(w)
for _, s := range statuses {
obj := map[string]interface{}{
"schedule": s.Schedule,
"status": s.Status,
"last_error": s.LastError, // Keep even if empty
"imported_bar_count": s.ImportedBarCount, // Keep even if zero
"missing_count": s.MissingCount,
"gap_count": s.GapCount,
"duplicate_count": s.DuplicateCount,
"provider_delay": s.ProviderDelay,
}
if !s.LastSuccess.IsZero() {
obj["last_success"] = s.LastSuccess.Format(time.RFC3339)
} else {
obj["last_success"] = nil
}
if !s.NextRun.IsZero() {
obj["next_run"] = s.NextRun.Format(time.RFC3339)
} else {
obj["next_run"] = nil
}
if err := enc.Encode(obj); err != nil {
return err
}
}
return nil
}