← back to La Socrata Ingester

src/adapters/arcgis.js

357 lines

import { fetchJson, sleep } from './http.js';

// ArcGIS tunnels errors as HTTP 200 with an {error:{code,messageCode}} BODY, so the
// HTTP-level retry in fetchJson never sees them. Throttle/5xx codes are transient and
// retried; anything else (e.g. 400 bad query) is permanent and thrown immediately so we
// fail fast instead of backing off 5x. NOTE: bare 499 is NOT transient — canonically it's
// "Token Required" (a permanent auth failure on these public sources); only 499 carrying
// messageCode CONT_0001 ("item momentarily inaccessible") is the transient we retry.
const TRANSIENT_ARCGIS = new Set([429, 500, 502, 503, 504]);
const isTransientArcgisError = (e) =>
  !!e && (TRANSIENT_ARCGIS.has(e.code) || e.messageCode === 'CONT_0001');

// fetchJson + ArcGIS-payload-aware retry with capped exponential backoff.
// httpTries is the HTTP-level retry budget inside fetchJson (default 5). The adaptive
// page-size backoff (below) passes a SMALL httpTries while it still has room to shrink a
// page, so an oversized-geometry band does not pay the full 5x/~30s backoff storm on every
// halving step — and it restores the full budget at the floor, so a genuine gateway outage
// (a page that 502s even at the floor) still surfaces loudly rather than being cut short.
async function fetchArcgis(url, { tries = 5, httpTries = 5 } = {}) {
  let attempt = 0;
  for (;;) {
    attempt++;
    const data = await fetchJson(url, { tries: httpTries }); // HTTP-level (429/5xx/network) retry already inside
    if (!data.error) return data;
    if (!isTransientArcgisError(data.error) || attempt >= tries) {
      throw new Error(`ArcGIS error: ${JSON.stringify(data.error).slice(0, 200)}`);
    }
    const wait = Math.min(30000, 500 * 2 ** attempt);
    console.error(
      `  ⟳ ArcGIS transient ${data.error.code ?? ''} ${data.error.messageCode ?? ''} — retry ${attempt}/${tries} in ${wait}ms`
    );
    await sleep(wait);
  }
}

// ── Adaptive page-size backoff (TK-11500) ───────────────────────────────────
// The NavigateLA gateway 502s on a GEOMETRY page whose response takes >30s to serialize.
// MEASURED on the Generalized Zoning layer: the OBJECTID 42000..56000 band carries ~22KB of
// geometry per feature (vs ~0.2KB elsewhere), so a 2000-row page there is ~44MB and the
// gateway times out. Because it is a fixed OBJECTID band and not a fixed depth, NEITHER
// offset- nor oid-cursor paging avoids it (both were live-tested and both 502 at the same
// band) — the real constraint is response SIZE. So on a gateway size-failure, HALVE the page
// and retry the SAME cursor position until the response fits (down to a floor), then grow the
// size back so the rest of the layer still pages fast (AIMD: multiplicative-decrease on
// failure, x2 grow-back on success). General — any oversized-geometry source benefits, not
// zoning-specific. A page that still fails at the floor is a real outage, not a size problem,
// so it is rethrown (fail-loud; the aggregate run's allowFailure exemption still catches a
// genuinely stuck source, so this degrades exactly as before rather than looping forever).
// Read at CALL time, not module-load time — same lesson the repoint kill switches learned
// (a module-level const bakes the value in at import and ignores a later env override).
const adaptiveMinPage = () => Math.max(1, Number(process.env.LA_ADAPTIVE_MIN_PAGE ?? 50));
// A gateway 502/503/504 in either shape fetchArcgis can throw it: the HTTP-level form from
// fetchJson ("HTTP 502 after N tries") and the ArcGIS body-level form ('ArcGIS error:
// {"code":502,...}'). This is the class we shrink for.
const isGatewaySizeFailure = (err) =>
  /HTTP 50[234]\b|"code":\s*50[234]\b/.test(String(err?.message || ''));
// After a shrunk page succeeds, grow back toward pageSize (x2) so throughput recovers once
// past a heavy band; a page that ran at full size stays there (no-op).
const nextPageSize = (usedCount, pageSize) =>
  usedCount < pageSize ? Math.min(pageSize, usedCount * 2) : pageSize;

// Fetch one page at `count`, shrinking on a gateway size-failure and retrying the SAME
// cursor. buildUrl(count) produces the query URL for a given resultRecordCount. Only shrinks
// for GEOMETRY pages — a non-geometry page is already tiny, so a transient there is a real
// upstream outage, not a size problem, and must not be masked. Returns { data, usedCount }
// where usedCount is the size that actually succeeded (feeds the end-of-layer test + grow-back).
async function fetchPageAdaptive(buildUrl, count, geom) {
  const floor = adaptiveMinPage();
  for (;;) {
    const atFloor = count <= floor;
    try {
      // Fail fast (httpTries:2) while there is still room to shrink; spend the full retry
      // budget only at the floor, so a real outage (fails even at the floor) still throws.
      const data = await fetchArcgis(buildUrl(count), { httpTries: atFloor ? 5 : 2 });
      return { data, usedCount: count };
    } catch (err) {
      if (geom && !atFloor && isGatewaySizeFailure(err)) {
        const next = Math.max(floor, Math.floor(count / 2));
        console.error(
          `  ⤵ ArcGIS geometry page too large — halving resultRecordCount ${count} → ${next} at the same cursor ` +
          `(${String(err.message).slice(0, 70)})`
        );
        count = next;
        continue;
      }
      throw err;
    }
  }
}

// ── Layer-identity preflight ────────────────────────────────────────────────
// An ArcGIS layer id is a POSITIONAL INDEX, not a stable identifier. On 2026-09-05
// LA re-indexed the NavigateLA MapServer and EVERY layer id shifted +1, silently
// re-pointing all 9 of our layers at a different dataset. Only 2 failed loudly (a
// Group Layer and a layer missing our oidField); the other 7 landed on queryable
// layers and ingested a stranger's rows under our labels for 6 days with no error.
// So: when a source declares expectName, verify the LIVE layer name before paging
// and fail LOUD on drift. Ingesting the wrong layer is strictly worse than not
// ingesting at all — one metadata GET per source per run ($0, public API).
const normName = (s) => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');

// ── Verified auto-repoint (TK-10955, Steve 2026-09-11) ──────────────────────
// Positional ids drifted twice in six days (2026-09-05, 2026-09-11), so fail-loud-only
// meant a weekly outage. Policy is now: on a name mismatch, FIND the layer by exact name,
// PROVE it on the authoritative plane, then ingest — and still fail loud on anything we
// cannot prove (a rename, no match, or an ambiguous match).
//
// Two discovery paths, in order:
//   1. the service catalog (…/MapServer?f=json) — one cached GET, covers any drift distance;
//   2. a bounded ±N window scan of per-layer endpoints — the fallback for when the catalog
//      is STALE, which was MEASURED on 2026-09-11: it served a 452-layer body (old index)
//      while per-layer endpoints already served 453. Resolving off the catalog ALONE would
//      have returned the wrong layer, so a catalog hit is never trusted on its own.
// Either way the candidate is re-proved on /MapServer/<id>?f=json — the plane /query
// follows — before a single row is read.
const LAYER_ENDPOINT_RE = /^(.*)\/(\d+)$/;
// Read at CALL time, not module-load time. As module-level consts these were baked in at
// import and the kill switch silently did nothing — caught by its own negative test.
const repointWindow = () => Math.max(0, Number(process.env.LA_REPOINT_WINDOW ?? 10));
const autoRepointDisabled = () => process.env.LA_NO_AUTO_REPOINT === '1';

// One catalog GET per process — but a FAILED fetch is deliberately NOT cached. Caching the
// failure would let a single transient blip on the first drifted source silently downgrade
// every later source in the same run to the window-scan path (still safe, since the window
// path also demands full proof, but needlessly narrower). Re-try it instead.
const catalogCache = new Map(); // baseUrl -> Promise<catalog>, successful fetches only
function fetchCatalog(baseUrl, fetcher) {
  if (!catalogCache.has(baseUrl)) {
    const p = Promise.resolve(fetcher(`${baseUrl}?f=json`));
    catalogCache.set(baseUrl, p);
    p.catch(() => catalogCache.delete(baseUrl)); // forget failures so the next source retries
  }
  return catalogCache.get(baseUrl).catch(() => null);
}
export function _resetCatalogCache() { catalogCache.clear(); } // tests only

const nameMatches = (meta, src) => normName(meta?.name) === normName(src.expectName);

// Field/type fingerprint. Closes the "a replacement dataset reuses the exact name" risk:
// a same-named layer that lacks our oidField (or every one of our nameFields) is NOT ours.
// Returns 'ok' | 'unverifiable' | 'mismatch'. 'unverifiable' means the metadata carried no
// field list, so we could not measure — deliberately NOT folded into 'ok'.
function fingerprint(meta, src) {
  if (normName(meta?.type) !== 'feature layer') return 'mismatch';
  const list = Array.isArray(meta?.fields) ? meta.fields : null;
  if (!list || list.length === 0) return 'unverifiable';
  const fields = new Set(list.map((f) => f?.name));
  if (!fields.has(src.oidField || 'OBJECTID')) return 'mismatch';
  const nameFields = Array.isArray(src.nameFields) ? src.nameFields : [];
  if (nameFields.length && !nameFields.some((f) => fields.has(f))) return 'mismatch';
  return 'ok';
}

// Probe failures are counted, not just swallowed. A probe that ERRORED is not evidence the
// layer is absent, and the loud failure message must not imply it was: fetcher already
// retries 429/5xx five times, so a surviving error is real, and reporting how many probes
// errored is what tells a human "upstream was flaky" apart from "it genuinely moved away".
// Either way we fail loud rather than ingest, so this is a message-honesty fix, not a gate.
async function probeLayer(baseUrl, id, fetcher, stats = null) {
  if (!Number.isInteger(id) || id < 0) return null;
  try {
    return await fetcher(`${baseUrl}/${id}?f=json`);
  } catch {
    if (stats) stats.errors++;
    return null;
  }
}

// A candidate is accepted ONLY on full proof: exact name AND a verified fingerprint.
// 'unverifiable' is never enough to MOVE onto a new layer (asymmetry is intentional —
// see resolveLayerIdentity for why the pinned id is allowed to stay on 'unverifiable').
const candidateProved = (meta, src) => !!meta && nameMatches(meta, src) && fingerprint(meta, src) === 'ok';

async function findLayerByName(baseUrl, pinnedId, src, fetcher) {
  const tried = [];
  const stats = { errors: 0 };
  const cat = await fetchCatalog(baseUrl, fetcher);
  const catHits = (cat?.layers ?? []).filter(
    (l) => normName(l?.name) === normName(src.expectName) && normName(l?.type) === 'feature layer'
  );
  if (catHits.length === 1) {
    const id = catHits[0].id;
    tried.push(`catalog->${id}`);
    if (candidateProved(await probeLayer(baseUrl, id, fetcher, stats), src)) return { id, via: 'catalog', tried };
    tried.push(`catalog->${id} FAILED re-proof (stale catalog?)`);
  } else if (catHits.length > 1) {
    tried.push(`catalog ambiguous (${catHits.length} layers named "${src.expectName}")`);
  } else {
    tried.push('catalog: no exact-name Feature Layer');
  }

  // Fallback: scan a bounded window on the authoritative per-layer plane. Scans the WHOLE
  // window (does not stop at the first hit) so an ambiguous match is detected, not masked.
  const hits = [];
  const win = repointWindow();
  for (let d = 1; d <= win; d++) {
    for (const id of [pinnedId + d, pinnedId - d]) {
      if (candidateProved(await probeLayer(baseUrl, id, fetcher, stats), src)) hits.push(id);
    }
  }
  tried.push(
    `window +/-${win}: ${hits.length} proved match(es)${hits.length ? ` [${hits.join(', ')}]` : ''}` +
    (stats.errors ? ` (${stats.errors} probe(s) ERRORED — absence here is not proof of absence)` : '')
  );
  if (hits.length === 1) return { id: hits[0], via: `window+/-${win}`, tried };
  return { id: null, ambiguous: hits.length > 1, tried };
}

// Resolve the endpoint a source should actually page. Returns { endpoint, repointed, from,
// to, via }. Throws the loud drift error when the layer cannot be PROVED anywhere.
export async function resolveLayerIdentity(src, fetcher = fetchArcgis) {
  if (!src.expectName) return { endpoint: src.endpoint, repointed: false }; // opt-in

  const pinned = await fetcher(`${src.endpoint}?f=json`);
  if (nameMatches(pinned, src)) {
    const fp = fingerprint(pinned, src);
    if (fp === 'mismatch') {
      // Right name, wrong shape: the dataset behind our label changed. Never auto-move on
      // this — a human must look, because "same name, different schema" is how a
      // replacement dataset would impersonate ours.
      throw new Error(
        `ArcGIS layer schema drift: ${src.endpoint} still reports "${pinned?.name}" but no longer ` +
        `matches this source's shape (type=${pinned?.type}, oidField=${src.oidField || 'OBJECTID'}, ` +
        `nameFields=${JSON.stringify(src.nameFields ?? [])}). Upstream replaced the dataset — ` +
        `do NOT ingest; review the source definition.`
      );
    }
    // fp === 'unverifiable' (metadata carried no field list) keeps the PINNED id, matching
    // pre-existing behaviour. We decline to break a working pin on missing metadata, but we
    // still refuse to MOVE to a new layer on the same weak evidence.
    return { endpoint: src.endpoint, repointed: false };
  }

  const m = LAYER_ENDPOINT_RE.exec(src.endpoint);
  const driftMsg =
    `ArcGIS layer identity drift: ${src.endpoint} is now "${pinned?.name ?? '(unnamed)'}" ` +
    `but this source expects "${src.expectName}".`;
  if (autoRepointDisabled() || !m) {
    throw new Error(`${driftMsg} Upstream re-indexed its layer ids — repoint the layer id (verify by NAME), do NOT ingest.`);
  }

  const [, baseUrl, pinnedIdStr] = m;
  const found = await findLayerByName(baseUrl, Number(pinnedIdStr), src, fetcher);
  if (found.id == null) {
    throw new Error(
      `${driftMsg} Could not PROVE where it moved, so nothing was ingested. Tried: ${found.tried.join('; ')}. ` +
      (found.ambiguous
        ? 'Multiple layers matched — ambiguous, a human must choose.'
        : 'A rename, a removal, or a shift beyond the search window — repoint by hand (verify by NAME).')
    );
  }

  const endpoint = `${baseUrl}/${found.id}`;
  console.error(
    `  ⚠ AUTO-REPOINT ${pinnedIdStr} -> ${found.id} for "${src.expectName}" (via ${found.via}; ` +
    `name + schema re-proved on the per-layer endpoint). Upstream shifted its positional ids; ` +
    `update the pinned id in src/sources.js.`
  );
  return { endpoint, repointed: true, from: Number(pinnedIdStr), to: found.id, via: found.via };
}

// Back-compat wrapper: throws on drift, never repoints. Kept because it is the narrow
// "is this layer what we think it is" assertion the tests exercise directly.
export async function assertLayerIdentity(src, fetcher = fetchArcgis) {
  if (!src.expectName) return;
  const meta = await fetcher(`${src.endpoint}?f=json`);
  if (normName(meta?.name) !== normName(src.expectName)) {
    throw new Error(
      `ArcGIS layer identity drift: ${src.endpoint} is now "${meta?.name ?? '(unnamed)'}" ` +
      `but this source expects "${src.expectName}". Upstream re-indexed its layer ids — ` +
      `repoint the layer id (verify by NAME), do NOT ingest.`
    );
  }
}

// ArcGIS FeatureServer/MapServer paginator. Yields { rows, url } per page, where
// each row is the feature's attributes (with __geometry attached when src.geometry).
//
// Two pagination modes:
//   • 'oid'  (src.paginate==='oid') — OBJECTID-cursor: where <oid> > lastMax, ordered
//     by oid, no resultOffset. Robust for large/deep extracts (avoids the deep-offset
//     400 "Invalid query parameters" that offset paging hits past ~800 pages).
//   • 'offset' (default) — resultOffset/resultRecordCount. Fine for small layers.
//
// src fields: endpoint (…/{n}), orderBy?, oidField? (default OBJECTID), cursorField?,
//   defaultWhere?, paginate? ('oid'|'offset'), geometry? (bool)
// opts: since, pageSize, maxRows, fullScan, whereOverride
export async function* arcgisPages(src, opts = {}) {
  const { since = null, pageSize = 2000, maxRows = Infinity, fullScan = false, whereOverride = null } = opts;
  // Resolve BEFORE building the query url: a verified auto-repoint changes the endpoint.
  const resolved = await resolveLayerIdentity(src);
  const base = `${resolved.endpoint}/query`;
  const oidField = src.oidField || 'OBJECTID';
  const geom = !!src.geometry;

  // base filters (everything except the oid cursor)
  const filters = [];
  if (whereOverride) filters.push(whereOverride);
  else if (fullScan) { /* all rows */ }
  else if (since && src.cursorField) filters.push(`${src.cursorField} >= '${since}'`);
  else if (src.defaultWhere) filters.push(src.defaultWhere);

  const common = {
    outFields: '*',
    returnGeometry: geom ? 'true' : 'false',
    f: 'json',
  };
  if (geom) common.outSR = '4326'; // WGS84 lat/lon

  const mapRows = (feats) =>
    feats.map((ft) => (geom ? { ...ft.attributes, __geometry: ft.geometry } : ft.attributes));

  if (src.paginate === 'oid') {
    let lastOid = -1, fetched = 0, curCount = pageSize;
    for (;;) {
      if (fetched >= maxRows) break;
      const where = [...filters, `${oidField} > ${lastOid}`].join(' AND ');
      const remaining = maxRows - fetched;
      const buildUrl = (n) =>
        `${base}?${new URLSearchParams({ ...common, where, orderByFields: oidField, resultRecordCount: String(n) })}`;
      // Adaptive page size: shrink on an oversized-geometry gateway 502, grow back on success.
      const { data, usedCount } = await fetchPageAdaptive(buildUrl, Math.min(curCount, remaining), geom);
      const feats = data.features || [];
      if (!feats.length) break;
      yield { rows: mapRows(feats), url: buildUrl(usedCount) };
      lastOid = Math.max(...feats.map((ft) => ft.attributes[oidField]));
      fetched += feats.length;
      curCount = nextPageSize(usedCount, pageSize);
      if (feats.length < usedCount) break;
    }
    return;
  }

  // offset mode
  let offset = 0, curCount = pageSize;
  const where = filters.length ? filters.join(' AND ') : '1=1';
  const buildUrl = (n) => {
    const params = { ...common, where, resultOffset: String(offset), resultRecordCount: String(n) };
    // src.noOrder: omit orderByFields — the NavigateLA gateway 502s on sorted
    // geometry queries; MapServer returns natural OBJECTID order anyway.
    if (!src.noOrder) params.orderByFields = src.orderBy || oidField;
    return `${base}?${new URLSearchParams(params)}`;
  };
  for (;;) {
    const remaining = maxRows - offset;
    if (remaining <= 0) break;
    // Adaptive page size: shrink on an oversized-geometry gateway 502, grow back on success.
    const { data, usedCount } = await fetchPageAdaptive(buildUrl, Math.min(curCount, remaining), geom);
    const feats = data.features || [];
    if (!feats.length) break;
    yield { rows: mapRows(feats), url: buildUrl(usedCount) };
    offset += feats.length;
    curCount = nextPageSize(usedCount, pageSize);
    if (!data.exceededTransferLimit && feats.length < usedCount) break;
  }
}