diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/BasketProbeService.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/BasketProbeService.java
new file mode 100644
index 0000000..58f634b
--- /dev/null
+++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/BasketProbeService.java
@@ -0,0 +1,153 @@
+package com.lincfusion.udyamlinc.ingestion;
+
+import com.lincfusion.udyamlinc.ingestion.client.DataGovClient;
+import com.lincfusion.udyamlinc.ingestion.client.MandiRecord;
+import com.lincfusion.udyamlinc.master.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.util.*;
+
+/**
+ * Asks the live feed one question per basket pair: does this spelling return
+ * anything, and if not, whose fault is it likely to be.
+ *
+ *
This exists because of how Agmarknet fails. A misspelt commodity, a
+ * district renamed on our side but not theirs, and a market that genuinely had
+ * no arrivals all produce the same response — HTTP 200, empty record list. A
+ * nightly run folds all three into one number and the console shows a feed that
+ * looks healthy while a commodity quietly contributes nothing.
+ *
+ *
The probe separates them using two facts the ingestion loop does not have:
+ * whether this commodity has ever returned rows anywhere (so the
+ * spelling is known good), and whether this district has returned rows for
+ * anything else (so the district filter is known good). A pair that is empty
+ * while both of its axes work elsewhere is a real market fact. A commodity
+ * empty in every district it is basketed in is almost certainly our spelling.
+ *
+ *
It writes nothing but the verified flag. Deliberately: a probe that
+ * ingested would make "check the spelling" and "load a year of prices" the same
+ * button, and the first needs to be cheap enough to run whenever a basket
+ * changes.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class BasketProbeService {
+
+ /** One week back is enough to prove a filter matches; the goal is not history. */
+ private static final int PROBE_WEEKS = 8;
+
+ private final DataGovClient client;
+ private final DistrictRepository districtRepository;
+ private final CommodityRepository commodityRepository;
+ private final DistrictCommodityRepository districtCommodityRepository;
+
+ public record PairResult(String districtCode, String district, String commodityCode,
+ String sourceCode, boolean benchmark, boolean wasVerified,
+ int rows, LocalDate newestArrival, String verdict) {
+ }
+
+ public record ProbeReport(boolean configured, int pairs, int returningRows, int empty, int failed,
+ List suspectCommodities, List suspectDistricts,
+ int newlyVerified, List results) {
+ }
+
+ /**
+ * @param commodityCode probe only this commodity, or null for the whole basket set
+ * @param persist write {@code sourceVerified = true} for commodities that
+ * returned rows. Off by default — a probe should be safe to
+ * run against production without changing what it measures.
+ */
+ public ProbeReport probe(String commodityCode, boolean persist) {
+ if (!client.configured()) {
+ return new ProbeReport(false, 0, 0, 0, 0, List.of(), List.of(), 0, List.of());
+ }
+
+ Map commodities = new HashMap<>();
+ commodityRepository.findAll().forEach(c -> commodities.put(c.getId(), c));
+ List districts = districtRepository.findByActiveTrue();
+
+ List results = new ArrayList<>();
+ int failed = 0;
+
+ for (District district : districts) {
+ for (DistrictCommodity dc : districtCommodityRepository.findByDistrictIdAndActiveTrue(district.getId())) {
+ Commodity commodity = commodities.get(dc.getCommodityId());
+ if (commodity == null || !DataGovClient.SOURCE.equals(commodity.getSource())) continue;
+ if (commodityCode != null && !commodityCode.equalsIgnoreCase(commodity.getCode())) continue;
+
+ String sourceCode = commodity.getSourceCode() == null
+ ? commodity.getName() : commodity.getSourceCode();
+ List records = client.fetchPrices(
+ district.feedState(), district.feedDistrict(), sourceCode, PROBE_WEEKS);
+
+ if (records == null) {
+ failed++;
+ results.add(new PairResult(district.getCode(), district.getName(), commodity.getCode(),
+ sourceCode, Boolean.TRUE.equals(dc.getBenchmark()),
+ Boolean.TRUE.equals(commodity.getSourceVerified()), 0, null, "CALL_FAILED"));
+ continue;
+ }
+ LocalDate newest = records.stream().map(MandiRecord::arrivalDate)
+ .filter(Objects::nonNull).max(LocalDate::compareTo).orElse(null);
+ results.add(new PairResult(district.getCode(), district.getName(), commodity.getCode(),
+ sourceCode, Boolean.TRUE.equals(dc.getBenchmark()),
+ Boolean.TRUE.equals(commodity.getSourceVerified()),
+ records.size(), newest, records.isEmpty() ? "EMPTY" : "ROWS"));
+ }
+ }
+
+ // A commodity that came back empty in every district that trades it, on a
+ // run where other commodities in those same districts returned rows. The
+ // district axis is therefore working, which leaves the spelling.
+ Set workingDistricts = new HashSet<>();
+ Set workingCommodities = new HashSet<>();
+ for (PairResult r : results) {
+ if (r.rows() > 0) {
+ workingDistricts.add(r.districtCode());
+ workingCommodities.add(r.commodityCode());
+ }
+ }
+ List suspectCommodities = results.stream()
+ .map(PairResult::commodityCode).distinct()
+ .filter(c -> !workingCommodities.contains(c))
+ .filter(c -> results.stream()
+ .filter(r -> r.commodityCode().equals(c))
+ .anyMatch(r -> workingDistricts.contains(r.districtCode())))
+ .sorted().toList();
+
+ // The mirror image: a district empty on everything while its commodities
+ // work elsewhere — the district or state spelling, not the crop.
+ List suspectDistricts = results.stream()
+ .map(PairResult::districtCode).distinct()
+ .filter(d -> !workingDistricts.contains(d))
+ .filter(d -> results.stream()
+ .filter(r -> r.districtCode().equals(d))
+ .anyMatch(r -> workingCommodities.contains(r.commodityCode())))
+ .sorted().toList();
+
+ int newlyVerified = 0;
+ if (persist) {
+ for (String code : workingCommodities) {
+ Optional c = commodityRepository.findByCode(code);
+ if (c.isPresent() && !Boolean.TRUE.equals(c.get().getSourceVerified())) {
+ c.get().setSourceVerified(true);
+ commodityRepository.save(c.get());
+ newlyVerified++;
+ }
+ }
+ }
+
+ int returning = (int) results.stream().filter(r -> r.rows() > 0).count();
+ int empty = (int) results.stream().filter(r -> "EMPTY".equals(r.verdict())).count();
+ log.info("Basket probe — {} pairs, {} returned rows, {} empty, {} failed. Suspect spellings: {}",
+ results.size(), returning, empty, failed,
+ suspectCommodities.isEmpty() ? "none" : suspectCommodities);
+
+ return new ProbeReport(true, results.size(), returning, empty, failed,
+ suspectCommodities, suspectDistricts, newlyVerified, results);
+ }
+}
diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java
index db8d755..b9bb527 100644
--- a/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java
+++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java
@@ -33,6 +33,41 @@ public class IngestionController {
private final ApiLogRepository apiLogRepository;
private final DistrictRepository districtRepository;
private final CommodityRepository commodityRepository;
+ private final BasketProbeService basketProbeService;
+ private final NoFeedPriceSimulationService noFeedPriceSimulationService;
+
+ /**
+ * Three years of weekly prices for milk, egg, fodder and sugarcane — the
+ * commodities no API publishes. Rows carry {@code source = SIMULATED} and
+ * never collide with a real MANUAL or AGMARKNET row, because the upsert key
+ * includes the source.
+ */
+ @PostMapping("/simulate/no-feed")
+ public ApiResponse simulateNoFeed() {
+ NoFeedPriceSimulationService.SimulationResult result = noFeedPriceSimulationService.simulate();
+ return ApiResponse.ok(result.message(), result);
+ }
+
+ /**
+ * One call per basket pair, reporting which spellings actually match the
+ * feed. A write only in the {@code persist=true} sense — it sets the
+ * verified flag and ingests nothing.
+ *
+ * Run this after changing a basket or adding a commodity, and before
+ * reading anything into a night of empty results. Under {@code /admin} it is
+ * supervisor-only by the existing write rule.
+ */
+ @PostMapping("/probe")
+ public ApiResponse probe(
+ @RequestParam(required = false) String commodity,
+ @RequestParam(defaultValue = "false") boolean persist) {
+ BasketProbeService.ProbeReport report = basketProbeService.probe(commodity, persist);
+ if (!report.configured()) {
+ return ApiResponse.ok("No DATAGOV_API_KEY configured — nothing probed", report);
+ }
+ return ApiResponse.ok("%d pairs probed · %d returned rows · %d suspect spellings"
+ .formatted(report.pairs(), report.returningRows(), report.suspectCommodities().size()), report);
+ }
// ── what ran ────────────────────────────────────────────
@@ -154,8 +189,18 @@ public class IngestionController {
return ApiResponse.ok("Ingestion triggered", scheduler.runAll("MANUAL"));
}
+ /**
+ * @param lookbackWeeks a one-off deep window, for the backfill that gives
+ * {@code price_z_score} a multi-year seasonal mean.
+ * {@code POST /admin/ingestion/run/AGMARKNET?lookbackWeeks=156}
+ * is the three-year pull. Omitted, the configured nightly
+ * window applies — the deep pull is many times the calls,
+ * so it is asked for explicitly and never scheduled.
+ */
@PostMapping("/run/{source}")
- public ApiResponse runOne(@PathVariable String source) {
- return ApiResponse.ok("Ingestion triggered", scheduler.run(source.toUpperCase(), "MANUAL"));
+ public ApiResponse runOne(@PathVariable String source,
+ @RequestParam(required = false) Integer lookbackWeeks) {
+ return ApiResponse.ok("Ingestion triggered",
+ scheduler.run(source.toUpperCase(), "MANUAL", lookbackWeeks));
}
}
diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionScheduler.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionScheduler.java
index 0cd0c84..5df94ed 100644
--- a/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionScheduler.java
+++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionScheduler.java
@@ -60,6 +60,15 @@ public class IngestionScheduler {
* nothing" must not look the same in the console.
*/
public IngestionRun run(String source, String trigger) {
+ return run(source, trigger, null);
+ }
+
+ /**
+ * @param lookbackWeeks a one-off window for this run, or {@code null} for the
+ * configured one. Only Agmarknet honours it; the climate
+ * archive is already pulled against a fixed baseline.
+ */
+ public IngestionRun run(String source, String trigger, Integer lookbackWeeks) {
if (!running.compareAndSet(false, true)) {
return record(source, trigger, IngestionRun.SKIPPED,
new IngestionResult(0, 0, 0, 0, "An ingestion pass is already running"),
@@ -76,7 +85,7 @@ public class IngestionScheduler {
}
IngestionResult result = switch (source) {
- case DataGovClient.SOURCE -> mandiService.ingest();
+ case DataGovClient.SOURCE -> mandiService.ingest(lookbackWeeks);
case OpenMeteoClient.SOURCE -> climateService.ingest();
default -> throw new IllegalArgumentException("Unknown ingestion source: " + source);
};
diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/MandiIngestionService.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/MandiIngestionService.java
index 9b29733..cdc4d48 100644
--- a/src/main/java/com/lincfusion/udyamlinc/ingestion/MandiIngestionService.java
+++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/MandiIngestionService.java
@@ -39,6 +39,7 @@ public class MandiIngestionService {
private final CommodityRepository commodityRepository;
private final MarketRepository marketRepository;
private final MarketSignalRepository signalRepository;
+ private final DistrictCommodityRepository districtCommodityRepository;
@Value("${udyam.ingestion.mandi.lookback-weeks:12}")
private int lookbackWeeks;
@@ -52,6 +53,20 @@ public class MandiIngestionService {
* how a nightly job takes the pool down. Each upsert commits on its own.
*/
public IngestionResult ingest() {
+ return ingest(null);
+ }
+
+ /**
+ * @param lookbackWeeksOverride how far back to reach for this run only, or
+ * {@code null} for the configured nightly window.
+ * A deep value is the one-off backfill that gives
+ * {@code price_z_score} the multi-year seasonal
+ * mean it needs; it is deliberately not the
+ * default, because paging three years of arrivals
+ * for every district on every nightly run would
+ * be several thousand calls to earn nothing.
+ */
+ public IngestionResult ingest(Integer lookbackWeeksOverride) {
List districts = districtRepository.findByActiveTrue();
List commodities = commodityRepository.findBySourceAndActiveTrue(DataGovClient.SOURCE);
@@ -59,18 +74,25 @@ public class MandiIngestionService {
return new IngestionResult(0, 0, 0, 0, "Nothing to pull — " + districts.size()
+ " active districts, " + commodities.size() + " Agmarknet commodities");
}
+ Map byId = commodities.stream()
+ .collect(Collectors.toMap(Commodity::getId, c -> c));
- LocalDate horizon = Weeks.startOf(LocalDate.now()).minusWeeks(lookbackWeeks);
+ int weeks = lookbackWeeksOverride == null || lookbackWeeksOverride <= 0
+ ? lookbackWeeks
+ : lookbackWeeksOverride;
+ LocalDate horizon = Weeks.startOf(LocalDate.now()).minusWeeks(weeks);
LocalDateTime observedAt = LocalDateTime.now();
- int fetched = 0, written = 0, skipped = 0, failed = 0;
+ int fetched = 0, written = 0, skipped = 0, failed = 0, pairs = 0;
for (District district : districts) {
Long marketId = marketRepository.findFirstByDistrictIdAndPrimaryMarketTrue(district.getId())
.map(Market::getId).orElse(null);
- for (Commodity commodity : commodities) {
+ for (Commodity commodity : basketFor(district, byId, commodities)) {
+ pairs++;
String sourceCode = commodity.getSourceCode() == null ? commodity.getName() : commodity.getSourceCode();
- List records = client.fetchPrices(district.getState(), district.getName(), sourceCode);
+ List records = client.fetchPrices(
+ district.feedState(), district.feedDistrict(), sourceCode, weeks);
if (records == null) {
failed++;
@@ -94,12 +116,38 @@ public class MandiIngestionService {
}
}
- String message = "%d districts × %d commodities · %d rows fetched · %d weeks written · %d calls failed"
- .formatted(districts.size(), commodities.size(), fetched, written, failed);
+ String message = "%d districts · %d basket pairs · %d week lookback · %d rows fetched · %d weeks written · %d calls failed"
+ .formatted(districts.size(), pairs, weeks, fetched, written, failed);
log.info("Agmarknet ingestion — {}", message);
return new IngestionResult(fetched, written, skipped, failed, message);
}
+ /**
+ * What this district trades, from {@code district_commodity}.
+ *
+ * Falls back to every Agmarknet commodity when a district has no basket
+ * rows, and says so. The fallback is the old cross-product behaviour, kept
+ * because an unseeded basket should degrade to pulling too much rather than
+ * to pulling nothing — a district silently ingesting no prices at all would
+ * show up only as features going stale a week later, whereas over-pulling
+ * shows up immediately in the run's failed-call count.
+ */
+ private List basketFor(District district, Map byId, List all) {
+ List basket = districtCommodityRepository
+ .findByDistrictIdAndActiveTrue(district.getId());
+ if (basket.isEmpty()) {
+ log.warn("District {} has no basket — falling back to all {} Agmarknet commodities",
+ district.getCode(), all.size());
+ return all;
+ }
+ // A basket may name a commodity that is inactive or not Agmarknet-sourced
+ // (milk, fodder); byId holds only the pullable ones, so those drop out.
+ return basket.stream()
+ .map(dc -> byId.get(dc.getCommodityId()))
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
private void upsert(District district, Commodity commodity, Long marketId,
LocalDate weekStart, List rows, LocalDateTime observedAt) {
diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/client/DataGovClient.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/client/DataGovClient.java
index 6a72c3d..2cde9b2 100644
--- a/src/main/java/com/lincfusion/udyamlinc/ingestion/client/DataGovClient.java
+++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/client/DataGovClient.java
@@ -51,6 +51,14 @@ public class DataGovClient {
@Value("${udyam.ingestion.datagov.page-limit:1000}")
private int pageLimit;
+ /**
+ * Hard ceiling on pages per (district, commodity), whatever the lookback asks
+ * for. A backstop, not a tuning knob: without it a mis-set lookback would page
+ * an 81-million-row resource until the key's quota ran out.
+ */
+ @Value("${udyam.ingestion.datagov.max-pages:40}")
+ private int maxPages;
+
// Filter field names, because they must match the resource's own field names.
@Value("${udyam.ingestion.datagov.field.state:state}")
private String fieldState;
@@ -69,6 +77,12 @@ public class DataGovClient {
@Value("${udyam.ingestion.datagov.sort-field:Arrival_Date}")
private String sortField;
+ @Value("${udyam.ingestion.datagov.retry-attempts:3}")
+ private int retryAttempts;
+
+ @Value("${udyam.ingestion.datagov.retry-backoff-ms:45000}")
+ private long retryBackoffMs;
+
public DataGovClient(@Qualifier("externalFeedClient") RestClient http, ApiLogRepository apiLogRepository) {
this.http = http;
this.apiLogRepository = apiLogRepository;
@@ -88,10 +102,103 @@ public class DataGovClient {
* the run continues — a feed being down degrades tomorrow's freshness, it
* does not abort ingestion.
*/
- @SuppressWarnings("unchecked")
public List fetchPrices(String state, String district, String commoditySourceCode) {
+ return fetchPrices(state, district, commoditySourceCode, 0);
+ }
+
+ /**
+ * Pages until the feed runs out, the requested window is covered, or the page
+ * ceiling is hit.
+ *
+ * One page was enough while the lookback was twelve weeks. It is not enough
+ * for the multi-year window {@code price_z_score} needs: a district reports
+ * several varieties across several markets every arrival day, so a thousand
+ * rows sorted newest-first can be a few months rather than a few years — and
+ * the shortfall is invisible, because a truncated page and a district that
+ * genuinely stopped reporting look identical downstream.
+ *
+ *
{@code lookbackWeeks} of zero or less means "one page", preserving the
+ * previous behaviour for callers that do not care.
+ */
+ public List fetchPrices(String state, String district, String commoditySourceCode,
+ int lookbackWeeks) {
if (!configured()) return null;
+ LocalDate horizon = lookbackWeeks <= 0 ? null : LocalDate.now().minusWeeks(lookbackWeeks);
+ boolean sorted = sortField != null && !sortField.isBlank();
+ int pageCeiling = horizon == null ? 1 : Math.max(1, maxPages);
+
+ List all = new ArrayList<>();
+ boolean anyCallSucceeded = false;
+
+ for (int page = 0; page < pageCeiling; page++) {
+ Page result = fetchPage(state, district, commoditySourceCode, page * pageLimit);
+ if (result == null) {
+ // An outage mid-way through a deep backfill: keep what earlier pages
+ // returned rather than discarding it, but never report a total
+ // failure as an empty district.
+ return anyCallSucceeded ? all : null;
+ }
+ anyCallSucceeded = true;
+ all.addAll(result.records());
+
+ // A short page is the end of the result set.
+ if (result.rawCount() < pageLimit) break;
+ if (horizon == null) break;
+
+ // Sorted newest-first, so once a page's oldest usable row predates the
+ // window, every later page does too. Without the sort there is no such
+ // guarantee and the ceiling is the only stop.
+ if (sorted && result.oldest() != null && result.oldest().isBefore(horizon)) break;
+ }
+
+ if (all.isEmpty()) {
+ log.warn("data.gov.in returned no usable records for {}/{}/{} — check the filter field names and the district's source spelling",
+ state, district, commoditySourceCode);
+ }
+ return all;
+ }
+
+ /** One page of records, or {@code null} when the call itself failed. */
+ private record Page(List records, int rawCount, LocalDate oldest) {
+ }
+
+ /** Set by {@link #attemptPage} so the retry loop can tell a 429 from a real failure. */
+ private volatile boolean lastWasRateLimited;
+
+ /**
+ * A page, retried when data.gov.in rate-limits us.
+ *
+ * The limit is a quota per window, not per call, so it is reached by the
+ * run rather than by any one request — and once reached, every
+ * remaining call in that run fails. A 62-pair basket run immediately after a
+ * 62-pair probe took all 62 pairs down together and recorded the whole
+ * ingestion as FAILED with zero rows.
+ *
+ *
Without this the failure is also silently misattributed: a 429 arrives
+ * as a failed call on the district that happened to be next in the loop, so
+ * the freshness screen blames whichever district the quota ran out on rather
+ * than the run. Open-Meteo already retried for exactly this reason; data.gov
+ * did not, and it is the noisier of the two.
+ */
+ private Page fetchPage(String state, String district, String commoditySourceCode, int offset) {
+ for (int attempt = 1; ; attempt++) {
+ Page result = attemptPage(state, district, commoditySourceCode, offset);
+ if (result != null || attempt >= Math.max(1, retryAttempts)) return result;
+ if (!lastWasRateLimited) return null;
+ log.info("data.gov.in rate-limited ({}/{}) — waiting {}ms before retrying {}/{}",
+ attempt, retryAttempts, retryBackoffMs, district, commoditySourceCode);
+ try {
+ Thread.sleep(retryBackoffMs);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private Page attemptPage(String state, String district, String commoditySourceCode, int offset) {
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(baseUrl + "/" + resourceId)
.queryParam("api-key", apiKey)
.queryParam("format", "json")
@@ -100,6 +207,10 @@ public class DataGovClient {
.queryParam("filters[" + fieldDistrict + "]", district)
.queryParam("filters[" + fieldCommodity + "]", commoditySourceCode);
+ if (offset > 0) {
+ builder.queryParam("offset", offset);
+ }
+
// Newest first, so the page the caller keeps is the page it asked for.
if (sortField != null && !sortField.isBlank()) {
builder.queryParam("sort[" + sortField + "]", "desc");
@@ -115,20 +226,27 @@ public class DataGovClient {
: (List