Skip to content

Run an ingest

An ingest — the loop that polls the published dataset, notices a new publication, pulls one model’s documents as a coherent set, and serves them from its own storage — is two different kinds of code. The questions with correct answers (“is this set one publication”, “why is this document missing”, “is this run late”) are package verbs, and windgram/transport and windgram/derive answer every one of them. The loop around those verbs — the scheduler, the store, retention, what your product tells its users when a feed runs late — is policy whose shape belongs to the consumer and its runtime. This page is the recipe for wiring one, not a module to import; it is the server-side counterpart of Wire an inspector.

The dataset is static files, so there is no webhook to subscribe to and none is needed: the poll is the subscription. One fetch of runs.json — the cross-model run index, regenerated wholesale at every publish — answers “what run is current for every model”. loadRuns({ fetch, baseUrl }) fetches it with the same discriminated miss semantics as every other loader in the transport guide.

The cadence is yours. A sensible loop wakes at a small fraction of the fastest runIntervalHours it serves — every few minutes is plenty when the fastest feed publishes six-hourly — and a poll that finds nothing new costs one small document.

A publication is identified by the pair (run.referenceTime, run.generatedAt); the fact and its consequences are defined in Data and package versioning. For the loop that means: remember the last pair you ingested per model, and treat any change as work. A new referenceTime is a new run; a later generatedAt for the same referenceTime is a corrected re-publication, and re-ingesting it is exactly as mandatory — comparing referenceTime alone would serve retracted values forever.

detect-publications.ts
import type { RunsIndexEntry } from "windgram/contract";
import { loadRuns } from "windgram/transport";
/** The last pair ingested per model slug — persisted however you persist things. */
export type SeenRuns = Record<string, RunsIndexEntry>;
export async function modelsToIngest(
baseUrl: string,
seen: SeenRuns,
): Promise<{ changed: string[]; index: SeenRuns }> {
const index = await loadRuns({ fetch, baseUrl });
if ("miss" in index) {
// Either miss is loud here: "absent" means the data root itself is gone.
console.error(`runs.json ${index.miss} at ${index.url}`);
return { changed: [], index: seen };
}
const changed = Object.keys(index.runs).filter((slug) => {
const previous = seen[slug];
const current = index.runs[slug];
return (
!previous ||
previous.referenceTime !== current.referenceTime ||
previous.generatedAt !== current.generatedAt
);
});
return { changed, index: index.runs };
}

Advance seen[slug] to the fresh pair only after that model’s documents ingest coherently below — a publish caught mid-flight then stays on the work list and is retried by the next tick, for free.

A model’s sites are separate files behind separate cache entries, so around a publish, per-site fetches can straddle two runs even when each manifest/document pair looks internally consistent on its own. loadSiteSet exists for exactly this: it fetches the model’s manifest once as the commit point, then every site document, and requires each document to carry that manifest’s run, retrying once on a mid-publish mix — the transport guide defines the contract. The result discriminates on syncing:

ingest-coherent-set.ts
import { parseWindgramProfileJson, type WindgramProfile } from "windgram/contract";
import { loadSiteSet } from "windgram/transport";
export interface IngestedRun {
referenceTime: string;
documents: Record<string, WindgramProfile>;
}
/** Returns the coherent publication to store, or null to wait for the next poll. */
export async function ingestProfileModel(
baseUrl: string,
modelSlug: string,
siteSlugs: readonly string[],
): Promise<IngestedRun | null> {
const set = await loadSiteSet({
fetch,
baseUrl,
modelSlug,
siteSlugs,
guard: parseWindgramProfileJson,
});
if ("miss" in set) {
// The whole model missed — loud either way for a feed you serve.
console.error(`${modelSlug} manifest ${set.miss} at ${set.url}`);
return null;
}
if (set.syncing) {
// A publish is mid-flight; set.runsSeen names the runs observed.
// Ingest nothing — the next poll reads cleanly.
return null;
}
for (const [siteSlug, miss] of Object.entries(set.misses)) {
// "absent" is routine: a site outside this model's domain.
if (miss.miss === "invalid") console.error(`contract break at ${miss.url} (${siteSlug})`);
}
return { referenceTime: set.referenceTime, documents: set.documents };
}

Three behaviours in that code carry the recipe. On { syncing: true } the loop ingests nothing — not even the sites that agreed with the manifest — because a partial ingest is a torn store, and the next poll reads the finished publication cleanly while your store keeps serving what it already holds. Per-site misses never poison the set: "absent" sites are routine and "invalid" is a contract break to log loudly, exactly as in the miss table. And a coherent set may honestly be the previous publication — all-old is not syncing, it is the newest complete forecast there is; store it under its referenceTime and let the identity-pair check decide whether it was news. For a smoke model the recipe is identical with parseSmokeDocumentJson as the guard.

Observation documents are ingested per site with loadObservation — a guarded single fetch, no manifest anchor, no coherence dance — and that is a proof, not a shortcut. An observation document has no run: it is a self-contained rolling window of measured instants whose identity lives in its own observed block, and the observation manifest’s referenceTime is a maximum across all the dataset’s sites, so per-site “skew” against it is the aggregate’s normal state, never a tear. The worst case is being one internally-consistent granule behind — honestly timestamped by observed.lastObservedAt, un-retryable past the CDN’s cache, and self-healing on the next poll tick. The transport guide carries the full argument.

ingest-observations.ts
import type { ObservationDocument } from "windgram/contract";
import { loadObservation } from "windgram/transport";
export async function ingestObservations(
baseUrl: string,
modelSlug: string,
siteSlugs: readonly string[],
): Promise<Record<string, ObservationDocument>> {
const documents: Record<string, ObservationDocument> = {};
await Promise.all(
siteSlugs.map(async (siteSlug) => {
const result = await loadObservation({ fetch, baseUrl, modelSlug, siteSlug });
if ("miss" in result) {
if (result.miss === "invalid") console.error(`contract break at ${result.url}`);
return;
}
documents[siteSlug] = result;
}),
);
return documents;
}

Because there is no run to anchor, observation series also have no publication pair to detect: poll them on their own tick, sized against the catalogue’s cadenceMinutes rather than any runIntervalHours.

Publishes take time and providers have bad days, so gaps are a when, not an if: a syncing set, a run that never appears, an ingest tick that dies halfway. The recipe absorbs all of them one way — the store serves the newest coherent publication it holds until a newer one has ingested completely, then swaps atomically under the new referenceTime. Never serve a partially ingested run, and never delete on a miss: a model that went quiet still has a perfectly good predecessor run, honestly dated by its own run block, and a reader told “this is the 06Z run; the 12Z is late” is better served than one shown nothing.

How many predecessors to keep — one, a season, forever — is retention, and retention is consumer policy, not a dataset property. The dataset’s own history archives already keep the per-site record of everything published — readable programmatically with the windgram/history loaders — so your store only needs what your product serves hot.

Not every feed you ingest carries the same weight, and the gap-handling above should not pretend otherwise. A baseline feed is one your product cannot serve its purpose without; a bonus feed enriches the picture while it is there — a second opinion from another model, a smoke overlay, an observation series. The distinction matters because their failures mean different things: a baseline model gone stale is your outage — alert, escalate, apologize — while a bonus feed going quiet is weather, or a provider’s bad day — say so honestly in the product and keep serving everything else. One freshness grade should never take the whole product down with it.

Which feeds are the baseline is product policy: the catalogue declares what each model publishes, never which one you depend on. Naming the baseline is your decision; treating the two failure classes differently is the recipe.

A store that serves through gaps must answer “how current is this?” honestly. runFreshness from windgram/derive grades a runs.json entry "current" | "delayed" | "stale", and its inputs split exactly along the fact/policy line this page keeps drawing. The facts are the catalogue’s: runIntervalHours (how often a successor run appears) and typicalPublicationLagHours (the upper end of normal for this dataset’s publish after referenceTime) — both declared per model in the model catalogue. The boundaries are yours: both thresholds count run intervals of age beyond the lag, and they are deliberately required parameters, because how much lateness a product tolerates before warning its users is display policy, never a dataset property.

grade-feeds.ts
import type { ModelCatalogue, RunsIndex } from "windgram/contract";
import { runFreshness, type RunFreshness } from "windgram/derive";
/** This product's tolerance — yours will differ, and that is the point. */
const THRESHOLDS = {
currentIntervals: 1, // the successor run may simply not exist yet
staleAfterIntervals: 3, // a whole run skipped, and the one after is late too
};
export function gradeFeeds(
index: RunsIndex,
catalogue: ModelCatalogue,
now: string,
): Record<string, RunFreshness> {
const grades: Record<string, RunFreshness> = {};
for (const model of [...catalogue.models, ...(catalogue.smokeModels ?? [])]) {
const entry = index.runs[model.slug];
if (entry) grades[model.slug] = runFreshness(entry, model, now, THRESHOLDS);
}
return grades;
}

Pass the runs.json entry and the catalogue entry straight in — age is now − referenceTime, so a corrected re-publication never makes a forecast look younger. A "delayed" run is still the newest forecast there is; "stale" means the feed has missed enough runs that presenting it as current weather would be dishonest — which grade triggers which product behaviour is the baseline-versus-bonus decision above. Observation datasets never come here: they have no runs; judge them against their catalogue cadenceMinutes.