Skip to content

Commit 8c2d565

Browse files
pragmaximclaude
andcommitted
fix(fiat-rates): discard partial reconcile results on early exit to avoid losing token series
ReconcileHistoricalRates accumulates every series (base coin x each vsCurrency, then every token) into one shared per-day record, so a day record is only as complete as it will ever be once all targets have run. The previous code persisted the partial map on any early exit (shutdown/budget abort, Cloudflare ban, or DB fill error). After a base-phase or mid-token-phase abort that left a day with base rates but no token rates; because stage-1 detection is whole-day key-only, the day was then seen as "present" and never reconciled again -- silently dropping its token series for good. Make persistence all-or-nothing: write only after a clean, complete pass; on any early exit discard the partial map and report 0 filled, so the next startup re-reconciles the whole gap. Move the fetched-units/tokens metrics to the clean-completion path so discarded fetches are not counted, and generalize observeFetchedToken to take a count. Tradeoff: an aborted run no longer resumes from partial progress and refetches the gap next startup (a future per-day completion checkpoint can restore that). An isolated per-series failure on an otherwise-clean run still persists a partial-day hole -- tracked separately (see #1545). Tests: TokenPhaseAbortDiscardsBaseOnly (verified to fail on the old persist-on-abort behavior) and NoTokensPersistsBaseOnly (clean base-only pass still persists). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7407ab8 commit 8c2d565

2 files changed

Lines changed: 193 additions & 32 deletions

File tree

fiat/coingecko.go

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,7 @@ func (cg *Coingecko) getHistoricalTicker(ctx context.Context, tickersToUpdate ma
887887
}
888888
cg.observeFetchedUnits(phase, written)
889889
if token != "" && written > 0 {
890-
cg.observeFetchedToken(phase)
890+
cg.observeFetchedToken(phase, 1)
891891
}
892892
cg.observeUnable(phase, fiatUnableNoBaseTicker, noBaseTicker)
893893
return true, nil
@@ -899,9 +899,9 @@ func (cg *Coingecko) observeFetchedUnits(phase string, n int) {
899899
}
900900
}
901901

902-
func (cg *Coingecko) observeFetchedToken(phase string) {
903-
if cg.metrics != nil {
904-
cg.metrics.FiatRatesFetchedTokens.With(common.Labels{"phase": phase}).Inc()
902+
func (cg *Coingecko) observeFetchedToken(phase string, n int) {
903+
if cg.metrics != nil && n > 0 {
904+
cg.metrics.FiatRatesFetchedTokens.With(common.Labels{"phase": phase}).Add(float64(n))
905905
}
906906
}
907907

@@ -1154,10 +1154,19 @@ func (cg *Coingecko) missingDayWindow(windowDays int) (missing map[uint]struct{}
11541154
// population) or when no CDN URL is configured.
11551155
//
11561156
// ctx bounds the whole pass: it carries both the shutdown signal and a wall-clock budget, so a
1157-
// SIGTERM or a persistently throttling CDN aborts the pass promptly instead of stalling
1158-
// startup. On abort, any days already fetched are persisted so the next startup resumes from
1159-
// the remaining gap; a budget timeout is also recorded as fiat_rates_unable_total{reason=
1160-
// "budget_exhausted"} so it is visible in monitoring.
1157+
// SIGTERM or a persistently throttling CDN aborts the pass promptly instead of stalling startup.
1158+
// Persistence is all-or-nothing per pass: results are written only after the loop has run to
1159+
// completion over every target. On any early exit (abort, budget timeout, Cloudflare ban, or DB
1160+
// error) the partially fetched days are discarded, not persisted, so a day is never marked
1161+
// "present" while still missing the series the un-run targets would have filled (which the
1162+
// whole-day key-only stage-1 scan could not later detect); the next startup re-reconciles the
1163+
// gap. A budget timeout is recorded as fiat_rates_unable_total{reason="budget_exhausted"} so it
1164+
// is visible in monitoring. (An isolated per-series fetch failure is skipped, not an early exit:
1165+
// the pass still completes and persists, leaving that series' day a partial-day hole.)
1166+
//
1167+
// Scope: this repairs days that are wholly absent from the DB. A day that has a record but is
1168+
// missing a particular vsCurrency or token (a partial-day hole) is treated as present and is not
1169+
// repaired here; that is handled separately.
11611170
func (cg *Coingecko) ReconcileHistoricalRates(ctx context.Context, windowDays int, maxGapDays int) (int, error) {
11621171
bootstrapInProgress, _, err := historicalBootstrapInProgress(cg.db)
11631172
if err != nil {
@@ -1248,17 +1257,27 @@ func (cg *Coingecko) ReconcileHistoricalRates(ctx context.Context, windowDays in
12481257
len(missingDays), windowDays, time.Unix(earliestMissing, 0).UTC().Format("2006-01-02"), fetchDays, len(targets))
12491258

12501259
// ---- Stage 2: fetch the missing-day range from the CDN and fill only those days ----
1251-
// targetDays ensures only the (few) missing-day records are touched and persisted, so the
1252-
// shared map holds at most len(missingDays) records regardless of how many series we scan.
1260+
// targetDays ensures only the (few) missing-day records are touched, so the shared map holds at
1261+
// most len(missingDays) records regardless of how many series we scan.
1262+
//
1263+
// All-or-nothing persistence: every target writes into the SAME per-day records (base coin x
1264+
// each vsCurrency first, then every token), so a day record is only as complete as it will ever
1265+
// be once ALL targets have run. We therefore persist the map only after a clean, complete pass.
1266+
// On any early exit -- a shutdown/budget abort, a Cloudflare ban, or a DB fill error -- the
1267+
// accumulated records are missing the series from the un-run targets (e.g. after a base-phase
1268+
// abort they hold base rates but no token rates). Because stage-1 detection is whole-day
1269+
// key-only, persisting such a record would mark the day "present" and it would never be
1270+
// reconciled again, silently dropping the un-run series for good. So on an early exit we discard
1271+
// the partial map and let the next startup re-reconcile the whole gap from scratch. (A future
1272+
// per-day completion checkpoint could let an aborted run resume without refetching.)
12531273
tickersToUpdate := make(map[uint]*common.CurrencyRatesTicker)
1254-
filled, failed, repairedTokens := 0, 0, 0
1274+
filled, failed, repairedTokens, noBaseTotal := 0, 0, 0, 0
12551275
var banErr, abortErr error
12561276
abortAt := 0
12571277
for i, c := range targets {
1258-
// Abort promptly on shutdown or budget timeout; persist what we already fetched (below)
1259-
// so the next startup resumes from the remaining gap rather than refetching from scratch.
1260-
// The check before the fetch catches cancellation between iterations; the one after
1261-
// catches cancellation during the in-flight fetch.
1278+
// Abort promptly on shutdown or budget timeout. The check before the fetch catches
1279+
// cancellation between iterations; the one after catches cancellation during the in-flight
1280+
// fetch. The accumulated map is discarded after the loop (see above).
12621281
if err := ctx.Err(); err != nil {
12631282
abortErr, abortAt = err, i
12641283
break
@@ -1272,7 +1291,7 @@ func (cg *Coingecko) ReconcileHistoricalRates(ctx context.Context, windowDays in
12721291
failed++
12731292
if isCoingeckoCloudflareBanError(err) {
12741293
cg.observeUnable(fiatPhaseReconcile, fiatUnableProviderBan, 1)
1275-
banErr = err
1294+
banErr, abortAt = err, i
12761295
glog.Errorf("FiatRates reconcile: Cloudflare ban fetching %s/%s, stopping: %v", c.coinId, c.vsCurrency, err)
12771296
break
12781297
}
@@ -1282,35 +1301,41 @@ func (cg *Coingecko) ReconcileHistoricalRates(ctx context.Context, windowDays in
12821301
}
12831302
written, noBaseTicker, err := cg.fillFromMarketChart(tickersToUpdate, mc, c.vsCurrency, c.token, true, missingDays)
12841303
if err != nil {
1285-
if storeErr := cg.storeTickers(tickersToUpdate); storeErr != nil {
1286-
return filled, storeErr
1287-
}
1288-
return filled, fmt.Errorf("reconcile: fill %s/%s: %w", c.coinId, c.vsCurrency, err)
1304+
// A DB fill error is an abnormal mid-pass exit: discard the partial map (do not persist)
1305+
// and surface the error so the next startup retries the whole gap.
1306+
return 0, fmt.Errorf("reconcile: fill %s/%s: %w", c.coinId, c.vsCurrency, err)
12891307
}
12901308
filled += written
1291-
cg.observeFetchedUnits(fiatPhaseReconcile, written)
1309+
noBaseTotal += noBaseTicker
12921310
if c.token != "" && written > 0 {
12931311
repairedTokens++
1294-
cg.observeFetchedToken(fiatPhaseReconcile)
12951312
}
1296-
cg.observeUnable(fiatPhaseReconcile, fiatUnableNoBaseTicker, noBaseTicker)
1297-
}
1298-
if err := cg.storeTickers(tickersToUpdate); err != nil {
1299-
return filled, err
13001313
}
1314+
1315+
// Early exit: discard the incomplete map (see the block comment above) so no day is wrongly
1316+
// marked present; nothing is persisted, so report 0 filled.
13011317
if abortErr != nil {
13021318
remaining := len(targets) - abortAt
1303-
// a budget timeout (vs. a clean shutdown) is a problem worth surfacing in monitoring:
1304-
// the window was too small or the CDN too slow to finish.
1319+
// A budget timeout (vs. a clean shutdown) is worth surfacing in monitoring: the window was
1320+
// too small or the CDN too slow to finish within the budget.
13051321
if errors.Is(abortErr, context.DeadlineExceeded) {
13061322
cg.observeUnable(fiatPhaseReconcile, fiatUnableBudgetExhausted, remaining)
13071323
}
1308-
glog.Warningf("FiatRates reconcile stage 2: aborted (%v), persisted %d point(s) (%d token series), %d fetch failures, %d series unprocessed; remaining gap will be reconciled on next startup", abortErr, filled, repairedTokens, failed, remaining)
1309-
return filled, nil
1324+
glog.Warningf("FiatRates reconcile stage 2: aborted (%v) after %d/%d series, %d fetch failures; discarding partial results, gap will be reconciled on next startup", abortErr, abortAt, len(targets), failed)
1325+
return 0, nil
13101326
}
1311-
glog.Infof("FiatRates reconcile stage 2: filled %d point(s) across %d series (%d token series), %d fetch failures", filled, len(targets), repairedTokens, failed)
13121327
if banErr != nil {
1313-
return filled, banErr
1328+
glog.Warningf("FiatRates reconcile stage 2: Cloudflare ban after %d/%d series, %d fetch failures; discarding partial results, gap will be reconciled on next startup", abortAt, len(targets), failed)
1329+
return 0, banErr
13141330
}
1331+
1332+
// Clean, complete pass: persist, then record what was actually fetched and persisted.
1333+
if err := cg.storeTickers(tickersToUpdate); err != nil {
1334+
return 0, err
1335+
}
1336+
cg.observeFetchedUnits(fiatPhaseReconcile, filled)
1337+
cg.observeFetchedToken(fiatPhaseReconcile, repairedTokens)
1338+
cg.observeUnable(fiatPhaseReconcile, fiatUnableNoBaseTicker, noBaseTotal)
1339+
glog.Infof("FiatRates reconcile stage 2: filled %d point(s) across %d series (%d token series), %d fetch failures", filled, len(targets), repairedTokens, failed)
13151340
return filled, nil
13161341
}

fiat/coingecko_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,3 +1614,139 @@ func TestReconcileHistoricalRates_BudgetExhaustedAbortsMidBackfill(t *testing.T)
16141614
t.Fatal("expected the backfill loop to attempt at least one market_chart fetch before the budget expired")
16151615
}
16161616
}
1617+
1618+
// A budget/shutdown abort during the token phase must NOT persist the base-only day records
1619+
// already accumulated by the (finished) base phase. If it did, stage-1's whole-day key-only scan
1620+
// would treat those days as "present" next startup and never reconcile their token rates again.
1621+
// The aborted pass must discard everything so the gap is re-reconciled in full later. (The same
1622+
// discard path covers a base-phase abort, which would persist partial base rates.)
1623+
func TestReconcileHistoricalRates_TokenPhaseAbortDiscardsBaseOnly(t *testing.T) {
1624+
config := common.Config{CoinName: "fakecoin"}
1625+
d, _, tmp := setupRocksDB(t, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}, &config)
1626+
defer closeAndDestroyRocksDB(t, d, tmp)
1627+
1628+
if err := d.FiatRatesSetHistoricalBootstrapComplete(true); err != nil {
1629+
t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err)
1630+
}
1631+
// present d2, d4, d5; d3 is the interior hole to repair.
1632+
d2, d3, d4, d5 := midnightDaysAgo(2), midnightDaysAgo(3), midnightDaysAgo(4), midnightDaysAgo(5)
1633+
seedDailyTicker(t, d, d2, map[string]float32{"usd": 222}, nil)
1634+
seedDailyTicker(t, d, d4, map[string]float32{"usd": 444}, nil)
1635+
seedDailyTicker(t, d, d5, map[string]float32{"usd": 555}, nil)
1636+
1637+
var baseFetched int32
1638+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1639+
switch r.URL.Path {
1640+
case "/simple/supported_vs_currencies":
1641+
_, _ = w.Write([]byte(`["usd"]`))
1642+
case "/coins/list":
1643+
_, _ = w.Write([]byte(`[{"id":"ethereum","symbol":"eth","name":"Ethereum","platforms":{}},{"id":"token-a","symbol":"ta","name":"Token A","platforms":{"ethereum":"0xabc"}}]`))
1644+
case "/coins/ethereum/market_chart":
1645+
// base phase succeeds, filling d3's base rate into the shared map
1646+
atomic.AddInt32(&baseFetched, 1)
1647+
_, _ = w.Write([]byte(fmt.Sprintf(`{"prices":[[%d,333]]}`, d3.Unix()*1000)))
1648+
case "/coins/token-a/market_chart":
1649+
// token phase blocks until the budget cancels the request -> abort mid-token-phase
1650+
<-r.Context().Done()
1651+
default:
1652+
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
1653+
}
1654+
}))
1655+
defer mockServer.Close()
1656+
1657+
cg := &Coingecko{
1658+
coin: "ethereum",
1659+
platformIdentifier: "ethereum",
1660+
platformVsCurrency: "eth",
1661+
bootstrapURL: mockServer.URL,
1662+
tipURL: mockServer.URL,
1663+
httpClient: mockServer.Client(),
1664+
db: d,
1665+
plan: coingeckoPlanFree,
1666+
}
1667+
1668+
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
1669+
defer cancel()
1670+
1671+
filled, err := cg.ReconcileHistoricalRates(ctx, 365, 90)
1672+
if err != nil {
1673+
t.Fatalf("token-phase abort must not return an error, got: %v", err)
1674+
}
1675+
if filled != 0 {
1676+
t.Fatalf("aborted reconciliation must persist nothing, got filled=%d", filled)
1677+
}
1678+
if atomic.LoadInt32(&baseFetched) == 0 {
1679+
t.Fatal("expected the base phase to run (and fill d3) before the abort")
1680+
}
1681+
// Key assertion: the base-only d3 record must NOT have been persisted, so d3 stays a hole and
1682+
// is re-reconciled later instead of being silently frozen without its token rate.
1683+
t3, err := d.FiatRatesGetTicker(&d3)
1684+
if err != nil {
1685+
t.Fatalf("FiatRatesGetTicker d3 failed: %v", err)
1686+
}
1687+
if t3 != nil {
1688+
t.Fatalf("base-only day was persisted on abort (token rates would be lost forever): %+v", t3)
1689+
}
1690+
// present days must remain untouched
1691+
t2, err := d.FiatRatesGetTicker(&d2)
1692+
if err != nil {
1693+
t.Fatalf("FiatRatesGetTicker d2 failed: %v", err)
1694+
}
1695+
if t2 == nil || t2.Rates["usd"] != 222 {
1696+
t.Fatalf("present day was modified: got %+v, want usd=222", t2)
1697+
}
1698+
}
1699+
1700+
// A coin without tokens reconciles base rates only; a clean pass must still persist them
1701+
// (base-only is "complete" when no tokens are configured -- the discard-on-abort guard must not
1702+
// over-prune the normal completion path).
1703+
func TestReconcileHistoricalRates_NoTokensPersistsBaseOnly(t *testing.T) {
1704+
config := common.Config{CoinName: "fakecoin"}
1705+
d, _, tmp := setupRocksDB(t, &testBitcoinParser{BitcoinParser: bitcoinTestnetParser()}, &config)
1706+
defer closeAndDestroyRocksDB(t, d, tmp)
1707+
1708+
if err := d.FiatRatesSetHistoricalBootstrapComplete(true); err != nil {
1709+
t.Fatalf("FiatRatesSetHistoricalBootstrapComplete failed: %v", err)
1710+
}
1711+
// present d2, d4, d5; d3 is the interior hole to repair.
1712+
d2, d3, d4, d5 := midnightDaysAgo(2), midnightDaysAgo(3), midnightDaysAgo(4), midnightDaysAgo(5)
1713+
seedDailyTicker(t, d, d2, map[string]float32{"usd": 222}, nil)
1714+
seedDailyTicker(t, d, d4, map[string]float32{"usd": 444}, nil)
1715+
seedDailyTicker(t, d, d5, map[string]float32{"usd": 555}, nil)
1716+
1717+
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1718+
switch r.URL.Path {
1719+
case "/simple/supported_vs_currencies":
1720+
_, _ = w.Write([]byte(`["usd"]`))
1721+
case "/coins/ethereum/market_chart":
1722+
_, _ = w.Write([]byte(fmt.Sprintf(`{"prices":[[%d,333]]}`, d3.Unix()*1000)))
1723+
default:
1724+
http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
1725+
}
1726+
}))
1727+
defer mockServer.Close()
1728+
1729+
cg := &Coingecko{
1730+
coin: "ethereum",
1731+
bootstrapURL: mockServer.URL,
1732+
tipURL: mockServer.URL,
1733+
httpClient: mockServer.Client(),
1734+
db: d,
1735+
plan: coingeckoPlanFree,
1736+
}
1737+
1738+
filled, err := cg.ReconcileHistoricalRates(context.Background(), 365, 90)
1739+
if err != nil {
1740+
t.Fatalf("ReconcileHistoricalRates failed: %v", err)
1741+
}
1742+
if filled != 1 {
1743+
t.Fatalf("unexpected filled points: got %d, want 1", filled)
1744+
}
1745+
t3, err := d.FiatRatesGetTicker(&d3)
1746+
if err != nil {
1747+
t.Fatalf("FiatRatesGetTicker d3 failed: %v", err)
1748+
}
1749+
if t3 == nil || t3.Rates["usd"] != 333 {
1750+
t.Fatalf("interior hole base rate not repaired: got %+v, want usd=333", t3)
1751+
}
1752+
}

0 commit comments

Comments
 (0)