package batch import ( "context" "encoding/json" "fmt" "strings" "time" "sub2api-cn-relay-manager/internal/store/sqlite" ) type ConfirmationResult struct { StatusCode int Message string } type ConfirmationItemStore interface { List(ctx context.Context) ([]sqlite.ImportRunItem, error) Upsert(ctx context.Context, item sqlite.ImportRunItem) error } type ConfirmationLeaseClaimer interface { TryAcquireLease(ctx context.Context, itemID, workerID string, now time.Time, leaseDuration time.Duration) (sqlite.ImportRunItem, bool, error) } type ConfirmationEventStore interface { Append(ctx context.Context, event sqlite.ImportRunItemEvent) error } type ConfirmationWorker struct { WorkerID string ItemStore ConfirmationItemStore EventStore ConfirmationEventStore LeaseDuration time.Duration RetryDelay time.Duration Confirmer func(ctx context.Context, item sqlite.ImportRunItem) (ConfirmationResult, error) } func (w ConfirmationWorker) Tick(ctx context.Context, now time.Time) error { if w.ItemStore == nil { return fmt.Errorf("item store is required") } if w.EventStore == nil { return fmt.Errorf("event store is required") } if w.Confirmer == nil { return fmt.Errorf("confirmer is required") } items, err := w.ItemStore.List(ctx) if err != nil { return err } for _, item := range items { if !isConfirmationCandidate(item, now) { continue } if claimer, ok := w.ItemStore.(ConfirmationLeaseClaimer); ok { claimedItem, claimed, err := claimer.TryAcquireLease(ctx, item.ItemID, w.WorkerID, now, w.LeaseDuration) if err != nil { return err } if !claimed { continue } item = claimedItem } if err := w.ConfirmItem(ctx, item, now); err != nil { return err } } return nil } func (w ConfirmationWorker) ConfirmItem(ctx context.Context, item sqlite.ImportRunItem, now time.Time) error { result, err := w.Confirmer(ctx, item) if err != nil { return err } if strings.TrimSpace(item.LeaseOwner) == "" { item.ConfirmationAttempts++ item.LeaseOwner = strings.TrimSpace(w.WorkerID) item.LeaseUntil = now.Add(defaultDuration(w.LeaseDuration, time.Minute)).Format(time.RFC3339) } switch { case result.StatusCode >= 200 && result.StatusCode < 300: item.ConfirmationStatus = string(ConfirmationConfirmed) item.CurrentStage = string(ItemStageValidate) item.NextRetryAt = "" item.LastError = "" item.LastErrorStage = "" item.LeaseOwner = "" item.LeaseUntil = "" if err := w.ItemStore.Upsert(ctx, item); err != nil { return err } return w.EventStore.Append(ctx, sqlite.ImportRunItemEvent{ EventID: confirmationEventID(item.ItemID, "stage_transition", now), RunID: item.RunID, ItemID: item.ItemID, EventType: "stage_transition", Stage: string(ItemStageValidate), Attempt: item.ConfirmationAttempts, Message: "confirmation succeeded", PayloadJSON: `{"confirmation_status":"confirmed"}`, }) case result.StatusCode == 403 && supportsProbe403Advisory(item.CapabilityProfileJSON): item.ConfirmationStatus = string(ConfirmationAdvisory) item.CurrentStage = string(ItemStageValidate) item.AdvisoryMessagesJSON = appendAdvisoryJSON(item.AdvisoryMessagesJSON, "initial_probe_race_expected") item.LastError = strings.TrimSpace(result.Message) item.LastErrorStage = string(ItemStageConfirm) item.NextRetryAt = "" item.LeaseOwner = "" item.LeaseUntil = "" if err := w.ItemStore.Upsert(ctx, item); err != nil { return err } return w.EventStore.Append(ctx, sqlite.ImportRunItemEvent{ EventID: confirmationEventID(item.ItemID, "advisory_added", now), RunID: item.RunID, ItemID: item.ItemID, EventType: "advisory_added", Stage: string(ItemStageConfirm), Attempt: item.ConfirmationAttempts, Message: "initial probe race handled as advisory", PayloadJSON: `{"advisory":"initial_probe_race_expected"}`, }) case isWarmupRetryCandidate(result): item.RetryCount++ item.LastRetryAt = now.Format(time.RFC3339) item.NextRetryAt = now.Add(defaultDuration(w.RetryDelay, time.Second)).Format(time.RFC3339) item.LastError = strings.TrimSpace(result.Message) item.LastErrorStage = string(ItemStageConfirm) item.LeaseOwner = "" item.LeaseUntil = "" if err := w.ItemStore.Upsert(ctx, item); err != nil { return err } return w.EventStore.Append(ctx, sqlite.ImportRunItemEvent{ EventID: confirmationEventID(item.ItemID, "retry_scheduled", now), RunID: item.RunID, ItemID: item.ItemID, EventType: "retry_scheduled", Stage: string(ItemStageConfirm), Attempt: item.ConfirmationAttempts, Message: "initial 503 no available accounts, retry scheduled", PayloadJSON: fmt.Sprintf(`{"next_retry_at":%q}`, item.NextRetryAt), }) default: item.ConfirmationStatus = string(ConfirmationFailed) item.CurrentStage = string(ItemStageDone) item.LastError = strings.TrimSpace(result.Message) item.LastErrorStage = string(ItemStageConfirm) item.NextRetryAt = "" item.LeaseOwner = "" item.LeaseUntil = "" if err := w.ItemStore.Upsert(ctx, item); err != nil { return err } return nil } } func isConfirmationCandidate(item sqlite.ImportRunItem, now time.Time) bool { if item.CurrentStage != string(ItemStageConfirm) { return false } if item.ConfirmationStatus != string(ConfirmationPending) { return false } if !isRetryDue(item.NextRetryAt, now) { return false } if !leaseExpired(item.LeaseUntil, now) { return false } return true } func isRetryDue(nextRetryAt string, now time.Time) bool { nextRetryAt = strings.TrimSpace(nextRetryAt) if nextRetryAt == "" { return true } parsed, err := time.Parse(time.RFC3339, nextRetryAt) if err != nil { return true } return !parsed.After(now) } func leaseExpired(leaseUntil string, now time.Time) bool { leaseUntil = strings.TrimSpace(leaseUntil) if leaseUntil == "" { return true } parsed, err := time.Parse(time.RFC3339, leaseUntil) if err != nil { return true } return parsed.Before(now) } func supportsProbe403Advisory(capabilityProfileJSON string) bool { var payload struct { TransportProfile struct { KnownAdvisories []string `json:"known_advisories"` } `json:"transport_profile"` } if err := json.Unmarshal([]byte(strings.TrimSpace(capabilityProfileJSON)), &payload); err != nil { return false } for _, advisory := range payload.TransportProfile.KnownAdvisories { if advisory == "initial_probe_race_expected" { return true } } return false } func isWarmupRetryCandidate(result ConfirmationResult) bool { message := strings.ToLower(strings.TrimSpace(result.Message)) return result.StatusCode == 503 && strings.Contains(message, "no available accounts") } func appendAdvisoryJSON(rawJSON, advisory string) string { advisory = strings.TrimSpace(advisory) if advisory == "" { return defaultJSONString(rawJSON, "[]") } values := []string{} if err := json.Unmarshal([]byte(defaultJSONString(rawJSON, "[]")), &values); err != nil { values = []string{} } for _, existing := range values { if existing == advisory { payload, _ := json.Marshal(values) return string(payload) } } values = append(values, advisory) payload, err := json.Marshal(values) if err != nil { return defaultJSONString(rawJSON, "[]") } return string(payload) } func confirmationEventID(itemID, eventType string, now time.Time) string { return fmt.Sprintf("%s-%s-%d", itemID, eventType, now.UnixNano()) } func defaultDuration(value, fallback time.Duration) time.Duration { if value > 0 { return value } return fallback } func defaultJSONString(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value }