Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 23 additions & 12 deletions products/cohorts/backend/models/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,22 +315,33 @@ def warm_team_cohort_dependency_cache(team_id: int, batch_size: int = 1000):


def _on_cohort_changed(cohort: Cohort, always_invalidate: bool = False):
new_dependencies = extract_cohort_dependencies(cohort)
existing_dependencies = cache.get(_cohort_dependencies_key(cohort.id))
dependencies_changed = existing_dependencies is None or set(existing_dependencies) != new_dependencies
# Under autocommit this runs synchronously inside Cohort.save(), so a Redis error here escapes the
# save and can leave the cohort stuck with is_calculating=True. The dependency caches rebuild on the
# next change or warm pass, so a failed refresh is safe to log and continue past.
try:
new_dependencies = extract_cohort_dependencies(cohort)
existing_dependencies = cache.get(_cohort_dependencies_key(cohort.id))
dependencies_changed = existing_dependencies is None or set(existing_dependencies) != new_dependencies

# If the dependencies haven't changed, no need to refresh the cache
if not always_invalidate and not cohort.deleted and not dependencies_changed:
return
# If the dependencies haven't changed, no need to refresh the cache
if not always_invalidate and not cohort.deleted and not dependencies_changed:
return

cache.delete(_cohort_dependencies_key(cohort.id))
cache.delete(_cohort_dependents_key(cohort.id))
cache.delete(_cohort_dependencies_key(cohort.id))
cache.delete(_cohort_dependents_key(cohort.id))

if existing_dependencies:
for dep_id in existing_dependencies:
cache.delete(_cohort_dependents_key(dep_id))
if existing_dependencies:
for dep_id in existing_dependencies:
cache.delete(_cohort_dependents_key(dep_id))

warm_team_cohort_dependency_cache(cohort.team_id)
warm_team_cohort_dependency_cache(cohort.team_id)
except Exception as error:
logger.exception(
"failed_to_refresh_cohort_dependency_cache",
cohort_id=cohort.pk,
team_id=cohort.team_id,
error=str(error),
)
Comment thread
posthog[bot] marked this conversation as resolved.


def _has_backfillable_filters(cohort: Cohort, kind: CohortBackfillKind) -> bool:
Expand Down
14 changes: 14 additions & 0 deletions products/cohorts/backend/models/test/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,20 @@ def test_warm_team_cohort_dependency_cache_refreshes_ttl(self) -> None:
args, kwargs = mock_set_many.call_args
self.assertEqual(kwargs.get("timeout"), DEPENDENCY_CACHE_TIMEOUT)

def test_cohort_save_survives_redis_error_in_dependency_refresh(self) -> None:
# A non-recalculation save fires _on_cohort_changed synchronously (see mock_transaction).
# A Redis error there must not escape Cohort.save() and leave the cohort stuck calculating.
cohort = self._create_cohort(name="Test Cohort")
with mock.patch(
"products.cohorts.backend.models.dependencies.cache.get",
side_effect=Exception("redis unavailable"),
):
cohort.name = "renamed"
cohort.save()

cohort.refresh_from_db()
self.assertEqual(cohort.name, "renamed")

def test_cache_miss_get_cohort_dependencies(self) -> None:
cohort_a = self._create_cohort(name="Test Cohort A")
cohort_b = self._create_cohort(
Expand Down
19 changes: 13 additions & 6 deletions products/cohorts/backend/models/test/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1360,10 +1360,17 @@ def test_original_error_surfaces_when_recovery_history_save_fails(self):
assert mock_history.save.call_count == 2
mock_connections[DEFAULT_DB_ALIAS].close.assert_called_once()

def test_original_error_surfaces_when_reset_calculating_save_fails(self):
# calculate_people_ch's finally block resets is_calculating on the same connection the recovery
# bookkeeping save uses. If that reset write hits the dropped connection it must not raise out of
# finally and mask the real calculation error - it runs through the same reconnect-and-retry.
@parameterized.expand(
[
# A dropped Postgres connection, and a Redis error escaping the save's post_save signals.
("connection_error", OperationalError("the connection is closed")),
("redis_error", Exception("redis unavailable")),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: This case is named as a Redis error escaping the save's post_save signals, but the call it patches cannot produce one. _safe_reset_calculating_state (cohort.py:654) writes through a queryset .update(), and Django's QuerySet.update() fires no model signals.

The signal-firing call site is different: the bookkeeping self.save(...) at cohort.py:736 does fire signals and has no test.

Suggested change
("redis_error", Exception("redis unavailable")),
("non_connection_error", Exception("bookkeeping write failed")),

Fix the comment at test_util.py:1367 the same way.

]
)
def test_original_error_surfaces_when_reset_calculating_save_fails(self, _name, bookkeeping_error):
# calculate_people_ch's finally block resets is_calculating through the recovery bookkeeping.
# If that reset write fails - a dropped connection, or a Redis error from the save's signals -
# it must not raise out of finally and mask the real calculation error.
cohort = _create_cohort(
team=self.team,
name="c",
Expand All @@ -1376,11 +1383,11 @@ def test_original_error_surfaces_when_reset_calculating_save_fails(self):
"products.cohorts.backend.models.util.recalculate_cohortpeople",
side_effect=real_error,
),
# The is_calculating reset write fails on the dropped connection, initial attempt and retry.
# The is_calculating reset write fails, initial attempt and retry.
patch.object(
Cohort,
"_safe_reset_calculating_state",
side_effect=OperationalError("the connection is closed"),
side_effect=bookkeeping_error,
) as mock_reset,
# Patch connections so the reconnect doesn't close the real test transaction's connection.
patch("products.cohorts.backend.models.util.connections") as mock_connections,
Expand Down
35 changes: 21 additions & 14 deletions products/cohorts/backend/models/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import TYPE_CHECKING, Any, Optional, Union, cast

from django.conf import settings
from django.db import DEFAULT_DB_ALIAS, InterfaceError, OperationalError, connections
from django.db import DEFAULT_DB_ALIAS, connections
from django.utils import timezone

import structlog
Expand Down Expand Up @@ -156,15 +156,16 @@ def save_recovery_bookkeeping(save_fn: Callable[[], None], *, cohort_id: int, te
"""Persist post-calculation bookkeeping, surviving a Postgres connection dropped mid-recalculation.

A long recalculation can outlive its connection (the server closes it unexpectedly); the first
write afterwards then raises a connection error. Left unguarded on the error/finally recovery
path, that cascades into "the connection is closed" - burying the real root-cause error and
leaving the cohort stuck with is_calculating=True. Reconnect and retry once so the bookkeeping
still lands and the original error is what propagates; if the retry fails too, swallow it (a
recovery write must never mask the failure it is recording).
write afterwards then raises a connection error. The save also fires post_save signal handlers
that touch Redis, so a Redis blip can escape here too. Left unguarded on the error/finally
recovery path, either failure buries the real root-cause error and leaves the cohort stuck with
is_calculating=True. Reconnect and retry once so the bookkeeping still lands and the original
error is what propagates; if the retry fails too, swallow it (a recovery write must never mask
the failure it is recording).
"""
try:
Comment thread
posthog[bot] marked this conversation as resolved.
save_fn()
except (InterfaceError, OperationalError):
except Exception:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Widening the exception catch widens what gets retried, but retry only makes sense for connection failures. For dropped connections nothing was written, so re-running save_fn() is safe. For other exceptions like Redis or broker failures, the row is already committed by the time the receiver fails (Django sends post_save after the UPDATE). The retry then unnecessarily closes a healthy connection and re-runs the whole save, re-firing every post_save receiver on the cohort, including the still-unguarded update_team_flags_cache.delay(...) at local_evaluation.py:958.

The blast radius is small today, which is why this is a suggestion. CONN_MAX_AGE is 0, so the reconnect costs a single connection attempt, and the update_fields at cohort.py:736 omit filters, so no duplicate backfill run can be enqueued.

This isn't the approach you already declined. Every exception still gets swallowed and captured, so error tracking doesn't become less visible. The reconnect-and-retry logic narrows specifically to when it makes sense: failures that a reconnect can fix. It requires InterfaceError and OperationalError from django.db at util.py:11.

     try:
-        save_fn()
-    except Exception:
-        connections[DEFAULT_DB_ALIAS].close()  # next query opens a fresh connection
-        try:
+        try:
             save_fn()
-        except Exception as retry_error:
-            # A swallowed retry means the cohort is stuck with is_calculating=True and no bookkeeping
-            # recorded. Surface it to error tracking, matching how other swallowed cohort-calculation
-            # errors are captured, so it alerts rather than only living in structured logs.
-            logger.warning("cohort_recalc_recovery_save_failed", cohort_id=cohort_id, team_id=team_id, exc_info=True)
-            capture_exception(retry_error, additional_properties={"cohort_id": cohort_id, "team_id": team_id})
+        except (InterfaceError, OperationalError):
+            connections[DEFAULT_DB_ALIAS].close()  # next query opens a fresh connection
+            save_fn()
+    except Exception as error:
+        # A swallowed failure means the cohort is stuck with is_calculating=True and no bookkeeping
+        # recorded. Surface it to error tracking, matching how other swallowed cohort-calculation
+        # errors are captured, so it alerts rather than only living in structured logs.
+        logger.warning("cohort_recalc_recovery_save_failed", cohort_id=cohort_id, team_id=team_id, exc_info=True)
+        capture_exception(error, additional_properties={"cohort_id": cohort_id, "team_id": team_id})

Update the non-connection error case in test_util.py:1398: the reconnect no longer runs, so the assertions become assert mock_reset.call_count == 1 and close.assert_not_called(). The connection_error case keeps its current assertions.

connections[DEFAULT_DB_ALIAS].close() # next query opens a fresh connection
try:
save_fn()
Expand Down Expand Up @@ -224,14 +225,20 @@ def run_cohort_query(
# If calculation succeeded and we scheduled a delayed task, cancel it and run immediately
# This avoids waiting the full timeout when the query completed quickly
if delayed_task and history and query and not settings.TEST:
if delayed_task.state in ["PENDING", "RECEIVED"]:
delayed_task.revoke() # Cancel the delayed task
# The recalculation result is already computed. This rescheduling is best-effort stats
# bookkeeping that talks to the Celery result backend and broker, so a Redis blip must
# not discard the result. The delayed task still runs after its original countdown.
try:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: If delayed_task.revoke() succeeds and collect_cohort_query_stats.apply_async then raises, the countdown task is already cancelled and nothing replaces it. That CohortCalculationHistory row never gets its queries payload, and collect_cohort_query_stats is the only thing that writes that field. The comment at util.py:236 says the delayed task still runs after its original countdown, which holds when the state read or the revoke fails, but not in this case.

No test covers this block. test_survives_broker_error_scheduling_initial_stats_task (test_util.py:1405) looks like it should, but its side_effect raises on every apply_async call. The first one at util.py:220 fails, so delayed_task stays None, and the if delayed_task and ... gate at util.py:233 skips this entire block. The test's own mock_apply.assert_called_once() is what proves the block never ran. Every other test has settings.TEST true, which skips the scheduling altogether.

Publishing before revoking makes the comment true in every ordering. A publish failure leaves the countdown task alone, and a revoke failure just means both tasks run, which collect_cohort_query_stats already handles by returning early when history.queries is populated.

             try:
-                if delayed_task.state in ["PENDING", "RECEIVED"]:
-                    delayed_task.revoke()  # Cancel the delayed task
-
-                # Run immediately since the query already completed
                 collect_cohort_query_stats.apply_async(
                     args=[cohort_tag, cohort_id, start_time.isoformat(), history.id, query],
                     countdown=COHORT_STATS_COLLECTION_DELAY_SECONDS,
                 )
+
+                # Publish before revoking so a failure here leaves the countdown task in place.
+                if delayed_task.state in ["PENDING", "RECEIVED"]:
+                    delayed_task.revoke()
             except Exception as error:

Add a test case where the first apply_async returns a task and the second raises. Set state to "PENDING" on that returned mock. A bare MagicMock fails the in ["PENDING", "RECEIVED"] check and leaves the revoke line uncovered.

if delayed_task.state in ["PENDING", "RECEIVED"]:
delayed_task.revoke() # Cancel the delayed task

# Run immediately since the query already completed
collect_cohort_query_stats.apply_async(
args=[cohort_tag, cohort_id, start_time.isoformat(), history.id, query],
countdown=COHORT_STATS_COLLECTION_DELAY_SECONDS,
)
# Run immediately since the query already completed
collect_cohort_query_stats.apply_async(
args=[cohort_tag, cohort_id, start_time.isoformat(), history.id, query],
countdown=COHORT_STATS_COLLECTION_DELAY_SECONDS,
)
except Exception as error:
logger.warning("cohort_stats_collection_scheduling_failed", cohort_id=cohort_id, error=str(error))
Comment thread
posthog[bot] marked this conversation as resolved.

return result, end_time

Expand Down
Loading