Skip to content

Commit cb7f195

Browse files
committed
fix: skip transitland-atlas sources whose feed id isn't in the local atlas
Upstream Transitous occasionally lands a catalog change before the matching transitland-atlas submodule is mirrored, leaving sources whose `transitland-atlas-id` resolves to nothing. Both `fetch.py` (data-manager GTFS download) and `generate-motis-config.py` (host-side motis build) hard-`sys.exit(1)` on the first such source, which kills the rest of the country's pipeline for unrelated reasons. Added a small preprocessor that walks `feeds/*.json`, builds an index of every id present in `transitland-atlas/feeds/*`, and marks any `type: transitland-atlas` source with an unknown id as `skip: true`. Idempotent across runs, runs against the operator's working copy of the catalog so a clean upstream pull silently undoes the marks once the atlas catches up. Runs in both pipelines so a fresh deployment makes it past `f-euregiobike~aachen~gbfs` and similar drift without manual intervention.
1 parent d880423 commit cb7f195

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

packages/cli/src/lib/motis-data.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,70 @@ async function ensureTransitousCatalog(
184184
return catalogDir;
185185
}
186186

187+
/**
188+
* Walk the catalog's `feeds/*.json` files and mark every `transitland-atlas`
189+
* source whose `transitland-atlas-id` is no longer present in the local
190+
* `transitland-atlas/feeds/` submodule as `skip: true`.
191+
*
192+
* Upstream Transitous occasionally lands a catalog change before the
193+
* matching atlas update is mirrored, or vice versa, leaving sources whose
194+
* atlas reference resolves to nothing. Both `fetch.py` and
195+
* `generate-motis-config.py` exit `1` on the first such source, which kills
196+
* the build for unrelated reasons. Marking them `skip: true` lets the rest
197+
* of the catalog proceed; once upstream catches up, a clean catalog pull
198+
* stops triggering this code path.
199+
*
200+
* Returns the number of sources newly marked. Idempotent across runs.
201+
*/
202+
function skipUnresolvableAtlasSources(catalogDir: string): number {
203+
const atlasDir = join(catalogDir, "transitland-atlas", "feeds");
204+
const feedsDir = join(catalogDir, "feeds");
205+
if (!existsSync(atlasDir) || !existsSync(feedsDir)) return 0;
206+
207+
const knownAtlasIds = new Set<string>();
208+
for (const fileName of readdirSync(atlasDir)) {
209+
if (!fileName.endsWith(".json")) continue;
210+
try {
211+
const data = JSON.parse(readFileSync(join(atlasDir, fileName), "utf-8")) as {
212+
feeds?: Array<{ id?: string }>;
213+
};
214+
for (const feed of data.feeds ?? []) {
215+
if (feed.id) knownAtlasIds.add(feed.id);
216+
}
217+
} catch {
218+
// Skip a malformed atlas file rather than refusing to mark anything.
219+
}
220+
}
221+
222+
let markedCount = 0;
223+
for (const fileName of readdirSync(feedsDir)) {
224+
if (!fileName.endsWith(".json")) continue;
225+
const feedPath = join(feedsDir, fileName);
226+
let data: { sources?: Array<Record<string, unknown>> };
227+
try {
228+
data = JSON.parse(readFileSync(feedPath, "utf-8")) as {
229+
sources?: Array<Record<string, unknown>>;
230+
};
231+
} catch {
232+
continue;
233+
}
234+
let modified = false;
235+
for (const source of data.sources ?? []) {
236+
if (source.type !== "transitland-atlas") continue;
237+
const atlasId = source["transitland-atlas-id"];
238+
if (typeof atlasId !== "string" || knownAtlasIds.has(atlasId)) continue;
239+
if (source.skip === true) continue;
240+
source.skip = true;
241+
markedCount++;
242+
modified = true;
243+
}
244+
if (modified) {
245+
writeFileSync(feedPath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
246+
}
247+
}
248+
return markedCount;
249+
}
250+
187251
async function ensureTransitousToolsImage(
188252
rootDir: string,
189253
image: string,
@@ -426,6 +490,10 @@ export async function buildMotisData(
426490
}
427491

428492
const transitousCatalogDir = await ensureTransitousCatalog(dataDir, transitousRepoUrl, runner);
493+
// Sanitise the catalog before any container runs against it. Sources
494+
// referencing atlas feeds that were dropped (or not yet mirrored) make
495+
// generate-motis-config.py exit on the first one and kill the build.
496+
skipUnresolvableAtlasSources(transitousCatalogDir);
429497
const transitousDownloadsDir = resolve(dataDir, TRANSITOUS_DOWNLOADS_DIR);
430498
const feedProxyKeyFile = ensureFeedProxyKeyFile(feedProxyDir);
431499
mkdirSync(transitousDownloadsDir, { recursive: true });

services/data-manager/src/jobs/transitous-pipeline.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,64 @@ async function resetTransitousCatalog(catalogDir: string, runner: CommandRunner)
184184
}
185185
}
186186

187+
/**
188+
* Walk the catalog's `feeds/*.json` files and mark every `transitland-atlas`
189+
* source whose `transitland-atlas-id` is no longer in the local
190+
* `transitland-atlas/feeds/` submodule as `skip: true`.
191+
*
192+
* Upstream Transitous occasionally lands a catalog change before the
193+
* matching atlas update is mirrored, leaving sources whose atlas reference
194+
* resolves to nothing. fetch.py exits 1 on the first such source, killing
195+
* the rest of the country's pipeline. Marking them skipped lets the rest
196+
* of the feeds proceed; once upstream catches up, a clean catalog pull
197+
* stops triggering this code path.
198+
*/
199+
function skipUnresolvableAtlasSources(catalogDir: string): void {
200+
const atlasDir = join(catalogDir, "transitland-atlas", "feeds");
201+
const feedsDir = join(catalogDir, "feeds");
202+
if (!existsSync(atlasDir) || !existsSync(feedsDir)) return;
203+
204+
const knownAtlasIds = new Set<string>();
205+
for (const fileName of readdirSync(atlasDir)) {
206+
if (!fileName.endsWith(".json")) continue;
207+
try {
208+
const data = JSON.parse(readFileSync(join(atlasDir, fileName), "utf-8")) as {
209+
feeds?: Array<{ id?: string }>;
210+
};
211+
for (const feed of data.feeds ?? []) {
212+
if (feed.id) knownAtlasIds.add(feed.id);
213+
}
214+
} catch {
215+
// Tolerate a malformed atlas file rather than refusing to mark anything.
216+
}
217+
}
218+
219+
for (const fileName of readdirSync(feedsDir)) {
220+
if (!fileName.endsWith(".json")) continue;
221+
const feedPath = join(feedsDir, fileName);
222+
let data: { sources?: Array<Record<string, unknown>> };
223+
try {
224+
data = JSON.parse(readFileSync(feedPath, "utf-8")) as {
225+
sources?: Array<Record<string, unknown>>;
226+
};
227+
} catch {
228+
continue;
229+
}
230+
let modified = false;
231+
for (const source of data.sources ?? []) {
232+
if (source.type !== "transitland-atlas") continue;
233+
const atlasId = source["transitland-atlas-id"];
234+
if (typeof atlasId !== "string" || knownAtlasIds.has(atlasId)) continue;
235+
if (source.skip === true) continue;
236+
source.skip = true;
237+
modified = true;
238+
}
239+
if (modified) {
240+
writeFileSync(feedPath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
241+
}
242+
}
243+
}
244+
187245
function applyApiKeysOverlay(catalogDir: string, overlayPath: string): number {
188246
if (!existsSync(overlayPath)) return 0;
189247

@@ -535,6 +593,11 @@ export async function downloadGtfsViaTransitous(
535593
// operator deletions, even when the pipeline ends in the resume
536594
// (failure) branch and never gets to wholesale-replace the store.
537595
pruneOrphanedGtfsDatasets(opts.store);
596+
// Sanitise the catalog before fetch.py reads it. Sources referencing
597+
// atlas feeds that have been dropped (or aren't yet mirrored) make
598+
// fetch.py exit on the first one and abandon every later source in
599+
// the same country.
600+
skipUnresolvableAtlasSources(catalogDir);
538601
applyApiKeysOverlay(
539602
catalogDir,
540603
opts.apiKeysPath ?? process.env.TRANSITOUS_API_KEYS_PATH ?? DEFAULT_TRANSITOUS_API_KEYS_PATH,

0 commit comments

Comments
 (0)