Skip to content

Proposal: Conflict log history table for Logical Replication - #407

Open
pg-hub-mirror[bot] wants to merge 2 commits into
masterfrom
pg-hub/mirror-patch-51e282b662d16149
Open

pg-hub-mirror[bot] wants to merge 2 commits into
masterfrom
pg-hub/mirror-patch-51e282b662d16149

Conversation

@pg-hub-mirror

@pg-hub-mirror pg-hub-mirror Bot commented Sep 17, 2026

Copy link
Copy Markdown

Read-only mirror. Reply and review on pgsql-hackers; activity here is not sent upstream.

  • Original author: Dilip Kumar <dilipbalaut(at)gmail(dot)com>
  • Mailing list: pgsql-hackers
  • Message-ID: CAFiTN-uZfzOC9Fo6ga4RhR0RohEVWWmegHEP3n=F0uKiJTzWqA@mail.gmail.com
  • Original email

Patch files:


On Wed, Sep 16, 2026 at 8:56 AM Masahiko Sawada <sawada(dot)mshk(at)gmail(dot)com> wrote:
Thanks Sawada-san for you detailed analysis.

On Wed, Sep 9, 2026 at 8:41 AM Dilip Kumar <dilipbalaut(at)gmail(dot)com> wrote:

On Tue, Sep 1, 2026 at 4:03 PM shveta malik <shveta(dot)malik(at)gmail(dot)com> wrote:

Here is detailed analysis and summary of the problem and all the
alternatives we tried. The problem is that converting a
TupleTableSlot to JSON can result in rendered JSON text exceeding
PostgreSQL's MaxAllocSize limit (1 GB). When that happens, memory
allocation (palloc() or enlargeStringInfo()) throws a hard ERROR.

This issue is not unique to conflict logging:

  1. pgoutput does not handle this: if a row's formatted textual
    representation exceeds 1 GB in logicalrep_write_tuple(),
    OidOutputFunctionCall() or palloc() fails with an unrecoverable hard
    error.
  2. For conflict logging, converting to JSON exacerbates the problem
    because a) every byte below 0x20 expands into a 6-byte \uXXXX
    sequence. b) JSONB values (e.g., repeated numeric expansions like
    1e131071), arrays, and composites can be compact on disk but expand to
    hundreds of megabytes or gigabytes when serialized to text. c)
    user-Defined Types (UDTs): A compact binary UDT (e.g., a run-length
    encoded vector) can expand into gigabytes inside its typoutput
    function.
  3. Other conflict logging extensions (e.g., pgactive and pgEdge) also
    have this issue, and none of them handle it.

Because storing multi-gigabyte or hundreds-of-megabytes values in a
conflict log table is neither practical nor desirable for post-mortem
debugging, we have been exploring several options to gracefully handle
oversized attributes without erroring out. Below is a summary of the
options explored, along with their pros and cons.

Thank you for summarizing the potential solutions. It made the problem
much clearer. IIUC it can technically happen that the constructed
JSONB data exceed 1GB limitation, but it's a relatively rare case in
practice.

Option 1: Attribute-Level Size Capping via ErrorSaveContext (escontext)
Enforce a reasonable threshold (e.g., 16 KB) per attribute during JSON
conversion. Pass an ErrorSaveContext to an extended serialization
function (datum_to_json_extended()). As serialization recurses through
arrays, composites, or JSONB containers, cumulative size is monitored.
If an attribute exceeds the limit, errsave() records the soft error
and returns (Datum) 0. The caller detects
SOFT_ERROR_OCCURRED(&escontext) and cleanly replaces that attribute in
the outer JSON tuple with an omission object: {"omitted": true,
"length": ...}.
Pros:

  • Retains the full tuple structure and all other normal-sized
    columns for analysis.
  • Cleanly handles unbounded recursion in nested structures (arrays,
    composites, and JSONB) by aborting early.
    Cons:
  • Does not protect against typoutput failures: standard PostgreSQL
    output functions invoked via OidOutputFunctionCall() do not accept an
    ErrorSaveContext parameter.
  • If a UDT (or built-in bytea with a large payload in hex format)
    exceeds 1 GB inside its typoutput function, palloc() throws a hard
    ERROR before size checks run; escontext cannot intercept it.

This is the most complete answer if we do want full-tuple logging. But
there is a large amount of new code in the JSON code required for v1,
and as you note it still doesn't close the typoutput hole. I don't
think the complexity is justified by the extra diagnostic value.
I agree with your analysis.

Option 2: Record Only Replica Identity (RI) Columns Instead of Full Tuples
Instead of serializing entire remote and local tuples to JSON, only
serialize the Replica Identity key columns (typically Primary Key or
Unique Index attributes) identifying the conflicting row.
Pros:

  • Drastically reduces conflict log storage overhead and serialization cost.
  • For standard integer, UUID, or short text keys, the payload is
    tiny and never approaches memory limits.
    Cons:
  • Does not fully eliminate the problem: Replica Identity can be
    defined on a UDT or large composite key. If that key attribute expands
    to > 1 GB in typoutput, conflict logging will still error out. In
    short, this leaves us with the same problem as Option 1.

I think recording only the RI columns would work for conflict
detection/resolution purposes. Showing the RI columns of a RI FULL
table would still have the problem, so we might want to either show
nothing or ask users to disable conflict history logging in that case.
But recording only the RI columns makes an already rare case
even rarer, and would cover most use cases. We can improve such cases
later in a separate patch.
+1, I think storing RI make sense and when there is RI FULL we may
store the NULL value and makr RI FULL as true. Yes and we can always
improve this based on feedback once its being used.

Option 3: Whitelist Only Fixed-Length / Safe Built-in Data Types in v1
In the initial version of conflict logging, only serialize columns
with guaranteed small, bounded types (e.g., fixed-length types like
int2, int4, int8, float4, float8, bool, date, timestamp, uuid). Any
varlena type, container, or UDT is automatically omitted without
invoking its output function.
Pros:

  • Completely immune to palloc() 1 GB overflow by construction.
  • Simple to implement with zero chance of erroring out.
  • Safe baseline that can be incrementally expanded in future releases.
    Cons:
  • Could be restrictive: common types like text, varchar, jsonb, and
    numeric are omitted even when their values
    are just a few bytes (e.g., a 10-character varchar column).

Text and numeric keys are the common case, and omitting them would be
worse to me than the problem we are solving.
Yeah make sense.

Option 4: Wrap Attribute Serialization in PG_TRY() / PG_CATCH()
While serializing each attribute of the tuple to JSON, wrap the
conversion (specifically OidOutputFunctionCall()) inside an internal
subtransaction with a PG_TRY() / PG_CATCH() block:
Pros:

  • Catches all hard errors, including 1 GB palloc() exhaustion inside
    uncooperative typoutput functions, memory allocation failures, or
    corrupted data.
  • Enables full support for all data types (built-in, JSONB, varlena,
    and UDTs) without risking apply worker retry loops.
  • Does not require changing PostgreSQL's global typoutput function
    signature to support escontext.
    Cons:
  • Using PG_TRY() and internal subtransactions adds management
    overhead (though conflict logging is an exceptional path, not the main
    transaction fast-path).
  • Consumes Transaction IDs (subXIDs), though read-only in-memory
    subtransactions are relatively lightweight.

I don't think we can classify the error reliably. enlargeStringInfo()
reports ERRCODE_PROGRAM_LIMIT_EXCEEDED, an oversized palloc() request
reports ERRCODE_INTERNAL_ERROR, and a genuine allocation failure
reports ERRCODE_OUT_OF_MEMORY. We cannot tell "this value was too
large to render" from "this backend is really out of memory" or from a
bug in some type's output function, and silently swallowing the latter
two in an apply worker seems worse than the disease.

Overall, I prefer option 2 and deal with the very rare cases in a
separate patch if necessary.
Yes, that makes sense. I have prepared a top-up patch using approach
2, IMHO the edge case should realistically only trigger when a
user-defined type’s output function produces an unusually massive
value. I'll see if I can construct a test for this. While we could
theoretically simulate it with a minimal on-disk type whose output
function generates a 1GB string, that feels more like an
artificial/pathological case than a realistic workload.
--
Regards,
Dilip Kumar
Google

Dilip Kumar added 2 commits September 17, 2026 16:44
…table

This patch introduces the core logic to populate the conflict log table whenever
a logical replication conflict is detected. It captures the remote transaction
details along with the corresponding local state at the time of the conflict.

Only resolved (LOG-level) conflicts are recorded in the conflict log table.
Conflicts that raise an ERROR (such as unique constraint violations) halt
replication and abort the transaction, so they are logged exclusively to the
server log.

Local Conflicts Column: The 'local_conflicts' column is typed as an array of
JSON objects (json[]). Although currently recorded conflicts involve a single
local tuple, the column type is preserved as a JSON array for future-proofing.
This avoids modifying the exposed table schema and dealing with upgrade handling
when multi-row conflict resolution is introduced in the future.

The JSON array uses the following structured format:
[ { "xid": "1001", "commit_ts": "2025-12-25 10:00:00+05:30", "origin": "node_1",
"tuple": {"id": 1, "val": "old_data"} }, ... ]

Example of querying the structured conflict data:

SELECT remote_xid, relname, remote_origin, local_conflicts[1] ->> 'xid' AS local_xid,
       local_conflicts[1] ->> 'tuple' AS local_tuple
FROM pg_conflict.pg_conflict_log_16396;

 remote_xid | relname  | remote_origin | local_xid |     local_tuple
------------+----------+---------------+-----------+---------------------
        760 | test     | pg_16406      | 771       | {"a":1,"b":10}
        765 | conf_tab | pg_16406      | 775       | {"a":2,"b":2,"c":2}

The remote transaction details (xid, final LSN, commit timestamp) recorded
in the conflict log table are fetched from the apply worker's remote
transaction context through GetRemoteTransactionInfoForConflict() instead of
new global variables.
@pg-hub-mirror

pg-hub-mirror Bot commented Sep 17, 2026

Copy link
Copy Markdown
Author

Earlier design discussion: Discussion #179

@pg-hub-mirror pg-hub-mirror Bot added area:storage Storage, access methods, buffers, or I/O source:pgsql-hackers Mirrored from pgsql-hackers type:patch Mail thread contains a PostgreSQL patch area:planner Planner and optimizer area:testing Tests and buildfarm area:wal Write-ahead logging and recovery area:sql SQL language or commands area:replication Physical or logical replication labels Sep 17, 2026
@pg-hub-mirror pg-hub-mirror Bot locked and limited conversation to collaborators Sep 17, 2026
@pg-hub-mirror pg-hub-mirror Bot added cf:pg20-2 PostgreSQL CommitFest status:needs-review CommitFest: Needs review labels Sep 17, 2026
@pg-hub-mirror pg-hub-mirror Bot added this to the PG20-2 milestone Sep 17, 2026
@pg-hub-mirror pg-hub-mirror Bot unlocked this conversation Sep 18, 2026
@pg-hub-mirror

pg-hub-mirror Bot commented Sep 18, 2026

Copy link
Copy Markdown
Author

shveta malik <shveta(dot)malik(at)gmail(dot)com> via pgsql-hackers · original email

v74 needs a rebase; it isn't compiling due to commit 926627b which
has removed RelationGetQualifiedRelationName().
thanks
Shveta

@pg-hub-mirror pg-hub-mirror Bot locked and limited conversation to collaborators Sep 18, 2026
@pg-hub-mirror pg-hub-mirror Bot unlocked this conversation Sep 18, 2026
@pg-hub-mirror

pg-hub-mirror Bot commented Sep 18, 2026

Copy link
Copy Markdown
Author

shveta malik <shveta(dot)malik(at)gmail(dot)com> via pgsql-hackers · original email

On Fri, Sep 18, 2026 at 1:35 PM Nisha Moond <nisha(dot)moond412(at)gmail(dot)com> wrote:

On Thu, Sep 17, 2026 at 8:06 PM Dilip Kumar <dilipbalaut(at)gmail(dot)com> wrote:

Overall, I prefer option 2 and deal with the very rare cases in a
separate patch if necessary.

Yes, that makes sense. I have prepared a top-up patch using approach
2, IMHO the edge case should realistically only trigger when a
user-defined type’s output function produces an unusually massive
value. I'll see if I can construct a test for this. While we could
theoretically simulate it with a minimal on-disk type whose output
function generates a 1GB string, that feels more like an
artificial/pathological case than a realistic workload.

Thanks for the patch. Please find a few initial comments; I’m still reviewing.

  1. Issue Fire create_upper_paths_hook for UPPERREL_PARTIAL_GROUP_AGG #2 from [1] is not fixed yet.
    replica_identity is currently built from the index's full descriptor,
    so INCLUDE columns are included. This is incorrect because:
    a) INCLUDE columns are not part of the lookup key, so this does not
    match the log, which reports only key columns.
    b) On UPDATE, an INCLUDE column can contain arbitrarily large
    text/json values and unnecessarily contribute to the tuple size.

The attached diff copies only the index key attributes. Please
consider it if the approach looks reasonable.


2) Now that remote_tuple has been removed, there is some information
loss when the replica identity key itself is updated.
For example:
-- pub
INSERT INTO t1 VALUES (3,'three');
-- sub
DELETE FROM t1 WHERE a = 3;
-- pub
UPDATE t1 SET a = 30 WHERE a = 3;

Server log:
  conflict detected on relation "public.t1": conflict=update_missing
  DETAIL:  Could not find the row to be updated: remote row (30,
three), replica identity (a)=(3).

Conflict log table: replica_identity = {"a":3}, and there is no
mention of the new key 30 anywhere.

For update_missing the remote change is skipped, but for
update_origin_differs the change is applied, so the local row now has
a=30 while the CLT row names a=3, and nothing connects the two. The
log will show the remote tuple, though.

I agree. I too noticed this. Need to think more here.
Another point of concern: we don't know from the CLT which column
conflicted, out of the potentially large number of columns in a user
tab. Consider an update_origin_differs case:
Table t(id PK, val), with id=10, val=20 on both nodes.
On the subscriber, the row is changed to val=200.
At the same time, on the publisher, it is changed to val=300.
The publisher's change comes in as an update_origin_differs conflict
and overwrites 200 with 300. But the CLT only records something like:
SELECT conflict_type, replica_identity, local_conflicts FROM
pg_conflict.pg_conflict_log_16390;
update_origin_differs | {"id":10} | [{"xid":"...", "commit_ts":"...",
"origin":null}]
So we know row id=10 had a conflict, but not which column conflicted,
or what the old and new values were. Is there a concrete way for a
user to find that information given only the CLT? Is changed col info
worth adding to CLT?
thanks
Shveta

@pg-hub-mirror pg-hub-mirror Bot locked and limited conversation to collaborators Sep 18, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area:planner Planner and optimizer area:replication Physical or logical replication area:sql SQL language or commands area:storage Storage, access methods, buffers, or I/O area:testing Tests and buildfarm area:wal Write-ahead logging and recovery cf:pg20-2 PostgreSQL CommitFest source:pgsql-hackers Mirrored from pgsql-hackers status:needs-review CommitFest: Needs review type:patch Mail thread contains a PostgreSQL patch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants