99 * cascade coverage clustering, total count. Everything here is either a
1010 * write the caller needs reflected in the response, or a read needed to
1111 * decide one.
12- * - `runBatchIngestEffects` — the post-insert fire-and-forget extras: publish
13- * to the ReleaseHub DO, the web revalidate ping, latest-cache invalidation, and
14- * embed + `embeddedAt` marking. These already tolerate failure independently
15- * (each has its own catch/logEvent), so they run concurrently via
16- * `Promise.allSettled` rather than sequentially.
12+ * - `runBatchIngestEffects` — the post-insert extras: publish to the ReleaseHub
13+ * DO, the web revalidate ping, latest-cache invalidation, generate-content
14+ * (same eligibility as poll), then embed + `embeddedAt` marking. Publish /
15+ * revalidate / invalidate tolerate failure independently and run concurrently
16+ * via `Promise.allSettled`. Generate is awaited *before* embed so the
17+ * Vectorize payload sees `summary` / `title_generated` when the org is opted
18+ * in — matching `runContentAndEmbedSteps`. The scrape persister skips both
19+ * generate and embed; the DeterministicUpdate workflow runs them as durable
20+ * steps later.
1721 *
1822 * Request-shaped concerns (body parsing, `releases`-array validation, `mode`
1923 * validation → `enrichMode`) stay in the route handler — they're HTTP
@@ -50,6 +54,8 @@ import { embedAndUpsertReleases } from "@releases/search/embed-releases.js";
5054import { logEvent } from "@releases/lib/log-event" ;
5155import { FLAGS , flag , type FlagshipBinding } from "@releases/lib/flags" ;
5256import type { MediaTransformBinding } from "./media-ingest.js" ;
57+ import { generateContentForReleases } from "./ingest-steps.js" ;
58+ import type { TextModelEnv } from "./text-model.js" ;
5359
5460/**
5561 * Shape-coerce media via {@link normalizeMediaBind}, then rewrite each item's
@@ -115,15 +121,57 @@ export interface BatchIngestEnv {
115121
116122/**
117123 * Env slice used by `runBatchIngestEffects` — a union of the effect helpers'
118- * own env slices, plus the embed config env.
124+ * own env slices, plus the embed config env and the summarize-lane bindings
125+ * `generateContentForReleases` reads via `resolveSummarizeModel`.
119126 */
120- export interface BatchEffectsEnv extends PublishEnv , WebRevalidateEnv , InvalidationEnv , EmbedEnv {
127+ export interface BatchEffectsEnv
128+ extends PublishEnv , WebRevalidateEnv , InvalidationEnv , EmbedEnv , TextModelEnv {
121129 // Typed loosely (matches the route's `Env.Bindings` Cloudflare `VectorizeIndex`)
122130 // and narrowed via cast at the call site below — see the note in
123131 // embedSourceSideEffect about why the cast is needed.
124132 RELEASES_INDEX : unknown ;
125133}
126134
135+ /** Rows loaded for the embed pass — the subset `embedAndUpsertReleases` reads. */
136+ export type BatchEmbedRow = {
137+ id : string ;
138+ title : string ;
139+ content : string ;
140+ summary : string | null ;
141+ version : string | null ;
142+ publishedAt : string | null ;
143+ sourceId : string ;
144+ type : ReleaseType ;
145+ } ;
146+
147+ export type BatchEffectsOpts = {
148+ skipEmbed ?: boolean ;
149+ skipInvalidate ?: boolean ;
150+ /**
151+ * Skip the generate-content fill. The D1 scrape persister passes this
152+ * because DeterministicUpdate runs `generateContentForReleases` as a
153+ * durable workflow step later — without it, a scrape update would
154+ * summarize twice.
155+ */
156+ skipSummarize ?: boolean ;
157+ /**
158+ * Test seam. Production omits this and uses `generateContentForReleases`.
159+ * Same injection style as `runGenerateContent({ generate })`.
160+ */
161+ generateContent ?: (
162+ db : D1Db ,
163+ env : BatchEffectsEnv ,
164+ src : Source ,
165+ insertedIds : string [ ] ,
166+ ) => Promise < number > ;
167+ /**
168+ * Test seam. Production omits this and uses `embedAndUpsertReleases`.
169+ * Called with the rows loaded *after* generate so a test can assert the
170+ * embed payload already carries `summary`.
171+ */
172+ embedReleases ?: ( rows : BatchEmbedRow [ ] ) => Promise < void > ;
173+ } ;
174+
127175/**
128176 * Durable core: deny filter, title dedup, media R2 mirror, chunked upsert,
129177 * cascade coverage clustering, total count.
@@ -368,20 +416,24 @@ export async function ingestReleaseBatch(
368416}
369417
370418/**
371- * The post-insert waitUntil extras: ReleaseHub publish, web revalidate, embed +
372- * embeddedAt marking, latest-cache invalidation. Awaitable — the caller
373- * decides whether to await inline or hand it to `waitUntil`.
419+ * The post-insert waitUntil extras: ReleaseHub publish, web revalidate,
420+ * latest-cache invalidation, generate-content, then embed + `embeddedAt`
421+ * marking. Awaitable — the caller decides whether to await inline or hand
422+ * it to `waitUntil`.
374423 *
375424 * Publish/revalidate/invalidate already tolerate failure independently (each
376425 * logs its own error internally), so they run concurrently via
377- * `Promise.allSettled` rather than one blocking the others.
426+ * `Promise.allSettled` rather than one blocking the others. Generate is
427+ * sequential with embed (generate first) so the embed SELECT sees
428+ * `summary` / `title_generated` when the org is opted in. A generate
429+ * failure is fail-open: we still embed the raw body.
378430 */
379431export async function runBatchIngestEffects (
380432 db : D1Db ,
381433 env : BatchEffectsEnv ,
382434 src : Source ,
383435 result : BatchIngestResult ,
384- opts ?: { skipEmbed ?: boolean ; skipInvalidate ?: boolean } ,
436+ opts ?: BatchEffectsOpts ,
385437) : Promise < void > {
386438 const { visiblePublishRows, insertedIds } = result ;
387439 const tasks : Array < Promise < unknown > > = [ ] ;
@@ -390,7 +442,8 @@ export async function runBatchIngestEffects(
390442 // `tail -f`, the upcoming web live view, webhook delivery) see new
391443 // releases in real time. Coverage-side rows are excluded — they're
392444 // not shown in default feeds and shouldn't broadcast on the live tail
393- // either.
445+ // either. Publish stays concurrent with generate: the live payload
446+ // omits `summary` by contract (`events.md`).
394447 if ( visiblePublishRows . length > 0 ) {
395448 tasks . push (
396449 publishReleaseEvents ( env , {
@@ -434,94 +487,112 @@ export async function runBatchIngestEffects(
434487 ) ;
435488 }
436489
437- // Fire-and-forget: embed the rows we just wrote. Never fails the write —
438- // embedAndUpsertReleases catches every error internally and logs to
439- // console.
440- if ( ! opts ?. skipEmbed && insertedIds . length > 0 ) {
441- tasks . push (
442- ( async ( ) => {
443- try {
444- const embedConfig = await buildEmbedConfig ( env ) ;
445- if ( ! embedConfig ) return ;
446- // Load the rows back so we have full content, category, etc.
447- // We need the org/product category for metadata filtering.
448- const [ orgRow ] = src . orgId
449- ? await db
450- . select ( { category : organizations . category } )
451- . from ( organizations )
452- . where ( eq ( organizations . id , src . orgId ) )
453- : [ { category : null as string | null } ] ;
454- // D1 bind-param cap is 100; chunk the IN clause so we stay
455- // well clear of the limit even if the caller posts a large
456- // batch. See `./d1-limits.ts`.
457- const rowsToEmbed : Array < {
458- id : string ;
459- title : string ;
460- content : string ;
461- summary : string | null ;
462- version : string | null ;
463- publishedAt : string | null ;
464- sourceId : string ;
465- type : ReleaseType ;
466- } > = [ ] ;
467- for ( let i = 0 ; i < insertedIds . length ; i += RELEASES_ID_IN_CHUNK_SIZE ) {
468- const slice = insertedIds . slice ( i , i + RELEASES_ID_IN_CHUNK_SIZE ) ;
469- // oxlint-disable-next-line no-await-in-loop -- D1 chunked select (100 bind param limit for inArray)
470- const rows = await db
471- . select ( {
472- id : releases . id ,
473- title : releases . title ,
474- content : releases . content ,
475- summary : releases . summary ,
476- version : releases . version ,
477- publishedAt : releases . publishedAt ,
478- sourceId : releases . sourceId ,
479- type : releases . type ,
480- } )
481- . from ( releases )
482- . where ( inArray ( releases . id , slice ) ) ;
483- rowsToEmbed . push ( ...rows ) ;
484- }
485-
486- const category = orgRow ?. category ?? null ;
487- await embedAndUpsertReleases ( {
488- // oxlint-disable-next-line no-map-spread -- copy-on-write required; r is a DB row
489- releases : rowsToEmbed . map ( ( r ) => ( {
490- ...r ,
491- orgId : src . orgId ,
492- productId : src . productId ,
493- category,
494- } ) ) ,
495- // See note in embedSourceSideEffect about the cast.
496- vectorIndex :
497- env . RELEASES_INDEX as unknown as import ( "@releases/search/vector-search.js" ) . VectorizeIndex ,
498- embedConfig,
499- onPersisted : async ( ids ) => {
500- if ( ids . length === 0 ) return ;
501- // Mark the rows as embedded. D1's 100 bind-param cap means
502- // the embeddedAt SET + N IN-clause ids must total ≤100, so
503- // we chunk IDs — see `./d1-limits.ts`.
504- const now = new Date ( ) . toISOString ( ) ;
505- for ( let i = 0 ; i < ids . length ; i += RELEASES_ID_IN_CHUNK_SIZE ) {
506- const slice = ids . slice ( i , i + RELEASES_ID_IN_CHUNK_SIZE ) ;
507- // oxlint-disable-next-line no-await-in-loop -- D1 chunked update (100 bind param limit)
508- await db
509- . update ( releases )
510- . set ( { embeddedAt : now } )
511- . where ( inArray ( releases . id , slice ) ) ;
512- }
513- } ,
514- } ) ;
515- } catch ( err ) {
516- logEvent ( "warn" , {
517- component : "sources-batch" ,
518- event : "embed-side-effect-failed" ,
519- err : err instanceof Error ? err : String ( err ) ,
520- } ) ;
521- }
522- } ) ( ) ,
523- ) ;
490+ const shouldGenerate = ! opts ?. skipSummarize && insertedIds . length > 0 ;
491+ const shouldEmbed = ! opts ?. skipEmbed && insertedIds . length > 0 ;
492+ if ( shouldGenerate || shouldEmbed ) {
493+ tasks . push ( generateThenEmbed ( db , env , src , insertedIds , opts ) ) ;
524494 }
525495
526496 await Promise . allSettled ( tasks ) ;
527497}
498+
499+ /**
500+ * Generate (opt-in, fail-open) then embed. Split out of `runBatchIngestEffects`
501+ * so publish/revalidate stay concurrent with this chain instead of waiting
502+ * on the LLM.
503+ */
504+ async function generateThenEmbed (
505+ db : D1Db ,
506+ env : BatchEffectsEnv ,
507+ src : Source ,
508+ insertedIds : string [ ] ,
509+ opts ?: BatchEffectsOpts ,
510+ ) : Promise < void > {
511+ if ( ! opts ?. skipSummarize && insertedIds . length > 0 ) {
512+ try {
513+ const generate = opts ?. generateContent ?? generateContentForReleases ;
514+ await generate ( db , env , src , insertedIds ) ;
515+ } catch ( err ) {
516+ logEvent ( "warn" , {
517+ component : "sources-batch" ,
518+ event : "generate-side-effect-failed" ,
519+ sourceId : src . id ,
520+ err : err instanceof Error ? err : String ( err ) ,
521+ } ) ;
522+ }
523+ }
524+
525+ if ( opts ?. skipEmbed || insertedIds . length === 0 ) return ;
526+
527+ try {
528+ // Load the rows back so we have full content, category, and (when
529+ // generate just ran) the new summary. Chunk the IN clause for D1's
530+ // 100 bind-param cap — see `./d1-limits.ts`.
531+ const [ orgRow ] = src . orgId
532+ ? await db
533+ . select ( { category : organizations . category } )
534+ . from ( organizations )
535+ . where ( eq ( organizations . id , src . orgId ) )
536+ : [ { category : null as string | null } ] ;
537+ const rowsToEmbed : BatchEmbedRow [ ] = [ ] ;
538+ for ( let i = 0 ; i < insertedIds . length ; i += RELEASES_ID_IN_CHUNK_SIZE ) {
539+ const slice = insertedIds . slice ( i , i + RELEASES_ID_IN_CHUNK_SIZE ) ;
540+ // oxlint-disable-next-line no-await-in-loop -- D1 chunked select (100 bind param limit for inArray)
541+ const rows = await db
542+ . select ( {
543+ id : releases . id ,
544+ title : releases . title ,
545+ content : releases . content ,
546+ summary : releases . summary ,
547+ version : releases . version ,
548+ publishedAt : releases . publishedAt ,
549+ sourceId : releases . sourceId ,
550+ type : releases . type ,
551+ } )
552+ . from ( releases )
553+ . where ( inArray ( releases . id , slice ) ) ;
554+ rowsToEmbed . push ( ...rows ) ;
555+ }
556+
557+ if ( opts ?. embedReleases ) {
558+ await opts . embedReleases ( rowsToEmbed ) ;
559+ return ;
560+ }
561+
562+ const embedConfig = await buildEmbedConfig ( env ) ;
563+ if ( ! embedConfig ) return ;
564+
565+ const category = orgRow ?. category ?? null ;
566+ await embedAndUpsertReleases ( {
567+ // oxlint-disable-next-line no-map-spread -- copy-on-write required; r is a DB row
568+ releases : rowsToEmbed . map ( ( r ) => ( {
569+ ...r ,
570+ orgId : src . orgId ,
571+ productId : src . productId ,
572+ category,
573+ } ) ) ,
574+ // See note in embedSourceSideEffect about the cast.
575+ vectorIndex :
576+ env . RELEASES_INDEX as unknown as import ( "@releases/search/vector-search.js" ) . VectorizeIndex ,
577+ embedConfig,
578+ onPersisted : async ( ids ) => {
579+ if ( ids . length === 0 ) return ;
580+ // Mark the rows as embedded. D1's 100 bind-param cap means
581+ // the embeddedAt SET + N IN-clause ids must total ≤100, so
582+ // we chunk IDs — see `./d1-limits.ts`.
583+ const now = new Date ( ) . toISOString ( ) ;
584+ for ( let i = 0 ; i < ids . length ; i += RELEASES_ID_IN_CHUNK_SIZE ) {
585+ const slice = ids . slice ( i , i + RELEASES_ID_IN_CHUNK_SIZE ) ;
586+ // oxlint-disable-next-line no-await-in-loop -- D1 chunked update (100 bind param limit)
587+ await db . update ( releases ) . set ( { embeddedAt : now } ) . where ( inArray ( releases . id , slice ) ) ;
588+ }
589+ } ,
590+ } ) ;
591+ } catch ( err ) {
592+ logEvent ( "warn" , {
593+ component : "sources-batch" ,
594+ event : "embed-side-effect-failed" ,
595+ err : err instanceof Error ? err : String ( err ) ,
596+ } ) ;
597+ }
598+ }
0 commit comments