CF7016 Fix LSN of logical decoding message — Review
code-review · generated from canonical Markdown
code-review
postgresql-master
commitfest-7016-review2026-08-03T19:12:22.448741+00:00
Front matter
schema: review-report-v2 title: "CF7016 Fix LSN of logical decoding message — Review" commitfest: 61 patch: 7016 author: Atsushi Torikoshi canonical_message_id: d99c688994ab3a998afe26e61fe4f69f@oss.nttdata.com head: 7cb9060dcde08ca63df1f0d701b0674e0e8ae811 diff_sha: 88d6a2831768958d8824f8c59c6feddeaec96889 verdict: "ready-for-committer (test follow-up required)" blocker_split: verified: 0 unverified: 0 tags: - code-review - commitfest-7016 - lsn-logical-message - pg-review-artifact-v2 - postgresql - review-report-v2 summary: "The one-line decode.c fix (buf->endptr to buf->origptr) is correct, safe, documented, and supported by pgsql-hackers thread consensus. One test-only MAJOR (F-001) requires strengthening 020_messages.pl to actually assert LSN correctness. Verdict: ready-for-committer (test follow-up required)."
CF7016 — Fix LSN of logical decoding message
- CommitFest: 61, Patch: 7016
- Title: Fix LSN of logical decoding message
- Author: Atsushi Torikoshi <torikoshia@oss.nttdata.com>
- Canonical Message-ID:
d99c688994ab3a998afe26e61fe4f69f@oss.nttdata.com - Thread: pgsql-hackers thread 648032 ("Why is the LSN reported for pg_logical_emit_message() different from other decoded operations?")
- Frozen HEAD:
7cb9060dcde08ca63df1f0d701b0674e0e8ae811 - Scope identity (diff_sha):
88d6a2831768958d8824f8c59c6feddeaec96889 - Changed symbol:
logicalmsg_decode(src/backend/replication/logical/decode.c:606-677)
Deterministic Verdict
ready-for-committer (test follow-up required)
- Blocker split: verified=0, unverified=0
- Findings: 1 MAJOR (test-only, verified), 1 MINOR (verified), 1 NIT (verified)
- No surviving BLOCKER. No correctness/security MAJOR or live thread objection. The MAJOR F-001 is test-only (the decode.c fix is correct; the test needs strengthening). Thread consensus supports the fix with no objections.
- Per the verdict rubric: test-only MAJORs =>
ready-for-committer (test follow-up required).
Scope
Uncommitted diff vs HEAD 7cb9060dcde:
| File | Change |
|---|---|
src/backend/replication/logical/decode.c line 671 | buf->endptr → buf->origptr in logicalmsg_decode() |
src/test/subscription/t/020_messages.pl line 110 | Removes lsn = '$message_lsn' AND filter from WHERE clause |
The patch makes the LSN reported for pg_logical_emit_message() (the message_lsn exposed to output plugins and the logical replication protocol) equal to the start LSN of the LogicalMessage WAL record (buf->origptr), instead of the end LSN (buf->endptr). All other decoded DML records already use origptr. The change aligns the implementation with both the surrounding code and the existing documentation.
Findings
(MAJOR, tests) — Test coverage regression
Location: src/test/subscription/t/020_messages.pl:97-99,110
Mechanism: The v2 patch removes the lsn = '$message_lsn' filter from the WHERE clause (line 110) but keeps the $message_lsn variable assignment (lines 97-99) dead/unused. The only remaining assertion for the non-transactional message is is($result, qq(77|0), ...) which checks byte values only — no LSN assertion. The test would pass even if the buf->endptr→buf->origptr fix were reverted, so it does not guard against regression of this specific bug. Yugo Nagata explicitly requested the dead variable cleanup on 2026-06-09; v2 did not address it.
Verification: verified — inspection + reproduction (two-run TAP gate). The patched test (v2) does NOT catch the bug; reverting decode.c to endptr would still pass this test.
Critical correction: The test-author's original premise (asserting LSN equality between pg_logical_emit_message() return and the slot's lsn column) was empirically proven wrong. pg_logical_emit_message() returns XLogInsert() = EndRecPtr (the record end), not origptr (the start). On base code, both the slot and the function return endptr — they match. The fix makes the slot report origptr (start), so the correct oracle is slot_lsn < pg_logical_emit_message()_return, not equality.
Repair packet:
- Instruction: Capture
pg_logical_emit_message()return into$txn_message_lsn(transactional) and$message_lsn(non-transactional). Add(lsn < '$..._lsn'::pg_lsn)::textto the SELECT columns. Asserttrue|1|pgoutput(transactional) andtrue|77|0(non-transactional). Remove the deadlsn = '$message_lsn' ANDfilter; keepWHERE xid = 0. Both variables are now LIVE. - Code example (exact-postimage):
``perl is($result, qq(true|77|0), 'non-transactional message slot LSN is the record start (less than pg_logical_emit_message() return); message is M, flag is non-transactional'); ``
- Patch (HEAD-bound, unified-diff): sha256
683e7d4b1787e2833eea14a206add2a20f62c737ddc587928dd7f3e13c84b9d0, applies to HEAD7cb9060dcde08ca63df1f0d701b0674e0e8ae811. - Repair validation:
passed— two-run TAP gate: - Run A (base/endptr + test v3): FAILED 2/5 subtests. Transactional got
false|1|pgoutput, expectedtrue|1|pgoutput. Non-transactional gotfalse|77|0, expectedtrue|77|0. Byte checks pass; only LSN wrong — catches the bug. - Run B (fixed/origptr + test v3): PASSED 5/5 subtests. Two-run gate satisfied: fail-on-old, pass-on-new.
- Test example: TAP test in
src/test/subscription/t/020_messages.pl. The twois()assertions with the(lsn < $..._lsn::pg_lsn)::textcomparison are the oracle. On base (endptr): slot lsn == emit return, solsn < emit_returnis false → FAIL. On fixed (origptr): slot lsn = origptr < endptr = emit return, solsn < emit_returnis true → PASS.
(MINOR, doc) — Missing explanatory comment
Location: src/backend/replication/logical/decode.c:671
Mechanism: The patch changes LSN semantics (endptr → origptr) with no comment. The original endptr choice (commit 3fe3511d05127c, 2016) was never explained. A future reader could revert the fix without understanding why origptr is correct.
Verification: verified — inspection.
Repair packet:
- Instruction: Add a 5-line block comment immediately before the
ReorderBufferQueueMessage()call explaining thatorigptr(record start) is used for consistency with other decoded change types and per the documentation for the message callback LSN. - Code example (exact-postimage):
```c /*
- Use the record's start LSN (origptr), consistent with other decoded
- change types (INSERT/UPDATE/DELETE all queue with buf->origptr). The
- documentation says the message callback receives the WAL location of the
- message, i.e. the record start, not the end.
*/ ReorderBufferQueueMessage(ctx->reorder, xid, snapshot, buf->origptr, ```
- Patch (HEAD-bound, unified-diff): sha256
0cdac90f5dd0dd6356ee9ae23c9b35abd4e9eb1c0361d6f51d3703daec99fe6e, applies to HEAD7cb9060dcde08ca63df1f0d701b0674e0e8ae811. - Repair validation:
passed— apply-check OK, apply OK, diff-check OK (no whitespace errors), compile:gcc -c -o decode.o decode.cexited 0, no warnings, decode.o rebuilt (17976 bytes). Documentation-only; no codegen change. - Test example: waived (design-choice) — comment-only change; compile is the only relevant gate. Runtime evidence from F-001's two-run TAP gate already exercises the changed code path.
(NIT, release-process) — Back-patch / release-note consideration
Location: doc/src/sgml/release-19.sgml (release notes)
Mechanism: The LSN change is observable to consumers. On the base code, the slot's lsn column for a logical message equals EndRecPtr (end of record). After the fix, it equals ReadRecPtr (start of record). A consumer (e.g. Debezium) comparing pre-fix to post-fix LSNs for the same WAL record would see a different (smaller) value. The original bug report explicitly mentions the Debezium data-loss issue. The thread (message 5f78375e8107feba80374921cfd87633, 2026-07-15) discusses back-patch options.
Verification: verified — inspection.
Repair: No code change. Add a release note when backpatching: "The LSN reported for logical decoding messages (pg_logical_emit_message) is now the WAL record start LSN, consistent with other decoded change types. Previously it was the record end LSN." All repair fields waived (required-external-resource) — this is a release-note process step, not a code defect.
Verification Summary
| Finding | Severity | Class | Status | Method | pass_on_new |
|---|---|---|---|---|---|
| F-001 | MAJOR | tests | verified | inspection + two-run TAP | true (corrected repair) |
| F-002 | MINOR | doc | verified | inspection | true (comment-only) |
| NIT-001 | NIT | release-process | verified | inspection | n/a |
- Verified: 3, Refuted: 0, Cannot-verify: 0
- Verified BLOCKER/MAJOR: 1 (F-001, test-only). Unverified BLOCKER/MAJOR: 0.
- No refuted findings to drop.
Repair Validation Summary
| Finding | Patch sha256 | Validation status | Methods | Evidence |
|---|---|---|---|---|
| F-001 | 683e7d4b... | passed | apply-check, diff-check, targeted-test, repro, two-run | Run A: FAIL 2/5 (catches bug). Run B: PASS 5/5. Two-run gate satisfied. |
| F-002 | 0cdac90f... | passed | apply-check, diff-check, compile, static-check | apply OK, diff-check OK, compile exit 0, no warnings. |
| NIT-001 | n/a | waived | static-check | No code change to validate. Release-note process step. |
All verified findings have concrete code examples and HEAD-bound unified-diff patches with SHA-256 hashes. F-001's two-run TAP gate provides the strongest evidence: the test fails on base (endptr) and passes on fixed (origptr), proving it catches the bug. F-002's compile validation confirms the comment addition is syntactically correct. NIT-001 requires only a release note when backpatched.
Changed-Code Coverage
Coverage requirement
- Status:
required - Changed instrumentable paths:
src/backend/replication/logical/decode.c
Measurement
- Status:
measured - Head:
7cb9060dcde08ca63df1f0d701b0674e0e8ae811 - Counter epoch:
fresh— all.gcdafiles deleted before test run; verified 0.gcdafiles before execution. After test: 1048.gcdafiles includingdecode.gcda(2304 bytes). No stamp mismatch, merge mismatch, or profiling errors.
Execution lane
| Lane | Status | Evidence |
|---|---|---|
| host-disposable | passed | TAP test t/020_messages.pl: All tests successful (5 tests, 2 wallclock secs). Fresh coverage-instrumented build at frozen HEAD with CF7016 patch applied. |
- Selected lane:
host-disposable - Configure:
./configure --enable-debug --enable-cassert --enable-tap-tests --enable-coverage - CFLAGS:
-O0 -g -fprofile-arcs -ftest-coverage - Test command:
make -C src/test/subscription check PROVE_TESTS=t/020_messages.pl - Environment:
LC_ALL=C LANG=C TZ=UTC - Tools: gcc 15.2.0, gcov 15.2.0, gcovr 8.6, make 4.4.1, prove TAP::Harness v3.48 / Perl v5.40.1
Raw metrics
| Metric | Count |
|---|---|
| changed executable lines | 1 |
| covered changed lines | 1 |
| compiler uncovered changed lines | 0 |
| semantic uncovered changed lines | 0 |
| structural uncovered changed lines | 0 |
| changed lines without coverage coordinates | 0 |
| changed branches | 0 |
| covered changed branches | 0 |
| integrity errors | 0 |
Changed-line coverage: 1/1 (100%). The single changed line (decode.c:671) has gcovr execution count 9 — executed 9 times during the test run. The line is a function call argument with no branches.
Changed line detail
path: src/backend/replication/logical/decode.c line: 671 function: logicalmsg_decode count: 9 source: ReorderBufferQueueMessage(ctx->reorder, xid, snapshot, buf->origptr,
Touched function diagnostic
| Function | Path | Lines | Entered | Whole-function lines (covered/total) | Whole-function branches (covered/total) |
|---|---|---|---|---|---|
logicalmsg_decode | src/backend/replication/logical/decode.c | 607–677 | true | 26/33 | 0/0 |
The whole_function_branches total of 0 reflects gcovr's decision analysis heuristic which did not identify source-level conditional decisions on the changed line (line 671 is a function call, not a control-flow construct). The function does contain branches elsewhere (line 668 if (!message->transactional) with 3/6 true/false split), but these are outside the changed scope.
Uncovered regions
None. All changed executable lines are covered.
Uncovered branches
None. The changed line has no branches.
Excluded changed coordinates
None. No compiler-no-coverage-coordinate, structural-attribution, or raw-compiler-branch-attribution exclusions.
Non-instrumented changes
| Path | Reason |
|---|---|
src/test/subscription/t/020_messages.pl | TAP (Perl) test file — not C code, not instrumentable by gcov. Changed line 110 removes a WHERE clause filter to verify messages by xid=0 regardless of LSN, validating the endptr→origptr fix. |
COV-* findings
None. The changed line is fully covered by the existing test t/020_messages.pl, which emits both transactional and non-transactional logical messages.
Residual limits
- Coverage measured only for the changed implementation file under the single test
t/020_messages.pl. Whole-function line coverage (26/33) and branch data reflect execution under this test set only. - The changed line (671) is a function call argument with no source-level conditional decision; changed-branch coverage is vacuously 0/0.
pgsql-hackers Thread Ledger
- Thread ID: 648032
- Subject: "Why is the LSN reported for pg_logical_emit_message() different from other decoded operations?"
- Root Message-ID:
d99c688994ab3a998afe26e61fe4f69f@oss.nttdata.com - Status:
reconciled— consensus supports theorigptrfix; no objections. - Ledger report slug:
commitfest-7016-thread-ledger - Committers active: None yet.
Message chronology (8 messages)
| # | Date | Sender | Kind | Summary |
|---|---|---|---|---|
| 1 | 2026-06-08 | Torikoshi | patch | v1: reports bug, traces to commit 3fe3511d05127c, Debezium data-loss motivation |
| 2 | 2026-06-09 | Nagata | review | Confirms doc says "WAL location of the message" = origptr. Requests dead $message_lsn cleanup. |
| 3 | 2026-06-09 | Horiguchi | review | Independent confirmation: origptr is correct per protocol doc |
| 4 | 2026-06-11 | Torikoshi | author-reply | Summarizes consensus; socializes consumer-impact rationale |
| 5 | 2026-06-18 | Kuroda | review | No blockers; Fujitsu's proprietary consumer unaffected |
| 6 | 2026-06-19 | Torikoshi | author-reply | Debezium discussion ongoing |
| 7 | 2026-07-15 | Torikoshi | patch | v2; argues for minor-release fix (option 2.1); v2 does NOT address Nagata's TEST-001 request |
| 8 | 2026-07-21 | Solai | review | Functional verification on PG19beta2; "patch looks good to me" |
Per-finding dispositions
| Finding | Disposition | Thread evidence |
|---|---|---|
| F-001 (dead variable + weakened test) | already-raised | Nagata (msg 2) explicitly requested cleanup; v2 did not address |
| F-002 (missing comment) | new | No thread participant raised the missing comment |
| NIT-001 (release-note) | already-raised | Author (msg 7) raised back-patch/release-note consideration |
Development-branch catversion exception
Not applicable — no catalog change in this patch. No catversion bump required.
Security Review
No security findings. The one-line LSN change aligns logical messages with DML ordering, closes a consumer-side data-loss pitfall, and touches no privilege boundary, catalog write, SQL-string construction, crypto path, or memory-safety-sensitive code. The test-only filter removal introduces no injection surface. Safe from the pg-security-reviewer perspective.
- Privilege/role escalation: NOT APPLICABLE (CWE-269)
- Catalog write race / TOCTOU: NOT APPLICABLE (CWE-367)
- Ordering/delivery guarantees: IMPROVED (closes data-loss gap, not regressed)
- SQL injection in test change: NOT APPLICABLE (CWE-89)
- C memory safety: CLEAN (scalar swap of two
XLogRecPtrvalues; no allocation, pointer arithmetic, or lifetime change) - Crypto/TLS/superuser guards: NOT APPLICABLE
Aggregated Test Plan
- F-001 TAP test (020_messages.pl): Two assertions
(lsn < $..._lsn::pg_lsn)::textwith expected'true|1|pgoutput'(transactional) and'true|77|0'(non-transactional). Two-run gate: FAIL on base (endptr), PASS on fixed (origptr). Promote to Aggregated Test Plan. - F-002: No runtime test (comment-only). Compile gate only.
- NIT-001: No test (release note only).
Residual risk
- The decode.c fix is correct and low-risk. The primary residual is the test-strengthening follow-up (F-001): without it, a future regression of the
endptr→origptrchange would not be caught by020_messages.pl. - When backpatched, consumers (e.g. Debezium) that stored the pre-fix message LSN as a restart point will observe a small LSN delta (from end-of-record to start-of-record). A release note is advisable (NIT-001).
- No committer has weighed in on the thread yet. No live objection blocks the fix.
Provenance / Model routes
| Seat | Model | Handoff report slug | markdown_sha256 |
|---|---|---|---|
| pg-patch-reviewer | glm-5.2:cloud (fixed) | commitfest-7016-review-handoff-patch-reviewer | 8d6e6dd844ed173b8f936449a548c19e3e54a0403534f35f948905f4540d9ff5 |
| pg-security-reviewer | glm-5.2:cloud (fixed) | commitfest-7016-review-handoff-security-reviewer | 0ae92f5aff4ab78abe6c78aa46a6be658e2220321bc29aa051e300c86b5240dd |
| pg-test-author | glm-5.2:cloud (fixed) | commitfest-7016-review-handoff-test-author | 30e1315395dc345c8d24318e3397e9510e52509a4271e6a31904e459f4aaf271 |
| pg-finding-verifier | glm-5.2:cloud (fixed) | commitfest-7016-review-handoff-verifier | f3d76441e55812abad1c80817ab16a0816a22a787501005d067d6aa1c2444188 |
| pg-change-coverage-reviewer | glm-5.2:cloud (fixed) | commitfest-7016-review-handoff-coverage-reviewer | d1f098f4e3808314ddeb3df337d1a0a998fcac949b938017abd6f95a771342bc |
| thread-ledger | (coordinator) | commitfest-7016-thread-ledger | 289374851a4c070c3bccaa2f3d80e89b59c3b534b21be3d96d9a7a35b8c92655 |
All handoff reports verified by the coordinator via get_report: source postgresql-master, scope_identity 88d6a2831768958d8824f8c59c6feddeaec96889, metadata schema amauta-review-handoff/v1.
v2 Artifact Validation
The structured pg-review-artifact-v2 JSON was assembled and validated with validate_review_artifact.py. Result: VALIDATION PASSED. Exit code 0.
Maintainer
decode.c recent top authors (3-year window): Peter Eisentraut (4), Álvaro Herrera (3), Masahiko Sawada (3). Last commit touching logicalmsg_decode: 1fdbca159e00 (Masahiko Sawada, 2026-01-28).
Filesystem boundary compliance
- Review worktree was READ-ONLY; never modified, committed, staged, or pushed.
- All candidate edits, builds, tests, and coverage counters were in disposable scratch space.
- All durable citations use repo-relative paths; no local absolute paths or credentials persisted.