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

649 lines
20 KiB
Go

package scheduler
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
func TestDecideBackfillFreshState(t *testing.T) {
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Selector: "kospi",
Timeframe: "daily",
Timezone: "Asia/Seoul",
Cadence: "24h0m0s",
BackfillWindow: "7d",
Status: "success",
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
if dec.Status != "fresh" {
t.Errorf("expected status=fresh, got %s", dec.Status)
}
if dec.HasBackfill {
t.Error("expected has_backfill=false for fresh state")
}
if dec.Retry {
t.Error("expected retry=false for fresh state")
}
if dec.Reason != "all expected dates are present" {
t.Errorf("unexpected reason: %s", dec.Reason)
}
}
func TestDecideBackfillMissingDates(t *testing.T) {
missingDates := []time.Time{
time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC),
time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC),
time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC),
}
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Selector: "kospi",
Timeframe: "daily",
Timezone: "Asia/Seoul",
Cadence: "24h0m0s",
BackfillWindow: "7d",
Status: "success",
MissingDates: missingDates,
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
if dec.Status != "stale" {
t.Errorf("expected status=stale for missing dates, got %s", dec.Status)
}
if !dec.HasBackfill {
t.Error("expected has_backfill=true for missing dates")
}
if !dec.Retry {
t.Error("expected retry=true for missing dates")
}
if dec.MissingCount != 3 {
t.Errorf("expected missing_count=3, got %d", dec.MissingCount)
}
if dec.BackfillFrom.IsZero() {
t.Error("expected backfill_from to be set")
}
// The earliest missing date is 2026-06-15
if !dec.BackfillFrom.Equal(time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)) {
t.Errorf("unexpected backfill_from: %s", dec.BackfillFrom.Format("2006-01-02"))
}
if !strings.Contains(dec.Reason, "3 missing date(s)") {
t.Errorf("unexpected reason: %s", dec.Reason)
}
}
func TestDecideBackfillGapBuckets(t *testing.T) {
gapBuckets := []GapBucket{
{From: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)},
{From: time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)},
}
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Selector: "kospi",
Timeframe: "daily",
Timezone: "Asia/Seoul",
Cadence: "24h0m0s",
BackfillWindow: "7d",
Status: "success",
GapBuckets: gapBuckets,
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
ExpectedFrom: time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC),
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
if dec.Status != "stale" {
t.Errorf("expected status=stale for gap buckets, got %s", dec.Status)
}
if !dec.HasBackfill {
t.Error("expected has_backfill=true for gap buckets")
}
if dec.GapCount != 2 {
t.Errorf("expected gap_count=2, got %d", dec.GapCount)
}
if !strings.Contains(dec.Reason, "2 gap bucket(s)") {
t.Errorf("unexpected reason: %s", dec.Reason)
}
}
func TestDecideBackfillProviderDelayOnly(t *testing.T) {
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Selector: "kospi",
Timeframe: "daily",
Timezone: "Asia/Seoul",
Cadence: "24h0m0s",
BackfillWindow: "7d",
Status: "success",
ProviderDelay: 2,
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
if dec.Status != "stale" {
t.Errorf("expected status=stale for provider delay, got %s", dec.Status)
}
if dec.HasBackfill {
t.Error("expected has_backfill=false for provider delay only")
}
if dec.Retry {
t.Error("expected retry=false for provider delay only")
}
if dec.MissingCount != 0 {
t.Errorf("expected missing_count=0, got %d", dec.MissingCount)
}
if dec.ProviderDelay != 2 {
t.Errorf("expected provider_delay=2, got %d", dec.ProviderDelay)
}
}
func TestDecideBackfillPreservesErrorState(t *testing.T) {
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Selector: "kospi",
Timeframe: "daily",
Timezone: "Asia/Seoul",
Cadence: "24h0m0s",
BackfillWindow: "7d",
Status: "error",
LastError: "connection timeout to KIS API",
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
if dec.Status != "error" {
t.Errorf("expected status=error, got %s", dec.Status)
}
if dec.HasBackfill {
t.Error("expected has_backfill=false for error state")
}
if !dec.Retry {
t.Error("expected retry=true for error state")
}
if !strings.Contains(dec.Reason, "connection timeout to KIS API") {
t.Errorf("unexpected reason: %s", dec.Reason)
}
if dec.LastError != "connection timeout to KIS API" {
t.Errorf("unexpected last_error: %s", dec.LastError)
}
}
func TestDecideBackfillDistinguishesMissingGapProviderDelay(t *testing.T) {
window := 7 * 24 * time.Hour
// Case 1: Missing dates only
obs1 := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Status: "success",
MissingDates: []time.Time{time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)},
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
dec1 := DecideBackfill(obs1, window)
if dec1.Status != "stale" {
t.Errorf("case 1 (missing): expected stale, got %s", dec1.Status)
}
if !dec1.HasBackfill {
t.Error("case 1 (missing): expected has_backfill=true")
}
// Case 2: Gap buckets only
obs2 := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Status: "success",
GapBuckets: []GapBucket{{From: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)}},
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
ExpectedFrom: time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC),
}
dec2 := DecideBackfill(obs2, window)
if dec2.Status != "stale" {
t.Errorf("case 2 (gap): expected stale, got %s", dec2.Status)
}
if !dec2.HasBackfill {
t.Error("case 2 (gap): expected has_backfill=true")
}
// Case 3: Provider delay only
obs3 := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Status: "success",
ProviderDelay: 3,
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
dec3 := DecideBackfill(obs3, window)
if dec3.Status != "stale" {
t.Errorf("case 3 (delay): expected stale, got %s", dec3.Status)
}
if dec3.HasBackfill {
t.Error("case 3 (delay): expected has_backfill=false")
}
if dec3.Retry {
t.Error("case 3 (delay): expected retry=false")
}
// Case 4: Error
obs4 := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Status: "error",
LastError: "api timeout",
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
dec4 := DecideBackfill(obs4, window)
if dec4.Status != "error" {
t.Errorf("case 4 (error): expected error, got %s", dec4.Status)
}
}
func TestDecideBackfillDuplicateOnly(t *testing.T) {
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Selector: "kospi",
Status: "success",
DuplicateCount: 5,
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
// Duplicate-only should still be fresh if no missing/gap/error.
if dec.Status != "fresh" {
t.Errorf("expected status=fresh for duplicate-only, got %s", dec.Status)
}
if dec.HasBackfill {
t.Error("expected has_backfill=false for duplicate-only")
}
}
func TestDecideBackfillBackfillWindowCutoff(t *testing.T) {
// Missing dates older than the backfill window should be clamped.
missingDates := []time.Time{
time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), // older than 7-day window
time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC),
}
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Selector: "kospi",
Status: "success",
MissingDates: missingDates,
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
if dec.Status != "stale" {
t.Errorf("expected status=stale, got %s", dec.Status)
}
if !dec.HasBackfill {
t.Error("expected has_backfill=true")
}
// Should be clamped to window start (2026-06-13)
windowStart := time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC)
if !dec.BackfillFrom.Equal(windowStart) {
t.Errorf("expected backfill_from=%s, got %s", windowStart.Format("2006-01-02"), dec.BackfillFrom.Format("2006-01-02"))
}
}
func TestDecideBackfillNilSlices(t *testing.T) {
obs := FreshnessObservation{
Schedule: "daily-daily",
Provider: market.ProviderKIS,
Status: "success",
MissingDates: nil,
GapBuckets: nil,
ExpectedTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
if dec.Status != "fresh" {
t.Errorf("expected status=fresh for nil slices, got %s", dec.Status)
}
}
func TestWriteBackfillTextAndJSONL(t *testing.T) {
decisions := []BackfillDecision{
{
Schedule: "daily-daily",
Status: "fresh",
Reason: "all expected dates are present",
HasBackfill: false,
Retry: false,
},
{
Schedule: "daily-daily",
Status: "stale",
Reason: "backfill needed: 3 missing date(s) from 2026-06-15 to 2026-06-20",
HasBackfill: true,
Retry: true,
BackfillFrom: time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC),
BackfillTo: time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC),
MissingCount: 3,
StaleDates: []time.Time{
time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC),
time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC),
time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC),
},
},
{
Schedule: "daily-daily",
Status: "error",
Reason: "previous operation failed: connection timeout",
LastError: "connection timeout",
HasBackfill: false,
Retry: true,
},
}
// Test text output
var textBuf bytes.Buffer
if err := WriteBackfillText(&textBuf, decisions); err != nil {
t.Fatalf("WriteBackfillText: %v", err)
}
text := textBuf.String()
textLines := strings.Split(strings.TrimSpace(text), "\n")
if len(textLines) != 3 {
t.Errorf("expected 3 text lines, got %d", len(textLines))
}
// Check line 1 (fresh)
if !strings.Contains(textLines[0], "status=fresh") {
t.Errorf("line 1 should contain status=fresh: %s", textLines[0])
}
if !strings.Contains(textLines[0], "backfill_from=NONE") {
t.Errorf("line 1 should contain backfill_from=NONE: %s", textLines[0])
}
// Check line 2 (stale)
if !strings.Contains(textLines[1], "status=stale") {
t.Errorf("line 2 should contain status=stale: %s", textLines[1])
}
if !strings.Contains(textLines[1], "backfill_from=20260615") {
t.Errorf("line 2 should contain backfill_from=20260615: %s", textLines[1])
}
if !strings.Contains(textLines[1], "backfill_to=20260620") {
t.Errorf("line 2 should contain backfill_to=20260620: %s", textLines[1])
}
// Check line 3 (error)
if !strings.Contains(textLines[2], "status=error") {
t.Errorf("line 3 should contain status=error: %s", textLines[2])
}
// Test JSONL output (single-line JSON per decision, no SetIndent)
var jsonBuf bytes.Buffer
if err := WriteBackfillJSONL(&jsonBuf, decisions); err != nil {
t.Fatalf("WriteBackfillJSONL: %v", err)
}
jsonLines := strings.Split(strings.TrimSpace(jsonBuf.String()), "\n")
if len(jsonLines) != 3 {
t.Fatalf("expected 3 JSON lines, got %d", len(jsonLines))
}
// Parse and verify each JSON line
for i, jsonLine := range jsonLines {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(jsonLine), &parsed); err != nil {
t.Fatalf("line %d: failed to parse JSON: %v", i, err)
}
if parsed["schedule"] != "daily-daily" {
t.Errorf("line %d: expected schedule=daily-daily, got %v", i, parsed["schedule"])
}
expectedStatus := []string{"fresh", "stale", "error"}[i]
if parsed["status"] != expectedStatus {
t.Errorf("line %d: expected status=%s, got %v", i, expectedStatus, parsed["status"])
}
// Fresh case: backfill_from should not be present
if i == 0 {
if val, ok := parsed["backfill_from"]; ok && val != nil {
t.Errorf("line %d: expected backfill_from absent or nil for fresh, got %v", i, val)
}
}
// Stale case: should have stale_dates
if i == 1 {
staleDates, ok := parsed["stale_dates"].([]interface{})
if !ok {
t.Errorf("line %d: expected stale_dates to be an array, got %T", i, parsed["stale_dates"])
} else if len(staleDates) != 3 {
t.Errorf("line %d: expected 3 stale_dates, got %d", i, len(staleDates))
}
}
// Error case: should have last_error
if i == 2 {
if parsed["last_error"] != "connection timeout" {
t.Errorf("line %d: expected last_error=connection timeout, got %v", i, parsed["last_error"])
}
}
}
}
func TestDecideBackfillNilObservation(t *testing.T) {
// Empty observation with no status should not trigger error or backfill.
obs := FreshnessObservation{}
window := 7 * 24 * time.Hour
dec := DecideBackfill(obs, window)
// Should be fresh (empty = no missing, no gaps, no error).
if dec.Status != "fresh" {
t.Errorf("expected status=fresh for empty observation, got %s", dec.Status)
}
}
func TestMakeRefreshStatus(t *testing.T) {
now := time.Now().UTC()
nextRun := now.Add(24 * time.Hour)
// Case 1: Success status (obs is success, dec is fresh)
obsSuccess := FreshnessObservation{
Schedule: "daily-success",
Status: "success",
DuplicateCount: 2,
ProviderDelay: 0,
}
decFresh := BackfillDecision{
Status: "fresh",
MissingCount: 0,
GapCount: 0,
}
status1 := MakeRefreshStatus(obsSuccess, decFresh, now, nextRun, 150)
if status1.Status != "success" {
t.Errorf("expected status=success, got %s", status1.Status)
}
if status1.ImportedBarCount != 150 {
t.Errorf("expected imported_bar_count=150, got %d", status1.ImportedBarCount)
}
if !status1.LastSuccess.Equal(now) {
t.Errorf("expected last_success=%v, got %v", now, status1.LastSuccess)
}
if !status1.NextRun.Equal(nextRun) {
t.Errorf("expected next_run=%v, got %v", nextRun, status1.NextRun)
}
// Case 2: Stale status (obs is success, dec is stale)
obsStale := FreshnessObservation{
Schedule: "daily-stale",
Status: "success",
DuplicateCount: 0,
ProviderDelay: 1,
}
decStale := BackfillDecision{
Status: "stale",
MissingCount: 3,
GapCount: 1,
}
status2 := MakeRefreshStatus(obsStale, decStale, now, nextRun, 0)
if status2.Status != "stale" {
t.Errorf("expected status=stale, got %s", status2.Status)
}
if status2.ImportedBarCount != 0 {
t.Errorf("expected imported_bar_count=0, got %d", status2.ImportedBarCount)
}
// Case 3: Error status (obs is error or dec is error)
obsErr := FreshnessObservation{
Schedule: "daily-err",
Status: "error",
LastError: "connection timeout",
}
decErr := BackfillDecision{
Status: "error",
LastError: "previous operation failed: connection timeout",
}
status3 := MakeRefreshStatus(obsErr, decErr, time.Time{}, nextRun, 0)
if status3.Status != "error" {
t.Errorf("expected status=error, got %s", status3.Status)
}
if status3.LastError != "connection timeout" {
t.Errorf("expected last_error='connection timeout', got %q", status3.LastError)
}
}
func TestWriteRefreshStatusTextAndJSONL(t *testing.T) {
lastSuccess := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC)
nextRun := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC)
statuses := []RefreshStatus{
{
Schedule: "daily-success",
Status: "success",
LastSuccess: lastSuccess,
LastError: "",
NextRun: nextRun,
ImportedBarCount: 100,
MissingCount: 0,
GapCount: 0,
DuplicateCount: 1,
ProviderDelay: 0,
},
{
Schedule: "daily-error",
Status: "error",
LastSuccess: time.Time{}, // Zero time
LastError: "API key expired",
NextRun: time.Time{},
ImportedBarCount: 0,
MissingCount: 0,
GapCount: 0,
DuplicateCount: 0,
ProviderDelay: 0,
},
}
// Test Text output
var textBuf bytes.Buffer
if err := WriteRefreshStatusText(&textBuf, statuses); err != nil {
t.Fatalf("WriteRefreshStatusText: %v", err)
}
text := textBuf.String()
textLines := strings.Split(strings.TrimSpace(text), "\n")
if len(textLines) != 2 {
t.Errorf("expected 2 text lines, got %d", len(textLines))
}
// Check success line
if !strings.Contains(textLines[0], "status=success") {
t.Errorf("line 1 should contain status=success: %s", textLines[0])
}
if !strings.Contains(textLines[0], "last_success=2026-06-20T12:00:00Z") {
t.Errorf("line 1 should contain last_success timestamp: %s", textLines[0])
}
if !strings.Contains(textLines[0], "imported_bar_count=100") {
t.Errorf("line 1 should contain imported_bar_count=100: %s", textLines[0])
}
// Check error line
if !strings.Contains(textLines[1], "status=error") {
t.Errorf("line 2 should contain status=error: %s", textLines[1])
}
if !strings.Contains(textLines[1], "last_success=NONE") {
t.Errorf("line 2 should contain last_success=NONE: %s", textLines[1])
}
if !strings.Contains(textLines[1], `last_error="API key expired"`) {
t.Errorf("line 2 should contain last_error=\"API key expired\": %s", textLines[1])
}
// Test JSONL output
var jsonBuf bytes.Buffer
if err := WriteRefreshStatusJSONL(&jsonBuf, statuses); err != nil {
t.Fatalf("WriteRefreshStatusJSONL: %v", err)
}
jsonLines := strings.Split(strings.TrimSpace(jsonBuf.String()), "\n")
if len(jsonLines) != 2 {
t.Fatalf("expected 2 JSON lines, got %d", len(jsonLines))
}
// Parse line 1
var parsed1 map[string]interface{}
if err := json.Unmarshal([]byte(jsonLines[0]), &parsed1); err != nil {
t.Fatalf("failed to parse JSON line 1: %v", err)
}
if parsed1["schedule"] != "daily-success" {
t.Errorf("expected schedule=daily-success, got %v", parsed1["schedule"])
}
if parsed1["status"] != "success" {
t.Errorf("expected status=success, got %v", parsed1["status"])
}
if parsed1["last_success"] != "2026-06-20T12:00:00Z" {
t.Errorf("expected last_success=2026-06-20T12:00:00Z, got %v", parsed1["last_success"])
}
if val, ok := parsed1["last_error"]; !ok || val != "" {
t.Errorf("expected empty last_error to be present, got %v", val)
}
if val, ok := parsed1["imported_bar_count"]; !ok || val != 100.0 { // float64 in JSON unmarshal
t.Errorf("expected imported_bar_count=100, got %v", val)
}
// Parse line 2
var parsed2 map[string]interface{}
if err := json.Unmarshal([]byte(jsonLines[1]), &parsed2); err != nil {
t.Fatalf("failed to parse JSON line 2: %v", err)
}
if parsed2["status"] != "error" {
t.Errorf("expected status=error, got %v", parsed2["status"])
}
if parsed2["last_success"] != nil {
t.Errorf("expected last_success=nil, got %v", parsed2["last_success"])
}
if parsed2["last_error"] != "API key expired" {
t.Errorf("expected last_error=API key expired, got %v", parsed2["last_error"])
}
if val, ok := parsed2["imported_bar_count"]; !ok || val != 0.0 {
t.Errorf("expected zero imported_bar_count to be present, got %v", val)
}
}