Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion products/signals/backend/temporal/grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,7 @@ def _augment_candidates_with_batch(
async def _process_signal_batch(
batch: list[EmitSignalInputs],
cached_type_examples: Optional[FetchSignalTypeExamplesOutput] = None,
emit_prep_drops: bool = True,
) -> tuple[int, FetchSignalTypeExamplesOutput]:
"""
Process a batch of signals with parallel preparation (steps 1-4) and sequential
Expand All @@ -1071,6 +1072,10 @@ async def _process_signal_batch(
Earlier signals in the batch are injected into later signals' candidate sets via
local cosine distance comparison, eliminating the need for per-signal CH waits
within a batch.

A prep-phase (steps 1-4) failure re-raises so the caller can decide whether to retry.
Callers that retry the batch pass emit_prep_drops=False and own the terminal
signal_dropped telemetry, so a deterministic failure is not counted once per attempt.
"""
team_id = batch[0].team_id
# Purely defensive
Expand Down Expand Up @@ -1195,7 +1200,8 @@ async def _process_signal_batch(
team_id=team_id,
batch_size=len(batch),
)
await asyncio.gather(*(capture_signal_dropped(signal, e, stage="grouping_prep") for signal in batch))
if emit_prep_drops:
await asyncio.gather(*(capture_signal_dropped(signal, e, stage="grouping_prep") for signal in batch))
raise

# === SEQUENTIAL PHASE (steps 5-7) ===
Expand Down
95 changes: 91 additions & 4 deletions products/signals/backend/temporal/grouping_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Comment on lines +40 to +42

Copy link
Copy Markdown
Contributor Author

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

must_fix

Why we think it's a valid issue
  • Checked: whether the failure path distinguishes a transient dependency failure from a signal-specific one, how long the three-attempt budget actually lasts in wall clock, and what the pre-change path did with the same error. Read _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.
  • Found: no classification exists anywhere on the path. _handle_batch_failure increments the counter for every key unconditionally (grouping_v2.py:274-277); error is only ever passed through to _dead_letter for telemetry (grouping_v2.py:280, 310). Grepping grouping_v2.py returns no reference to non_retryable, ApplicationError, or any retryability check, so an embedding-service 5xx and a permanently malformed signal consume the budget identically.
  • Found: the inner policies only cover seconds. The prep activities use RetryPolicy(maximum_attempts=3) for type examples, embeddings, and semantic search, and maximum_attempts=5 for 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.
  • Found: the outer budget is minutes, not hours. Each round costs at most BATCH_COLLECT_TIMEOUT of 30s plus the failed prep plus a fixed RETRY_BACKOFF of 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.
  • Found: the drop is permanent and unbounded in scope. _dead_letter emits 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.
  • Impact: confirmed customer-visible data loss from an ordinary failure mode. Any outage of the LLM gateway, ClickHouse, or Postgres lasting longer than about two minutes discards every signal flowing through the pipeline, and those signals never become reports. This is a regression against the branch point: the legacy path re-stashed and slept without a cap (grouping_v2.py:327-332), so the same batch survived a ten-minute dependency outage and drained on recovery. The change converts recoverable delay into permanent loss for a failure class that has nothing to do with poison data.
  • Impact: the trigger is the same dependency that caused the incident this PR addresses — a 500 from the embedding API. A single-signal 500 motivated the fix; a broader 500 burst is the ordinary generalization of it, not a speculative case.
Issue description

_handle_batch_failure ignores whether error is 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)
## Context
@products/signals/backend/temporal/grouping_v2.py#L40-42
@products/signals/backend/temporal/grouping_v2.py#L247-258
@products/signals/backend/temporal/grouping_v2.py#L268-290

<issue_description>
`_handle_batch_failure` ignores whether `error` is 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.
</issue_description>

<issue_validation>
- **Checked:** whether the failure path distinguishes a transient dependency failure from a signal-specific one, how long the three-attempt budget actually lasts in wall clock, and what the pre-change path did with the same error. Read `_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.
- **Found:** no classification exists anywhere on the path. `_handle_batch_failure` increments the counter for every key unconditionally (grouping_v2.py:274-277); `error` is only ever passed through to `_dead_letter` for telemetry (grouping_v2.py:280, 310). Grepping `grouping_v2.py` returns no reference to `non_retryable`, `ApplicationError`, or any retryability check, so an embedding-service 5xx and a permanently malformed signal consume the budget identically.
- **Found:** the inner policies only cover seconds. The prep activities use `RetryPolicy(maximum_attempts=3)` for type examples, embeddings, and semantic search, and `maximum_attempts=5` for 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.
- **Found:** the outer budget is minutes, not hours. Each round costs at most `BATCH_COLLECT_TIMEOUT` of 30s plus the failed prep plus a fixed `RETRY_BACKOFF` of 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.
- **Found:** the drop is permanent and unbounded in scope. `_dead_letter` emits 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.
- **Impact:** confirmed customer-visible data loss from an ordinary failure mode. Any outage of the LLM gateway, ClickHouse, or Postgres lasting longer than about two minutes discards every signal flowing through the pipeline, and those signals never become reports. This is a regression against the branch point: the legacy path re-stashed and slept without a cap (grouping_v2.py:327-332), so the same batch survived a ten-minute dependency outage and drained on recovery. The change converts recoverable delay into permanent loss for a failure class that has nothing to do with poison data.
- **Impact:** the trigger is the same dependency that caused the incident this PR addresses — a 500 from the embedding API. A single-signal 500 motivated the fix; a broader 500 burst is the ordinary generalization of it, not a speculative case.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
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.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

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_failure and _process_collected handle 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.


# 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-emission failures are retried as preparation failures

must_fix

Why we think it's a valid issue
  • Checked: whether _process_signal_batch can raise after signals are assigned, and what the new catch does with that error. Read the prep-phase try (products/signals/backend/temporal/grouping.py:1088-1205), the sequential phase (grouping.py:1207-1373), step 7 (grouping.py:1375-1395), the parallel variant (products/signals/backend/temporal/parallel_grouping.py:233, 331, 377), and the caller's catch at products/signals/backend/temporal/grouping_v2.py:251-259.
  • Found: step 7 sits outside every try in the function. wait_for_signal_in_clickhouse_activity runs with RetryPolicy(maximum_attempts=2) and a 1h5m start-to-close (grouping.py:1377-1395), and its error propagates straight out of _process_signal_batch, after assign_and_emit_signal_activity has already written the signals and their reports (grouping.py:1306-1324; parallel_grouping.py:233).
  • Found: the code already documents the overlap. The prep-phase handler's comment states that the outer workflow handler "also catches post-emission failures (the CH wait in step 7), where signals were successfully assigned" (grouping.py:1196-1197). The new catch at grouping_v2.py:251 is a bare except Exception, so it cannot separate the two classes, yet its comment claims the path owns terminal telemetry for prep failures (grouping_v2.py:248-249).
  • Found: each retry re-emits. signal_id is a fresh uuid.uuid4() per invocation in both the sequential loop (grouping.py:1231) and the parallel path (parallel_grouping.py:331), so a re-run assigns new signals to reports rather than resuming.
  • Found: the terminal telemetry is mislabeled. _dead_letter reads the original S3 signals from collected.signals_by_key and emits capture_signal_dropped(..., stage="grouping_prep") for each (grouping_v2.py:300-310), regardless of the fact that those signals were emitted on an earlier attempt. capture_signal_dropped feeds both metrics.increment_dropped and the product-analytics event stream (products/signals/backend/temporal/drop_telemetry.py:57-59).
  • Impact: on a ClickHouse-wait failure the batch is dead-lettered as a prep-stage drop while its signals are present in reports, so the signal_dropped metric gains a new false-positive source — the same metric this PR exists to make trustworthy for the anomaly alert. The Dropping poison signal batch after repeated failures log (grouping_v2.py:301-306) then sends an operator to look for missing signals that were actually written, possibly several times.
  • Priority: lowered to should_fix. The re-emission on retry is pre-existing: the legacy path re-stashes and retries the identical way with no bound (grouping_v2.py:312-332), so this change reduces the duplicate writes from unbounded to three rounds. The genuinely new harm is wrong telemetry and a misleading terminal log, not user-facing signal loss, and it needs the CH wait to exhaust both attempts in three consecutive rounds. Worth fixing by distinguishing the prep failure from a post-emission failure, but it does not block the improvement the PR delivers.
Issue description

_process_signal_batch can raise after assignment when wait_for_signal_in_clickhouse_activity fails. The broad catch requeues those keys as if preparation failed. Each retry creates new signal IDs and repeats report writes. The terminal attempt then reports the assigned signals as grouping_prep drops.

Suggested fix

Return phase information or raise a dedicated preparation exception from grouping.py. Call _handle_batch_failure only for that exception. When a post-emission wait fails, consume the keys and report the wait failure without signal_dropped.

Prompt to fix with AI (copy-paste)
## Context
@products/signals/backend/temporal/grouping_v2.py#L247-258
@products/signals/backend/temporal/grouping_v2.py#L299-310

<issue_description>
`_process_signal_batch` can raise after assignment when `wait_for_signal_in_clickhouse_activity` fails. The broad catch requeues those keys as if preparation failed. Each retry creates new signal IDs and repeats report writes. The terminal attempt then reports the assigned signals as `grouping_prep` drops.
</issue_description>

<issue_validation>
- **Checked:** whether `_process_signal_batch` can raise after signals are assigned, and what the new catch does with that error. Read the prep-phase `try` (products/signals/backend/temporal/grouping.py:1088-1205), the sequential phase (grouping.py:1207-1373), step 7 (grouping.py:1375-1395), the parallel variant (products/signals/backend/temporal/parallel_grouping.py:233, 331, 377), and the caller's catch at products/signals/backend/temporal/grouping_v2.py:251-259.
- **Found:** step 7 sits outside every `try` in the function. `wait_for_signal_in_clickhouse_activity` runs with `RetryPolicy(maximum_attempts=2)` and a 1h5m start-to-close (grouping.py:1377-1395), and its error propagates straight out of `_process_signal_batch`, after `assign_and_emit_signal_activity` has already written the signals and their reports (grouping.py:1306-1324; parallel_grouping.py:233).
- **Found:** the code already documents the overlap. The prep-phase handler's comment states that the outer workflow handler "also catches post-emission failures (the CH wait in step 7), where signals were successfully assigned" (grouping.py:1196-1197). The new catch at grouping_v2.py:251 is a bare `except Exception`, so it cannot separate the two classes, yet its comment claims the path owns terminal telemetry for prep failures (grouping_v2.py:248-249).
- **Found:** each retry re-emits. `signal_id` is a fresh `uuid.uuid4()` per invocation in both the sequential loop (grouping.py:1231) and the parallel path (parallel_grouping.py:331), so a re-run assigns new signals to reports rather than resuming.
- **Found:** the terminal telemetry is mislabeled. `_dead_letter` reads the original S3 signals from `collected.signals_by_key` and emits `capture_signal_dropped(..., stage="grouping_prep")` for each (grouping_v2.py:300-310), regardless of the fact that those signals were emitted on an earlier attempt. `capture_signal_dropped` feeds both `metrics.increment_dropped` and the product-analytics event stream (products/signals/backend/temporal/drop_telemetry.py:57-59).
- **Impact:** on a ClickHouse-wait failure the batch is dead-lettered as a prep-stage drop while its signals are present in reports, so the `signal_dropped` metric gains a new false-positive source — the same metric this PR exists to make trustworthy for the anomaly alert. The `Dropping poison signal batch after repeated failures` log (grouping_v2.py:301-306) then sends an operator to look for missing signals that were actually written, possibly several times.
- **Priority:** lowered to `should_fix`. The re-emission on retry is pre-existing: the legacy path re-stashes and retries the identical way with no bound (grouping_v2.py:312-332), so this change reduces the duplicate writes from unbounded to three rounds. The genuinely new harm is wrong telemetry and a misleading terminal log, not user-facing signal loss, and it needs the CH wait to exhaust both attempts in three consecutive rounds. Worth fixing by distinguishing the prep failure from a post-emission failure, but it does not block the improvement the PR delivers.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Return phase information or raise a dedicated preparation exception from `grouping.py`. Call `_handle_batch_failure` only for that exception. When a post-emission wait fails, consume the keys and report the wait failure without `signal_dropped`.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 _process_signal_batch, and it runs after the signals and their reports have already been written in step 6. So when that wait (or the summary-workflow spawn after it) fails, the error looks identical to a preparation failure to the catch in _process_collected. The batch is then retried as if nothing had been written — each retry assigns brand-new signal IDs and writes the reports again — and at the attempt cap the already-written signals are dead-lettered and counted as grouping_prep drops. That pollutes the same signal_dropped metric this PR exists to make trustworthy, and the 'Dropping poison signal batch' log sends an operator hunting for signals that are actually present.

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 except Exception in _process_collected can't tell a preparation failure from a post-emission failure. The fix you suggest is the right shape — raise a dedicated preparation exception (or return phase info) from grouping.py, send only that to the dead-letter/retry path, and handle a post-emission wait failure separately without emitting drops. But choosing that behavior is a design decision, not a mechanical change: consuming the keys means skipping the step-7 ClickHouse-visibility wait that the summary workflows and the next batch's semantic search rely on, so a human needs to confirm that's safe and decide what telemetry a post-emission failure should record. That correctness question can't be proven in this environment, which has no Postgres for the database-backed signals suites.

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 grouping_prep drop. This doesn't block the PR — the duplicate re-emission already existed unbounded on the legacy path, so this change bounds it; the newly-introduced harm is wrong telemetry, not signal loss.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A poison key exhausts healthy keys' retry budgets

must_fix

Why we think it's a valid issue
  • Checked: whether a collected round really holds more than one key, whether a single bad signal fails the whole round, and whether the re-stash rebuilds the same key set. Traced _collect_next_batch (products/signals/backend/temporal/grouping_v2.py:137-171), _process_signal_batch's prep phase (products/signals/backend/temporal/grouping.py:1062-1205), the buffer flush shape (products/signals/backend/temporal/buffer.py:35-36, 308-321), and the re-stash plus collection order.
  • Found: the round is multi-key whenever flushes are partial. BUFFER_FLUSH_TIMEOUT_SECONDS = 5 (buffer.py:36) flushes an under-full buffer, so each key can carry far fewer than BATCH_COLLECT_MAX_SIGNALS = 20 signals (grouping_v2.py:37), and the loop at grouping_v2.py:141-169 keeps popping keys until it reaches 20 signals or the 30s deadline. A stalled team — the exact incident state — has many such keys queued.
  • Found: one bad signal fails the whole round. The prep phase wraps the type-example fetch, embeddings, candidate search, and report-context fetch in one try and re-raises for the entire batch (grouping.py:1194-1205), with the comment stating the whole batch is explainable as dropped. So _process_collected catches a batch-wide error (grouping_v2.py:251) and _handle_batch_failure charges an attempt to every key in collected.object_keys (grouping_v2.py:274-277).
  • Found: the same batch reforms each round. Retry keys go back at the head in their original order (grouping_v2.py:285) and _collect_next_batch pops from the head (grouping_v2.py:156), while newly submitted keys append at the tail (grouping_v2.py:105). The head keys and their signal counts are unchanged, so the collection loop stops at the same boundary and rebuilds an identical key set. All of them reach MAX_BATCH_ATTEMPTS in the same round and land in dead_keys together (grouping_v2.py:277-282).
  • Impact: confirmed permanent loss of healthy signals. _dead_letter drops every signal of every dead key and emits signal_dropped for each (grouping_v2.py:300-310) with no re-queue, so signals that would have prepared successfully on their own never become reports. The loss is user-visible and lands on the same teams the PR targets.
  • Impact: the gap also contradicts the stated design. signals_by_key exists so "a dead-lettered key drops only its own signals" (grouping_v2.py:60-61), but because the failure is batch-wide and membership is stable across rounds, the per-key partition never isolates the poison key — it only splits signals after every co-collected key has already been condemned.
  • Priority: lowered to should_fix. On master the same healthy keys are already stuck behind the poison batch forever, together with every later signal for that team, so this PR reduces total loss rather than introducing a regression. The collateral is bounded to the keys that share the failing round (at most about 20 signals) and stops once the poison key leaves the buffer. It is worth fixing — retrying keys individually after a combined failure, and charging attempts only to a key that fails alone — but it does not block the improvement the PR delivers.
Issue description

One 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 fix

Isolate 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)
## Context
@products/signals/backend/temporal/grouping_v2.py#L274-277
@products/signals/backend/temporal/grouping_v2.py#L284-285

<issue_description>
One 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.
</issue_description>

<issue_validation>
- **Checked:** whether a collected round really holds more than one key, whether a single bad signal fails the whole round, and whether the re-stash rebuilds the same key set. Traced `_collect_next_batch` (products/signals/backend/temporal/grouping_v2.py:137-171), `_process_signal_batch`'s prep phase (products/signals/backend/temporal/grouping.py:1062-1205), the buffer flush shape (products/signals/backend/temporal/buffer.py:35-36, 308-321), and the re-stash plus collection order.
- **Found:** the round is multi-key whenever flushes are partial. `BUFFER_FLUSH_TIMEOUT_SECONDS = 5` (buffer.py:36) flushes an under-full buffer, so each key can carry far fewer than `BATCH_COLLECT_MAX_SIGNALS = 20` signals (grouping_v2.py:37), and the loop at grouping_v2.py:141-169 keeps popping keys until it reaches 20 signals or the 30s deadline. A stalled team — the exact incident state — has many such keys queued.
- **Found:** one bad signal fails the whole round. The prep phase wraps the type-example fetch, embeddings, candidate search, and report-context fetch in one `try` and re-raises for the entire batch (grouping.py:1194-1205), with the comment stating the whole batch is explainable as dropped. So `_process_collected` catches a batch-wide error (grouping_v2.py:251) and `_handle_batch_failure` charges an attempt to every key in `collected.object_keys` (grouping_v2.py:274-277).
- **Found:** the same batch reforms each round. Retry keys go back at the head in their original order (grouping_v2.py:285) and `_collect_next_batch` pops from the head (grouping_v2.py:156), while newly submitted keys append at the tail (grouping_v2.py:105). The head keys and their signal counts are unchanged, so the collection loop stops at the same boundary and rebuilds an identical key set. All of them reach `MAX_BATCH_ATTEMPTS` in the same round and land in `dead_keys` together (grouping_v2.py:277-282).
- **Impact:** confirmed permanent loss of healthy signals. `_dead_letter` drops every signal of every dead key and emits `signal_dropped` for each (grouping_v2.py:300-310) with no re-queue, so signals that would have prepared successfully on their own never become reports. The loss is user-visible and lands on the same teams the PR targets.
- **Impact:** the gap also contradicts the stated design. `signals_by_key` exists so "a dead-lettered key drops only its own signals" (grouping_v2.py:60-61), but because the failure is batch-wide and membership is stable across rounds, the per-key partition never isolates the poison key — it only splits signals after every co-collected key has already been condemned.
- **Priority:** lowered to `should_fix`. On master the same healthy keys are already stuck behind the poison batch forever, together with every later signal for that team, so this PR reduces total loss rather than introducing a regression. The collateral is bounded to the keys that share the failing round (at most about 20 signals) and stops once the poison key leaves the buffer. It is worth fixing — retrying keys individually after a combined failure, and charging attempts only to a key that fails alone — but it does not block the improvement the PR delivers.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Isolate 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.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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, _handle_batch_failure charges an attempt to every key in the round, not just the one that caused the failure. Because failed keys are re-stashed at the head of the buffer and collection also pops from the head (new keys append at the tail), the same multi-key round reforms each iteration, so every co-collected key hits the attempt cap together and is dead-lettered together. Healthy keys that only shared a round with the poison key lose their signals, which is the opposite of the per-key isolation the signals_by_key grouping was meant to give.

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, _process_collected catches any failure from _process_signal_batch, including post-emission failures in the sequential phase (the step-7 ClickHouse wait) where some signals were already assigned. Re-running preparation per key on that kind of failure could create duplicate reports. Getting that right means distinguishing a prep-phase failure (safe to reprocess) from a post-emission one, and that can't be proven safe without the database-backed signals test suites, which don't run in this environment.

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:
Expand All @@ -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):
Expand All @@ -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()

Expand Down
4 changes: 4 additions & 0 deletions products/signals/backend/temporal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ class TeamSignalGroupingV2Input:
team_id: int
pending_batch_keys: list[str] = field(default_factory=list)
paused_until: Optional[datetime] = None
# Failed-processing attempt count per S3 batch key, carried across continue_as_new so a
# poison batch is bounded rather than retried forever. Defaults empty so histories written
# before this field replay unchanged.
batch_key_attempts: dict[str, int] = field(default_factory=dict)


@dataclass
Expand Down
89 changes: 89 additions & 0 deletions products/signals/backend/test/test_grouping_v2_workflow.py
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
Loading