Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
42 changes: 42 additions & 0 deletions .agent-tasks/2026-03-23--14--generated-columns-rejection/plan.md
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.
40 changes: 40 additions & 0 deletions .agent-tasks/2026-03-23--14--generated-columns-rejection/task.md
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)`.
5 changes: 5 additions & 0 deletions .changeset/upgrade-publication-generated-columns.md
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.
27 changes: 23 additions & 4 deletions packages/sync-service/lib/electric/postgres/configuration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ defmodule Electric.Postgres.Configuration do
@type publication_status :: %{
can_alter_publication?: boolean(),
publishes_all_operations?: boolean(),
publishes_generated_columns?: boolean()
publishes_generated_columns?: boolean(),
pg_supports_generated_columns?: boolean()
}

@type relation_actions :: %{
Expand Down Expand Up @@ -54,26 +55,44 @@ defmodule Electric.Postgres.Configuration do
CASE WHEN current_setting('server_version_num')::int >= 180000
THEN (to_jsonb(p) ->> 'pubgencols') = 's'
ELSE FALSE
END AS publishes_generated_columns
END AS publishes_generated_columns,
current_setting('server_version_num')::int >= 180000 AS pg_supports_generated_columns
FROM pg_publication as p WHERE pubname = $1;
"""

Postgrex.query!(conn, query, [publication_name])
|> case do
%Postgrex.Result{
rows: [[can_alter_publication, publishes_all_operations, publishes_generated_columns]]
rows: [
[
can_alter_publication,
publishes_all_operations,
publishes_generated_columns,
pg_supports_generated_columns
]
]
} ->
%{
can_alter_publication?: can_alter_publication,
publishes_all_operations?: publishes_all_operations,
publishes_generated_columns?: publishes_generated_columns
publishes_generated_columns?: publishes_generated_columns,
pg_supports_generated_columns?: pg_supports_generated_columns
}

%Postgrex.Result{num_rows: 0} ->
:not_found
end
end

@spec alter_publication_set_generated_columns(Postgrex.conn(), String.t()) :: :ok
def alter_publication_set_generated_columns(conn, publication_name) do
query =
"ALTER PUBLICATION #{Utils.quote_name(publication_name)} SET (publish_generated_columns = stored)"

Postgrex.query!(conn, query, [])
:ok
end

@spec add_table_to_publication(Postgrex.conn(), String.t(), Electric.oid_relation(), timeout()) ::
:ok | {:error, term()}
def add_table_to_publication(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +259 to +263
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip the generated-column upgrade in manual publishing mode

When ELECTRIC_MANUAL_TABLE_PUBLISHING=true, the rest of this module avoids mutating the publication via can_update_publication?/1, but this new branch bypasses that guard and will still run ALTER PUBLICATION whenever a PG18 publication reports publish_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 👍 / 👎.

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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't auto-upgrade publications explicitly set to none

This upgrade path cannot distinguish a publication that predates PG18 from one that an operator intentionally configured with publish_generated_columns = none: check_publication_status!/2 only gives us a boolean, and every false value reaches this ALTER PUBLICATION. That means a deployment relying on the existing none behavior (for example, to keep generated columns excluded and use the columns= workaround) will be silently flipped to stored on startup or the next shape addition, changing replication semantics for all tables instead of preserving the configured mode.

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,49 @@ defmodule Electric.Postgres.ConfigurationTest do
publishes_all_operations?: true
} = Configuration.check_publication_status!(conn, publication)
end

test "returns pg_supports_generated_columns? based on PG version", %{
pool: conn,
publication_name: publication
} do
pg_version = Support.TestUtils.fetch_pg_version(conn)

status = Configuration.check_publication_status!(conn, publication)

if pg_version >= 180_000 do
assert %{pg_supports_generated_columns?: true} = status
else
assert %{pg_supports_generated_columns?: false} = status
end
end
end

describe "alter_publication_set_generated_columns/2" do
test "sets publish_generated_columns to stored on PG18+", %{
pool: conn,
publication_name: publication
} do
pg_version = Support.TestUtils.fetch_pg_version(conn)

if pg_version >= 180_000 do
# First ensure the publication doesn't publish generated columns
Postgrex.query!(
conn,
"ALTER PUBLICATION \"#{publication}\" SET (publish_generated_columns = 'none')",
[]
)

assert %{publishes_generated_columns?: false} =
Configuration.check_publication_status!(conn, publication)

# Now alter it
assert :ok =
Configuration.alter_publication_set_generated_columns(conn, publication)

assert %{publishes_generated_columns?: true} =
Configuration.check_publication_status!(conn, publication)
end
end
end

describe "concurrent publication updates" do
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,47 @@ defmodule Electric.Replication.PublicationManagerTest do
end
end

describe "publication generated columns upgrade" do
test "upgrades publication to publish generated columns on PG18+", ctx do
pg_version = Support.TestUtils.fetch_pg_version(ctx.pool)

if pg_version >= 180_000 do
# Remove publish_generated_columns from the publication to simulate
# a publication created by an older Electric version
Postgrex.query!(
ctx.pool,
"ALTER PUBLICATION #{ctx.publication_name} SET (publish_generated_columns = 'none')",
[]
)

# Verify it's not publishing generated columns
status =
Electric.Postgres.Configuration.check_publication_status!(
ctx.pool,
ctx.publication_name
)

assert %{publishes_generated_columns?: false, pg_supports_generated_columns?: true} =
status

# Adding a shape should trigger the configurator, which should
# automatically upgrade the publication
shape = generate_shape(ctx.relation_with_oid, @where_clause_1)
assert :ok == PublicationManager.add_shape(ctx.stack_id, @shape_handle_1, shape)
assert_pub_tables(ctx, [ctx.relation])

# Verify the publication now publishes generated columns
status =
Electric.Postgres.Configuration.check_publication_status!(
ctx.pool,
ctx.publication_name
)

assert %{publishes_generated_columns?: true} = status
end
end
end

describe "publication misonfiguration" do
setup do
test_pid = self()
Expand Down
Loading