package adapters import ( "context" "log" oppintel "github.com/missionworkforce/missionmatch/internal/modules/opportunity_intelligence" ) // FallbackAwardsSource runs a primary OpportunitySource and, only if it fails before yielding any // record, runs a fallback source instead. It is how the new SAM Contract Awards feed stays primary while // the free unauthenticated USASpending feed remains the resilient fallback. Once the primary has yielded // at least one record the run commits to it, so a mid pull failure never mixes two feeds and double // counts the same award. A yield error (a downstream storage or database failure) is surfaced, not // retried against the fallback, because that is the ingester aborting, not the source failing. type FallbackAwardsSource struct { primary oppintel.OpportunitySource fallback oppintel.OpportunitySource } // NewFallbackAwardsSource pairs a primary and a fallback source. func NewFallbackAwardsSource(primary, fallback oppintel.OpportunitySource) *FallbackAwardsSource { return &FallbackAwardsSource{primary: primary, fallback: fallback} } // Name reports the primary source name, since that is the feed a healthy run uses. func (f *FallbackAwardsSource) Name() string { return f.primary.Name() } // Fetch runs the primary, falling back to the secondary only when the primary fails before yielding. func (f *FallbackAwardsSource) Fetch(ctx context.Context, yield func(oppintel.OpportunityRecord) error) error { yielded := 0 counting := func(rec oppintel.OpportunityRecord) error { yielded++ return yield(rec) } err := f.primary.Fetch(ctx, counting) if err == nil { return nil } if yielded > 0 { // The primary already produced records (a throttle here is a soft stop the ingester tolerates, // a yield error is the ingester aborting). Either way, do not switch feeds mid run. return err } log.Printf("awards primary source %s failed before yielding (%v), falling back to %s", f.primary.Name(), err, f.fallback.Name()) return f.fallback.Fetch(ctx, yield) }