Skip to content

Commit 7bb5e7c

Browse files
authored
fix(api): forward the outbound-ping bindings the workflow env was dropping (#2200)
Follow-up to #2199. Two bugs, one root cause. 1. IndexNow has been a no-op on the workflow ingest path since it shipped. `buildFetchOneEnv` is an exhaustive projection of the workflow env down to `FetchOneEnv`, and it never forwarded `INDEXNOW_KEY`. Every workflow-driven fetch therefore logged `indexnow / skipped / no_key_binding` and no search engine was ever pinged. Confirmed in prod logs. Every forwarded field is optional, so the omission type-checked. The `GuardedFetchOneEnv` compile guard exists precisely to stop this -- it was added after the same class of drop hit the Anthropic key, then again after it hit the OpenRouter lane vars -- but its key list was named `AiCriticalFetchKeys` and scoped to AI bindings, so a non-AI credential fell straight through. Renamed to `CriticalFetchKeys` and widened: the failure mode is not "an AI pass is disabled", it is "an optional binding is dropped from an exhaustive projection, the omission type-checks, and the feature degrades to its disabled state in silence". Any binding whose absence is indistinguishable from being deliberately switched off now belongs there. `INDEXNOW_KEY`, `INDEXING_DISABLED`, `WEB_SERVICE_KEY` and `WEB_BASE_URL` are now forwarded and guarded. 2. The #2199 revalidate ping missed the dominant ingest path. `cron/poll-fetch.ts` runs its own post-insert effects block and never calls `runBatchIngestEffects`, where the ping was wired -- so on-demand ISR revalidation only fired for the manual fetch route and the scrape persister, not for the workflow that drives most ingest. Wired into the inline block beside the IndexNow call. It would also have skipped at `no_secret_binding` regardless, for the reason above. Both pings fail open to "skipped", which is why neither showed up as an error -- the skip is indistinguishable from the feature being correctly disabled. The extended `*-resolve-env` regression tests now pin all four.
1 parent 6044f36 commit 7bb5e7c

5 files changed

Lines changed: 113 additions & 20 deletions

File tree

docs/architecture/web.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ Dynamic routes carry `revalidate = 86400` so first-render cost amortizes across
5555

5656
## ISR revalidation
5757

58-
Org, source, product and release pages are statically rendered. **Freshness comes from an ingest-time ping, not from the clock.** When `runBatchIngestEffects` inserts releases it calls `notifyWebRevalidate` (`workers/api/src/lib/web-revalidate.ts`) — a sibling of the IndexNow ping, same trigger and same fire-and-forget semantics — which POSTs the affected `{ orgSlug, sourceSlug?, productSlug? }` to web's `POST /api/revalidate`. The route's whole contract (bearer auth, body validation, path derivation) lives in `web/src/lib/revalidate-request.ts` so it is testable without `next/cache`; the route module only injects `revalidatePath` and the secret.
58+
Org, source, product and release pages are statically rendered. **Freshness comes from an ingest-time ping, not from the clock.** `notifyWebRevalidate` (`workers/api/src/lib/web-revalidate.ts`) — a sibling of the IndexNow ping, same trigger and same fire-and-forget semantics — POSTs the affected `{ orgSlug, sourceSlug?, productSlug? }` to web's `POST /api/revalidate`.
59+
60+
It is wired at **both** post-insert effect sites, which are separate code paths: `runBatchIngestEffects` (batch route + scrape persister) and the inline effects block in `cron/poll-fetch.ts` (the workflow path, which does not call `runBatchIngestEffects`). Wiring only the first leaves the ping firing for manual fetches alone — the workflow is the dominant ingest driver.
61+
62+
The credentials reach `fetchOne` through `buildFetchOneEnv` (`workers/api/src/workflows/_fetch-env.ts`), an exhaustive projection guarded by `CriticalFetchKeys`. **Any binding whose absence is indistinguishable from the feature being switched off must be listed there**`INDEXNOW_KEY` was not, and the IndexNow ping was a silent no-op on the workflow path from the day it shipped. The route's whole contract (bearer auth, body validation, path derivation) lives in `web/src/lib/revalidate-request.ts` so it is testable without `next/cache`; the route module only injects `revalidatePath` and the secret.
5963

6064
`export const revalidate = 86400` on those pages is the **backstop for a dropped ping**, not the mechanism. Time-based ISR fits this content badly: the long tail of org pages is low-traffic enough that a short window regenerates pages nothing changed on (writes ≈ requests), while a long one would leave fresh releases invisible. Windows and the shared default live in `web/src/lib/isr.ts`.
6165

workers/api/src/cron/poll-fetch.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import { invalidateLatestCache } from "../lib/latest-cache.js";
8686
import type { InvalidationEnv } from "../lib/latest-cache.js";
8787
import type { InsertedReleaseRow } from "../events/build-event.js";
8888
import { notifyIndexNowForSource, type IndexNowEnv } from "../lib/indexnow.js";
89+
import { notifyWebRevalidate, type WebRevalidateEnv } from "../lib/web-revalidate.js";
8990
import { clusterAndPersistCascades } from "../lib/cluster-cascades.js";
9091
import { resolveOrgSlug, resolveProductSlug } from "../lib/slug-lookups.js";
9192
import { logEvent } from "@releases/lib/log-event";
@@ -705,7 +706,8 @@ export const DEFAULT_FETCH_MAX_ENTRIES = 200;
705706
// source lock), STATUS_HUB, DETERMINISTIC_UPDATE_WORKFLOW, and the MA_* cap
706707
// vars. Summary-only crawl-enabled feeds delegate through it — see
707708
// {@link shouldDelegateToCrawl} / {@link delegateScrapeToUpdateWorkflow}.
708-
export interface FetchOneEnv extends IndexNowEnv, TextModelEnv, UpdateDispatchEnv {
709+
export interface FetchOneEnv
710+
extends IndexNowEnv, WebRevalidateEnv, TextModelEnv, UpdateDispatchEnv {
709711
GITHUB_TOKEN?: string;
710712
/**
711713
* Optional Vectorize bindings for semantic-search side effects. Typed as
@@ -1600,18 +1602,38 @@ export async function ingestRawReleases(
16001602
// INDEXNOW_KEY binding is absent (dev). Per-release URLs are intentionally
16011603
// out of scope — see https://github.com/buildinternet/releases/issues/649.
16021604
if (visiblePublishRows.length > 0) {
1605+
const slugResolvers = {
1606+
resolveOrgSlug: (id: string) => resolveOrgSlug(db, id),
1607+
resolveProductSlug: (id: string) => resolveProductSlug(db, id),
1608+
};
1609+
16031610
await notifyIndexNowForSource(
16041611
env,
1612+
slugResolvers,
16051613
{
1606-
resolveOrgSlug: (id) => resolveOrgSlug(db, id),
1607-
resolveProductSlug: (id) => resolveProductSlug(db, id),
1614+
slug: source.slug,
1615+
orgId: source.orgId,
1616+
productId: source.productId,
1617+
isHidden: source.isHidden,
1618+
discovery: source.discovery,
16081619
},
1620+
visiblePublishRows.length,
1621+
);
1622+
1623+
// Bust web's ISR entries for the pages this insert just changed. This path
1624+
// runs its own effects rather than going through `runBatchIngestEffects`,
1625+
// so the ping has to be wired here too — it is the workflow (and therefore
1626+
// the dominant) ingest path, and wiring only the batch path left it firing
1627+
// for manual fetches alone. Same fire-and-forget semantics as IndexNow;
1628+
// gating differs (see web-revalidate.ts on `discovery === "on_demand"`).
1629+
await notifyWebRevalidate(
1630+
env,
1631+
slugResolvers,
16091632
{
16101633
slug: source.slug,
16111634
orgId: source.orgId,
16121635
productId: source.productId,
16131636
isHidden: source.isHidden,
1614-
discovery: source.discovery,
16151637
},
16161638
visiblePublishRows.length,
16171639
);

workers/api/src/workflows/_fetch-env.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,32 @@
1616
* call for three weeks. That fail-open is deliberately SILENT (an empty model var
1717
* is the per-lane off switch), so the only symptom was an Anthropic bill — until
1818
* the account hit its spend cap on 2026-07-23 and both lanes started erroring.
19-
* The `AiCriticalFetchKeys` guard below now covers the OpenRouter set too.
19+
* The guard below now covers the OpenRouter set too.
20+
*
21+
* And a third time: `INDEXNOW_KEY` was never forwarded at all, so the IndexNow
22+
* ping logged `skipped / no_key_binding` on every workflow-driven fetch from the
23+
* day this path shipped — no search engine was ever notified. The guard was
24+
* named `AiCriticalFetchKeys` and scoped to AI bindings only, so a non-AI
25+
* credential fell straight through it. It is now `CriticalFetchKeys` and covers
26+
* any binding whose absence is indistinguishable from the feature being off.
2027
*/
2128
import { getSecret } from "@releases/lib/secrets";
2229
import type { MediaTransformBinding } from "../lib/media-ingest.js";
2330
import type { FetchOneEnv } from "../cron/poll-fetch.js";
2431
import type { AnthropicEnv } from "../lib/anthropic.js";
2532
import type { TextModelEnv } from "../lib/text-model.js";
2633
import type { InvalidationEnv } from "../lib/latest-cache.js";
34+
import type { IndexNowEnv } from "../lib/indexnow.js";
35+
import type { WebRevalidateEnv } from "../lib/web-revalidate.js";
2736

2837
/**
2938
* The workflow-env fields forwarded into a `FetchOneEnv`. `FLAGS` rides on
3039
* `InvalidationEnv`; the Anthropic key + gateway opts ride on `AnthropicEnv`.
3140
* Every field is optional, so any concrete workflow env (PollAndFetchWorkflowEnv,
3241
* OnboardSourceWorkflowEnv, …) is structurally assignable.
3342
*/
34-
export interface WorkflowFetchEnv extends InvalidationEnv, AnthropicEnv, TextModelEnv {
43+
export interface WorkflowFetchEnv
44+
extends InvalidationEnv, AnthropicEnv, TextModelEnv, IndexNowEnv, WebRevalidateEnv {
3545
GITHUB_TOKEN?: { get(): Promise<string> };
3646
RELEASES_INDEX?: unknown;
3747
CHANGELOG_CHUNKS_INDEX?: unknown;
@@ -64,13 +74,20 @@ export interface WorkflowFetchEnv extends InvalidationEnv, AnthropicEnv, TextMod
6474
}
6575

6676
/**
67-
* The forwarded fields whose silent omission disables an ingest-time AI pass:
68-
* the Anthropic client inputs (enrichment + marketing classifier), the
69-
* feed-enrich tuning vars, and the Browser-Rendering creds enrichment escalates
70-
* with. This is the exact set the original drop no-opped, and the set the
71-
* `*-resolve-env` regression tests pin.
77+
* The forwarded fields whose silent omission disables an ingest-time side
78+
* effect: the Anthropic client inputs (enrichment + marketing classifier), the
79+
* feed-enrich tuning vars, the Browser-Rendering creds enrichment escalates
80+
* with, and the outbound-ping credentials (IndexNow, web ISR revalidation).
81+
*
82+
* Scope note: this list was `AiCriticalFetchKeys` and covered only the AI
83+
* bindings, which is how `INDEXNOW_KEY` fell through and left the IndexNow ping
84+
* a permanent no-op on the workflow path from the day it shipped. The failure
85+
* mode is not specific to AI passes — it is "an optional binding is dropped
86+
* from an exhaustive projection, the omission type-checks, and the feature
87+
* degrades to its disabled state in silence." Any binding whose absence is
88+
* indistinguishable from being deliberately switched off belongs here.
7289
*/
73-
type AiCriticalFetchKeys =
90+
type CriticalFetchKeys =
7491
| "ANTHROPIC_API_KEY"
7592
| "ANTHROPIC_BASE_URL"
7693
| "AI_GATEWAY_TOKEN"
@@ -85,27 +102,33 @@ type AiCriticalFetchKeys =
85102
| "FEED_ENRICH_MAX_PER_FIRE"
86103
| "FEED_THIN_CHARS"
87104
| "CLOUDFLARE_ACCOUNT_ID"
88-
| "CLOUDFLARE_API_TOKEN";
105+
| "CLOUDFLARE_API_TOKEN"
106+
// Outbound pings. Both fail open to "skipped", so a drop looks exactly like
107+
// the feature being off — see the scope note above.
108+
| "INDEXNOW_KEY"
109+
| "INDEXING_DISABLED"
110+
| "WEB_SERVICE_KEY"
111+
| "WEB_BASE_URL";
89112

90113
/**
91-
* `FetchOneEnv` with the AI-critical keys promoted from optional to required —
114+
* `FetchOneEnv` with the critical keys promoted from optional to required —
92115
* `-?` forces each KEY to appear in the builder's return literal (dropping a
93116
* line is a compile error), while `| undefined` preserves fail-open: the binding
94117
* itself may still resolve to undefined at runtime. Note this is deliberately
95118
* NOT `Required<Pick<…>>`, which would strip `undefined` from the VALUE and
96119
* reject the genuinely-optional source bindings the builder forwards.
97120
*/
98121
type GuardedFetchOneEnv = FetchOneEnv & {
99-
[K in AiCriticalFetchKeys]-?: FetchOneEnv[K] | undefined;
122+
[K in CriticalFetchKeys]-?: FetchOneEnv[K] | undefined;
100123
};
101124

102125
/**
103126
* Project a workflow env down to the `FetchOneEnv` slice. The only async work is
104127
* resolving the GitHub token secret; everything else is a binding hand-off. Keep
105128
* the field list exhaustive — a dropped binding silently no-ops the corresponding
106-
* ingest-time AI pass (see the module header). The {@link GuardedFetchOneEnv}
107-
* return type turns dropping one of the AI-critical bindings into a compile
108-
* error rather than a silent prod regression.
129+
* ingest-time pass or outbound ping (see the module header). The
130+
* {@link GuardedFetchOneEnv} return type turns dropping one of the critical
131+
* bindings into a compile error rather than a silent prod regression.
109132
*/
110133
export async function buildFetchOneEnv(env: WorkflowFetchEnv): Promise<GuardedFetchOneEnv> {
111134
const githubToken = (await getSecret(env.GITHUB_TOKEN).catch(() => null)) ?? undefined;
@@ -153,5 +176,12 @@ export async function buildFetchOneEnv(env: WorkflowFetchEnv): Promise<GuardedFe
153176
FEED_THIN_CHARS: env.FEED_THIN_CHARS,
154177
CLOUDFLARE_ACCOUNT_ID: env.CLOUDFLARE_ACCOUNT_ID,
155178
CLOUDFLARE_API_TOKEN: env.CLOUDFLARE_API_TOKEN,
179+
// Outbound pings fired from fetchOne's post-insert effects. Missing since
180+
// the workflow path shipped, which is why every workflow-driven fetch
181+
// logged `indexnow / skipped / no_key_binding`.
182+
INDEXNOW_KEY: env.INDEXNOW_KEY,
183+
INDEXING_DISABLED: env.INDEXING_DISABLED,
184+
WEB_SERVICE_KEY: env.WEB_SERVICE_KEY,
185+
WEB_BASE_URL: env.WEB_BASE_URL,
156186
};
157187
}

workers/api/src/workflows/poll-and-fetch.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export {
5454
};
5555
import { type AnthropicEnv } from "../lib/anthropic.js";
5656
import { type TextModelEnv } from "../lib/text-model.js";
57+
import { type IndexNowEnv } from "../lib/indexnow.js";
58+
import { type WebRevalidateEnv } from "../lib/web-revalidate.js";
5759
import { makeBotFetch } from "../lib/web-bot-auth-fetch.js";
5860

5961
/**
@@ -67,7 +69,12 @@ export type PollAndFetchWorkflowEnv = InvalidationEnv &
6769
// `buildFetchOneEnv` can forward it. Omitting it is what silently pinned the
6870
// marketing-classifier + feed-enrich lanes to Anthropic Haiku — see the
6971
// history note in `_fetch-env.ts`.
70-
TextModelEnv & {
72+
TextModelEnv &
73+
// Outbound-ping credentials, same reason: `buildFetchOneEnv` can only forward
74+
// what this type admits. `INDEXNOW_KEY` was absent here, so the IndexNow ping
75+
// skipped with `no_key_binding` on every workflow-driven fetch.
76+
IndexNowEnv &
77+
WebRevalidateEnv & {
7178
DB: D1Database;
7279
CRON_ENABLED?: string;
7380
GITHUB_TOKEN?: { get(): Promise<string> };

workers/api/test/poll-fetch-resolve-env.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ function buildEnv(): PollAndFetchWorkflowEnv {
3535
FEED_ENRICH_MODEL: "deepseek/deepseek-v4-flash",
3636
SUMMARIZE_MODEL: "deepseek/deepseek-v4-flash",
3737
EXTRACT_MODEL: "deepseek/deepseek-v4-pro",
38+
INDEXNOW_KEY: secret("indexnow-key"),
39+
INDEXING_DISABLED: "false",
40+
WEB_SERVICE_KEY: secret("web-service-key"),
41+
WEB_BASE_URL: "https://releases.sh",
3842
} as unknown as PollAndFetchWorkflowEnv;
3943
}
4044

@@ -82,4 +86,30 @@ describe("resolveFetchEnv (poll-and-fetch workflow)", () => {
8286
expect(fetchEnv.SUMMARIZE_MODEL).toBe("deepseek/deepseek-v4-flash");
8387
expect(fetchEnv.EXTRACT_MODEL).toBe("deepseek/deepseek-v4-pro");
8488
});
89+
90+
// The same drop, third occurrence — this time on the non-AI side effects, which
91+
// the AI-only guard never covered. `INDEXNOW_KEY` was missing since the workflow
92+
// path was introduced, so every workflow-driven fetch logged
93+
// `indexnow / skipped / no_key_binding` and no search engine was ever pinged.
94+
// Nothing errors; the skip is indistinguishable from "correctly disabled".
95+
it("forwards the IndexNow key + kill switch so the ping isn't a permanent no-op", async () => {
96+
const env = buildEnv();
97+
const fetchEnv = await resolveFetchEnv(env);
98+
expect(fetchEnv.INDEXNOW_KEY).toBe(env.INDEXNOW_KEY);
99+
expect(fetchEnv.INDEXING_DISABLED).toBe("false");
100+
});
101+
102+
it("forwards the web service key so on-demand ISR revalidation fires on ingest", async () => {
103+
const env = buildEnv();
104+
const fetchEnv = await resolveFetchEnv(env);
105+
expect(fetchEnv.WEB_SERVICE_KEY).toBe(env.WEB_SERVICE_KEY);
106+
});
107+
108+
// Shared by both pings for building target URLs. Both fall back to
109+
// `https://releases.sh`, so a drop is invisible in prod and wrong everywhere
110+
// else — staging/dev would silently aim at the production host.
111+
it("forwards WEB_BASE_URL so the pings don't fall back to the prod host", async () => {
112+
const fetchEnv = await resolveFetchEnv(buildEnv());
113+
expect(fetchEnv.WEB_BASE_URL).toBe("https://releases.sh");
114+
});
85115
});

0 commit comments

Comments
 (0)