Skip to content

Remote UPDATE/DELETE silently skipped as update_delete on rows that exist and are lockable (self-compiled 2.1.8) #327

Description

@gzuco

Summary

On a two-node group, some remote UPDATEs are consistently rejected on the receiving node with

CONFLICT: remote UPDATE: could not find existing row. Resolution: skip_change; PKEY:

The row exists, is reachable by primary key, is lockable and is updatable locally. INSERTs always replicate; UPDATE/DELETE are skipped. The result is permanent, silent data divergence: the channel stays healthy (slot active, reserved, ~0 lag), nothing is logged as an error, and only pgactive.pgactive_conflict_history records the loss.

It is reproducible on demand: every UPDATE on the affected row produces a new conflict row.

Update (important): the behaviour is symmetric. It was initially observed only in the
direction RDS → self-compiled node, which suggested a problem with that node. It is not.
Updating the same row from the node towards RDS produces the identical conflict
on RDS (update_deleteskip_change, local_tuple null) — the first conflict ever
recorded on that instance. So the affected row cannot be located by the apply worker on
either side, while being perfectly reachable by SQL on both. This rules out node-local
state (restore, re-join leftovers, build) as the explanation and points at something about
the row itself, or about how its key is transmitted.

Reproduction (production data, two nodes)

-- on the writer node
UPDATE product_feature SET role='addon'
 WHERE product_code='travelsafe_vpn_monthly' AND feature_key='voice.text_chat';
-- UPDATE 1

On the peer, ~20 ms later:

conflict_type       | update_delete
conflict_resolution | skip_change
local_tuple         | (null)
remote_tuple        | {"product_code":"travelsafe_vpn_monthly","feature_key":"voice.text_chat",
                       "display_order":10,"config":null,"role":"addon"}

Meanwhile, on that same peer, the very same row:

SELECT count(*) FROM product_feature
 WHERE product_code='travelsafe_vpn_monthly' AND feature_key='voice.text_chat';         -- 1

BEGIN;
SELECT ctid FROM product_feature
 WHERE product_code='travelsafe_vpn_monthly' AND feature_key='voice.text_chat'
   FOR UPDATE;                                                                          -- (0,35), locked fine
UPDATE product_feature SET role='addon'
 WHERE product_code='travelsafe_vpn_monthly' AND feature_key='voice.text_chat';         -- UPDATE 1
ROLLBACK;

Running the same UPDATE in the opposite direction (on the node, towards RDS) yields the same conflict on RDS, with the same empty local_tuple.

An UPDATE issued in the same transaction as a batch is applied partially: in the original incident 5 rows were updated on the writer and 4 arrived, one was skipped. In a controlled test with 7 freshly inserted rows, 2 replicated and 5 were skipped, with no discernible pattern in the key (upper/lower case, hyphen, leading underscore, leading digit all present on both sides). The UPDATE of a row inserted by replication seconds earlier is also skipped, and its DELETE too (delete_deleteskip_change).

Ruled out by testing

Hypothesis Verification Result
Missing PK / replica identity relreplident='d', PK (product_code, feature_key) present on both ruled out
Column layout mismatch pg_attribute incl. dropped placeholders — identical fingerprints ruled out
Invalid / corrupt PK index EXPLAIN ANALYZE shows real Index Scan; index-only scan returns the row; heap count = index count ruled out
Collation / glibc divergence glibc 2.26 (RDS) vs 2.36 (node), but comparisons and orderings identical on both, incl. 1027 rows with Cyrillic/Korean names ruled out
Key bytes differing hex of both key columns identical; searching one node with the bytes taken from the other returns the row ruled out
Visibility / locking txid_status = committed on both; FOR UPDATE succeeds; local UPDATE = 1 row ruled out
Stale relcache in apply worker worker terminated and restarted → identical behaviour ruled out
Replication set {default,all} for the table on both nodes ruled out
Leftover replication origin orphan origin from an earlier join dropped → no change ruled out
Node restart full Postgres restart of the node → no change ruled out
Node-local state / build same conflict occurs on RDS in the reverse direction ruled out

With tracing enabled

pgactive.debug_trace_replay=on, pgactive.log_conflicts_to_logfile=on, pgactive.log_min_messages=debug2:

TRACE: apply BEGIN  ... xid 447331 ... from node aws-milano
TRACE: apply UPDATE from remote relation public.product_feature ... (action #2)
CONFLICT: remote UPDATE: could not find existing row. Resolution: skip_change; PKEY:
TRACE: apply COMMIT ... (action #3)

A different row of the same table, updated 10 seconds later, applies cleanly with no conflict. (The empty PKEY: is expected — pgactive_conflict_logging.c builds that string from the local tuple, which is null by definition here.)

Reading process_remote_update() in src/pgactive_apply.c, the lookup path is RelationGetReplicaIndex()build_index_scan_key() on the remote tuple → find_pkey_tuple(). Everything that feeds that path checks out from the outside, so the failure appears to be inside the lookup itself.

Environment

Node A Node B
pgactive 2.1.8, built from source 2.1.8, Amazon RDS binary
PostgreSQL 17.10 (now 17.11) 17.10
OS / build Debian bookworm, postgres:17.x-bookworm image, ./configure && make Amazon RDS
Settings skip_ddl_replication=on, log_conflicts_to_table=on same

The self-compiled node was re-joined on 2026-08-09 (pgactive_join_group with bypass_collation_check := true, after several failed attempts), and every conflict on that node dates from then. However, since the same failure now reproduces on RDS for the same row, the re-join no longer looks like the cause. A second table, identity.breach_catalog, produces ~970 update_delete → skip_change conflicts every night when a batch job updates it.

Possibly related

We reported #324 (backend crash on DDL statements over ~1100 bytes) on the same self-compiled node. That one is clearly build-specific; this one, given the symmetry, appears not to be.

Impact

Silent, permanent divergence between nodes on an otherwise healthy channel. In our case a single skipped row broke product purchases for roughly half of the users for 24 hours, discovered only because a tester reported it. There is no error and no lag: the only trace is a row in pgactive_conflict_history, which nothing surfaces by default.

Notes

Happy to run further tests — it reproduces reliably on demand, in both directions — or to provide the full trace output.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions