Skip to content

Wait for in-flight writers before adding tables to the publication - #4787

Merged
alco merged 1 commit into
mainfrom
fix/publication-add-table-writer-barrier
Sep 1, 2026
Merged

Wait for in-flight writers before adding tables to the publication#4787
alco merged 1 commit into
mainfrom
fix/publication-add-table-writer-barrier

Conversation

@alco

@alco alco commented Aug 27, 2026

Copy link
Copy Markdown
Member

Fixes #4773

Problem

Logical decoding evaluates publication membership per change, against the catalog state at that point in the WAL. A transaction that wrote to a table before ALTER PUBLICATION … ADD TABLE committed therefore never has those writes emitted by the replication stream, even if it commits afterwards. The only way for such writes to reach a shape is the initial snapshot, and the snapshot only includes them if the transaction has committed by the time the snapshot is taken.

ALTER PUBLICATION … ADD TABLE takes only a SHARE UPDATE EXCLUSIVE lock, which doesn't conflict with writers, so the addition can commit — and the shape waiting on it can start snapshotting — while such a writer is still open. Its writes then end up in neither the snapshot (excluded via xip_list) nor the log, and the shape is silently missing them for its whole lifetime.

INSERT while unpublished  → not decoded (predates the addition)
                            └── transaction stays open
ADD TABLE commits
snapshot taken            → transaction excluded via xip_list
transaction commits       → rows permanently absent from the shape

The first-time-add path (configure_table_for_replication/4) has always been protected by accident: its ALTER TABLE … REPLICA IDENTITY FULL takes ACCESS EXCLUSIVE in the same transaction, which can't be granted until every in-flight writer has finished. The unprotected path is the add-only one, taken when the table is already REPLICA IDENTITY FULL — i.e. every re-add after the last shape on a table was removed (TRUNCATE invalidation as in the report, shape expiry, schema-change invalidation, …) and first adds of tables the user pre-set to FULL.

Fix

add_table_to_publication/4 now takes LOCK TABLE … IN SHARE MODE in the same transaction as the ADD TABLE, but only when this transaction is the one actually adding the table. SHARE conflicts with the ROW EXCLUSIVE lock every writer holds until its transaction ends, so the addition doesn't commit until all in-flight writers have finished; writers that arrive meanwhile queue up behind it and write after the addition, which is decoded normally. This makes the add-only path behave the way the first-time-add path already does, with a weaker lock.

  • The lock is skipped when ADD TABLE reports the table as already present: there is no gap to close, and — since the savepoint rollback releases the SHARE UPDATE EXCLUSIVE lock — two concurrent configurators could otherwise both hold SHARE and deadlock upgrading to ACCESS EXCLUSIVE for the replica identity change (caught by the existing concurrency test).
  • The wait is bounded by the existing action timeout. On expiry the whole transaction, addition included, is rolled back and the failure is reported as a DbConfigurationError of type table_lock_timeout (a known, retryable error → 503 with Retry-After for clients, no shape removal), instead of leaking the raw query_canceled as an "unexpected error" 500. A server-side lock_timeout (lock_not_available) is mapped the same way.
  • configure_table_for_replication/4 rolls back explicitly when a nested step fails, so the reason propagates instead of the bare {:error, :rollback} → "Transaction unexpectedly rolled back" that a failed nested transaction would otherwise produce.

Trade-off

Shape creation on a table now waits for in-flight writers on that table (bounded by the action timeout, 5 s by default). While the SHARE request is pending, new writers on the table queue behind it — the same behaviour the first-time-add path has always had. A pending SHARE request also queues behind VACUUM / CREATE INDEX CONCURRENTLY on the table; a non-blocking variant of the barrier that avoids that is tracked separately.

Independently of Electric, the same "write while unpublished, commit after" transaction triggers a walsender relation-cache bug in PostgreSQL that drops later changes to the table (fixed upstream in PG 18, back-patched in the August 2025 minor releases); the deployment guidance should recommend a PG minor that includes it.

…dition

Logical decoding evaluates publication membership per change, against
the catalog state at that point in the WAL. A transaction that wrote to
a table before `ALTER PUBLICATION ... ADD TABLE` committed never has
those writes emitted by the replication stream, even if it commits
after. They can only reach a shape via the initial snapshot, which only
includes them if the writer has committed by the time the snapshot is
taken.

`ALTER PUBLICATION ... ADD TABLE` takes only a SHARE UPDATE EXCLUSIVE
lock, which doesn't conflict with writers, so the addition could commit
and the waiting shape start snapshotting while such a writer was still
open. Its writes then ended up in neither the snapshot (excluded via
xip_list) nor the log, and the shape was silently missing them for its
whole lifetime.

The first-time-add path was protected by accident: its `ALTER TABLE ...
REPLICA IDENTITY FULL` takes ACCESS EXCLUSIVE in the same transaction,
which can't be granted until every in-flight writer has finished. The
add-only path, taken whenever the table is already REPLICA IDENTITY
FULL - i.e. every re-add after the last shape on a table was removed,
such as after a TRUNCATE invalidated it - had no such barrier.

Take `LOCK TABLE ... IN SHARE MODE` in the same transaction as the
addition, only when this transaction is the one actually adding the
table. SHARE conflicts with the ROW EXCLUSIVE lock every writer holds
until its transaction ends, so the addition doesn't commit until all
in-flight writers have finished, while writers arriving meanwhile queue
behind it and write after the addition, which is decoded normally.

The lock is skipped when the table was already present: there is no gap
to close, and since the savepoint rollback releases the SHARE UPDATE
EXCLUSIVE lock, two concurrent configurators could otherwise both hold
SHARE and deadlock upgrading to ACCESS EXCLUSIVE for the replica
identity change.

A lock wait timeout rolls the whole transaction back and is reported as
a `DbConfigurationError` of type `table_lock_timeout`, a known
retryable error, instead of leaking the raw `query_canceled` as an
unexpected error. `configure_table_for_replication/4` rolls back
explicitly on a nested failure so that reason propagates instead of a
bare "Transaction unexpectedly rolled back".

Fixes #4773

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzRyeLXJcRzHi5hxMQsgaw
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.05%. Comparing base (d9d2666) to head (ede5b29).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #4787       +/-   ##
===========================================
- Coverage   73.71%   60.05%   -13.67%     
===========================================
  Files          88      397      +309     
  Lines       10094    43772    +33678     
  Branches     3104    12592     +9488     
===========================================
+ Hits         7441    26286    +18845     
- Misses       2595    17405    +14810     
- Partials       58       81       +23     
Flag Coverage Δ
packages/agents 72.77% <ø> (?)
packages/agents-mcp 77.70% <ø> (?)
packages/agents-mobile 80.67% <ø> (?)
packages/agents-runtime 83.78% <ø> (?)
packages/agents-server 75.47% <ø> (-0.18%) ⬇️
packages/agents-server-ui 8.32% <ø> (?)
packages/electric-ax 51.06% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/start 82.83% <ø> (?)
packages/typescript-client 91.95% <ø> (+0.11%) ⬆️
packages/y-electric 56.05% <ø> (ø)
typescript 60.05% <ø> (-13.67%) ⬇️
unit-tests 60.05% <ø> (-13.67%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@alco
alco merged commit 5ce464c into main Sep 1, 2026
71 of 75 checks passed
@alco
alco deleted the fix/publication-add-table-writer-barrier branch September 1, 2026 09:50
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

This PR has been released! 🚀

The following packages include changes from this PR:

  • @core/sync-service@1.8.0

Thanks for contributing to Electric!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TRUNCATE unpublishes the table; writes made before the next shape re-publishes it are silently lost forever

1 participant