From d40c6fd23ef47aae89e67d2577f185969b7653f0 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Sun, 30 Aug 2026 14:01:33 +0300 Subject: [PATCH 1/3] fix(postgres): explain a dropped table instead of raw driver text When a table or column a sync reads is dropped or renamed on the source, the streaming query fails with "relation ... does not exist" (or the column variant). That was already non-retryable, but the stored latest_error was the raw psycopg text, which echoes the relation name and a SQL fragment back and gives no next step. Give the bucket an actionable message. Supabase's realtime-partition message still wins for those partitions through first-match selection. Generated-By: PostHog Desktop Task-Id: c515c2ae-d0fd-42c5-81fc-44b3650ff0ae --- .../data_imports/sources/postgres/source.py | 11 ++++++++++- .../sources/postgres/test_postgres.py | 16 ++++++++++++++++ .../sources/supabase/test_supabase_source.py | 19 +++++++++++-------- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py index b2b7859c0251..24465fa9d137 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py @@ -514,7 +514,16 @@ def get_non_retryable_errors(self) -> dict[str, str | None]: "dashboard for this branch's connection settings, then re-enable the sync." ), "FATAL: no such database": None, - "does not exist": None, + # A relation or column the sync reads was dropped or renamed on the source, so the + # streaming query fails with SQLSTATE 42P01 ("relation ... does not exist") or 42703 + # ("column ... does not exist"). The stored schema/query is fixed until the customer + # changes it, so every retry replays the same statement. Already non-retryable through + # this bucket; the actionable message replaces the raw psycopg text, which echoes the + # relation name and a SQL fragment back into `latest_error`. + "does not exist": ( + "A table or column this sync reads no longer exists in your source database. Remove it " + "from the source's selected tables, or reset and re-sync this table, then re-enable the sync." + ), "timestamp too small": None, "QueryTimeoutException": None, # Activity-layer twin of the `QueryTimeoutException` key above. That key only matches once diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py index 39dcc3c383ce..d4260d8034d8 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py @@ -453,6 +453,22 @@ def test_unrecognized_session_parameter_surfaces_actionable_message(self, source assert matches[0] is not None, "unrecognized session parameter must surface an actionable message" assert "session setting" in matches[0].lower() + def test_missing_relation_surfaces_actionable_message(self, source): + # A dropped/renamed table or column stays non-retryable, but must surface an actionable + # message rather than the raw psycopg text (which echoes the relation name and SQL fragment). + # Mirror the finalizer's first-match selection so a reorder that shadows it with an earlier + # None-valued key, or a revert of this bucket back to None, is caught. The relation name is + # invented, not a real customer value. + error_msg = 'relation "public.orders" does not exist LINE 1: DECLARE _cur CURSOR FOR SELECT * FROM "public"."orders"' + matches = [ + friendly + for pattern, friendly in source.get_non_retryable_errors().items() + if error_message_matches(error_msg, [pattern]) + ] + assert matches, "a dropped relation must be classified non-retryable" + assert matches[0] is not None, "a dropped relation must surface an actionable message, not raw driver text" + assert "no longer exists" in matches[0].lower() + def test_connect_timeout_surfaces_actionable_message(self, source): # A persistently timing-out connect stays non-retryable, but must surface firewall/reachability # guidance rather than the bare "connection timeout expired" driver text. Mirror the finalizer's diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/supabase/test_supabase_source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/supabase/test_supabase_source.py index 0c763149e1ce..8cac2e8554a8 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/supabase/test_supabase_source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/supabase/test_supabase_source.py @@ -231,21 +231,24 @@ def _resolve_friendly_error(source: SupabaseSource, raw_error: str) -> str | Non @pytest.mark.parametrize( - "raw_error,expect_message", + "raw_error,expect_realtime_message", [ - # Retention dropped the dated realtime.messages partition — actionable message, not the - # inherited generic "does not exist" (which resolves to None / the raw driver string). + # Retention dropped the dated realtime.messages partition, so its specific realtime message + # must win over the inherited generic "does not exist" bucket (first matching key wins). ('relation "realtime.messages_2020_01_01" does not exist', True), - # A regular missing table must still fall through to the generic (None) mapping, so the + # A regular missing table must fall through to the generic missing-relation message, so the # realtime key stays specific and doesn't swallow every "does not exist". ('relation "public.orders" does not exist', False), ], ) -def test_expired_realtime_partition_gets_actionable_message(raw_error, expect_message): +def test_expired_realtime_partition_gets_actionable_message(raw_error, expect_realtime_message): friendly = _resolve_friendly_error(SupabaseSource(), raw_error) - if expect_message: - assert friendly is not None + # Both cases are non-retryable with an actionable message now; only the realtime partition gets + # the realtime-specific copy. + assert friendly is not None + if expect_realtime_message: assert "realtime.messages" in friendly else: - assert friendly is None + assert "realtime.messages" not in friendly + assert "no longer exists" in friendly.lower() From 92d1257d63b9d24c86e8e9879fdcf94742716bcb Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 1 Sep 2026 12:01:11 +0100 Subject: [PATCH 2/3] fix(postgres): satisfy ruff format on new test The long error-message literal added in test_missing_relation_surfaces_actionable_message exceeded the line length and needed to be wrapped in parens per ruff format. This was failing the Python code quality CI check. --- .../temporal/data_imports/sources/postgres/test_postgres.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py index d4260d8034d8..b7ad6fa62ea7 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/test_postgres.py @@ -459,7 +459,9 @@ def test_missing_relation_surfaces_actionable_message(self, source): # Mirror the finalizer's first-match selection so a reorder that shadows it with an earlier # None-valued key, or a revert of this bucket back to None, is caught. The relation name is # invented, not a real customer value. - error_msg = 'relation "public.orders" does not exist LINE 1: DECLARE _cur CURSOR FOR SELECT * FROM "public"."orders"' + error_msg = ( + 'relation "public.orders" does not exist LINE 1: DECLARE _cur CURSOR FOR SELECT * FROM "public"."orders"' + ) matches = [ friendly for pattern, friendly in source.get_non_retryable_errors().items() From 53110add1063f2c100f36e9b19add871ed209077 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 1 Sep 2026 12:18:49 +0100 Subject: [PATCH 3/3] fix(postgres): word the missing-object message to not overclaim table/column Address review feedback: the "does not exist" bucket is a broad case-insensitive substring match and can catch other dropped Postgres objects besides tables/columns. Reworded the message to "something this sync depends on (a table, column, or other object)" instead of asserting it's always a table or column, while keeping the same actionable next steps. No test assertions depended on the exact "table or column" wording (they check for "no longer exists"), so this is a pure copy change. --- .../temporal/data_imports/sources/postgres/source.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py index 24465fa9d137..c2b2767a15bc 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/postgres/source.py @@ -519,10 +519,14 @@ def get_non_retryable_errors(self) -> dict[str, str | None]: # ("column ... does not exist"). The stored schema/query is fixed until the customer # changes it, so every retry replays the same statement. Already non-retryable through # this bucket; the actionable message replaces the raw psycopg text, which echoes the - # relation name and a SQL fragment back into `latest_error`. + # relation name and a SQL fragment back into `latest_error`. This key is a broad + # substring match (case-insensitive `does not exist` anywhere in the driver text), so it + # can also catch other dropped Postgres objects (e.g. a type or role); the message is + # worded to not overclaim it's always a table or column. "does not exist": ( - "A table or column this sync reads no longer exists in your source database. Remove it " - "from the source's selected tables, or reset and re-sync this table, then re-enable the sync." + "Something this sync depends on (a table, column, or other object) no longer exists in " + "your source database. Remove it from the source's selected tables, or reset and re-sync " + "this table, then re-enable the sync." ), "timestamp too small": None, "QueryTimeoutException": None,