Repository navigation
fix: a failed published-atlases fetch can half-generate a tracker atlas and still exit 0 (#3203) #3239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: a failed published-atlases fetch can half-generate a tracker atlas and still exit 0 (#3203) #3239
Changes from all commits
6b9d387
54982bc
4c46b99
b8dfe5a
05b28d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,22 +3,51 @@ import type { | |
| TrackerSourceDatasetResponse, | ||
| TrackerSourceStudy, | ||
| } from "@/types/network"; | ||
| import { readFile } from "fs/promises"; | ||
| import { PHASE_PRODUCTION_BUILD } from "next/constants"; | ||
| import { inspect } from "util"; | ||
| import { PUBLISHED_ATLASES_SNAPSHOT_ENV } from "./constants"; | ||
| import type { PublishedAtlas } from "./types"; | ||
|
|
||
| // Module-level cache for published atlases during build. Stores the in-flight | ||
| // Promise so concurrent callers share a single fetch. | ||
| // Promise so concurrent callers share a single load. | ||
| let publishedAtlasesPromise: Promise<PublishedAtlas[]> | null = null; | ||
|
|
||
| // Advice appended when `next build` runs without a snapshot path. | ||
| const BUILD_HINT = | ||
| "(run `npm run build`, which snapshots the published atlases before `next build`)"; | ||
|
|
||
| // Advice appended when the build's snapshot file is gone, e.g. removed by a tmp | ||
| // cleaner mid-build. | ||
| const SNAPSHOT_MISSING_HINT = | ||
| "(the snapshot `scripts/build.sh` wrote for this build was removed; run the build again)"; | ||
|
|
||
| // Fields an item must carry as strings for the publication gate to match on | ||
| // it. Items missing one are skipped, and a body where every item is missing one | ||
| // fails the build, so a renamed field cannot make every atlas silently read as | ||
| // unpublished. | ||
| const PUBLISHED_ATLAS_MATCH_KEYS = [ | ||
| "shortNameSlug", | ||
| "version", | ||
| ] as const satisfies readonly (keyof PublishedAtlas)[]; | ||
|
|
||
| /** | ||
| * Returns the tracker base URL, read at call time to ensure env vars are loaded. | ||
| * @returns tracker base URL. | ||
| * Fetches and validates the tracker's published atlases. Used directly by | ||
| * `next dev` and by the snapshot script that runs before `next build`. | ||
| * Failures (network error, non-2xx, malformed body, missing tracker URL) are | ||
| * rethrown with the reason in the message. | ||
| * @returns published atlases. | ||
| */ | ||
| function getTrackerUrl(): string { | ||
| const url = process.env.NEXT_PUBLIC_ATLAS_TRACKER_URL; | ||
| if (!url) { | ||
| throw new Error("NEXT_PUBLIC_ATLAS_TRACKER_URL is not configured"); | ||
| export async function fetchPublishedAtlases(): Promise<PublishedAtlas[]> { | ||
| try { | ||
| const data = await fetchTrackerApi<unknown>( | ||
| "/api/published-atlases", | ||
| "published atlases" | ||
| ); | ||
| return parsePublishedAtlases(data); | ||
| } catch (err) { | ||
| throw publishedAtlasesError(err); | ||
| } | ||
| return url; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -80,38 +109,52 @@ export function fetchTrackerSourceStudies( | |
| } | ||
|
|
||
| /** | ||
| * Returns the cached list of published atlases, fetching on first call. | ||
| * On failure (network error, non-2xx, malformed body), logs a warning and | ||
| * resolves to an empty list so callers can fall back to "no atlases | ||
| * published" — protecting non-tracker pages from tracker outages. | ||
| * @returns list of published atlases. | ||
| * Finds the published atlas with the given slug and version. When two records | ||
| * match, the first is returned. | ||
| * @param shortNameSlug - Atlas short name slug (e.g., "gut"). | ||
| * @param version - Atlas version (e.g., "v1.0"). | ||
| * @returns the published atlas, or undefined when it is not published. | ||
| */ | ||
| async function findPublishedAtlas( | ||
| shortNameSlug: string, | ||
| version: string | ||
| ): Promise<PublishedAtlas | undefined> { | ||
| const atlases = await getPublishedAtlases(); | ||
| return atlases.find( | ||
| (atlas) => | ||
| atlas.shortNameSlug === shortNameSlug && atlas.version === version | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the cached published atlases, loading them on first call. Failures | ||
| * are rethrown so the build fails deterministically rather than silently | ||
| * omitting tracker atlases; the rejected promise is cleared so a long-lived | ||
| * `next dev` server can retry on the next request. | ||
| * @returns published atlases. | ||
| */ | ||
| function getPublishedAtlases(): Promise<PublishedAtlas[]> { | ||
| if (!publishedAtlasesPromise) { | ||
| publishedAtlasesPromise = fetchTrackerApi<unknown>( | ||
| "/api/published-atlases", | ||
| "published atlases" | ||
| ) | ||
| .then((data) => { | ||
| if (!Array.isArray(data)) { | ||
| console.warn( | ||
| `Tracker /api/published-atlases returned a non-array body; treating as empty.` | ||
| ); | ||
| return []; | ||
| } | ||
| return data as PublishedAtlas[]; | ||
| }) | ||
| .catch((err) => { | ||
| console.warn( | ||
| `Failed to fetch published atlases from tracker; treating as empty. ${err}` | ||
| ); | ||
| publishedAtlasesPromise = null; | ||
| return []; | ||
| }); | ||
| publishedAtlasesPromise = loadPublishedAtlases().catch((err) => { | ||
| publishedAtlasesPromise = null; | ||
| throw err; | ||
| }); | ||
| } | ||
| return publishedAtlasesPromise; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the tracker base URL, read at call time to ensure env vars are loaded. | ||
| * @returns tracker base URL. | ||
| */ | ||
| function getTrackerUrl(): string { | ||
| const url = process.env.NEXT_PUBLIC_ATLAS_TRACKER_URL; | ||
| if (!url) { | ||
| throw new Error("NEXT_PUBLIC_ATLAS_TRACKER_URL is not configured"); | ||
| } | ||
| return url; | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether a tracker atlas is currently published. | ||
| * @param shortNameSlug - Atlas short name slug (e.g., "gut"). | ||
|
|
@@ -122,10 +165,107 @@ export async function isTrackerAtlasPublished( | |
| shortNameSlug: string, | ||
| version: string | ||
| ): Promise<boolean> { | ||
| const atlases = await getPublishedAtlases(); | ||
| return atlases.some( | ||
| (a) => a.shortNameSlug === shortNameSlug && a.version === version | ||
| ); | ||
| return (await findPublishedAtlas(shortNameSlug, version)) !== undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Loads the published atlases. `next build` reads the snapshot that | ||
| * `scripts/build.sh` writes before the build: Next runs getStaticPaths and | ||
| * getStaticProps across several worker processes, each with its own | ||
| * module-level cache, so fetching per worker could let pages disagree on which | ||
| * atlases are published if the tracker changed mid-build, shipping tabs that | ||
| * 404 (see #3203). A `next build` without the snapshot (e.g. a bare `npx next | ||
| * build`) therefore fails. Anything else, such as `next dev`, fetches live, | ||
| * even if the snapshot variable leaked into its environment. | ||
| * @returns published atlases. | ||
| */ | ||
| function loadPublishedAtlases(): Promise<PublishedAtlas[]> { | ||
| if (process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD) { | ||
| return fetchPublishedAtlases(); | ||
| } | ||
| const snapshotPath = process.env[PUBLISHED_ATLASES_SNAPSHOT_ENV]; | ||
| if (!snapshotPath) { | ||
| return Promise.reject( | ||
| publishedAtlasesError( | ||
| `${PUBLISHED_ATLASES_SNAPSHOT_ENV} is not set`, | ||
| BUILD_HINT | ||
| ) | ||
| ); | ||
| } | ||
| return readPublishedAtlasesSnapshot(snapshotPath); | ||
| } | ||
|
|
||
| /** | ||
| * Validates the published-atlases response. The body must be an array. Items | ||
| * missing a string `shortNameSlug` or `version` are skipped with a warning, so | ||
| * a malformed record for an atlas this portal does not configure cannot fail | ||
| * the build, but a body where every item is malformed fails it. Fields used | ||
| * only once an atlas is resolved (e.g. `id`) are checked by | ||
| * `resolveTrackerAtlas`. | ||
| * @param data - Parsed response body. | ||
| * @returns the well-formed items, typed as published atlases. | ||
| */ | ||
| function parsePublishedAtlases(data: unknown): PublishedAtlas[] { | ||
| if (!Array.isArray(data)) { | ||
| throw new Error("Tracker /api/published-atlases returned a non-array body"); | ||
| } | ||
| const atlases = data.filter((item, i) => { | ||
| const missingKey = PUBLISHED_ATLAS_MATCH_KEYS.find( | ||
| (key) => typeof item?.[key] !== "string" | ||
| ); | ||
| if (missingKey === undefined) return true; | ||
| console.warn( | ||
| `[tracker] Skipping /api/published-atlases item ${i}: no string "${missingKey}"` | ||
| ); | ||
| return false; | ||
| }); | ||
| if (data.length > 0 && atlases.length === 0) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is still the open point from my last review. The check only fails when the response has items and none of them is valid. If the tracker returns The stronger fix is to check the response against the tracker atlases configured in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed that both cases still exit 0. Before I change anything, here's why the two suggested checks don't work as stated, then three options. Which do you want? Why the suggested checks would fail correct builds. I checked the live tracker today. It publishes only
Options
My recommendation is B. It makes both of your cases fail loudly without breaking today's builds. The cost is the small config extraction and one env var to set on the rare build where the tracker should have nothing published.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for checking the live tracker, Fran. That settles it: we'll trust the tracker. Whatever it says is published, including nothing, is authoritative. So the empty-response and malformed-record cases are accepted by design, and there's no code change here and no follow-up issue. |
||
| throw new Error( | ||
| `Tracker /api/published-atlases has no item with string ${PUBLISHED_ATLAS_MATCH_KEYS.join(" and ")} fields` | ||
| ); | ||
| } | ||
| return atlases; | ||
| } | ||
|
|
||
| /** | ||
| * Builds the error thrown when the published atlases cannot be loaded. The | ||
| * reason goes in the message, not `cause`: Next's build workers send only an | ||
| * error's name, message and stack back to the main process, so a `cause` would | ||
| * never reach the build log. A string reason is used as is; anything else is | ||
| * printed with `util.inspect`, which includes the underlying error's stack, | ||
| * Node error details (code, address, port), `cause` chain and AggregateError | ||
| * `errors`. | ||
| * @param reason - The underlying failure, or a description of it. | ||
| * @param hint - Optional advice on how to fix it. | ||
| * @returns error describing the failure. | ||
| */ | ||
| function publishedAtlasesError(reason: unknown, hint?: string): Error { | ||
| const text = | ||
| typeof reason === "string" | ||
| ? reason | ||
| : inspect(reason, { breakLength: Infinity, depth: 5 }); | ||
| const parts = [`[tracker] Published atlases are unavailable: ${text}`, hint]; | ||
| return new Error(parts.filter(Boolean).join("\n")); | ||
| } | ||
|
|
||
| /** | ||
| * Reads this build's published-atlases snapshot. | ||
| * @param snapshotPath - Path of the snapshot written by `scripts/build.sh`. | ||
| * @returns published atlases. | ||
| */ | ||
| async function readPublishedAtlasesSnapshot( | ||
| snapshotPath: string | ||
| ): Promise<PublishedAtlas[]> { | ||
| try { | ||
| const text = await readFile(snapshotPath, "utf8"); | ||
| return parsePublishedAtlases(JSON.parse(text)); | ||
| } catch (err) { | ||
| const missing = (err as NodeJS.ErrnoException).code === "ENOENT"; | ||
| throw publishedAtlasesError( | ||
| err, | ||
| missing ? SNAPSHOT_MISSING_HINT : undefined | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -138,15 +278,17 @@ export async function resolveTrackerAtlas( | |
| shortNameSlug: string, | ||
| version: string | ||
| ): Promise<PublishedAtlas> { | ||
| const atlases = await getPublishedAtlases(); | ||
| const match = atlases.find( | ||
| (a) => a.shortNameSlug === shortNameSlug && a.version === version | ||
| ); | ||
| const match = await findPublishedAtlas(shortNameSlug, version); | ||
| if (!match) { | ||
| throw new Error( | ||
| `No published atlas found for slug="${shortNameSlug}" version="${version}"` | ||
| ); | ||
| } | ||
| if (typeof match.id !== "string") { | ||
| throw new Error( | ||
| `Published atlas slug="${shortNameSlug}" version="${version}" has no string "id"` | ||
| ); | ||
| } | ||
| return match; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| // Environment variable holding the path of this build's published-atlases | ||
| // snapshot. `scripts/build.sh` writes the snapshot once before `next build`, to | ||
| // a path unique to the build, and deletes it however the build ends; every | ||
| // build worker reads it, so all pages in a build agree on which atlases are | ||
| // published (see #3203). | ||
| export const PUBLISHED_ATLASES_SNAPSHOT_ENV = "PUBLISHED_ATLASES_SNAPSHOT"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| #!/bin/bash | ||
| set -e | ||
|
|
||
| # Snapshot the tracker's published atlases once, to a path unique to this | ||
| # build, so every `next build` worker agrees on which atlases are published | ||
| # (see #3203). The trap deletes the snapshot however the build ends. | ||
| TMP_DIR="${TMPDIR:-/tmp}" | ||
| PUBLISHED_ATLASES_SNAPSHOT=$(mktemp "${TMP_DIR%/}/published-atlases.XXXXXX") | ||
| export PUBLISHED_ATLASES_SNAPSHOT | ||
| trap 'rm -f "$PUBLISHED_ATLASES_SNAPSHOT"' EXIT | ||
|
|
||
| npx --no-install esrun ./scripts/snapshot-published-atlases.ts | ||
| npx --no-install next build --webpack "$@" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { fetchPublishedAtlases } from "@/apis/tracker/api"; | ||
| import { PUBLISHED_ATLASES_SNAPSHOT_ENV } from "@/apis/tracker/constants"; | ||
| import fsp from "fs/promises"; | ||
| import { createRequire } from "module"; | ||
| import path from "path"; | ||
|
|
||
| snapshotPublishedAtlases().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); | ||
|
|
||
| /** | ||
| * Fetches the tracker's published atlases once and writes them to the | ||
| * snapshot every `next build` worker reads, so all pages in a build agree on | ||
| * which atlases are published (see #3203). Run by `scripts/build.sh`, which | ||
| * sets the snapshot path. | ||
| */ | ||
| async function snapshotPublishedAtlases(): Promise<void> { | ||
| const snapshotPath = process.env[PUBLISHED_ATLASES_SNAPSHOT_ENV]; | ||
| if (!snapshotPath) { | ||
| throw new Error( | ||
| `${PUBLISHED_ATLASES_SNAPSHOT_ENV} is not set; run \`npm run build\`, which sets it` | ||
| ); | ||
| } | ||
| // Load `@next/env` through `next`, so env files are read by the exact copy | ||
| // `next build` uses, in the same order, rather than a separately pinned one. | ||
| const requireFromRoot = createRequire(path.resolve("package.json")); | ||
| const requireFromNext = createRequire( | ||
| requireFromRoot.resolve("next/package.json") | ||
| ); | ||
| const { loadEnvConfig } = requireFromNext("@next/env") as { | ||
| loadEnvConfig: (dir: string) => unknown; | ||
| }; | ||
| loadEnvConfig(process.cwd()); | ||
| const atlases = await fetchPublishedAtlases(); | ||
| await fsp.writeFile(snapshotPath, JSON.stringify(atlases, undefined, 2)); | ||
| console.log(`Wrote ${atlases.length} published atlases to ${snapshotPath}`); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.