feat(ingestion): per-step retry and concurrentlyPerGroup builder API - #69267
Conversation
|
jose-sequeira
left a comment
There was a problem hiding this comment.
Seems to simplify! LGTM
…ne-framework-readability # Conflicts: # nodejs/src/ingestion/framework/retrying-pipeline.test.ts
Problem
Two readability problems in the ingestion pipeline framework make pipeline definitions harder to scan than they should be:
groupBy(fn).concurrently((group) => group.sequentially((b) => ...)). The intermediate builders each expose exactly one method, so the chain forces boilerplate without adding information at the top level.builder.retry(callback, options)wrapping a whole sub-pipeline, plus a separatepipeBatchWithRetry(step, options). Two production pipelines wrapped long step sequences in a single retry block, so a transient failure late in the chain re-ran every step before it, including pure ones, which is bad for idempotency.The living docs in
nodejs/src/ingestion/framework/docs/had also drifted: chapter 3 claimed concurrency is always unbounded (there is amaxConcurrencycap now), and several framework features had no chapter at all.Changes
concurrentlyPerGroupOne top-level call instead of a
groupBy().concurrently()chain, same runtime behavior (ConcurrentlyGroupingBatchPipelineis unchanged). The callback receives a group builder whose only method issequentially, keeping within-group ordering explicit at the call site:Groups process concurrently (optionally capped), items within a group sequentially.
GroupingBatchPipelineBuilderis deleted;GroupProcessingBuilderremains as the group builder type. It holds a pipeline-completion closure rather than the grouping function and previous pipeline as fields: agroupingFnfield is contravariant inTOutputand would makeBatchPipelineBuilderinvariant, breaking covariant subpipeline callbacks such as the identity callback innewBatchingPipeline(there is a why-comment on the constructor).Retry as a per-step option
pipe(step, { retry })andpipeBatch(step, { retry })replacebuilder.retry(...)andpipeBatchWithRetry(...). A sharedRetryOptions { name?, tries?, sleepMs? }lives in the newframework/retry.ts(withStepRetry+withBatchRetry);RetryingPipelineandbatch-retry.tsare deleted. Retry semantics per step are unchanged: retriable errors back off and retry,isRetriable === falsegoes to DLQ, unknown errors rethrow after exhausting tries, attempts observed viaingestion_pipeline_retry_attempts. Failure logs use the same resolved name as the metric label, so a metric spike greps straight to its log lines.Retry now wraps a single step by design. The two multi-step retry blocks were unwrapped and each step classified by whether it does transient-failure-prone I/O:
ai/pipeline.ts(was one block of 11 steps,tries: 5):hog_transform_event)readonly_process_groups)emit_event)Per-distinct-id branches (
event-subpipeline.ts,ai-event-subpipeline.ts; was one block around the wholebranching,tries: 5):hog_transform_event)process_personless)process_persons)process_groups)emit_event)Note
createEmitEventStepdoes not await the produce, it pushes the promise into side effects. Its retry therefore only catches synchronous produce throws (for example local queue full); async delivery failures still surface through side-effect handling. Kept for parity with the old blocks.Warning
Behavior changes: a retriable failure mid-chain no longer re-runs earlier steps; non-retriable errors DLQ at the failing step instead of the whole block; the
ingestion_pipeline_retry_attemptsmetric'snamelabel moves from block names (ai_event,per_distinct_id) to per-step names. Error classification is part of the retry option: a step without{ retry }that throws anisRetriable: falseerror now crashes the consumer instead of DLQing (the old blocks classified errors for every step inside them, pure ones included). In practice only dependency-touching steps throw classified errors, and those all kept retry;ingestion-consumer.test.tsnow pins both sides of this (DLQ via a retry-wrapped step, crash via an unwrapped one). The DLQdlq_stepheader now names the failing step instead of the last step before the retry block.Removed
pipeConcurrentlyDeleted from the builder surface: it duplicated
concurrently()(sameConcurrentBatchProcessingPipelineunderneath) but took a pre-builtPipelineinstead of a builder callback, and had zero production call sites. Its only consumer, a framework integration test, now composes viaconcurrently((b) => ...).Living docs
maxConcurrency; chapter 7 documentsredirect'sawaitAckand thepreserveKeydefault; chapter 11 rewritten around per-step retry with a batch-retry sectionnewBatchingPipeline), 15 (consuming pipelines viafeed()/next()andBatchPipelineUnwrapper), 16 (tophog step metrics).claude/agents/ingestion/pipeline-composition-doctor.mdand theingestion-pipeline-doctor-nodejsskillHow did you test this code?
Automated tests only (no manual/stack testing):
retry.test.tscoveringwithStepRetry/withBatchRetrythrough the builder interface (transient retry, non-retriable DLQ, exhaustion rethrow, defaults, metric outcomes, name preservation)src/ingestion+tests/ingestionjest run compared against a clean master baseline in the same environment: identical failure sets (26 infra-dependent integration/e2e suites needing Redis/Postgres/Kafka/S3 fail on both), zero regressions from this diffGroupProcessingBuilderwas reproduced withtsc(typed field breaks the covariantnewBatchingPipelinecallback in chapter 14) before landing the closure-based fixThe new retry tests replace
retrying-pipeline.test.tsandbatch-retry.test.ts; they catch the same regressions (retry classification, DLQ conversion, exhaustion) at the new step-wrapper level, plus the new pass-through behavior ofpipe/pipeBatchwithout a retry option.Automatic notifications
Docs update
Internal framework only, living docs updated in this PR. No posthog.com docs affected (
skip-inkeep-docs).🤖 Agent context
Autonomy: Human-driven (agent-assisted)
groupBy/retry/pipeBatchWithRetrycall sites, docs coverage audit) and delegated implementation to Opus subagents in phases: framework core, call-site migration with the per-step retry review, and docs.groupedConcurrently,concurrentlyInGroups,concurrentlyPerGroup; pickedconcurrentlyPerGroupbecause the concurrency unit is the group. A first iteration had the callback receive the per-item pipeline builder directly; I changed it to receive a group builder with onlysequentiallyso within-group ordering stays visible at the call site.GroupProcessingBuilderinternals went through two iterations: a typedgroupingFnfield broke covariance (TS2322 in the batching docs chapter), a first fix erased the field type toany, and the final version replaces the fields with a typed pipeline-completion closure so nothing is erased.pipeConcurrentlyremoval came out of reviewing the builder surface during the docs gap-fill: it was about to be documented despite having no production users, so it was deleted instead.process_personless,emit_event) keep retry to stay close to the old block behavior; the tables above are the full classification for review./writing-testsconventions for the ported retry tests.