Hi hackers,
While reviewing the issue reported at [1] and the proposed solutions
at [2], I noticed a related problem: false negative conflict detection
when a 'ReplOriginId' gets reused.
In logical replication, conflict detection relies on the tuple’s
replication origin ('roident'). The problem is that if a subscription
is dropped and a new subscription later reuses the same origin ID, the
apply worker may incorrectly treat incoming changes as “its own”
changes and skip conflict detection.
A simple example:
1. Create subscription sub1 with 'roident = 1'
2. Replicate some rows into table 't1'
3. Drop 'sub1'
4. Create another subscription 'sub2'
5. `sub2` reuses 'roident = 1'
6. New updates arrive for rows previously written by 'sub1'
At this point, conflict detection sees:
tuple_origin == current_origin
and incorrectly assumes the row was written by the current
subscription instance, so no 'update_origin_differ' conflict is
raised.
This may look harmless in this simple setup, but it becomes
problematic if the new subscription is connected to a different
publisher, because real conflicts can then be silently missed.
I explored two possible approaches to solve this:
Approach 1. Zero out old origin IDs in commit_ts data when dropping a
subscription
----------------------
- When a subscription is dropped and its replication origin becomes
free, scan all 'commit_ts' SLRU entries and replace that old origin ID
with 'InvalidRepOriginId (0)'.
- So rows previously written by the old subscription would no longer
appear to belong to any active replication origin.
- A new subscription reusing the same 'roident' will always conflict
with origin '0'.
Pros:
- Fixes the stale-origin problem completely and may also help solve
the tablesync-origin issue discussed in [1]
- No additional checks needed during conflict detection
Cons:
- Requires scanning the entire 'commit_ts' SLRU during DROP
SUBSCRIPTION, so it can become very expensive on large systems
- Not crash-safe currently(patch):
- if the server crashes midway, some entries may still contain the
old origin ID
- after restart, reused origins can again lead to missed conflicts
- Making this fully crash-safe would likely require WAL logging or
recovery-time reprocessing.
Approach 2. Store replication origin creation time
----------------------
- Add a creation timestamp for each replication origin
- During conflict check:
if tuple_origin != current_origin
-> existing behavior
if tuple_origin == current_origin
-> compare tuple commit timestamp with origin creation time
if tuple_commit_ts <= origin_creation_time
-> treat as an origin reuse case and raise conflict
Pros:
-------
- No additional processing during DROP SUBSCRIPTION
- Lightweight runtime check (just one timestamp comparison)
- Naturally crash-safe since origin creation is WAL-logged already
Cons:
- Requires a catalog schema change
- The <= comparison can produce false-positive conflicts for rows
committed at the exact same microsecond as origin creation
- May require additional handling for upgraded origins
IMO, the second approach currently looks more practical because it
avoids the heavy SLRU scan and crash-recovery complexity.
Attached:
- Patch for approach 1
- Patch for approach 2
- A TAP test reproducing the issue
Note: The patches are manually tested for the reported issue, but not
yet tested for performance or additional edge cases.
Feedback and suggestions are welcome.
[1]
https://www.postgresql.org/message-id/CALDaNm3Y6Y4Mub6QC8fZKnNy5jZspELQYCoQF_FL2Zwzweu%3Dog%40mail.gmail.com
[2]
https://www.postgresql.org/message-id/CAA4eK1LxGXR7jOAKh0B8N362S-Q3b6GhBxxcV_HxUaicEPq5Cg%40mail.gmail.com
--
Thanks,
Nisha
From 5abf86e31601e19036dd0ba6da129b8a2563ce19 Mon Sep 17 00:00:00 2001
From: Nisha Moond <[email protected]>
Date: Wed, 13 May 2026 22:24:56 +0530
Subject: [PATCH v1 1/2] Approach 2 Detect origin-id reuse in origin differs
conflict checksp
Add a 'rocreated' timestamptz column to pg_replication_origin, set at
repliorigin creation.
In the apply worker's UPDATE/DELETE conflict checks, when the local
row's origin roident matches the current subscription's roident (which
previously meant 'no conflict'), additionally verify that the local
row's commit_ts > ro_created. If commit_ts <= ro_created, the roident
was reused from a prior DROP/CREATE SUBSCRIPTION and the row was
written by a different subscription; report the conflict.
---
doc/src/sgml/catalogs.sgml | 9 ++++
src/backend/replication/logical/origin.c | 58 ++++++++++++++++++++-
src/backend/replication/logical/worker.c | 50 ++++++++++++++++--
src/include/catalog/pg_replication_origin.h | 1 +
src/include/replication/origin.h | 1 +
5 files changed, 113 insertions(+), 6 deletions(-)
diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 4b474c13917..f4819594bdc 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -7360,6 +7360,15 @@ SCRAM-SHA-256$<replaceable><iteration count></replaceable>:<replaceable>&l
origin.
</para></entry>
</row>
+
+ <row>
+ <entry role="catalog_table_entry"><para role="column_definition">
+ <structfield>rocreated</structfield> <type>timestamptz</type>
+ </para>
+ <para>
+ Time at which this replication origin was created.
+ </para></entry>
+ </row>
</tbody>
</tgroup>
</table>
diff --git a/src/backend/replication/logical/origin.c b/src/backend/replication/logical/origin.c
index c9dfb094c2b..c90382af6a1 100644
--- a/src/backend/replication/logical/origin.c
+++ b/src/backend/replication/logical/origin.c
@@ -144,6 +144,14 @@ typedef struct ReplicationState
* Lock protecting remote_lsn and local_lsn.
*/
LWLock lock;
+
+ /*
+ * Wall-clock time when this origin was created (from
+ * pg_replication_origin.rocreated). Used to detect roident reuse: if a
+ * local row's commit_ts <= ro_created, the row was written by a prior
+ * subscription that held this ID.
+ */
+ TimestampTz ro_created;
} ReplicationState;
/*
@@ -359,6 +367,8 @@ replorigin_create(const char *roname)
values[Anum_pg_replication_origin_roident - 1] = ObjectIdGetDatum(roident);
values[Anum_pg_replication_origin_roname - 1] = roname_d;
+ values[Anum_pg_replication_origin_rocreated - 1] =
+ TimestampTzGetDatum(GetCurrentTimestamp());
tuple = heap_form_tuple(RelationGetDescr(rel), values, nulls);
CatalogTupleInsert(rel, tuple);
@@ -839,6 +849,7 @@ StartupReplicationOrigin(void)
/* copy data to shared memory */
replication_states[last_state].roident = disk_state.roident;
+ replication_states[last_state].ro_created = DT_NOBEGIN;
replication_states[last_state].remote_lsn = disk_state.remote_lsn;
last_state++;
@@ -1134,6 +1145,24 @@ ReplicationOriginExitCleanup(int code, Datum arg)
replorigin_session_reset_internal();
}
+/*
+ * Return the creation timestamp of the current session's replication origin,
+ * or DT_NOBEGIN if no session origin is active or the value has not been
+ * loaded yet.
+ *
+ * Used by the apply worker to detect roident reuse during conflict checking:
+ * if a local row's commit_ts <= the returned value, the row was written by a
+ * prior subscription that happened to hold the same origin ID.
+ */
+TimestampTz
+replorigin_get_creation_time(void)
+{
+ if (session_replication_state == NULL)
+ return DT_NOBEGIN;
+
+ return session_replication_state->ro_created;
+}
+
/*
* Setup a replication origin in the shared memory struct if it doesn't
* already exist and cache access to the specific ReplicationSlot so the
@@ -1263,9 +1292,9 @@ replorigin_session_setup(ReplOriginId node, int acquired_by)
Assert(!XLogRecPtrIsValid(session_replication_state->remote_lsn));
Assert(!XLogRecPtrIsValid(session_replication_state->local_lsn));
session_replication_state->roident = node;
+ session_replication_state->ro_created = DT_NOBEGIN;
}
-
Assert(session_replication_state->roident != InvalidReplOriginId);
if (acquired_by == 0)
@@ -1289,6 +1318,33 @@ replorigin_session_setup(ReplOriginId node, int acquired_by)
/* probably this one is pointless */
ConditionVariableBroadcast(&session_replication_state->origin_cv);
+
+ /*
+ * Load the origin's creation time from the catalog if not already cached.
+ * The guard fires because ro_created is explicitly set to DT_NOBEGIN in
+ * the free-slot branch above and in StartupReplicationOrigin(). It also
+ * fires for origins that pre-date this feature: those have NULL in the
+ * catalog, so isnull will be true and ro_created stays DT_NOBEGIN,
+ * meaning the roident-reuse check is safely skipped for them.
+ */
+ if (session_replication_state->ro_created == DT_NOBEGIN)
+ {
+ HeapTuple tuple;
+
+ tuple = SearchSysCache1(REPLORIGIDENT, ObjectIdGetDatum(node));
+ if (HeapTupleIsValid(tuple))
+ {
+ bool isnull;
+ Datum d;
+
+ d = SysCacheGetAttr(REPLORIGIDENT, tuple,
+ Anum_pg_replication_origin_rocreated,
+ &isnull);
+ if (!isnull)
+ session_replication_state->ro_created = DatumGetTimestampTz(d);
+ ReleaseSysCache(tuple);
+ }
+ }
}
/*
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index dd6fc38a41e..e0b6c021dfa 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -2905,6 +2905,41 @@ apply_handle_update(StringInfo s)
end_replication_step();
}
+/*
+ * Check whether the tuple's origin roident was reused by a new subscription.
+ * Returns true if a conflict should be reported despite matching roidents.
+ *
+ * When origins match (localorigin == current origin), the same numeric roident
+ * may have been recycled after a DROP/CREATE SUBSCRIPTION cycle. If the local
+ * row's commit_ts is at or before the current origin's creation time, the row
+ * was written by an earlier subscription, not the current one.
+ *
+ * Uses <= rather than < so that a row committed at the exact same microsecond
+ * as the origin was created is treated as belonging to the prior subscription;
+ * a false positive (spurious conflict) is safer than a false negative here.
+ */
+static inline bool
+IsRoidentReused(ReplOriginId localorigin, TimestampTz localts)
+{
+ TimestampTz origin_created;
+
+ if (localorigin == InvalidReplOriginId)
+ return false;
+
+ origin_created = replorigin_get_creation_time();
+
+ /*
+ * DT_NOBEGIN means no session origin is active, ro_created has not been
+ * loaded yet, or the origin pre-dates this feature (NULL in catalog) --
+ * in all cases skip the reuse check. localts == 0 when
+ * track_commit_timestamp is off; no timestamp to compare.
+ */
+ if (origin_created == DT_NOBEGIN || localts == 0)
+ return false;
+
+ return (localts <= origin_created);
+}
+
/*
* Workhorse for apply_handle_update()
* relinfo is for the relation we're actually updating in
@@ -2947,7 +2982,8 @@ apply_handle_update_internal(ApplyExecutionData *edata,
*/
if (GetTupleTransactionInfo(localslot, &conflicttuple.xmin,
&conflicttuple.origin, &conflicttuple.ts) &&
- conflicttuple.origin != replorigin_xact_state.origin)
+ (conflicttuple.origin != replorigin_xact_state.origin ||
+ IsRoidentReused(conflicttuple.origin, conflicttuple.ts)))
{
TupleTableSlot *newslot;
@@ -2989,7 +3025,8 @@ apply_handle_update_internal(ApplyExecutionData *edata,
&conflicttuple.xmin,
&conflicttuple.origin,
&conflicttuple.ts) &&
- conflicttuple.origin != replorigin_xact_state.origin)
+ (conflicttuple.origin != replorigin_xact_state.origin ||
+ IsRoidentReused(conflicttuple.origin, conflicttuple.ts)))
type = CT_UPDATE_DELETED;
else
type = CT_UPDATE_MISSING;
@@ -3142,7 +3179,8 @@ apply_handle_delete_internal(ApplyExecutionData *edata,
*/
if (GetTupleTransactionInfo(localslot, &conflicttuple.xmin,
&conflicttuple.origin, &conflicttuple.ts) &&
- conflicttuple.origin != replorigin_xact_state.origin)
+ (conflicttuple.origin != replorigin_xact_state.origin ||
+ IsRoidentReused(conflicttuple.origin, conflicttuple.ts)))
{
conflicttuple.slot = localslot;
ReportApplyConflict(estate, relinfo, LOG, CT_DELETE_ORIGIN_DIFFERS,
@@ -3484,7 +3522,8 @@ apply_handle_tuple_routing(ApplyExecutionData *edata,
&conflicttuple.xmin,
&conflicttuple.origin,
&conflicttuple.ts) &&
- conflicttuple.origin != replorigin_xact_state.origin)
+ (conflicttuple.origin != replorigin_xact_state.origin ||
+ IsRoidentReused(conflicttuple.origin, conflicttuple.ts)))
type = CT_UPDATE_DELETED;
else
type = CT_UPDATE_MISSING;
@@ -3510,7 +3549,8 @@ apply_handle_tuple_routing(ApplyExecutionData *edata,
if (GetTupleTransactionInfo(localslot, &conflicttuple.xmin,
&conflicttuple.origin,
&conflicttuple.ts) &&
- conflicttuple.origin != replorigin_xact_state.origin)
+ (conflicttuple.origin != replorigin_xact_state.origin ||
+ IsRoidentReused(conflicttuple.origin, conflicttuple.ts)))
{
TupleTableSlot *newslot;
diff --git a/src/include/catalog/pg_replication_origin.h b/src/include/catalog/pg_replication_origin.h
index 565d71ad0b3..d6079ca4844 100644
--- a/src/include/catalog/pg_replication_origin.h
+++ b/src/include/catalog/pg_replication_origin.h
@@ -51,6 +51,7 @@ CATALOG(pg_replication_origin,6000,ReplicationOriginRelationId) BKI_SHARED_RELAT
text roname BKI_FORCE_NOT_NULL;
#ifdef CATALOG_VARLEN /* further variable-length fields */
+ timestamptz rocreated BKI_FORCE_NOT_NULL;
#endif
} FormData_pg_replication_origin;
diff --git a/src/include/replication/origin.h b/src/include/replication/origin.h
index a69faf6eaaf..22bed238140 100644
--- a/src/include/replication/origin.h
+++ b/src/include/replication/origin.h
@@ -71,6 +71,7 @@ extern void replorigin_session_advance(XLogRecPtr remote_commit,
extern void replorigin_session_setup(ReplOriginId node, int acquired_by);
extern void replorigin_session_reset(void);
extern XLogRecPtr replorigin_session_get_progress(bool flush);
+extern TimestampTz replorigin_get_creation_time(void);
/* Per-transaction replication origin state manipulation */
extern void replorigin_xact_clear(bool clear_origin);
--
2.50.1 (Apple Git-155)
From ed125b8869186db8951842ea4f97467056684762 Mon Sep 17 00:00:00 2001
From: Nisha Moond <[email protected]>
Date: Fri, 8 May 2026 11:57:55 +0530
Subject: [PATCH v1 2/2] Add TAP test to detect conflict when origin-id is
reused
---
.../t/039_origin_reuse_conflict.pl | 68 +++++++++++++++++++
1 file changed, 68 insertions(+)
create mode 100644 src/test/subscription/t/039_origin_reuse_conflict.pl
diff --git a/src/test/subscription/t/039_origin_reuse_conflict.pl b/src/test/subscription/t/039_origin_reuse_conflict.pl
new file mode 100644
index 00000000000..efc58790f46
--- /dev/null
+++ b/src/test/subscription/t/039_origin_reuse_conflict.pl
@@ -0,0 +1,68 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test that roident reuse is detected as an update_origin_differs conflict.
+#
+# sub1 and sub2 both use copy_data=false (no tablesync, one origin each).
+# Dropping sub1 and creating sub2 reuses the same roident. A row written by
+# sub1's apply worker has the same origin number as sub2, so only
+# IsRoidentReused() can distinguish them.
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node_pub = PostgreSQL::Test::Cluster->new('publisher');
+$node_pub->init(allows_streaming => 'logical');
+$node_pub->start;
+
+my $node_sub = PostgreSQL::Test::Cluster->new('subscriber');
+$node_sub->init;
+$node_sub->append_conf('postgresql.conf', "track_commit_timestamp = on");
+$node_sub->start;
+
+$node_pub->safe_psql('postgres', "CREATE TABLE t (a int PRIMARY KEY, b text)");
+$node_sub->safe_psql('postgres', "CREATE TABLE t (a int PRIMARY KEY, b text)");
+
+my $pubconn = $node_pub->connstr . ' dbname=postgres';
+$node_pub->safe_psql('postgres', "CREATE PUBLICATION pub FOR TABLE t");
+
+$node_sub->safe_psql('postgres',
+ "CREATE SUBSCRIPTION sub1
+ CONNECTION '$pubconn'
+ PUBLICATION pub
+ WITH (copy_data = false)");
+
+# INSERT flows through sub1's apply worker, stamping the row with roident 1.
+$node_pub->safe_psql('postgres', "INSERT INTO t VALUES (1, 'a')");
+$node_pub->wait_for_catchup('sub1');
+
+# Drop sub1 (frees roident 1), sleep so sub2's ro_created > row's commit_ts.
+$node_sub->safe_psql('postgres', "DROP SUBSCRIPTION sub1");
+$node_sub->safe_psql('postgres', "SELECT pg_sleep(0.05)");
+
+$node_sub->safe_psql('postgres',
+ "CREATE SUBSCRIPTION sub2
+ CONNECTION '$pubconn'
+ PUBLICATION pub
+ WITH (copy_data = false)");
+
+my $log_start = -s $node_sub->logfile;
+
+# The row has origin=roident1 from sub1 with commit_ts=T1. sub2 also has
+# roident1 but ro_created=T2 > T1, so IsRoidentReused() fires.
+$node_pub->safe_psql('postgres', "UPDATE t SET b = 'b' WHERE a = 1");
+$node_pub->wait_for_catchup('sub2');
+
+my $logfile = slurp_file($node_sub->logfile, $log_start);
+like(
+ $logfile,
+ qr/conflict detected on relation "public\.t": conflict=update_origin_differs/,
+ 'update_origin_differs conflict reported for reused roident');
+
+my $val = $node_sub->safe_psql('postgres', "SELECT b FROM t WHERE a = 1");
+is($val, 'b', 'row updated to latest value after conflict');
+
+$node_sub->safe_psql('postgres', "DROP SUBSCRIPTION sub2");
+
+done_testing();
--
2.50.1 (Apple Git-155)
From 9ad2b5f8a4bbcd133c013229c93039176516bb8b Mon Sep 17 00:00:00 2001
From: Nisha Moond <[email protected]>
Date: Fri, 8 May 2026 14:55:12 +0530
Subject: [PATCH v1] Approach 1 Zero commit_ts origin on replication origin
drop
When a replication origin is dropped, scan the commit_ts data and set
nodeid = InvalidReplOriginId on every entry that records the freed
roident. This ensures that after DROP/CREATE SUBSCRIPTION reuse the
roident, the apply worker sees nodeid=0 != current_origin for rows
written by the old subscription and reports an origin_differs
conflict rather than silently skipping it.
---
src/backend/access/transam/commit_ts.c | 62 ++++++++++++++++++++++++
src/backend/replication/logical/origin.c | 8 +++
src/include/access/commit_ts.h | 1 +
src/test/subscription/t/029_on_error.pl | 2 +-
4 files changed, 72 insertions(+), 1 deletion(-)
diff --git a/src/backend/access/transam/commit_ts.c b/src/backend/access/transam/commit_ts.c
index 9e6fd5d4657..6851f21e810 100644
--- a/src/backend/access/transam/commit_ts.c
+++ b/src/backend/access/transam/commit_ts.c
@@ -271,6 +271,68 @@ TransactionIdSetCommitTs(TransactionId xid, TimestampTz ts,
&entry, SizeOfCommitTimestampEntry);
}
+/*
+ * Zero the nodeid field on every commit_ts SLRU entry that records the given
+ * origin. Called when a replication origin is dropped so that rows stamped
+ * with the freed roident appear to have no origin; a future apply worker
+ * that reuses the same roident will then see nodeid=0 != current_origin and
+ * report a conflict via the ordinary origin-differs check.
+ *
+ * Does nothing when track_commit_timestamp is off.
+ */
+void
+InvalidateCommitTsOrigin(ReplOriginId origin)
+{
+ TransactionId oldest;
+ TransactionId newest;
+ int64 firstpage;
+ int64 lastpage;
+
+ if (!track_commit_timestamp)
+ return;
+
+ oldest = TransamVariables->oldestCommitTsXid;
+ newest = TransamVariables->newestCommitTsXid;
+
+ if (!TransactionIdIsValid(oldest))
+ return;
+
+ firstpage = TransactionIdToCTsPage(oldest);
+ lastpage = TransactionIdToCTsPage(newest);
+
+ for (int64 pageno = firstpage; pageno <= lastpage; pageno++)
+ {
+ LWLock *lock = SimpleLruGetBankLock(CommitTsCtl, pageno);
+ int slotno;
+ bool modified = false;
+ TransactionId xid = (TransactionId)
+ (pageno * COMMIT_TS_XACTS_PER_PAGE);
+
+ LWLockAcquire(lock, LW_EXCLUSIVE);
+ slotno = SimpleLruReadPage(CommitTsCtl, pageno, true, &xid);
+
+ for (int i = 0; i < COMMIT_TS_XACTS_PER_PAGE; i++)
+ {
+ CommitTimestampEntry entry;
+ char *entryptr = CommitTsCtl->shared->page_buffer[slotno]
+ + SizeOfCommitTimestampEntry * i;
+
+ memcpy(&entry, entryptr, SizeOfCommitTimestampEntry);
+ if (entry.nodeid == origin)
+ {
+ entry.nodeid = InvalidReplOriginId;
+ memcpy(entryptr, &entry, SizeOfCommitTimestampEntry);
+ modified = true;
+ }
+ }
+
+ if (modified)
+ CommitTsCtl->shared->page_dirty[slotno] = true;
+
+ LWLockRelease(lock);
+ }
+}
+
/*
* Interrogate the commit timestamp of a transaction.
*
diff --git a/src/backend/replication/logical/origin.c b/src/backend/replication/logical/origin.c
index c9dfb094c2b..30d38cd30d3 100644
--- a/src/backend/replication/logical/origin.c
+++ b/src/backend/replication/logical/origin.c
@@ -71,6 +71,7 @@
#include <sys/stat.h>
#include "access/genam.h"
+#include "access/commit_ts.h"
#include "access/htup_details.h"
#include "access/table.h"
#include "access/xact.h"
@@ -490,6 +491,13 @@ replorigin_drop_by_name(const char *name, bool missing_ok, bool nowait)
replorigin_state_clear(roident, nowait);
+ /*
+ * Zero any commit_ts entries stamped with this origin so that a future
+ * subscription reusing the same roident sees nodeid=0 != current_origin
+ * and reports a conflict via the ordinary origin-differs check.
+ */
+ InvalidateCommitTsOrigin(roident);
+
/*
* Now, we can delete the catalog entry.
*/
diff --git a/src/include/access/commit_ts.h b/src/include/access/commit_ts.h
index 825ccda90ed..400de80fac0 100644
--- a/src/include/access/commit_ts.h
+++ b/src/include/access/commit_ts.h
@@ -37,6 +37,7 @@ extern void TruncateCommitTs(TransactionId oldestXact);
extern void SetCommitTsLimit(TransactionId oldestXact,
TransactionId newestXact);
extern void AdvanceOldestCommitTsXid(TransactionId oldestXact);
+extern void InvalidateCommitTsOrigin(ReplOriginId origin);
extern int committssyncfiletag(const FileTag *ftag, char *path);
diff --git a/src/test/subscription/t/029_on_error.pl b/src/test/subscription/t/029_on_error.pl
index 7d68759b6cd..c7c5c16d9f3 100644
--- a/src/test/subscription/t/029_on_error.pl
+++ b/src/test/subscription/t/029_on_error.pl
@@ -30,7 +30,7 @@ sub test_skip_lsn
# ERROR with its CONTEXT when retrieving this information.
my $contents = slurp_file($node_subscriber->logfile, $offset);
$contents =~
- qr/conflict detected on relation "public.tbl".*\n.*DETAIL:.* Could not apply remote change.*\n.*Key already exists in unique index "tbl_pkey", modified by .*origin.* in transaction \d+ at .*: key .*, local row .*\n.*CONTEXT:.* for replication target relation "public.tbl" in transaction \d+, finished at ([[:xdigit:]]+\/[[:xdigit:]]+)/m
+ qr/conflict detected on relation "public.tbl".*\n.*DETAIL:.* Could not apply remote change.*\n.*Key already exists in unique index "tbl_pkey", modified (?:locally|by .*) in transaction \d+ at .*\n.*CONTEXT:.* for replication target relation "public.tbl" in transaction \d+, finished at ([[:xdigit:]]+\/[[:xdigit:]]+)/m
or die "could not get error-LSN";
my $lsn = $1;
--
2.50.1 (Apple Git-155)