Skip to content

Commit 898ecd8

Browse files
authored
fix(portwatch): rename datedate_ for IMF reserved-keyword sweep (koala73#3495)
IMF PortWatch ran a backend reserved-keyword rename on 2026-04-29 (date → date_, year → year_, month → month_, day → day_). Field aliases stayed as the original names, but ArcGIS WHERE / outFields / orderByFields / outStatistics require the actual field name — alias ≠ queryable name. Every per-country windowed query started failing with HTTP 400 "Cannot perform query. Invalid query parameters." (00:04 UTC and 02:54 UTC runs both 100% rejected, log lines 416-419 and 356-360 of the bundle output). Diagnostic: error.details[] contains "'Invalid field: date' parameter is invalid" — top-level message is generic, details has the real signal. Confirmed via FeatureServer/0?f=json schema introspection. Fix: rename all six in-code references (outFields, orderByFields, two WHERE clauses, the per-row null-check on a.date_, and onStatisticField). The `timestamp 'YYYY-MM-DD HH:MM:SS'` SQL literal still works on the new esriFieldTypeDateOnly column, so the literal format is unchanged. Hardening: add a first-batch circuit-breaker. When ≥80% of batch 1 is rejected with the same "Invalid query parameters" error class, assume global upstream regression and skip the remaining 14 batches — the catch-path's extendExistingTtl preserves prior payloads. Cuts failure cost from ~30s to ~2s and keeps Sentry signal-to-noise sane on the next rename / policy change. The existing single-retry stays in place for the original 3-of-N flake case (BRA/IDN/NGA, 2026-04-20). Verified end-to-end against live ArcGIS: - patched WHERE returns features w/ attributes.date_ = "2026-03-30" - patched preflight returns max_date = "2026-04-24" Test updates: two static-source assertions moved to date_; per-row test-helper using r.date stays (it's an abstract aggregation test unrelated to the ArcGIS shape).
1 parent a750cfb commit 898ecd8

2 files changed

Lines changed: 48 additions & 11 deletions

File tree

scripts/seed-portwatch-port-activity.mjs

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ async function fetchWithTimeout(url, { signal } = {}) {
9494
// also for the global WHERE after the PR #3225 rollout. A single retry with
9595
// a short back-off clears it in practice. No retry loop — one attempt
9696
// bounded. Does not retry any other error class.
97+
//
98+
// IMPORTANT: a 100%-batch-rejected version of this error is NOT a flake —
99+
// it's an upstream schema-rename (e.g. `date` → `date_` on 2026-04-29 via
100+
// IMF's reserved-keyword sweep, see fetchAll's first-batch circuit-breaker).
101+
// The single-retry assumption only holds for sporadic 3-of-N rejections;
102+
// the circuit-breaker handles the global-regression case to avoid burning
103+
// 30s of doomed work per cron tick.
97104
async function fetchWithRetryOnInvalidParams(url, { signal } = {}) {
98105
try {
99106
return await fetchWithTimeout(url, { signal });
@@ -156,9 +163,12 @@ async function paginateWindowInto(portAccumMap, iso3, where, windowKind, { signa
156163
if (signal?.aborted) throw signal.reason ?? new Error('aborted');
157164
const params = new URLSearchParams({
158165
where,
159-
outFields: 'portid,portname,ISO3,date,portcalls_tanker,import_tanker,export_tanker',
166+
// IMF PortWatch renamed `date` → `date_` on 2026-04-29 (reserved-keyword
167+
// sweep, see ARCGIS_DATE_FIELD comment below). Field alias is still
168+
// "date" but the queryable name is `date_` — alias ≠ name in ArcGIS.
169+
outFields: 'portid,portname,ISO3,date_,portcalls_tanker,import_tanker,export_tanker',
160170
returnGeometry: 'false',
161-
orderByFields: 'portid ASC,date ASC',
171+
orderByFields: 'portid ASC,date_ ASC',
162172
resultRecordCount: String(PAGE_SIZE),
163173
resultOffset: String(offset),
164174
outSR: '4326',
@@ -168,7 +178,7 @@ async function paginateWindowInto(portAccumMap, iso3, where, windowKind, { signa
168178
const features = body.features ?? [];
169179
for (const f of features) {
170180
const a = f.attributes;
171-
if (!a || a.portid == null || a.date == null) continue;
181+
if (!a || a.portid == null || a.date_ == null) continue;
172182
const portId = String(a.portid);
173183
const calls = Number(a.portcalls_tanker ?? 0);
174184
const imports = Number(a.import_tanker ?? 0);
@@ -214,8 +224,8 @@ function parseMaxDateToAnchor(maxDateStr) {
214224

215225
// Fetch ONE country's activity rows, streaming into per-port accumulators.
216226
// Splits into TWO parallel windowed queries:
217-
// - Q1 (last30): WHERE ISO3='X' AND date > cutoff30
218-
// - Q2 (prev30): WHERE ISO3='X' AND date > cutoff60 AND date <= cutoff30
227+
// - Q1 (last30): WHERE ISO3='X' AND date_ > cutoff30
228+
// - Q2 (prev30): WHERE ISO3='X' AND date_ > cutoff60 AND date_ <= cutoff30
219229
// Each returns ~half the rows a single 60-day query would. Heavy countries
220230
// (USA/CHN/etc.) drop from ~90s → ~30s because max(Q1,Q2) < Q1+Q2.
221231
//
@@ -238,18 +248,21 @@ async function fetchCountryAccum(iso3, { signal, anchorEpochMs } = {}) {
238248

239249
const portAccumMap = new Map();
240250

251+
// ARCGIS_DATE_FIELD: queryable column is `date_`, not `date` — see comment
252+
// on outFields above. The `timestamp 'YYYY-MM-DD HH:MM:SS'` literal still
253+
// works on the new esriFieldTypeDateOnly column (verified 2026-04-29).
241254
await Promise.all([
242255
paginateWindowInto(
243256
portAccumMap,
244257
iso3,
245-
`ISO3='${iso3}' AND date > ${epochToTimestamp(cutoff30)}`,
258+
`ISO3='${iso3}' AND date_ > ${epochToTimestamp(cutoff30)}`,
246259
'last30',
247260
{ signal },
248261
),
249262
paginateWindowInto(
250263
portAccumMap,
251264
iso3,
252-
`ISO3='${iso3}' AND date > ${epochToTimestamp(cutoff60)} AND date <= ${epochToTimestamp(cutoff30)}`,
265+
`ISO3='${iso3}' AND date_ > ${epochToTimestamp(cutoff60)} AND date_ <= ${epochToTimestamp(cutoff30)}`,
253266
'prev30',
254267
{ signal },
255268
),
@@ -266,7 +279,10 @@ async function fetchCountryAccum(iso3, { signal, anchorEpochMs } = {}) {
266279
async function fetchMaxDate(iso3, { signal } = {}) {
267280
const outStats = JSON.stringify([{
268281
statisticType: 'max',
269-
onStatisticField: 'date',
282+
// See ARCGIS_DATE_FIELD comment in fetchCountryAccum: queryable name is
283+
// `date_`, not `date`. outStatisticFieldName is the response key —
284+
// unchanged on purpose so callers keep reading `attrs.max_date`.
285+
onStatisticField: 'date_',
270286
outStatisticFieldName: 'max_date',
271287
}]);
272288
const params = new URLSearchParams({
@@ -521,6 +537,25 @@ export async function fetchAll(progress, { signal } = {}) {
521537
const elapsed = ((Date.now() - activityStart) / 1000).toFixed(1);
522538
console.log(` [port-activity] batch ${batchIdx}/${batches}: ${countryData.size} countries published, ${errors.length} errors (${elapsed}s)`);
523539
}
540+
541+
// Circuit-breaker: if batch 1 is ≥80% rejected with the SAME class of
542+
// error, treat it as an upstream global regression (schema rename, policy
543+
// change, dataset moved) — not a flake that more retries will clear.
544+
// Skip the remaining batches and let the catch-path extendExistingTtl
545+
// preserve prior payloads. Cuts failure cost ~30s → ~2s and keeps Sentry
546+
// signal-to-noise sane during incidents like the 2026-04-29 IMF
547+
// PortWatch `date` → `date_` rename.
548+
if (batchIdx === 1 && batches > 1 && batch.length >= 5) {
549+
const sameClassRate = errors.filter(e => /Invalid query parameters/i.test(e)).length / batch.length;
550+
if (sameClassRate >= 0.8) {
551+
console.error(
552+
` [port-activity] CIRCUIT-BREAKER: ${(sameClassRate * 100).toFixed(0)}% of batch 1 rejected with "Invalid query parameters" — ` +
553+
`assuming upstream schema/policy regression. Skipping remaining ${batches - 1} batches; ` +
554+
`catch-path will extend TTLs on prior payloads. First error: ${errors[0]}`,
555+
);
556+
break;
557+
}
558+
}
524559
}
525560

526561
if (errors.length) {

tests/portwatch-port-activity-seed.test.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ describe('seed-portwatch-port-activity.mjs exports', () => {
5151
// H+F refactor: the WHERE clause is now built inline at the
5252
// paginateWindowInto call site (not as a `where:` param in a params
5353
// bag) because each window has a different date predicate.
54-
assert.match(src, /`ISO3='\$\{iso3\}'\s+AND\s+date\s*>/);
54+
// Field is `date_` post-2026-04-29 IMF reserved-keyword sweep (alias=date,
55+
// queryable name=date_). See ARCGIS_DATE_FIELD comment in the seeder.
56+
assert.match(src, /`ISO3='\$\{iso3\}'\s+AND\s+date_\s*>/);
5557
// Global where=date>X shape (PR #3225) must NOT be present.
56-
assert.doesNotMatch(src, /where:\s*`date\s*>\s*\$\{epochToTimestamp\(since\)\}`/);
58+
assert.doesNotMatch(src, /where:\s*`date_?\s*>\s*\$\{epochToTimestamp\(since\)\}`/);
5759
});
5860

5961
it('EP4 refs query fetches all ports globally with where=1=1', () => {
@@ -129,7 +131,7 @@ describe('seed-portwatch-port-activity.mjs exports', () => {
129131
it('fetchMaxDate preflight uses outStatistics for cheap cache invalidation', () => {
130132
assert.match(src, /async function fetchMaxDate/);
131133
assert.match(src, /statisticType:\s*'max'/);
132-
assert.match(src, /onStatisticField:\s*'date'/);
134+
assert.match(src, /onStatisticField:\s*'date_'/);
133135
});
134136

135137
it('fetchAll cache path: MGET preflight + maxDate check + reuse payload', () => {

0 commit comments

Comments
 (0)