-
Notifications
You must be signed in to change notification settings - Fork 3.3k
fix(signals): bound poison batch retries in grouping v2 #91367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| from posthog.temporal.common.client import async_connect | ||
| from posthog.temporal.common.scoped import scoped_temporal | ||
|
|
||
| from products.signals.backend.temporal.drop_telemetry import capture_signal_dropped | ||
| from products.signals.backend.temporal.grouping import ( | ||
| TYPE_EXAMPLES_CACHE_TTL, | ||
| FetchSignalTypeExamplesOutput, | ||
|
|
@@ -36,12 +37,18 @@ | |
| BATCH_COLLECT_MAX_SIGNALS = 20 | ||
| BATCH_COLLECT_TIMEOUT = timedelta(seconds=30) | ||
| RETRY_BACKOFF = timedelta(seconds=10) | ||
| # A batch key that fails processing this many times is dead-lettered: its signals are dropped | ||
| # and the key leaves the buffer, so a deterministic failure cannot jam the team's pipeline. | ||
| MAX_BATCH_ATTEMPTS = 3 | ||
|
|
||
| # Patch ID for the multi-batch collection change. Once all in-flight workflows | ||
| # that recorded history under the old single-batch code have drained (they | ||
| # continue_as_new every iteration, so this is fast), replace this | ||
| # workflow.patched() call with workflow.deprecate_patch() and eventually remove. | ||
| _PATCH_COLLECT_BATCH = "collect-batch-signals-v1" | ||
| # Patch ID for bounding retries and dead-lettering poison batches. In-flight runs replay the | ||
| # old unbounded stash-and-sleep path; fresh runs take the bounded path. | ||
| _PATCH_BOUNDED_RETRY = "bounded-batch-retry-v1" | ||
|
|
||
|
|
||
| @dataclass | ||
|
|
@@ -50,6 +57,8 @@ class CollectedBatch: | |
|
|
||
| signals: list[EmitSignalInputs] = field(default_factory=list) | ||
| object_keys: list[str] = field(default_factory=list) | ||
| # Signals grouped by their source S3 key, so a dead-lettered key drops only its own signals. | ||
| signals_by_key: dict[str, list[EmitSignalInputs]] = field(default_factory=dict) | ||
|
|
||
|
|
||
| @activity.defn | ||
|
|
@@ -78,6 +87,7 @@ class TeamSignalGroupingV2Workflow: | |
|
|
||
| def __init__(self) -> None: | ||
| self._batch_key_buffer: list[str] = [] | ||
| self._batch_key_attempts: dict[str, int] = {} | ||
| self._cached_type_examples: Optional[FetchSignalTypeExamplesOutput] = None | ||
| self._type_examples_fetched_at: Optional[datetime] = None | ||
| self._paused_until: Optional[datetime] = None | ||
|
|
@@ -120,6 +130,7 @@ def _continue_as_new(self, input: TeamSignalGroupingV2Input) -> None: | |
| team_id=input.team_id, | ||
| pending_batch_keys=list(self._batch_key_buffer), | ||
| paused_until=self._paused_until, | ||
| batch_key_attempts=dict(self._batch_key_attempts), | ||
| ) | ||
| ) | ||
|
|
||
|
|
@@ -155,6 +166,7 @@ async def _collect_next_batch(self) -> CollectedBatch: | |
| ) | ||
|
|
||
| collected.signals.extend(read_result.signals) | ||
| collected.signals_by_key[object_key] = read_result.signals | ||
|
|
||
| return collected | ||
|
|
||
|
|
@@ -221,6 +233,84 @@ async def _run_new_collect_batch_path(self, input: TeamSignalGroupingV2Input) -> | |
| self._batch_buffer_size_gauge.set(len(self._batch_key_buffer)) | ||
| return | ||
|
|
||
| if workflow.patched(_PATCH_BOUNDED_RETRY): | ||
| await self._process_collected(input, collected) | ||
| else: | ||
| await self._process_collected_unbounded(input, collected) | ||
|
|
||
| # continue_as_new after each processing round to keep history bounded. | ||
| # Carry over any pending keys that arrived while we were processing. | ||
| self._continue_as_new(input) | ||
|
|
||
| async def _process_collected(self, input: TeamSignalGroupingV2Input, collected: CollectedBatch) -> None: | ||
| """Process a collected batch, bounding retries so a poison batch cannot jam the buffer.""" | ||
| try: | ||
| # emit_prep_drops=False: a prep failure is retried, so this path owns the terminal | ||
| # signal_dropped telemetry (see _dead_letter) rather than emitting once per attempt. | ||
| dropped, _type_examples = await _process_signal_batch(collected.signals, emit_prep_drops=False) | ||
| except Exception as error: | ||
| logger.exception( | ||
| "Failed to process signal batch", | ||
| team_id=input.team_id, | ||
| batch_size=len(collected.signals), | ||
| batch_keys=collected.object_keys, | ||
| ) | ||
| await self._handle_batch_failure(input, collected, error) | ||
|
Comment on lines
+247
to
+258
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Post-emission failures are retried as preparation failuresWhy we think it's a valid issue
Issue description
Suggested fixReturn phase information or raise a dedicated preparation exception from Prompt to fix with AI (copy-paste)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and the facts check out. Step 7 — the wait for emitted signals to land in ClickHouse — runs outside every try/except in I'm escalating rather than fixing this unattended, and it should be resolved together with the sibling thread on this same catch block (the one about a poison key exhausting healthy keys' retry budgets) — both come from one root cause: the broad What a human needs to decide: (1) the prep-vs-post-emission signal (dedicated exception type or a phase field), (2) whether a post-emission wait failure should consume the keys or retry, given the skipped CH-visibility wait, and (3) what telemetry, if any, that failure should emit instead of a |
||
| return | ||
|
|
||
| for key in collected.object_keys: | ||
| self._batch_key_attempts.pop(key, None) | ||
| if self._signals_processed_counter is not None: | ||
| self._signals_processed_counter.add(len(collected.signals)) | ||
| if self._signals_dropped_counter is not None and dropped > 0: | ||
| self._signals_dropped_counter.add(dropped) | ||
|
|
||
| async def _handle_batch_failure( | ||
| self, input: TeamSignalGroupingV2Input, collected: CollectedBatch, error: BaseException | ||
| ) -> None: | ||
| """Count the attempt per key, dead-letter keys at the cap, and re-stash the rest for retry.""" | ||
| retry_keys: list[str] = [] | ||
| dead_keys: list[str] = [] | ||
| for key in collected.object_keys: | ||
| attempts = self._batch_key_attempts.get(key, 0) + 1 | ||
| self._batch_key_attempts[key] = attempts | ||
| (dead_keys if attempts >= MAX_BATCH_ATTEMPTS else retry_keys).append(key) | ||
|
Comment on lines
+274
to
+277
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A poison key exhausts healthy keys' retry budgetsWhy we think it's a valid issue
Issue descriptionOne preparation failure increments the attempt counter for every collected key. The retry path puts those keys back together. Later rounds usually rebuild the same combined batch. At the cap, the workflow drops healthy keys that only shared a batch with the poison key. Suggested fixIsolate the source key after a combined failure. Process each key separately, or bisect the keys, only on the failure path. Increment and dead-letter only a key that fails in isolation. Prompt to fix with AI (copy-paste)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed — this is a real defect at the current head, and it works exactly as described. When a collected round holds several keys and processing fails, I'm escalating rather than fixing this unattended, for two reasons. First, the remedy is a design decision, not a contained change: isolating the culprit means adding a per-key or bisection retry path and deciding which of those to build. Second, and more important, What a human needs to decide: (1) per-key reprocessing vs bisection on the failure path, and (2) how the retry path should treat a post-emission partial-commit failure so reprocessing can't double-emit reports. This isn't a blocker for the PR — before this change the same healthy keys were stuck behind the poison batch forever, so the PR is still a clear improvement; this is the follow-up that makes the per-key isolation actually hold. |
||
|
|
||
| if dead_keys: | ||
| await self._dead_letter(input, collected, dead_keys, error) | ||
| for key in dead_keys: | ||
| self._batch_key_attempts.pop(key, None) | ||
|
|
||
| # Stash keys still under the attempt cap back at the head so they retry after continue_as_new. | ||
| self._batch_key_buffer = retry_keys + self._batch_key_buffer | ||
| if self._batch_buffer_size_gauge is not None: | ||
| self._batch_buffer_size_gauge.set(len(self._batch_key_buffer)) | ||
| if retry_keys: | ||
| # Sleep before retrying to avoid hot-looping on a deterministic failure. | ||
| await workflow.sleep(RETRY_BACKOFF) | ||
|
|
||
| async def _dead_letter( | ||
| self, | ||
| input: TeamSignalGroupingV2Input, | ||
| collected: CollectedBatch, | ||
| dead_keys: list[str], | ||
| error: BaseException, | ||
| ) -> None: | ||
| """Drop signals whose batch key hit the attempt cap, and emit their drop telemetry once.""" | ||
| dead_signals = [signal for key in dead_keys for signal in collected.signals_by_key.get(key, [])] | ||
| logger.error( | ||
| "Dropping poison signal batch after repeated failures", | ||
| team_id=input.team_id, | ||
| dead_keys=dead_keys, | ||
| signal_count=len(dead_signals), | ||
| ) | ||
| if self._signals_dropped_counter is not None and dead_signals: | ||
| self._signals_dropped_counter.add(len(dead_signals)) | ||
| for signal in dead_signals: | ||
| await capture_signal_dropped(signal, error, stage="grouping_prep") | ||
|
|
||
| async def _process_collected_unbounded(self, input: TeamSignalGroupingV2Input, collected: CollectedBatch) -> None: | ||
| """Legacy unbounded stash-and-sleep path, kept for in-flight runs replaying old history.""" | ||
| try: | ||
| dropped, _type_examples = await _process_signal_batch(collected.signals) | ||
| if self._signals_processed_counter is not None: | ||
|
|
@@ -241,10 +331,6 @@ async def _run_new_collect_batch_path(self, input: TeamSignalGroupingV2Input) -> | |
| self._batch_buffer_size_gauge.set(len(self._batch_key_buffer)) | ||
| await workflow.sleep(RETRY_BACKOFF) | ||
|
|
||
| # continue_as_new after each processing round to keep history bounded. | ||
| # Carry over any pending keys that arrived while we were processing. | ||
| self._continue_as_new(input) | ||
|
|
||
| @temporalio.workflow.run | ||
| async def run(self, input: TeamSignalGroupingV2Input) -> None: | ||
| with posthoganalytics.new_context(capture_exceptions=False): | ||
|
|
@@ -255,6 +341,7 @@ async def run(self, input: TeamSignalGroupingV2Input) -> None: | |
| async def _run_impl(self, input: TeamSignalGroupingV2Input) -> None: | ||
| # Restore state carried over from continue_as_new | ||
| self._batch_key_buffer.extend(input.pending_batch_keys) | ||
| self._batch_key_attempts = dict(input.batch_key_attempts) | ||
| self._paused_until = input.paused_until | ||
| start_time = workflow.now() | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import uuid | ||
| import asyncio | ||
| from datetime import timedelta | ||
|
|
||
| import pytest | ||
| from unittest.mock import patch | ||
|
|
||
| from temporalio import activity | ||
| from temporalio.exceptions import ApplicationError | ||
| from temporalio.testing import WorkflowEnvironment | ||
| from temporalio.worker import UnsandboxedWorkflowRunner, Worker | ||
|
|
||
| from products.signals.backend.temporal.drop_telemetry import CaptureSignalDroppedInput | ||
| from products.signals.backend.temporal.grouping_v2 import MAX_BATCH_ATTEMPTS, TeamSignalGroupingV2Workflow | ||
| from products.signals.backend.temporal.signal_queries import FetchSignalTypeExamplesInput | ||
| from products.signals.backend.temporal.types import ( | ||
| EmitSignalInputs, | ||
| ReadSignalsFromS3Input, | ||
| ReadSignalsFromS3Output, | ||
| TeamSignalGroupingV2Input, | ||
| ) | ||
|
|
||
| TASK_QUEUE = "test-grouping-v2-queue" | ||
| GROUPING_V2_MODULE = "products.signals.backend.temporal.grouping_v2" | ||
|
|
||
|
|
||
| class _Recorder: | ||
| def __init__(self) -> None: | ||
| self.reads = 0 | ||
| self.drops = 0 | ||
| self.dropped = asyncio.Event() | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_poison_batch_is_dead_lettered_after_attempt_cap(): | ||
| # A batch whose preparation always fails must not retry forever: it is retried up to the cap, | ||
| # then dead-lettered so the buffer drains, and its one signal drops exactly once — not once per | ||
| # attempt, which is what jammed a team's pipeline and inflated the signal_dropped metric. | ||
| recorder = _Recorder() | ||
| signal = EmitSignalInputs( | ||
| team_id=1, | ||
| source_product="error_tracking", | ||
| source_type="issue", | ||
| source_id=str(uuid.uuid4()), | ||
| description="poison signal", | ||
| ) | ||
|
|
||
| @activity.defn(name="read_signals_from_s3_activity") | ||
| async def fake_read(_input: ReadSignalsFromS3Input) -> ReadSignalsFromS3Output: | ||
| recorder.reads += 1 | ||
| return ReadSignalsFromS3Output(signals=[signal]) | ||
|
|
||
| @activity.defn(name="fetch_signal_type_examples_activity") | ||
| async def fake_prep_fails(_input: FetchSignalTypeExamplesInput) -> None: | ||
| raise ApplicationError("embedding API 500", non_retryable=True) | ||
|
|
||
| @activity.defn(name="capture_signal_dropped_activity") | ||
| async def fake_drop(_input: CaptureSignalDroppedInput) -> None: | ||
| recorder.drops += 1 | ||
| recorder.dropped.set() | ||
|
|
||
| # Shrink the collection window and retry backoff so the retry loop runs in milliseconds instead | ||
| # of the ~44s-per-round wall clock it takes in production. | ||
| with ( | ||
| patch(f"{GROUPING_V2_MODULE}.BATCH_COLLECT_TIMEOUT", timedelta(seconds=1)), | ||
| patch(f"{GROUPING_V2_MODULE}.RETRY_BACKOFF", timedelta(seconds=0)), | ||
| ): | ||
| async with await WorkflowEnvironment.start_time_skipping() as env: | ||
| async with Worker( | ||
| env.client, | ||
| task_queue=TASK_QUEUE, | ||
| workflows=[TeamSignalGroupingV2Workflow], | ||
| activities=[fake_read, fake_prep_fails, fake_drop], | ||
| workflow_runner=UnsandboxedWorkflowRunner(), | ||
| ): | ||
| handle = await env.client.start_workflow( | ||
| TeamSignalGroupingV2Workflow.run, | ||
| TeamSignalGroupingV2Input(team_id=1), | ||
| id=f"grouping-v2-{uuid.uuid4()}", | ||
| task_queue=TASK_QUEUE, | ||
| ) | ||
| await handle.signal(TeamSignalGroupingV2Workflow.submit_batch, "signals/batch/poison") | ||
| await asyncio.wait_for(recorder.dropped.wait(), timeout=30) | ||
| await env.client.get_workflow_handle(handle.id).terminate() | ||
|
|
||
| # Read once per attempt: the key is re-collected each round until the cap dead-letters it. | ||
| assert recorder.reads == MAX_BATCH_ATTEMPTS | ||
| # Emitted once for the signal, not once per attempt. | ||
| assert recorder.drops == 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Brief dependency outages cause permanent signal loss
Why we think it's a valid issue
_handle_batch_failure(products/signals/backend/temporal/grouping_v2.py:268-290), the legacy path (grouping_v2.py:312-332), the prep phase and its inner retry policies (products/signals/backend/temporal/grouping.py:1088-1205), and the round timing constants._handle_batch_failureincrements the counter for every key unconditionally (grouping_v2.py:274-277);erroris only ever passed through to_dead_letterfor telemetry (grouping_v2.py:280, 310). Greppinggrouping_v2.pyreturns no reference tonon_retryable,ApplicationError, or any retryability check, so an embedding-service 5xx and a permanently malformed signal consume the budget identically.RetryPolicy(maximum_attempts=3)for type examples, embeddings, and semantic search, andmaximum_attempts=5for query generation (grouping.py:1097-1159), all on Temporal's default one-second initial interval, so a shared outage surfaces to the workflow within seconds.BATCH_COLLECT_TIMEOUTof 30s plus the failed prep plus a fixedRETRY_BACKOFFof 10s (grouping_v2.py:38-39, 290) — the PR's own test cites "the ~44s-per-round wall clock it takes in production" (products/signals/backend/test/test_grouping_v2_workflow.py). Three attempts therefore expire in roughly one to two minutes, so the reviewer's "about 30 seconds" is optimistic but the order of magnitude holds._dead_letteremits telemetry and returns without re-queueing anything (grouping_v2.py:292-310), and the key never returns to the buffer, so the S3 object is never read again. During a sustained outage every batch fails, so keys keep reaching the cap in waves and each wave is discarded.Issue description
_handle_batch_failureignores whethererroris retryable. It spends the three-attempt budget on HTTP 5xx responses and other shared service failures. The inner retry policies use short default delays. The outer path adds only two ten-second waits before the third failure. A fast shared outage can dead-letter valid keys in about 30 seconds. This changes a brief service outage into permanent signal loss, even when no key contains poison data.Suggested fix
Classify the activity cause before consuming the terminal budget. Dead-letter known signal-specific or non-retryable failures. Move retryable shared failures to a deferred queue with exponential backoff. Continue to process new keys from the main buffer. Add a test where a retryable 5xx outlasts the fast retries and later recovers.
Prompt to fix with AI (copy-paste)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed, and this is the most important issue on the PR: as written it is a regression that can lose data. The retry bound treats every failure the same. There is no check anywhere on this path for whether the error is retryable — the failed error is only used for telemetry — so a brief outage of a shared dependency (the embedding gateway, ClickHouse, or Postgres) burns the same three-attempt budget as a genuinely poison signal. That budget is only about one to two minutes of wall clock, and a dead-lettered key is never put back in the buffer, so its S3 batch is never read again. During an outage longer than that, every batch fails, keys hit the cap in waves, and each wave is dropped for good. Before this change the pipeline only delayed those signals and drained them once the dependency recovered, so this converts recoverable delay into permanent loss for a failure class that has nothing to do with poison data.
I'm escalating rather than fixing this unattended, and it should be resolved together with the two sibling threads on this same failure path — all three come down to the fact that
_handle_batch_failureand_process_collectedhandle every failure identically instead of distinguishing a poison signal, a post-emission failure, and a transient shared outage. The classification the fix needs is genuinely hard: the incident trigger was an embedding-API 500, and the same 500 status can be either a transient shared outage (which should keep retrying) or a deterministic per-signal failure (the poison case this PR is meant to bound), so retryability alone doesn't separate them. The suggested deferred queue with exponential backoff is also new machinery that trades directly against the poison-loop protection. That tradeoff, and its behavior under a real outage, can't be proven in this environment, which has no Postgres for the database-backed signals suites.What a human needs to decide: (1) how to classify a shared-dependency/transient failure apart from a deterministic per-signal one on this path; (2) whether transient failures should be deferred-and-retried (queue + backoff, or a wall-clock-bounded retry that outlasts a typical outage) rather than counted toward the dead-letter budget; and (3) how aggressive the poison bound should stay so it still stops a real poison loop without discarding signals during an ordinary outage. Given this is a data-loss regression against the branch point, it is worth prioritizing before the PR merges.