Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
746fe27
fix(subscriptions): auto-disable when the owner left or lost access
a-lider Oct 26, 2026
8b68ec9
fix(alerts): shorten owner disable reasons and clarify the owner check
a-lider Oct 26, 2026
b5c0aa5
fix(subscriptions): send the reason in the delivery, then disable
a-lider Oct 26, 2026
a02ae6b
fix(alerts): record owner disables as invalid_configuration
a-lider Oct 26, 2026
68bd3ef
fix(alerts): move the owner check into the shared insight evaluator
a-lider Oct 26, 2026
552d975
chore(subscriptions): make the test user a member of the temporal tea…
a-lider Oct 28, 2026
7f8ddfb
fix(subscriptions): count a deleted owner and lost project access as …
a-lider Oct 28, 2026
895d515
fix(subscriptions): deliver a partly denied dashboard, disable even w…
a-lider Oct 29, 2026
620a1bb
fix(subscriptions): stop AI subscriptions whose owner can no longer r…
a-lider Oct 29, 2026
4517b08
Merge remote-tracking branch 'origin/master' into alex/auto-disable-r…
a-lider Oct 29, 2026
18bfe21
fix(subscriptions): stop when the owner can no longer view the dashbo…
a-lider Oct 29, 2026
47cd10f
refactor(subscriptions): one owner check for background queries
a-lider Oct 29, 2026
b56f00e
fix(subscriptions): a member who lost the project lost access, not left
a-lider Oct 29, 2026
150add7
chore(exports): read dashboard_id in the owner test
a-lider Oct 29, 2026
9008fb5
fix(subscriptions): check project access through the facade
a-lider Oct 29, 2026
3f9c518
refactor(subscriptions): raise inside the object access condition
a-lider Oct 29, 2026
42c2a39
fix(subscriptions): read has_project_access as a property, keep trans…
a-lider Oct 29, 2026
d365dab
refactor(subscriptions): drop the disable closure in deliver_subscrip…
a-lider Oct 29, 2026
d1b0665
chore(subscriptions): stub the owner check in database-free delivery …
a-lider Oct 29, 2026
ac7b51b
fix(subscriptions): keep a transient send failure from disabling the …
a-lider Nov 2, 2026
bb87e9d
fix(alerts): check the owner on the per-series evaluation path too
a-lider Nov 2, 2026
3c17c0d
fix(subscriptions): treat an ownerless subscription export as a delet…
a-lider Nov 2, 2026
29e3273
Merge remote-tracking branch 'origin/master' into alex/auto-disable-r…
a-lider Nov 2, 2026
1b4c820
chore(alerts): give the hourly bucket test's alert an owner
a-lider Nov 3, 2026
0c4e8dc
chore(subscriptions): expect the direct exporter call to re-raise the…
a-lider Nov 3, 2026
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
2 changes: 1 addition & 1 deletion .agents/skills/debugging-table-access-denied/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ Hints:
- On an HTTP request this is an expected 4xx, never captured. An error tracking issue means it was raised in a background context — figure out which from the event's query tags (`team_id`, `user_id`, `product`, `celery_task_id`, `temporal.*`).
- A `system.*` name is a system-table decision; a bare name is a warehouse table/view denial.
- No `user_id` on the event usually means a userless `Database.create_for` — it fails closed, and `bypass_warehouse_access_control` does not cover system tables. That's a bug in the calling code.
- Background jobs run as the resource's `created_by`; a creator who left the org is the known benign cause (cache warming, alerts, and exports already suppress capture for it via `creator_access_revoked`).
- Background jobs run as the resource's `created_by`. A creator who left the org is the known benign cause: cache warming suppresses capture for it via `creator_access_revoked`, exports refuse to run with `CreatorLeftOrganizationError`, and subscriptions and alerts auto-disable (look for the `alert auto disabled` event or the subscription's last delivery).
- Otherwise it's a real access rule (RBAC or entitlement) — see the readme for how a table's access level resolves, and check the `AccessControl` rows for the table's `access_scope`.
7 changes: 7 additions & 0 deletions posthog/query_creator_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
from posthog.ph_client import ph_scoped_capture


class CreatorLeftOrganizationError(Exception):
"""A background query refused to run because its owner is no longer in the organization."""

def __init__(self) -> None:
super().__init__("The owner is no longer in the organization.")


def creator_access_revoked(user: Optional[User], team: Team) -> bool:
"""True when `user` no longer has access to `team`: they were deleted (`user is None`),
deactivated, or are no longer a member of the team's org.
Expand Down
3 changes: 3 additions & 0 deletions posthog/tasks/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from prometheus_client import Counter, Histogram

from posthog.event_usage import EventSource, groups
from posthog.query_creator_access import CreatorLeftOrganizationError, creator_access_revoked
from posthog.settings import HOGQL_INCREASED_MAX_EXECUTION_TIME
from posthog.tasks.utils import CeleryQueue

Expand Down Expand Up @@ -121,6 +122,8 @@ def export_asset_direct(
export_source = source or EventSource.EXPORT

try:
if exported_asset.created_by is not None and creator_access_revoked(exported_asset.created_by, team):
raise CreatorLeftOrganizationError()
assert_export_authorization(exported_asset)
if exported_asset.export_format in (ExportedAsset.ExportFormat.CSV, ExportedAsset.ExportFormat.XLSX):
csv_exporter.export_tabular(exported_asset, limit=limit, source=export_source)
Expand Down
15 changes: 15 additions & 0 deletions posthog/tasks/test/test_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from posthog.hogql.errors import QueryError

from posthog.exceptions import ClickHouseAtCapacity
from posthog.models import User
from posthog.models.personal_api_key import PersonalAPIKey
from posthog.models.utils import generate_random_token_personal, hash_key_value
from posthog.tasks import exporter
Expand Down Expand Up @@ -122,6 +123,20 @@ def test_export_stores_exception_type_on_failure(self, mock_export: MagicMock, m
assert asset.exception_type == "QueryError"
assert asset.failure_type == FAILURE_TYPE_USER

@patch("products.exports.backend.tasks.image_exporter.export_image")
def test_export_does_not_run_after_its_owner_left(self, mock_export: MagicMock, mock_uuid: MagicMock) -> None:
former_member = User.objects.create_user(email="former-member@example.com", first_name="", password=None)
asset = ExportedAsset.objects.create(
team=self.team, dashboard=self.exported_asset.dashboard, export_format="image/png", created_by=former_member
)

exporter.export_asset(asset.id)

mock_export.assert_not_called()
asset.refresh_from_db()
assert asset.exception_type == "CreatorLeftOrganizationError"
assert asset.failure_type == FAILURE_TYPE_USER


class TestExportAssetFailureRecording(APIBaseTest):
@parameterized.expand(
Expand Down
26 changes: 0 additions & 26 deletions posthog/temporal/alerts/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,12 @@

from posthog.schema import AlertState

from posthog.hogql.errors import TableAccessDeniedError

from posthog.clickhouse.cancel import cancel_query_on_cluster
from posthog.clickhouse.query_tagging import Feature, Product, tag_queries
from posthog.dataclasses import frozen
from posthog.email import is_email_available
from posthog.errors import CH_TRANSIENT_ERRORS, CHQueryErrorQueryWasCancelled
from posthog.exceptions_capture import capture_exception
from posthog.query_creator_access import creator_access_revoked, report_creator_access_revoked
from posthog.schema_migrations.upgrade_manager import upgrade_insight
from posthog.sync import database_sync_to_async
from posthog.tasks.alerts.investigation_notifications import run_investigation_notification_safety_net
Expand Down Expand Up @@ -472,11 +469,11 @@


@temporalio.activity.defn
async def prepare_alert(inputs: PrepareAlertActivityInputs) -> PrepareAlertResult:

Check warning on line 472 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`prepare_alert` has cyclomatic complexity 15 (warn >10)

Check warning on line 472 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`prepare_alert` has cyclomatic complexity 15 (warn >10)
"""Load the alert, validate its config, and decide whether to evaluate."""

@database_sync_to_async(thread_sensitive=False)
def _prepare() -> PrepareAlertResult:

Check warning on line 476 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_prepare` has cyclomatic complexity 13 (warn >10)

Check warning on line 476 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_prepare` has cyclomatic complexity 13 (warn >10)
try:
alert = AlertConfiguration.objects.select_related("insight", "team", "team__organization", "threshold").get(
id=inputs.alert_id
Expand Down Expand Up @@ -630,7 +627,7 @@


@temporalio.activity.defn
async def evaluate_alert(inputs: EvaluateAlertActivityInputs) -> EvaluateAlertResult:

Check warning on line 630 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`evaluate_alert` has cyclomatic complexity 30 (warn >10)

Check warning on line 630 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`evaluate_alert` has cyclomatic complexity 30 (warn >10)
"""Run the insight ClickHouse query, apply the state machine, persist an AlertCheck row."""
info = temporalio.activity.info()
evaluation_id = f"{info.workflow_run_id}:{info.activity_id}"
Expand All @@ -647,7 +644,7 @@
if stopping.is_set():
raise _EvaluationStopped()

def _evaluate(alert: AlertConfiguration) -> EvaluateAlertResult:

Check warning on line 647 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_evaluate` has cyclomatic complexity 18 (warn >10)

Check warning on line 647 in posthog/temporal/alerts/activities.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_evaluate` has cyclomatic complexity 18 (warn >10)
_stop_if_cancelled()
evaluated_alert = alert
evaluated_fingerprint = _evaluation_fingerprint(alert)
Expand Down Expand Up @@ -700,29 +697,6 @@
# existing path instead of capturing it as an exception, which would pollute error
# tracking with a config problem that recurs on every check until fixed.
invalid_configuration = str(err)
except TableAccessDeniedError as err:
logger.exception("Alert failed to evaluate", alert_id=alert.id, exc_info=err)
# A revoked creator's access-denied error is a known limitation - report it as an event
# rather than capturing it, which would recur on every check until the alert is fixed.
if creator_access_revoked(alert.created_by, alert.team):
report_creator_access_revoked(
user=alert.created_by,
team=alert.team,
source="alert",
error=err,
properties={"alert_id": str(alert.id), "insight_id": alert.insight_id},
)
error = {"message": str(err)}
else:
capture_exception(
err,
additional_properties={
"alert_configuration_id": str(alert.id),
"insight_id": alert.insight_id,
"team_id": alert.team_id,
},
)
error = {"message": str(err), "traceback": traceback.format_exc()}
except Exception as err:
logger.exception("Alert failed to evaluate", alert_id=alert.id, exc_info=err)
capture_exception(
Expand Down
51 changes: 49 additions & 2 deletions posthog/temporal/tests/test_alerts_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
TrendsQuery,
)

from posthog.hogql.errors import TableAccessDeniedError

from posthog.clickhouse.query_tagging import get_query_tags
from posthog.constants import AvailableFeature
from posthog.errors import CHQueryErrorQueryWasCancelled
Expand All @@ -38,7 +40,7 @@
ClickHouseClusterMemoryLimitExceeded,
ClickHouseQueryMemoryLimitExceeded,
)
from posthog.models import Team, User
from posthog.models import OrganizationMembership, Team, User
from posthog.redis import get_client
from posthog.slo.types import SloOperation, SloOutcome
from posthog.tasks.alerts.utils import (
Expand Down Expand Up @@ -75,7 +77,12 @@
SkipReason,
)

from products.alerts.backend.evaluation.contract import AlertDataUnavailableError, AlertExtractionError
from products.alerts.backend.evaluation.contract import (
CREATOR_LEFT_DISABLE_REASON,
CREATOR_LOST_ACCESS_DISABLE_REASON,
AlertDataUnavailableError,
AlertExtractionError,
)
from products.alerts.backend.evaluation.validation import THRESHOLD_BOUNDS_REQUIRED_MESSAGE
from products.alerts.backend.facade.api import (
LLM_DETECTOR_UNAVAILABLE_ERROR_CODE,
Expand Down Expand Up @@ -1263,6 +1270,46 @@ async def test_evaluate_auto_disables_and_skips_error_tracking_on_configuration_
await sync_to_async(check.refresh_from_db)()
assert check.targets_notified

@pytest.mark.parametrize(
"owner_change, expected_message",
[
("left_organization", CREATOR_LEFT_DISABLE_REASON),
("lost_table_access", CREATOR_LOST_ACCESS_DISABLE_REASON),
],
)
async def test_evaluate_auto_disables_when_the_owner_can_no_longer_run_the_query(
self, alert_with_user, aorganization, owner_change, expected_message
) -> None:
owner = await sync_to_async(lambda: alert_with_user.subscribed_users.get())()
alert_with_user.created_by = owner
await sync_to_async(alert_with_user.save)(update_fields=["created_by"])
if owner_change == "left_organization":
await sync_to_async(
OrganizationMembership.objects.filter(organization_id=aorganization.id, user=owner).delete
)()

with (
# Patched under the real `check_alert_for_insight`, which turns the denial into the owner reason.
patch(
"products.alerts.backend.evaluation.dispatcher._check_alert_for_insight",
side_effect=TableAccessDeniedError("revenue"),
) as mock_check,
patch("posthog.temporal.alerts.activities.capture_exception") as mock_capture,
):
result = await ActivityEnvironment().run(
evaluate_alert, EvaluateAlertActivityInputs(alert_id=str(alert_with_user.id))
)

assert result.new_state == AlertState.ERRORED
mock_capture.assert_not_called()
# A departed owner's alert must stop even when its query would still run.
assert mock_check.called is (owner_change == "lost_table_access")
check = await sync_to_async(AlertCheck.objects.get)(pk=result.alert_check_id)
# The shared code routes the check to the "alert disabled" email, in-app notice and banner.
assert check.error == {"message": expected_message, "code": "invalid_configuration"}
refreshed = await sync_to_async(AlertConfiguration.objects.get)(pk=alert_with_user.pk)
assert refreshed.enabled is False

# Transient CH errors bubble up so Temporal's retry policy handles them.
# Capacity errors (codes 202/439) surface as ClickHouseAtCapacity, so that's what we simulate.
# A server-wide or per-user memory limit is the same kind of cluster pressure: recording it as
Expand Down
12 changes: 12 additions & 0 deletions products/alerts/backend/evaluation/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ class AlertDataUnavailableError(AlertExtractionError):
"""This check lacks usable data; keep the alert enabled for future checks."""


class AlertOwnerAccessError(AlertExtractionError):
"""The query runs with the owner's access, and the owner can no longer run it.

Both evaluation stacks disable the alert for an `AlertExtractionError`, so this subclass only
lets a test or a log tell the owner case apart.
"""


CREATOR_LEFT_DISABLE_REASON = "The alert owner is no longer in the organization."
CREATOR_LOST_ACCESS_DISABLE_REASON = "The alert owner no longer has access to some of the data it reads."


def lookback_intervals_for(condition: AlertCondition) -> int:
"""How many trailing intervals an extractor must fetch for this condition.

Expand Down
14 changes: 13 additions & 1 deletion products/alerts/backend/evaluation/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,23 @@

from posthog.schema import AlertCondition, InsightThreshold, IntervalType, NodeKind

from posthog.hogql.errors import ExposedHogQLError
from posthog.hogql.errors import ExposedHogQLError, TableAccessDeniedError

from posthog.api.services.query import ExecutionMode
from posthog.dataclasses import frozen
from posthog.errors import CH_TRANSIENT_ERRORS, ExposedCHQueryError
from posthog.hogql_queries.validation.validate_query import rule_violation_message
from posthog.query_creator_access import creator_access_revoked
from posthog.schema_migrations.upgrade_manager import upgrade_insight
from posthog.tasks.alerts.utils import WRAPPER_NODE_KINDS, AlertEvaluationResult
from posthog.utils import get_from_dict_or_attr

from products.alerts.backend.evaluation.comparator import evaluate_threshold
from products.alerts.backend.evaluation.contract import (
CREATOR_LEFT_DISABLE_REASON,
CREATOR_LOST_ACCESS_DISABLE_REASON,
AlertExtractionError,
AlertOwnerAccessError,
ComparableSeries,
DetectorExtractor,
ExtractionResult,
Expand Down Expand Up @@ -281,8 +285,16 @@ def check_alert_per_series(alert: AlertConfiguration) -> PerSeriesEvaluation | A


def check_alert_for_insight(alert: AlertConfiguration, *, evaluation_id: str | None = None) -> AlertEvaluationResult:
# The query runs with the owner's access. An alert with no owner runs userless and is left alone.
if alert.created_by is not None and creator_access_revoked(alert.created_by, alert.team):
# Checked before the query, because a query that reads no restricted table still succeeds.
raise AlertOwnerAccessError(CREATOR_LEFT_DISABLE_REASON)
try:
return _check_alert_for_insight(alert, evaluation_id=evaluation_id)
except TableAccessDeniedError as err:
if alert.created_by is None:
raise
Comment thread
a-lider marked this conversation as resolved.
Resolved
raise AlertOwnerAccessError(CREATOR_LOST_ACCESS_DISABLE_REASON) from err
except DelayedEvaluationUnavailable as err:
if is_llm_detector_config(alert.detector_config):
raise LLMDetectorUnavailableError(str(err)) from err
Expand Down
7 changes: 6 additions & 1 deletion products/alerts/backend/test/test_platform_source_cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
from posthog.tasks.alerts.utils import AlertEvaluationResult, RenderedInsightChart, next_check_time
from posthog.temporal.alerts.admission import inflight_alert_ids, release_evaluation_slot

from products.alerts.backend.evaluation.contract import AlertExtractionError
from products.alerts.backend.evaluation.contract import (
CREATOR_LEFT_DISABLE_REASON,
AlertExtractionError,
AlertOwnerAccessError,
)
from products.alerts.backend.evaluation.dispatcher import PerSeriesEvaluation, SeriesEvaluation
from products.alerts.backend.judge.contract import (
LLM_DETECTOR_UNAVAILABLE_MESSAGE,
Expand Down Expand Up @@ -447,6 +451,7 @@ def test_a_cadence_the_plan_no_longer_includes_errors_and_is_disabled(self) -> N
[
("bad_query_shape", AlertExtractionError("bad query shape")),
("ai_consent_withdrawn", LLMDetectorMisconfiguredError("consent withdrawn")),
("owner_left_or_lost_access", AlertOwnerAccessError(CREATOR_LEFT_DISABLE_REASON)),
]
)
def test_an_alert_production_cannot_evaluate_as_configured_errors_and_is_disabled(
Expand Down
2 changes: 2 additions & 0 deletions products/exports/backend/tasks/failure_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
ClickHouseQuerySizeExceeded,
ClickHouseQueryTimeOut,
)
from posthog.query_creator_access import CreatorLeftOrganizationError
from posthog.storage.object_storage import ObjectStorageError

from products.access_control.backend.facade.user_access_control import UserAccessControlError
Expand Down Expand Up @@ -234,6 +235,7 @@ class RetryableExportError(Exception):
ExcelColumnLimitExceeded,
InvalidExportContext,
UserAccessControlError, # The export creator has no access to a resource the query needs
CreatorLeftOrganizationError,
)

TIMEOUT_ERRORS = (
Expand Down
44 changes: 43 additions & 1 deletion products/subscriptions/backend/logic/auto_disable.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import uuid
from collections.abc import Iterable
from typing import NamedTuple
from urllib.parse import urlparse

import structlog

from posthog.hogql.errors import TableAccessDeniedError

from posthog.email import EmailMessage
from posthog.exceptions_capture import capture_exception
from posthog.models import User
from posthog.query_creator_access import CreatorLeftOrganizationError

from products.access_control.backend.facade.contracts import UserNotOrganizationMemberError
from products.access_control.backend.facade.user_access_control import UserAccessControlError
from products.notifications.backend.facade.api import (
NotificationData,
NotificationType,
Expand Down Expand Up @@ -58,6 +64,22 @@ class DisableReason(NamedTuple):
"Add files:write to a custom Slack app, or reconnect PostHog's Slack app, then try again."
),
)
CREATOR_LEFT_DISABLE_REASON = DisableReason(
key="creator_left_organization",
description="The owner of this subscription is no longer in the organization",
user_message=(
"Cannot re-enable {target_type} subscription: its owner is no longer in the organization. "
"Create a new subscription to keep receiving this report."
),
)
CREATOR_LOST_ACCESS_DISABLE_REASON = DisableReason(
key="creator_lost_access",
description="The owner of this subscription no longer has access to some of its data",
user_message=(
"Cannot re-enable {target_type} subscription until its owner has access to all of its data again. "
"Ask a project admin for access, or create a new subscription."
),
)
AI_PROMPT_INVALID_DISABLE_REASON = DisableReason(
key="ai_prompt_invalid",
description="AI subscription prompt or creator is invalid",
Expand All @@ -83,6 +105,21 @@ def get_subscription_disable_reason(target_type: str | None, integration_id: int
return None


# Export failures that mean the owner can no longer run the query, not that the export broke.
# Matched by name because delivery only sees the `exception_type` stored on the asset.
_CREATOR_LEFT_ERROR_NAMES = frozenset({CreatorLeftOrganizationError.__name__, UserNotOrganizationMemberError.__name__})
_CREATOR_LOST_ACCESS_ERROR_NAMES = frozenset({TableAccessDeniedError.__name__, UserAccessControlError.__name__})


def get_assets_owner_disable_reason(exception_types: Iterable[str | None]) -> DisableReason | None:
names = set(exception_types)
if names & _CREATOR_LEFT_ERROR_NAMES:
return CREATOR_LEFT_DISABLE_REASON
if names & _CREATOR_LOST_ACCESS_ERROR_NAMES:
return CREATOR_LOST_ACCESS_DISABLE_REASON
return None


def target_type_label(target_type: str | None) -> str:
if not target_type:
return ""
Expand Down Expand Up @@ -110,7 +147,10 @@ def _get_notification_creator(subscription: Subscription) -> User | None:
return creator


def disable_invalid_subscription(subscription: Subscription, reason: DisableReason) -> None:
def disable_invalid_subscription(
subscription: Subscription, reason: DisableReason, *, notify_owner: bool = True
) -> None:
"""Disable the subscription once. Pass `notify_owner=False` when the delivery itself already told the recipients why."""
# Compare-and-swap so only one racing caller sends the disabled-notification
# email (UUID4 campaign keys mean MessagingRecord can't dedup the duplicate).
rowcount = Subscription.objects.filter(pk=subscription.pk, enabled=True).update(enabled=False)
Expand All @@ -137,6 +177,8 @@ def disable_invalid_subscription(subscription: Subscription, reason: DisableReas
# SELECT — refresh_from_db() would also drop the eagerly-loaded created_by
# relation (loaded via select_related at the activity site).
subscription.enabled = False
if not notify_owner:
return

try:
create_subscription_auto_disabled_notification(subscription, reason)
Expand Down
5 changes: 5 additions & 0 deletions products/subscriptions/backend/logic/subscription_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from products.exports.backend.models.exported_asset import ExportedAsset
from products.product_analytics.backend.facade.models import Insight
from products.subscriptions.backend.facade.contracts import SharedInsightImage
from products.subscriptions.backend.logic.auto_disable import get_assets_owner_disable_reason
from products.subscriptions.backend.models.subscription import Subscription

logger = structlog.get_logger(__name__)
Expand Down Expand Up @@ -63,6 +64,10 @@ def _is_oom_exception_text(exception_text: str | None) -> bool:
def subscription_asset_error_message(asset: ExportedAsset) -> str:
# Recipients of scheduled subscriptions didn't author the query, so the OOM advice is
# unactionable. Original text stays on asset.exception/exception_type for our own logs.
# The raw access error ("You don't have access to table ...") reads as if the recipient lacks access.
owner_reason = get_assets_owner_disable_reason([asset.exception_type]) if asset.created_by_id else None
if owner_reason is not None:
return f"{owner_reason.description}."
is_oom_exception = asset.exception_type == _OOM_EXCEPTION_TYPE or _is_oom_exception_text(asset.exception)
if asset.exception and not is_oom_exception:
return asset.exception
Expand Down
Loading
Loading