Skip to content

Commit 3ca3490

Browse files
committed
fix(kb): harden ingestion reconciliation
1 parent b646f9c commit 3ca3490

5 files changed

Lines changed: 184 additions & 17 deletions

File tree

docs/async-and-workers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Bare `http.createServer`, two routes: `GET /healthz` and `POST /AddResponse`. No
4949
`packages/hatchet/src/index.ts:prepareHatchetTasks` registers two local workflows:
5050

5151
- `ingest-kb-resource` accepts the selected resource, version, and attempt identifiers, prepares the exact source bytes, then calls `packages/hatchet/src/kbIngestion.ts:dispatchKBIngestion`. Dispatch awaits `POST /v1/resources`, stores the returned operation identifier, and reuses the same version, digest, source URL, and idempotency key when an attempt is retried.
52-
- `monitor-kb-ingestions` runs every minute and calls `packages/hatchet/src/kbIngestion.ts:monitorActiveKBIngestions`. It polls `GET /v1/operations/{operation_id}` with bounded concurrency and applies only responses matching the local operation, resource version, and content digest.
52+
- `monitor-kb-ingestions` runs every minute and calls `packages/hatchet/src/kbIngestion.ts:monitorActiveKBIngestions`. It rotates through at most 32 active operations per tick, polls `GET /v1/operations/{operation_id}` eight at a time, and applies only responses matching the local operation, resource version, and content digest. A succeeded operation becomes `READY` only when its observed digest and actively serving version/digest match the local resource.
5353

5454
Operation events also return through the raw-body `/api/webhooks/kb-ingestion` route registered by `apps/backend-docker/src/app.ts:prepareApp`. `packages/graphql/src/services/knowledgeWebhooks.ts:handleKBIngestionWebhook` accepts the strict canonical event body and the four `X-Ingestion-*` headers, verifies an HMAC-SHA256 signature within the five-minute replay window against the current or previous webhook secret, then applies the same operation/version/digest correlation guards as polling. A resource becomes `READY` only when the platform reports that the expected version and digest are actively serving.
5555

packages/hatchet/src/kbIngestion.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from './kbIngestionApi.js'
1111

1212
const KB_INGESTION_POLL_CONCURRENCY = 8
13+
const KB_INGESTION_POLL_LIMIT = 32
1314

1415
export type KBIngestionLogger = {
1516
info?: (
@@ -40,6 +41,7 @@ export type MonitorKBIngestionsDependencies = {
4041
prisma: KBIngestionPrisma
4142
client?: KBIngestionApiClient
4243
env?: NodeJS.ProcessEnv
44+
now?: () => Date
4345
logger?: KBIngestionLogger
4446
}
4547

@@ -373,6 +375,20 @@ async function reconcileResource({
373375
return
374376
}
375377

378+
if (
379+
operation.status === 'succeeded' &&
380+
(operation.observedSha256 !== contentSha256 ||
381+
operation.serving.activeResourceVersion !== resource.resourceVersion ||
382+
operation.serving.activeSha256 !== contentSha256)
383+
) {
384+
await logErrorBestEffort(
385+
logger,
386+
'KB ingestion serving correlation failed',
387+
identifiers
388+
)
389+
return
390+
}
391+
376392
const transition = mapOperationStatus(operation.status)
377393
const sourceStatuses =
378394
operation.status === 'accepted'
@@ -408,15 +424,29 @@ export async function monitorActiveKBIngestions(
408424
dependencies: MonitorKBIngestionsDependencies
409425
): Promise<void> {
410426
const env = dependencies.env ?? process.env
411-
const resources = await dependencies.prisma.kBResource.findMany({
412-
where: {
413-
status: {
414-
in: [KBResourceStatus.QUEUED, KBResourceStatus.PROCESSING],
415-
},
416-
ingestionAttemptId: { not: null },
417-
contentSha256: { not: null },
418-
externalOperationId: { not: null },
427+
const activeWhere = {
428+
status: {
429+
in: [KBResourceStatus.QUEUED, KBResourceStatus.PROCESSING],
419430
},
431+
ingestionAttemptId: { not: null },
432+
contentSha256: { not: null },
433+
externalOperationId: { not: null },
434+
}
435+
const activeCount = await dependencies.prisma.kBResource.count({
436+
where: activeWhere,
437+
})
438+
if (activeCount === 0) {
439+
return
440+
}
441+
442+
const pollWindow = Math.floor(
443+
(dependencies.now?.() ?? new Date()).getTime() / 60_000
444+
)
445+
const skip =
446+
((pollWindow % activeCount) * KB_INGESTION_POLL_LIMIT) % activeCount
447+
const query = {
448+
where: activeWhere,
449+
orderBy: { id: 'asc' as const },
420450
select: {
421451
id: true,
422452
kbId: true,
@@ -425,9 +455,19 @@ export async function monitorActiveKBIngestions(
425455
contentSha256: true,
426456
externalOperationId: true,
427457
},
458+
}
459+
const resources = await dependencies.prisma.kBResource.findMany({
460+
...query,
461+
skip,
462+
take: KB_INGESTION_POLL_LIMIT,
428463
})
429-
if (resources.length === 0) {
430-
return
464+
if (resources.length < KB_INGESTION_POLL_LIMIT && skip > 0) {
465+
resources.push(
466+
...(await dependencies.prisma.kBResource.findMany({
467+
...query,
468+
take: Math.min(KB_INGESTION_POLL_LIMIT - resources.length, skip),
469+
}))
470+
)
431471
}
432472
const client = dependencies.client ?? createKBIngestionApiClient({ env })
433473

packages/hatchet/src/kbIngestionApi.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,12 @@ function parseOperationStatus(value: unknown): KBOperationStatusResponse {
197197
'updated_at',
198198
]) ||
199199
!isBoundedString(operation.operation_id, 255) ||
200+
typeof operation.status !== 'string' ||
200201
!['accepted', 'running', 'succeeded', 'failed', 'superseded'].includes(
201-
String(operation.status)
202+
operation.status
202203
) ||
203-
!['create', 'update', 'delete'].includes(String(operation.operation)) ||
204+
typeof operation.operation !== 'string' ||
205+
!['create', 'update', 'delete'].includes(operation.operation) ||
204206
!isBoundedString(operation.project_id, 255) ||
205207
!isBoundedString(operation.producer, 255) ||
206208
!isBoundedString(operation.external_resource_id, 512) ||

packages/hatchet/test/kbIngestion.test.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,12 @@ describe('KB ingestion reconciliation', () => {
261261
function monitorPrisma(resources: Record<string, unknown>[]) {
262262
return {
263263
kBResource: {
264-
findMany: vi.fn().mockResolvedValue(resources),
264+
count: vi.fn().mockResolvedValue(resources.length),
265+
findMany: vi
266+
.fn()
267+
.mockImplementation(async ({ skip = 0, take = resources.length }) =>
268+
resources.slice(skip, skip + take)
269+
),
265270
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
266271
},
267272
}
@@ -327,9 +332,16 @@ describe('KB ingestion reconciliation', () => {
327332
await monitorActiveKBIngestions({
328333
prisma: prisma as never,
329334
client: client({
330-
getOperation: vi
331-
.fn()
332-
.mockResolvedValue(operation({ status: 'succeeded' })),
335+
getOperation: vi.fn().mockResolvedValue(
336+
operation({
337+
status: 'succeeded',
338+
observedSha256: CONTENT_SHA256,
339+
serving: {
340+
activeResourceVersion: 3,
341+
activeSha256: CONTENT_SHA256,
342+
},
343+
})
344+
),
333345
}),
334346
})
335347

@@ -341,6 +353,59 @@ describe('KB ingestion reconciliation', () => {
341353
expect(update.data.ingestedAt).toBeInstanceOf(Date)
342354
})
343355

356+
it.each([
357+
{
358+
observedSha256: null,
359+
serving: {
360+
activeResourceVersion: 3,
361+
activeSha256: CONTENT_SHA256,
362+
},
363+
},
364+
{
365+
observedSha256: CONTENT_SHA256,
366+
serving: {
367+
activeResourceVersion: 2,
368+
activeSha256: CONTENT_SHA256,
369+
},
370+
},
371+
{
372+
observedSha256: CONTENT_SHA256,
373+
serving: {
374+
activeResourceVersion: 3,
375+
activeSha256: 'a'.repeat(64),
376+
},
377+
},
378+
])(
379+
'refuses success until the exact version and digest are actively serving',
380+
async (servingIdentity) => {
381+
const prisma = monitorPrisma([activeResource])
382+
const logger = { error: vi.fn() }
383+
384+
await monitorActiveKBIngestions({
385+
prisma: prisma as never,
386+
client: client({
387+
getOperation: vi.fn().mockResolvedValue(
388+
operation({
389+
status: 'succeeded',
390+
...servingIdentity,
391+
})
392+
),
393+
}),
394+
logger,
395+
})
396+
397+
expect(prisma.kBResource.updateMany).not.toHaveBeenCalled()
398+
expect(logger.error).toHaveBeenCalledWith(
399+
'KB ingestion serving correlation failed',
400+
{
401+
resourceId: RESOURCE_ID,
402+
kbId: KB_ID,
403+
ingestionAttemptId: ATTEMPT_ID,
404+
}
405+
)
406+
}
407+
)
408+
344409
it('refuses a status response that does not match every correlation field', async () => {
345410
const prisma = monitorPrisma([activeResource])
346411
const logger = { error: vi.fn() }
@@ -397,4 +462,44 @@ describe('KB ingestion reconciliation', () => {
397462
expect(getOperation).toHaveBeenCalledTimes(17)
398463
expect(peak).toBe(8)
399464
})
465+
466+
it('rotates a bounded 32-resource reconciliation window each minute', async () => {
467+
const resources = Array.from({ length: 49 }, (_, index) => ({
468+
...activeResource,
469+
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
470+
externalOperationId: `${OPERATION_ID}_${index}`,
471+
}))
472+
const prisma = {
473+
kBResource: {
474+
count: vi.fn().mockResolvedValue(resources.length),
475+
findMany: vi.fn().mockImplementation(async ({ skip = 0, take }) => {
476+
return resources.slice(skip, skip + take)
477+
}),
478+
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
479+
},
480+
}
481+
const getOperation = vi.fn().mockImplementation(async (operationId) => {
482+
const index = Number(operationId.split('_').at(-1))
483+
return operation({
484+
operationId,
485+
externalResourceId: resources[index]!.id,
486+
})
487+
})
488+
489+
await monitorActiveKBIngestions({
490+
prisma: prisma as never,
491+
client: client({ getOperation }),
492+
now: () => new Date(60_000),
493+
})
494+
495+
expect(prisma.kBResource.findMany).toHaveBeenNthCalledWith(
496+
1,
497+
expect.objectContaining({ skip: 32, take: 32 })
498+
)
499+
expect(prisma.kBResource.findMany).toHaveBeenNthCalledWith(
500+
2,
501+
expect.objectContaining({ take: 15 })
502+
)
503+
expect(getOperation).toHaveBeenCalledTimes(32)
504+
})
400505
})

packages/hatchet/test/kbIngestionApi.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,26 @@ describe('canonical ingestion API client', () => {
194194
)
195195
).rejects.toThrow('Ingestion API returned an invalid response')
196196
})
197+
198+
it.each([{ status: ['succeeded'] }, { operation: ['update'] }])(
199+
'rejects non-string operation enums',
200+
async (override) => {
201+
const fetchRequest = vi.fn().mockResolvedValue({
202+
ok: true,
203+
status: 200,
204+
json: vi.fn().mockResolvedValue({
205+
...operationResponse,
206+
...override,
207+
}),
208+
})
209+
210+
await expect(
211+
createKBIngestionApiClient({ env, fetchRequest }).getOperation(
212+
operationResponse.operation_id
213+
)
214+
).rejects.toThrow('Ingestion API returned an invalid response')
215+
}
216+
)
197217
})
198218

199219
describe('ingestion source preparation', () => {

0 commit comments

Comments
 (0)