alt/services/worker/internal/scheduler/backfill_test.go
toki 237a110c28 feat(worker): backfill scheduler 개선 및 코드 리뷰 문서 정리
- backfill.go: 리밸런싱 백필 로직 개선
- backfill_test.go: 테스트 코드 수정
- 코드 리뷰 문서 이동 (archive)
2026-06-21 07:30:37 +09:00

464 lines
14 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)
}
}