-
Notifications
You must be signed in to change notification settings - Fork 322
fix: Upgrade existing publications to publish generated columns on PG18+ #4045
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
b2fd0cc
49e6e19
3f22eac
63fa650
d6e08de
52058dd
621966c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| # Implementation Plan | ||
|
|
||
| ## Goal | ||
| Fix the publication upgrade path: when Electric owns a publication on PG18+ that doesn't have `publish_generated_columns = stored`, automatically alter it to add that setting. | ||
|
|
||
| ## Steps | ||
|
|
||
| ### Step 1: Update `check_publication_status!` to return PG version info | ||
| In `configuration.ex`, modify the query to also return whether PG >= 18 as a separate field (`pg_supports_generated_columns?`). This lets callers distinguish "PG < 18 so feature unavailable" from "PG >= 18 but publication not configured". | ||
|
|
||
| ### Step 2: Add publication upgrade logic in the configurator | ||
| In `configurator.ex`'s `check_publication_status/1`, after getting the status: | ||
| - If `pg_supports_generated_columns?` is true AND `publishes_generated_columns?` is false AND `can_alter_publication?` is true | ||
| - Execute `ALTER PUBLICATION <name> SET (publish_generated_columns = stored)` | ||
| - Update the status to reflect the change | ||
| - Log the upgrade | ||
|
|
||
| ### Step 3: Add `alter_publication_set_generated_columns` to Configuration | ||
| Add a new function in `configuration.ex` to execute the ALTER PUBLICATION statement. | ||
|
|
||
| ### Step 4: Add tests | ||
| - Test that `check_publication_status!` returns the new field | ||
| - Test that the configurator upgrades the publication when conditions are met | ||
| - Test that it doesn't attempt upgrade when PG < 18 or can't alter | ||
|
|
||
| ### Step 5: Create changeset entry | ||
|
|
||
| --- | ||
|
|
||
| ## Review discussion | ||
|
|
||
| ### Q: Why not just try the ALTER and catch the error? | ||
| A: Cleaner to check version first. Avoids noisy error logs and maintains separation of concerns. The version info is already queried in `check_publication_status!`. | ||
|
|
||
| ### Q: Should we handle the case where ALTER fails due to permissions? | ||
| A: The `can_alter_publication?` check already covers this - if Electric doesn't own the publication, it won't attempt the ALTER. The existing error path (`publication_missing_generated_columns`) provides clear guidance. | ||
|
|
||
| ### Q: Should this be in connection_setup.ex instead? | ||
| A: No, the configurator is the right place because: | ||
| 1. It already handles publication status checks periodically | ||
| 2. It has the right error handling infrastructure | ||
| 3. Connection setup only runs once on startup, but publication status can change |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # Progress Log | ||
|
|
||
| ## 2026-03-23 | ||
|
|
||
| ### Investigation | ||
|
|
||
| 1. Read upstream issue electric#4038 - user on PG18 (Railway) gets 400 error for tables with generated columns | ||
| 2. Traced error path through codebase: | ||
| - 400 error originates from `shape.ex:validate_selected_columns/4` (lines 400-402, 434-438) | ||
| - This checks `supports_generated_column_replication` from inspector | ||
| - Inspector checks `current_setting('server_version_num')::int >= 180000` | ||
| - On PG18, this should be `true` → no 400 error | ||
|
|
||
| 3. Identified two separate code paths for generated column errors: | ||
| - **Shape validation (400)**: checks PG version via inspector → rejects if PG < 18 | ||
| - **Publication manager (503)**: checks publication's `pubgencols` attribute → rejects if publication doesn't publish generated columns | ||
|
|
||
| 4. Found the real code gap: `connection_setup.ex` only sets `publish_generated_columns = stored` during `CREATE PUBLICATION`. If publication already exists (created by older Electric or on PG < 18), it's never upgraded. | ||
|
|
||
| 5. Conclusion: user's 400 error is likely due to running an older Electric version. But there IS a real bug in the publication upgrade path that would cause a 503 on PG18 with a pre-existing publication. | ||
|
|
||
| ### Implementation plan | ||
| - Fix the publication upgrade path: when Electric owns the publication, is on PG18+, and the publication doesn't have `publish_generated_columns = stored`, alter it to add that setting. | ||
| - Reply to upstream issue with analysis and guidance. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # Code Review -- Round 1 | ||
|
|
||
| **Commit reviewed:** b2fd0cc00 | ||
| **Reviewer:** Code review agent | ||
| **Date:** 2026-03-23 | ||
|
|
||
| ## Summary | ||
|
|
||
| This change fixes a real upgrade-path bug: when a user moves from PG < 18 to PG18+ with an existing Electric-owned publication, the publication lacks `publish_generated_columns = stored`, causing 503 errors for shapes involving stored generated columns. The fix detects this condition and automatically issues `ALTER PUBLICATION ... SET (publish_generated_columns = stored)`. | ||
|
|
||
| Overall, this is a well-structured, minimal, and focused fix. The implementation aligns closely with the plan. Below are the findings. | ||
|
|
||
| --- | ||
|
|
||
| ## What Was Done Well | ||
|
|
||
| - **Minimal and focused.** The change touches exactly the files it needs to: the configuration module (query + new function), the configurator (upgrade logic), and corresponding tests. No unrelated changes. | ||
| - **Correct placement.** Putting the upgrade logic in the configurator rather than connection setup is the right call -- the configurator already checks publication status periodically and has the error-handling infrastructure. | ||
| - **Pattern matching for control flow.** The `maybe_upgrade_generated_columns/2` function uses Elixir pattern matching idiomatically -- the three-field match clause is clear about when upgrade happens, and the fallthrough clause is a clean no-op. | ||
| - **SQL injection prevention.** The `alter_publication_set_generated_columns/2` function uses `Utils.quote_name/1` which properly escapes double quotes in identifiers, consistent with how publication names are handled elsewhere in the codebase (e.g., `exec_alter_publication_for_table`). | ||
| - **Logging.** The `Logger.notice` call provides appropriate visibility for an automatic schema-level change. | ||
|
|
||
| --- | ||
|
|
||
| ## Issues | ||
|
|
||
| ### Important (should fix) | ||
|
|
||
| **1. No error handling in `maybe_upgrade_generated_columns/2` for ALTER failure** | ||
|
|
||
| The function calls `Configuration.alter_publication_set_generated_columns/2` which uses `Postgrex.query!/3` (note the bang). If the ALTER fails for any reason (e.g., transient connection issue, unexpected permission problem despite the ownership check), this will raise an exception that propagates up through `check_publication_status/1`. | ||
|
|
||
| The caller wraps this in `Configuration.run_handling_db_connection_errors/1`, so connection errors will be caught. However, non-connection Postgrex errors (e.g., a `Postgrex.Error` from an unexpected SQL error) would crash the configurator process. | ||
|
|
||
| Consider either: | ||
| - Wrapping the call in a try/rescue and logging a warning on failure (then returning status unchanged, letting the existing `publication_missing_generated_columns` error path handle it downstream), or | ||
| - Using `Postgrex.query/3` (non-bang) and pattern matching on the result. | ||
|
|
||
| This is an edge case but aligns with defensive programming for DDL operations. | ||
|
|
||
| **2. Test in `publication_manager_test.exs` line 384 does not quote the publication name** | ||
|
|
||
| ```elixir | ||
| "ALTER PUBLICATION #{ctx.publication_name} SET (publish_generated_columns = 'none')" | ||
| ``` | ||
|
|
||
| The publication name is interpolated without quoting. While test publication names are unlikely to contain special characters, this is inconsistent with the production code which uses `Utils.quote_name/1`. The same issue exists in the `configuration_test.exs` test at line 338. For consistency and to avoid subtle test failures if publication naming conventions change, use `Utils.quote_name/1` or at minimum wrap in double quotes. | ||
|
|
||
| ### Suggestions (nice to have) | ||
|
|
||
| **3. Tests silently pass on PG < 18** | ||
|
|
||
| All three new tests use `if pg_version >= 180_000 do ... end` with no `else` clause. On PG < 18, these tests execute zero assertions and pass silently. This is a known pattern in the codebase (the existing `check_publication_status!` test at line 277 does the same), so it is consistent. However, consider adding a comment like `# Test is a no-op on PG < 18` or using `@tag :pg18` to make skip behavior explicit. | ||
|
|
||
| **4. The `pg_supports_generated_columns?` field leaks implementation detail into the type** | ||
|
|
||
| The `publication_status` type now includes `pg_supports_generated_columns?`, but this field is only consumed by `maybe_upgrade_generated_columns/2` in the configurator. No other caller needs to know whether PG supports generated columns -- they only care about `publishes_generated_columns?`. After the upgrade logic runs, the field is no longer meaningful. | ||
|
|
||
| This is a minor coupling concern. An alternative would be to have `check_publication_status!` return a separate `{:needs_upgrade, status}` tuple, or to handle the upgrade entirely within the configuration module. That said, the current approach is simple and readable, so this is just something to be aware of for future maintenance. | ||
|
|
||
| **5. Upgrade runs on every publication status check** | ||
|
|
||
| `maybe_upgrade_generated_columns/2` is called from `check_publication_status/1`, which runs periodically (based on `publication_refresh_period`). After the first successful upgrade, subsequent checks will see `publishes_generated_columns?: true` and the pattern match will fall through to the no-op clause. So this is fine in practice -- no repeated ALTERs. Just calling it out to confirm the logic is sound (it is). | ||
|
|
||
| --- | ||
|
|
||
| ## Plan Alignment | ||
|
|
||
| The implementation follows the plan from `plan.md` faithfully: | ||
|
|
||
| | Plan Step | Status | Notes | | ||
| |-----------|--------|-------| | ||
| | Step 1: Update `check_publication_status!` with PG version info | Done | New `pg_supports_generated_columns?` field added | | ||
| | Step 2: Add upgrade logic in configurator | Done | `maybe_upgrade_generated_columns/2` with correct conditions | | ||
| | Step 3: Add `alter_publication_set_generated_columns` | Done | Uses `Utils.quote_name` properly | | ||
| | Step 4: Add tests | Done | Three tests covering status field, alter function, and integration | | ||
| | Step 5: Create changeset | Done (separate commit) | | | ||
|
|
||
| No deviations from the plan. | ||
|
|
||
| --- | ||
|
|
||
| ## Verdict | ||
|
|
||
| The fix is correct, minimal, and well-tested for the target scenario. The main actionable item is adding error handling around the ALTER PUBLICATION call (Issue #1) to prevent a potential process crash on unexpected SQL errors. The test quoting issue (#2) is a minor hygiene item. Everything else is solid work. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # Task: Electric shape endpoint rejects stored generated columns on PostgreSQL 18 | ||
|
|
||
| ## Source | ||
| - Upstream issue: electric-sql/electric#4038 | ||
| - Task issue: electric-sql/alco-agent-tasks#14 | ||
|
|
||
| ## Problem | ||
| A user reports that Electric rejects shape requests for tables containing stored generated columns on PostgreSQL 18, returning a 400 error: | ||
|
|
||
| ```json | ||
| { | ||
| "message": "Invalid request", | ||
| "errors": { | ||
| "columns": [ | ||
| "The following columns are generated and cannot be included in the shape: hours, shift_date. You can exclude them from the shape by explicitly listing which columns to fetch in the 'columns' query param" | ||
| ] | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The user's publication is correctly configured with `publish_generated_columns = 'stored'` (verified via `pubgencols = 's'`). | ||
|
|
||
| ## Analysis | ||
|
|
||
| ### Two separate issues identified | ||
|
|
||
| #### 1. User's reported 400 error (likely version/environment issue) | ||
| The 400 error comes from `shape.ex:validate_selected_columns/4` which checks `supports_generated_column_replication` from the inspector. This is a PG version check (`server_version_num >= 180000`). On genuine PG18, this should return `true`. | ||
|
|
||
| The most likely explanation is: | ||
| - User is running an older Electric Docker image that predates PR #3297 (merged Oct 2025, available since v1.2.0) | ||
| - Or a stale Docker cache on Railway | ||
|
|
||
| #### 2. Real code gap: publication upgrade path (would cause 503) | ||
| When a publication was created by an older Electric version (or on PG < 18), it won't have `publish_generated_columns = stored`. If the user then upgrades to PG18, Electric: | ||
| - Detects PG18 → shape validation passes (no 400) | ||
| - But publication status check returns `publishes_generated_columns?: false` | ||
| - Relation tracker returns `DbConfigurationError` → 503 error | ||
|
|
||
| **The fix needed**: When Electric detects it owns the publication, is on PG18+, and the publication doesn't publish generated columns, it should `ALTER PUBLICATION ... SET (publish_generated_columns = stored)`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@core/sync-service': patch | ||
| --- | ||
|
|
||
| Automatically upgrade existing publications to set `publish_generated_columns = stored` when running on PostgreSQL 18+. This fixes the case where a publication was created by an older Electric version before PG18 generated columns support was added. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -247,11 +247,32 @@ defmodule Electric.Replication.PublicationManager.Configurator do | |
| {:error, err} | ||
|
|
||
| status -> | ||
| # If PG supports generated columns but publication doesn't publish them, | ||
| # and we can alter the publication, upgrade it | ||
| status = maybe_upgrade_generated_columns(status, state) | ||
| {:ok, status} | ||
| end | ||
| end) | ||
| end | ||
|
|
||
| defp maybe_upgrade_generated_columns( | ||
| %{ | ||
| pg_supports_generated_columns?: true, | ||
| publishes_generated_columns?: false, | ||
| can_alter_publication?: true | ||
| } = status, | ||
| state | ||
| ) do | ||
| Logger.notice( | ||
| "Upgrading publication #{state.publication_name} to publish generated columns (PostgreSQL 18+ detected)" | ||
| ) | ||
|
|
||
| Configuration.alter_publication_set_generated_columns(state.db_pool, state.publication_name) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This upgrade path cannot distinguish a publication that predates PG18 from one that an operator intentionally configured with Useful? React with 👍 / 👎. |
||
| %{status | publishes_generated_columns?: true} | ||
| end | ||
|
|
||
| defp maybe_upgrade_generated_columns(status, _state), do: status | ||
|
|
||
| @spec determine_publication_relation_actions(state(), RelationTracker.relation_filters()) :: | ||
| {:ok, Configuration.relation_actions()} | {:error, any()} | ||
| defp determine_publication_relation_actions(state, filters) do | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
ELECTRIC_MANUAL_TABLE_PUBLISHING=true, the rest of this module avoids mutating the publication viacan_update_publication?/1, but this new branch bypasses that guard and will still runALTER PUBLICATIONwhenever a PG18 publication reportspublish_generated_columns = none. In practice, simply starting Electric against a manually managed publication can now rewrite it, which violates the manual-mode contract and creates drift from the operator-managed SQL.Useful? React with 👍 / 👎.