Skip to content

Add support for ARIES UNDO and constant time recovery#28

Draft
gburd wants to merge 11 commits into
masterfrom
undo
Draft

Add support for ARIES UNDO and constant time recovery#28
gburd wants to merge 11 commits into
masterfrom
undo

Conversation

@gburd

@gburd gburd commented Jul 3, 2026

Copy link
Copy Markdown
Owner

No description provided.

@github-actions github-actions Bot force-pushed the master branch 4 times, most recently from 6850c08 to 3b6f413 Compare July 3, 2026 20:55
@github-actions github-actions Bot force-pushed the master branch 10 times, most recently from c3ee91f to c7e7712 Compare July 6, 2026 18:53
@github-actions github-actions Bot force-pushed the master branch 14 times, most recently from d8fed9e to 40f3392 Compare July 8, 2026 10:56
@github-actions github-actions Bot force-pushed the master branch 3 times, most recently from 15913cb to 06a2a69 Compare July 13, 2026 11:54
@gburd gburd force-pushed the master branch 2 times, most recently from 3b49b2a to 0b3e646 Compare July 13, 2026 13:08
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown

📜 Change history & discussion (Agora / pg.ddx.io)

All searches return only generic semantic noise — no message anywhere mentions RECNO, "pg_xattr", "SeqLock", "constant time recovery", or the transactional-fileops rmgr. These are semantic fallbacks, not real matches. Nothing links this PR to any actual thread or commitfest entry.

🧵 Related discussion

  • No mailing-list thread references this PR's contents. Searches for "ARIES UNDO / constant time recovery", "RECNO in-place MVCC table AM", and "transactional filesystem-ops WAL rmgr" returned only unrelated semantic-fallback hits (recovery_timeout, generic MVCC/WAL threads). No substring match exists for RECNO, pg_xattr, SeqLock, or "constant time recovery" anywhere in pgsql-hackers.
  • The only genuinely adjacent prior art is the zheap / undo-logs effort, which this PR does not cite and does not resemble in interface:

🔗 Related commits / prior art

  • No confirmed superseded commit. The tableam-based UNDO/in-place-update design space was previously the domain of the zheap project (built by EnterpriseDB / 2ndQuadrant, never merged). Relationship to this PR is not cited by the author; overlap is conceptual only. Confidence: moderate that this is a from-scratch reimplementation ignoring that history.

📋 Commitfest

  • No commitfest entry found linked to this work (thread cross-reference returned zero for the undo-logs thread; no thread for this PR exists to look up).

🧭 Context for reviewers

  • This PR has no traceable community provenance. It was not discussed on pgsql-hackers, has no commitfest entry, and does not reference the prior UNDO/zheap work it overlaps with. A change of this scope (new table AM, cluster-wide UNDO-in-WAL engine, new WAL rmgr, new portability wrapper, on-disk format changes) landing without a design thread is a hard blocker by project norms — feature this large is expected to go through a public design discussion and commitfest cycle.
  • Two commits are process red flags, not code: b0a68fe43eb ci: AI/LLM PR review (OCR via Bedrock + Agora MCP history) and 52de0eabb02 ci: fork upstream auto-sync indicate this is a downstream fork's automated pipeline, not an upstream submission. aff17f307fe is explicitly marked [DO NOT MERGE].
  • Scope is implausible for a single reviewable unit. RECNO (a full compressed/MVCC table AM with FSM, dictionary, overflow, dirtymap, locking), ARIES UNDO, a transactional-fileops rmgr, and nbtree/hash UNDO apply handlers are each multi-year efforts; bundling them defeats review. The prior UNDO effort (above) was split across dozens of patch revisions over years and still did not merge.
  • Design details (heap-compatible xmin/xmax visibility in an in-place AM, per-relation UNDO fork, pg_xattr for extended attributes) have no upstream discussion to validate them against. Treat every design claim in the PR description as unverified against community consensus.
  • Recommendation: do not review as-is. Require the author to (1) drop CI/DO-NOT-MERGE commits, (2) open a pgsql-hackers design thread, (3) register a commitfest entry, and (4) split into independently reviewable patch series before any technical review.

Generated by pg-history via the Agora MCP server (pg.ddx.io).

@gburd gburd force-pushed the undo branch 3 times, most recently from ca3e455 to 834f969 Compare July 13, 2026 21:47

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 240 issue(s).

  • 25 inline, 215 in summary (inline capped at 25)
  • ⚠️ 214 warning(s) during review

📄 src/backend/access/recno/Makefile

Unbounded page restore: hdr.page_len is a uint16 read verbatim from the undo payload and used directly as the memcpy length into the live page buffer, with no validation. This can (a) over-read the payload when hdr.page_len > payload_len - SizeOfNbtreeUndoDedup, and (b) overwrite memory past the buffer when hdr.page_len > PageGetPageSize(page) (or > BLCKSZ). A short/corrupt undo record thus corrupts an adjacent shared buffer inside a critical section on the abort hot path. Validate page_len before the memcpy.

💡 Suggested change

Before:

				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

After:

				if (hdr.page_len != PageGetPageSize(page) ||
					payload_len < SizeOfNbtreeUndoDedup + hdr.page_len)
				{
					UnlockReleaseBuffer(buffer);
					relation_close(indexrel, RowExclusiveLock);
					return UNDO_APPLY_ERROR;
				}

				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

📄 src/backend/access/recno/README

hdr.page_len is a uint16 read straight from the payload and used as the memcpy length into the BLCKSZ page buffer inside a critical section, but it is never validated. The only length check is payload_len < SizeOfNbtreeUndoDedup, which does not cover the trailing image. A corrupt or truncated undo payload (page_len > payload_len - SizeOfNbtreeUndoDedup) causes a heap over-read, and page_len > BLCKSZ overwrites memory past the page buffer. This is a data-corruption hazard on the index abort path. Validate the length before the memcpy (the delta-CLR redo path in undo_xlog.c validates lengths defensively before memcpy for the same reason).

💡 Suggested change

Before:

				/* Restore the full pre-dedup page image */
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

After:

				/*
				 * Validate the saved image length before copying: the payload
				 * must actually contain page_len trailing bytes, and the image
				 * must fit the page buffer.  A corrupt/truncated payload would
				 * otherwise over-read the record and over-write past the page.
				 */
				if (hdr.page_len != PageGetPageSize(page) ||
					payload_len < SizeOfNbtreeUndoDedup + hdr.page_len)
				{
					UnlockReleaseBuffer(buffer);
					relation_close(indexrel, RowExclusiveLock);
					return UNDO_APPLY_ERROR;
				}

				/* Restore the full pre-dedup page image */
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

📄 src/backend/access/recno/README

These SizeOf* macros are not fully parenthesized (offsetof(...) + sizeof(...)). Current call sites (e.g. payload_len < SizeOfNbtreeUndoInsertLeaf + hdr.itup_sz, payload + SizeOfNbtreeUndoDedup) happen to be correct due to +/< associativity, so there is no live bug, but this is a footgun: any future use adjacent to a higher-precedence operator or a cast would misparse. PostgreSQL convention (e.g. undo_xlog.h) parenthesizes such macros.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertLeaf	offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertLeaf	(offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size))

📄 src/backend/access/recno/recno_dict.c

Uninitialized output on unexpected orig_size. In the negative (tag 0x80) and positive (0x81-0x88) branches, when orig_size is neither sizeof(int32) nor sizeof(int64) the VARDATA payload is never written, so this returns a varlena whose data bytes are uninitialized palloc'd garbage. Only the tag<=0x7F branch has the defensive memset. Add an else that memsets (or better, elog(ERROR)) for the unexpected size in these branches to match.


📄 src/backend/access/recno/recno_dict.c

Entropy sampling assumes a varlena datum. orig_size here can be a fixed-length type's get_typlen result, and for a fixed-length (pass-by-ref or pass-by-value) datum VARDATA_ANY(DatumGetPointer(value)) dereferences a non-varlena as a varlena, yielding a wrong data pointer and reading orig_size bytes from an unintended location (OOB read / crash). This entropy branch only runs for comp_type == RECNO_COMP_ZSTD, which the chooser only returns for the varlena default branch, so it is not reachable via the current sole caller; but the public contract (comp_type can be passed explicitly, and the header doc claims fixed-size numeric support) makes this a latent footgun. Guard on the value actually being varlena before calling VARDATA_ANY.


📄 src/backend/access/recno/recno_vm.c

RecnoDeformTuple does not handle overflow pointers, unlike its sibling RecnoTupleToSlotWithOverflow. When a tuple has RECNO_INFOMASK_HASOVERFLOW set, over-threshold varlena attributes are stored as a RecnoOverflowPtr (with only a small/zero inline prefix) on the page. This function only checks for a compression header and otherwise returns the on-page bytes verbatim via PointerGetDatum(data_ptr). For an overflowed attribute that path hands back the raw overflow-pointer varlena instead of the real column value. Both callers are affected: recno_compute_index_update (deforms the OLD tuple to detect indexed-column changes -- comparing overflow-pointer bytes rather than real values can yield wrong index-update decisions and index corruption) and the logical-decoding image path (emits overflow-pointer bytes into the decoded tuple). Detect RecnoIsOverflowPtr(data_ptr) here and, when rel is non-NULL, fetch via RecnoFetchOverflowColumn (as RecnoTupleToSlotWithOverflow does).


📄 src/backend/access/rmgrdesc/Makefile

RecnoDeformTuple decodes compression but never handles overflow pointers. When a varlena attribute is an on-page overflow pointer (RECNO_INFOMASK_HASOVERFLOW / RecnoIsOverflowPtr), this branch falls through to values[i] = PointerGetDatum(data_ptr), returning the raw RecnoOverflowPtr varlena instead of the real column content. The sibling RecnoTupleToSlotWithOverflow in this same file correctly fetches the chain via RecnoIsOverflowPtr/RecnoFetchOverflowColumn, but this function does not.

This is not cosmetic: it corrupts two real callers.

  • RecnoXLogPrepareLogicalImage (recno_xlog.c:97) deforms the tuple then heap_form_tuples the result for logical decoding, so an overflowed column ships the pointer bytes into the logical-replication stream (silent data corruption on the subscriber).
  • recno_compute_index_update (recno_operations.c:1767/1789) datumIsEquals the deformed old value against the new value; for an overflowed indexed column it compares pointer bytes, misdetecting whether the column changed and risking stale/incorrect index entries.

rel is already available here, so add an overflow-pointer check that fetches via RecnoFetchOverflowColumn (and then applies the existing compression-header check on the fetched value, mirroring RecnoTupleToSlotWithOverflow) before the compression check.


📄 src/backend/access/undo/relundo_discard.c

Hot-path DEBUG1 logging. RelUndoReserve() runs on every DML operation and now emits multiple elog(DEBUG1, ...) calls per reserve; each evaluates its format string and arguments unconditionally (elog macro elision only skips below the compiled-in level, and DEBUG1 is compiled in). PostgreSQL keeps such hot paths free of per-call debug logging. Consider removing these tracing calls (here and the equivalents in RelUndoFinishWithTuple) before this is commit-ready.


📄 src/backend/access/undo/relundo_discard.c

Fragile hash/size coupling. The shift >> (32 - 6) hard-codes log2(64) = 6, tied implicitly to RELUNDO_HEAD_CACHE_SIZE == 64. The header comment above states bumping the size to 128/256 is "straightforward," but doing so would leave this shift at 6 while RELUNDO_HEAD_CACHE_MASK widens, so the hash would still spread over only 64 buckets while the table has more slots -- silently degrading distribution/collisions. Derive the shift from the size macro (e.g. a companion RELUNDO_HEAD_CACHE_BITS used both here and in the size definition) so they cannot drift.


📄 src/backend/access/common/index_prune.c (L59-L59)

The registries and prune_stats are plain process-local statics with no locking, yet the header documents this being driven by a background "UNDO discard worker" (a separate process). If handlers are registered per-backend at AM init, the discard worker process will see an empty registry and prune nothing; if registration ran concurrently there is no synchronization. The comment asserts "registration happens only at startup" but nothing enforces that. Since the module has no callers today (see the critical comment), this cannot be validated. If this infrastructure is kept, the registry/state must live where the consuming process can actually see it, with appropriate synchronization, and the design/threading model documented against the -hackers thread.


📄 src/backend/access/heap/heapam.c (L42-L43)

These three includes are not needed by any change in this file. heapam.c already uses GetCurrentTransactionId() (xact.h), XLogInsert()/recovery routines (xlog.h), and palloc/pfree/MemoryContext (memutils.h) throughout, and compiled without these direct includes before this patch. No new code in this diff requires them. Adding unused includes is unrelated churn that will draw a rejection on -hackers. Drop these lines. (high confidence)


📄 src/backend/access/heap/heapam.c (L59-L62)

Whitespace-only churn on an untouched line: removing this blank separator is unrelated to any functional change in this file. Keep the original formatting so the diff stays minimal (git blame stays clean, no needless merge conflicts). (high confidence)


📄 src/backend/access/heap/heapam.c (L115-L115)

Whitespace-only churn: this blank separator line was removed from untouched code, unrelated to the change. Revert to keep a minimal diff. (high confidence)


📄 src/backend/access/heap/heapam.c (L2141-L2145)

Cosmetic-only change: inserting a blank line after XLogBeginInsert(); in heap_insert serves no functional purpose and is unrelated churn on a hot WAL path. Remove it to keep the diff minimal. (high confidence)

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);
 
 		xlhdr.t_infomask2 = heaptup->t_data->t_infomask2;

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

📄 src/backend/access/heap/heapam.c (L3069-L3070)

Cosmetic-only change: the blank line inserted after XLogBeginInsert(); in heap_delete is unrelated churn. Remove it. (high confidence)

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

📄 src/backend/access/heap/heapam_handler.c (L156-L159)

These doc comments are inaccurate: the heap AM does not use UNDO, and both function bodies are empty no-ops. The comments describe activating/flushing an "UNDO write buffer" and batching UNDO records, none of which happens here (heap sets am_supports_undo = false). Per comment-accuracy discipline, comments must describe what the code does now.

More fundamentally, begin_bulk_insert/finish_bulk_insert are optional callbacks, and both table_begin_bulk_insert/table_finish_bulk_insert already null-check the pointer before invoking. Registering empty stubs for the heap AM (and the .begin_bulk_insert/.finish_bulk_insert entries below) is dead scaffolding that adds nothing and should be dropped from this patch — let the heap AM simply not register these callbacks.


📄 src/backend/access/heap/heapam_handler.c (L2682-L2682)

This comment fragment is superfluous and drifts toward narrating a different AM (RECNO). The .am_supports_undo = false field below is self-explanatory; a comment about what a future/other AM "will" set is aspirational and belongs (if anywhere) with that AM, not in the heap methods table. Recommend removing to keep the diff minimal.


📄 src/backend/access/heap/pruneheap.c (L21-L21)

Unused include. Nothing in pruneheap.c references any symbol from access/parallel.h (no IsParallelWorker, IsInParallelMode, ParallelWorkerNumber, etc.). This adds unrelated churn to the diff and should be removed to keep the patch minimal. (high confidence)


📄 src/backend/access/heap/pruneheap.c (L24-L24)

Redundant include. access/visibilitymap.h (included just above on the previous line) already includes access/visibilitymapdefs.h, so the VISIBILITYMAP_* macros were already available before this change. This line adds needless diff churn and should be removed. (high confidence)


📄 src/backend/access/heap/pruneheap.c (L25-L25)

Unused include. No symbol from access/xact.h is referenced in this file; the TransactionId type and its TransactionId* macros come from access/transam.h, which is already included. Remove to keep the diff minimal. (high confidence)


📄 src/backend/access/hash/hash_undo.c (L202-L209)

Correctness bug: the apply path marks whatever normal ItemId sits at the stored hdr.offset as LP_DEAD, with no re-verification that it is still the entry this transaction inserted. Hash page compaction shifts offsets between insert and abort/apply: PageIndexMultiDelete runs during VACUUM (hash.c), insert cleanup (hashinsert.c), and overflow squeeze (hashovfl.c). If a different, committed entry has moved onto hdr.offset, this silently marks a committed index entry dead, producing index-heap mismatch and wrong query results. The nbtree sibling deliberately guards against this (nbtree_undo.c:513-536) by re-identifying the entry via its heap TID before killing it, precisely because "concurrent inserts and leaf splits can shift a committed entry onto that offset". The hash payload (index_oid, blkno, offset) stores no data to re-verify against, so this cannot be fixed without extending the payload to carry the heap TID.


📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

Style nit (low): this new split-path if uses a braceless multi-line body, while the identical no-split call added below (line ~1454) wraps the same call in braces. Within one function the two branches should be consistent; PostgreSQL style favors braces for multi-line bodies. Suggest matching the braced form for consistency and to avoid a diff mismatch flagged by pgindent/reviewers.

💡 Suggested change

Before:

		if (heaprel != NULL && RelationAmSupportsUndo(heaprel))
			NbtreeUndoLogInsert(rel, heaprel, buf,
								postingoff == 0 ? itup : origitup,
								itemsz, newitemoff, isleaf);

After:

		if (heaprel != NULL && RelationAmSupportsUndo(heaprel))
		{
			NbtreeUndoLogInsert(rel, heaprel, buf,
								postingoff == 0 ? itup : origitup,
								itemsz, newitemoff, isleaf);
		}

📄 src/backend/access/nbtree/nbtree_undo.c (L602-L605)

Buffer-overflow hazard: the DEDUP apply path uses hdr.page_len as the memcpy length onto the live buffer page without validating it. Unlike the INSERT_LEAF path (which checks payload_len < SizeOfNbtreeUndoInsertLeaf + hdr.itup_sz), there is no check that payload_len >= SizeOfNbtreeUndoDedup + hdr.page_len, nor that hdr.page_len <= BLCKSZ. A truncated or corrupt undo payload with an oversized page_len will overrun the 8KB buffer page and corrupt adjacent shared-buffer memory, inside a critical section. Add both bounds checks (returning UNDO_APPLY_ERROR) before the memcpy.

💡 Suggested change

Before:

				if (payload_len < SizeOfNbtreeUndoDedup)
					return UNDO_APPLY_ERROR;

				memcpy(&hdr, payload, SizeOfNbtreeUndoDedup);

After:

				if (payload_len < SizeOfNbtreeUndoDedup)
					return UNDO_APPLY_ERROR;

				memcpy(&hdr, payload, SizeOfNbtreeUndoDedup);

				/*
				 * Validate the saved page image length before restoring it: it
				 * must fit in the payload and must not exceed a page.
				 */
				if (hdr.page_len > BLCKSZ ||
					payload_len < SizeOfNbtreeUndoDedup + hdr.page_len)
					return UNDO_APPLY_ERROR;

📄 src/backend/access/nbtree/nbtree_undo.c (L88-L88)

These SizeOf* macros are not fully parenthesized. Expanding e.g. SizeOfNbtreeUndoInsertLeaf to offsetof(...) + sizeof(Size) currently works in all the in-file use sites only by luck of left-to-right associativity (all uses combine it with lower- or equal-precedence +/<). Any future use adjacent to a higher-precedence operator (cast, *, []) would misparse. Wrap each definition in parentheses per PostgreSQL macro conventions.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertLeaf	offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertLeaf	(offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L103-L103)

Same parenthesization issue here.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertUpper	offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertUpper	(offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L116-L116)

Same parenthesization issue here.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDedup	offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDedup	(offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L129-L129)

Same parenthesization issue here. (Also note SizeOfNbtreeUndoDelete and its NbtreeUndoDelete struct are entirely unused - see the dead-code comment below.)

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDelete	offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDelete	(offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L255-L255)

Dead code / speculative scaffolding (YAGNI). NbtreeUndoLogDedup has no caller anywhere in the tree, so the entire DEDUP undo path - this log function, the NbtreeUndoDedup struct, and the DEDUP restore case in nbtree_undo_apply - is never exercised. Similarly, the NBTREE_UNDO_INSERT_POST / SPLIT_L / SPLIT_R / NEWROOT / DELETE subtypes are never written by any Log function; their apply/desc cases and the NbtreeUndoDelete struct exist only for a switch that can never dispatch to them. Per minimalism discipline, remove the unused Log function, struct, subtypes, and their apply/desc arms until they have a real caller. Shipping unused undo subtypes just adds surface area and untested code.


📄 src/backend/access/nbtree/nbtree_undo.c (L692-L694)

Aspirational / WIP comment shipping as final behavior. Per PostgreSQL comment discipline, comments must describe what the code does now, not a temporary state. "For now, skip and let the entries be re-created" signals unfinished work. If leaving deleted-tuple re-insertion to VACUUM is the intended, permanent behavior, document it as a known limitation and drop "For now"; otherwise this DELETE arm (and its subtype) is dead code (see above).


📄 src/backend/access/nbtree/nbtree_undo.c (L703-L704)

errmsg style: PostgreSQL messages should not embed a subsystem prefix like "nbtree UNDO:" in the text (context/rmgr already identifies the source), and should not carry an internal enum value as the whole message. This is a can't-happen case for a locally-defined subtype set; consider elog(ERROR)/Assert for the unreachable default rather than a translated WARNING with a hand-rolled prefix.


📄 src/backend/access/recno/Makefile (L38-L38)

These SizeOf* macros are not fully parenthesized. offsetof(...) + sizeof(...) expands unparenthesized into surrounding expressions; while current use sites (payload + SizeOfNbtreeUndoInsertLeaf, payload_len < SizeOfNbtreeUndoInsertLeaf + hdr.itup_sz) happen to group correctly by left-associativity, any future use with a higher-precedence operator will misparse. Wrap each definition in parentheses, matching the SizeOfXxx macro convention elsewhere in the tree.

💡 Suggested change

Before:

include $(top_srcdir)/src/backend/common.mk

After:

#define SizeOfNbtreeUndoInsertLeaf	(offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size))

📄 src/backend/access/recno/Makefile (L38-L38)

Size (itup_sz) is serialized verbatim into the on-disk/on-shared undo payload. Size is a machine-word type (4 bytes on 32-bit, 8 on 64-bit), so its width and padding in this struct differ across builds. An index tuple can never exceed BLCKSZ, so a fixed-width type such as uint16 is both sufficient and portable; using Size here is a portability hazard for the undo-log format.


📄 src/backend/access/recno/Makefile (L38-L38)

Lock upgrade via unlock-then-relock opens a TOCTOU window: after _bt_search lands on the leaf read-locked, BUFFER_LOCK_UNLOCK fully releases the lock (the pin alone does not prevent concurrent modification), so between the unlock and the exclusive re-acquire another backend may split or delete on this page. The subsequent full-page rescan and right-walk partly compensate, but the comment's claim that this ordering 'cannot deadlock' and the correctness of mixing a direct exclusive lock on stored_blkno (fast path) with a top-down BT_READ descent plus BT_WRITE right-walk (slow path) should be verified against the nbtree README latch-order rules. If not provably safe, this can deadlock against concurrent inserts/splits/VACUUM.


📄 src/backend/access/recno/Makefile (L38-L38)

Aspirational / WIP-flavored comments should describe what the code does now, not a temporary state. 'For now, skip and let the entries be re-created' (DELETE), 'Instead of removing it, we leave it in place' (INSERT_UPPER), and 'logged for completeness' in the file header signal unfinished behavior shipping as final. Reword to state the design decision as a documented limitation (e.g. structural artifacts of aborted transactions are reclaimed by VACUUM) without 'for now'.


📄 src/backend/access/recno/recno_clock.c (L207-L208)

The clock monitor worker sets SIGTERM to PG_SIG_DFL. Every other PostgreSQL background worker (autovacuum.c, bgwriter.c, walwriter.c, walsummarizer.c, etc.) installs SignalHandlerForShutdownRequest for SIGTERM. With PG_SIG_DFL, a normal smart/fast shutdown SIGTERM terminates this worker via the OS default disposition rather than a clean proc_exit, so the main loop never breaks on a shutdown request (it only handles WL_EXIT_ON_PM_DEATH) and RecnoClockShutdown() is never invoked, orphaning the mmap/fd. Install SignalHandlerForShutdownRequest and check ShutdownRequestPending in the loop.

💡 Suggested change

Before:

	pqsignal(SIGTERM, PG_SIG_DFL);
	BackgroundWorkerUnblockSignals();

After:

	pqsignal(SIGTERM, SignalHandlerForShutdownRequest);
	BackgroundWorkerUnblockSignals();

📄 src/backend/access/recno/recno_clock.c (L236-L238)

The main loop only reacts to WL_EXIT_ON_PM_DEATH; there is no handling for a shutdown-request signal, so a fast/smart shutdown cannot terminate this worker gracefully (and cleanup via RecnoClockShutdown does not run). After installing SignalHandlerForShutdownRequest, break/proc_exit on ShutdownRequestPending here.

💡 Suggested change

Before:

		/* Exit on termination request */
		if (rc & WL_EXIT_ON_PM_DEATH)
			proc_exit(1);

After:

		/* Exit on termination request */
		if (rc & WL_EXIT_ON_PM_DEATH)
			proc_exit(1);

		if (ShutdownRequestPending)
			proc_exit(0);

📄 src/backend/access/recno/recno_clock.c (L257-L258)

memcpy of ClockBoundData (two timespec + uint64 + two uint32) from an mmap'd region concurrently written by an external daemon is NOT atomic; the comment claiming an 'atomic read' is wrong. A concurrent daemon write can produce a torn read where earliest/latest/error_bound are mutually inconsistent, silently corrupting MVCC visibility/causality decisions. This change already adds src/include/storage/seqlock.h precisely for read-mostly shared data updated by writers; use that (or a version counter + read barrier) to detect torn reads, and there is no revalidation of staleness beyond zero-checks.


📄 src/backend/access/recno/recno_clock.c (L370-L371)

Portability violation: error_ms is uint64 but is printed with %lu. On LLP64 (Windows/MSVC) and 32-bit platforms, %lu is 32-bit while uint64 is 64-bit, producing truncated/garbage output and undefined behavior. Use UINT64_FORMAT (concatenated into the format string). Applies to the WARNING variant a few lines below as well.

💡 Suggested change

Before:

					(errmsg("recno clock error bound %lu ms exceeds 80%% of maximum %d ms",
							error_ms, recno_max_clock_offset_ms),

After:

					(errmsg("recno clock error bound " UINT64_FORMAT " ms exceeds 80%% of maximum %d ms",
							error_ms, recno_max_clock_offset_ms),

📄 src/backend/access/recno/recno_clock.c (L380-L381)

Portability violation: error_ms (uint64) printed with %lu; use UINT64_FORMAT for the same reason as the FATAL message above.

💡 Suggested change

Before:

				(errmsg("recno clock error bound %lu ms exceeds 50%% of maximum %d ms",
						error_ms, recno_max_clock_offset_ms)));

After:

				(errmsg("recno clock error bound " UINT64_FORMAT " ms exceeds 50%% of maximum %d ms",
						error_ms, recno_max_clock_offset_ms)));

📄 src/backend/access/recno/recno_clock.c (L317-L320)

Overflow/underflow hazard. recno_max_clock_offset_ms is int, so 'recno_max_clock_offset_ms * 1000' is computed in int and can overflow before being widened to uint64. Then earliest_us is int64 and 'physical_ms * 1000 - offset_us' mixes int64 and uint64: if physical_ms*1000 < offset_us this underflows to a huge value, corrupting the fallback bounds used by RecnoWaitForClockBound() causality checks. Compute the offset in int64 and guard the subtraction.

💡 Suggested change

Before:

		uint64		offset_us = recno_max_clock_offset_ms * 1000;

		result.earliest_us = (physical_ms * 1000) - offset_us;
		result.latest_us = (physical_ms * 1000) + offset_us;

After:

		int64		offset_us = (int64) recno_max_clock_offset_ms * 1000;
		int64		phys_us = (int64) physical_ms * 1000;

		result.earliest_us = phys_us - offset_us;
		result.latest_us = phys_us + offset_us;

📄 src/backend/access/recno/recno_clock.c (L427-L430)

RecnoWaitForClockBound() busy-waits with pg_usleep(1000) loops (up to 1s in the HLC branch, up to recno_max_clock_offset_ms ms in the bounds branch) but never calls CHECK_FOR_INTERRUPTS(). A backend blocked here cannot be cancelled or terminated (query cancel, SIGTERM) and can stall shutdown. Add CHECK_FOR_INTERRUPTS() inside both wait loops.

💡 Suggested change

Before:

			pg_usleep(1000);	/* 1ms */
			wait_us += 1000;

			if (wait_us > 1000000)	/* 1 second max wait */

After:

			CHECK_FOR_INTERRUPTS();
			pg_usleep(1000);	/* 1ms */
			wait_us += 1000;

			if (wait_us > 1000000)	/* 1 second max wait */

📄 src/backend/access/recno/recno_clock.c (L460-L462)

Second busy-wait loop in RecnoWaitForClockBound() likewise lacks CHECK_FOR_INTERRUPTS(), making it non-interruptible for up to recno_max_clock_offset_ms ms.

💡 Suggested change

Before:

		/* Wait a bit and retry */
		pg_usleep(1000);		/* 1ms */
		wait_us += 1000;

After:

		/* Wait a bit and retry */
		CHECK_FOR_INTERRUPTS();
		pg_usleep(1000);		/* 1ms */
		wait_us += 1000;

📄 src/backend/access/recno/recno_clock.c (L57-L57)

Placing an #include after code (this #define block) is unusual and against convention; all #include directives should be grouped at the top of the file. Move #include "utils/wait_event.h" up with the other includes.


📄 src/backend/access/recno/recno_clock.c (L548-L548)

The lock is initialized with LWTRANCHE_BUFFER_MAPPING, a semantically unrelated tranche (buffer mapping). This mislabels the lock in pg_stat_activity wait events and diagnostics. Sibling recno subsystems should use a dedicated named tranche (lwlocknames.txt / RequestNamedLWLockTranche). Using BUFFER_MAPPING here is incorrect.


📄 src/backend/access/recno/recno_clock.c (L134-L138)

Dead/duplicated init API. RecnoClockShmemInit() is a 'retained for backward compatibility' no-op for brand-new code (no prior release exists to be compatible with). It mixes the legacy ShmemInitStruct path with the PG_SHMEM_SUBSYSTEM callback (RecnoClockShmemCallbacks), and both paths call RecnoClockShmemInit_cb, which can double-initialize state (e.g. re-open/re-mmap the clockbound fd, restart the monitor). If the subsystem callbacks are the real path, remove this legacy function (YAGNI / minimal diff).


📄 src/backend/access/recno/recno_clock.c (L635-L639)

These three GUC assign hooks have empty bodies with unused parameters and only comments describing intent; they perform no assignment work beyond what the GUC machinery already does. A GUC does not need an assign hook merely to store its value. This is unnecessary scaffolding; drop the hooks and the GUC-table wiring unless they gain real behavior.


📄 src/backend/access/recno/recno_clock.c (L60-L60)

CLOCKBOUND_SHM_PATH '/dev/shm/clockbound' is Linux-specific: macOS, the BSDs, and Solaris do not provide /dev/shm as a POSIX shm mount, yet the #ifndef WIN32 guard treats every non-Windows platform as having it. Combined with reading the daemon's ClockBoundData layout directly with no endianness/padding/versioning contract, this integration is fragile across the platforms PostgreSQL targets. Document the hard Linux dependency and gate it accordingly.


📄 src/backend/access/recno/recno_compress.c (L855-L857)

Endianness bug: on-disk delta encoding is native-endian. memcpy(out_ptr + 2, &abs_val, bytes_needed) copies the low-order bytes_needed bytes only on little-endian; on big-endian (s390x, PPC64 BE) it copies the HIGH-order bytes, while RecnoDecompressDelta reads them back into the LOW bytes of a zeroed value. This mis-decodes on big-endian even on the same machine, and any little-endian-written value fails to decode on a big-endian replica/copy. RECNO tuples are persisted (recno_tuple.c) and physically replicated, so this is an on-disk/portability correctness bug. Serialize with fixed byte order (e.g. store via pg_hton*/store_att_byval-style explicit little-endian byte extraction) instead of memcpy of a native integer.


📄 src/backend/access/recno/recno_compress.c (L968-L970)

Data-corruption bug: input_size is truncated to a single byte here (& 0xFF), but the 0x89 branch is taken for any varlena value whose payload is not exactly 4 or 8 bytes. NUMERIC is a varlena type (attlen == -1) and is routed to DELTA by RecnoChooseCompressionType; a NUMERIC value with payload > 255 bytes will have its stored length silently truncated. RecnoDecompressDelta then reads stored_size = input[1] and memcpy's only the truncated length, corrupting the value. Store the full length (e.g. a fixed 4-byte little-endian field) instead of one byte.


📄 src/backend/access/recno/recno_compress.c (L1025-L1037)

Uninitialized-output bug: when orig_size is neither 4 nor 8, this negative-value branch (tag 0x80) falls through without writing to output at all, returning a palloc'd buffer with garbage contents. The same defect exists in the positive-value branch (tags 0x81-0x88) below. Only the tag<=0x7F branch memsets the fallback. Either handle the other-size case explicitly or convert the missing else into an elog(ERROR) for the can't-happen case.


📄 src/backend/access/recno/recno_compress.c (L1020-L1023)

Out-of-bounds read on corrupt/adversarial input: bytes_stored = input[1] and stored_size = input[1] come from stored data and are used directly as memcpy lengths without validating against comp_size (the header's comp_size field, also attacker-controllable). A crafted bytes_stored up to 255 reads past the compressed payload. RecnoDecompressDelta is reachable from read paths where header fields originate from persisted tuple bytes. Validate bytes_stored/stored_size against the available compressed length before memcpy.


📄 src/backend/access/recno/recno_compress.c (L452-L455)

Dict-compressed datums can hard-error on a legitimate read path. When a trained dictionary is active, header->dict_id is set on write; on read, RecnoDecompressAttribute errors if relid == InvalidOid. But recno_tuple.c calls this with rel ? RelationGetRelid(rel) : InvalidOid, and recno_slot.c passes slot->tts_tableOid (InvalidOid for transient slots). Any decode of a dict-compressed value with no relation context throws ERRCODE_DATA_CORRUPTED on valid data. Confirm every read path that can encounter dict-compressed tuples always supplies a valid relid, or make the datum self-describing.


📄 src/backend/access/recno/recno_compress.c (L155-L157)

Stale-cache / OID-reuse hazard: trained_cache and dict_cache are keyed by relid and populated lazily, but RecnoResetCompressionDict() is never called from any relcache-invalidation callback, transaction end, or DDL hook (verified: it has no callers in the tree). After DROP+CREATE reuses an OID, a cached trained blob from the old relation can be used to decompress data from the new one, silently corrupting values. Register a CacheRegisterRelcacheCallback (or equivalent invalidation) that evicts entries for the invalidated relid.


📄 src/backend/access/recno/recno_compress.c (L375-L377)

Silent narrowing without bounds check. dictid is uint32 (recno_dict_append/get_active return uint32) but is stored into a uint16 header field; comp_level likewise narrows an int to uint8. Current RECNO_DICT_MAX_DIRECTORY is 256 so this is not reachable today, but the truncation would alias dictionary ids and decompress with the wrong dictionary (silent corruption) if the id space ever grew. Add an Assert(dictid <= UINT16_MAX) (and level bound) to make the invariant explicit.


📄 src/backend/access/recno/recno_compress.c (L85-L88)

Aspirational/future-tense comments in committed code. Blocks like "A future enhancement could store dictionaries...", "To enable dictionary compression for persistent storage...", and the surrounding notes describe behavior that does not exist. PostgreSQL convention is that comments describe what the code does now and explain why, not speculate about future work. Trim these to the current behavior.


📄 src/backend/access/recno/recno_cts.c (L225-L226)

RecnoResetCompressionDict is defined and declared but has no callers anywhere in the tree, so both dict_cache and trained_cache are never invalidated. There is no relcache-invalidation hook (CacheRegisterRelcacheCallback) either. After a DROP/CREATE that reuses an OID, a stale trained-dictionary blob keyed by the old relid can be used to decompress data written under a different relation, giving wrong results. Wire this into relcache invalidation, or key the cache on something DROP-stable, before this can be relied on.

Confidence: moderate.


📄 src/backend/access/recno/recno_dict.c (L282-L282)

Endianness-dependent on-disk encoding. abs_val/uval are native-endian, and only the low bytes_needed bytes are memcpy'd; on big-endian these are the high-order (zero for small values) bytes, so the decoder (which memcpy's back into the low bytes of a zeroed uint64) reconstructs the wrong value even on a single big-endian machine, and physical replication / file copy across architectures corrupts numeric data. Confirmed reachable: compressed attributes are persisted to disk. Encode with an explicit little-endian byte layout (e.g. store byte-by-byte) so the on-disk form is architecture-independent.


📄 src/backend/access/recno/recno_dict.c (L282-L282)

bytes_stored comes from stored/on-disk data (input[1]) and is used directly as the memcpy length without validating it against comp_size or against sizeof(uint64). A corrupt tag length byte causes an out-of-bounds read into abs_val (which is only 8 bytes) or past the compressed buffer. Bound-check bytes_stored (1..8, and <= comp_size - 2) before the memcpy.


📄 src/backend/access/recno/recno_dict.c (L344-L345)

RecnoResetCompressionDict is defined (and declared in the header) but has no caller in the tree, so the per-backend dict_cache and trained_cache are never invalidated. Both caches are keyed by relid; after a DROP/CREATE that reuses an OID, a stale trained-dictionary blob can be used to decompress data written under a different relation, producing corruption. Wire this to relcache invalidation (CacheRegisterRelcacheCallback) or transaction/DDL cleanup, or drop the unused reset machinery if the caches are meant to be process-lifetime only.


📄 src/backend/access/recno/recno_diff.c (L211-L220)

Out-of-bounds read: the loop walks ptr through the diff buffer using untrusted diff->ndiffs and each seg->ins_len, but never validates that these reads stay within the diff record. seg is dereferenced (offset/del_len/ins_len) and ptr += seg->ins_len advances the read cursor with no check against diff->total_size (or a caller-supplied buffer length).

The diff pointer originates from the persisted UNDO fork on both apply paths (recno_undo.c apply_recno_undo, recno_pvs.c version walk), and those callers only validate the fixed header size (image_len < sizeof(RecnoDiffRecord) / payload_size < ... + sizeof(RecnoDiffRecord)), not the segment payload. A corrupted or truncated on-disk record with an inflated ndiffs/ins_len therefore causes reads past the allocated buffer during UNDO/rollback replay -- crash or information exposure.

Add a buffer bound: pass in the payload length (or use diff->total_size) and verify ptr + SizeOfRecnoDiffSegment <= end before dereferencing seg, and ptr + seg->ins_len <= end before reading old_bytes, for every iteration. total_size itself must first be validated against the actual payload length by the callers.


📄 src/backend/access/recno/recno_diff.c (L11-L12)

The file-header example is inaccurate: the on-disk fields are all uint16 (2 bytes), so RecnoDiffRecord header = 6 bytes and each RecnoDiffSegment header = 6 bytes (see SizeOfRecnoDiffRecord/SizeOfRecnoDiffSegment). A 4-byte change stores ~16 bytes, not "4 bytes offset + 4 bytes header". Correct the illustrative byte counts to match the actual field widths.


📄 src/backend/access/recno/recno_dirtymap.c (L272-L277)

The lock-free reader here uses the no-barrier pg_atomic_read_u64, and RecnoDirtyMapMark publishes with the no-barrier pg_atomic_write_u64. Neither provides a memory barrier (that is pg_atomic_read_membarrier_u64/pg_atomic_write_membarrier_u64). The entire lock-free correctness argument therefore rests on an external, in-code-undocumented happens-before edge: the writer's buffer-exclusive-lock release paired with the scanner's buffer-lock acquire. But at the primary call site (recno_handler.c:688) RecnoDirtyMapCheck runs in page-mode from a buffer that is "pinned but NOT locked" (see recno_scan_getnextslot). An in-place UPDATE that marks the page after the scanner's SHARE lock was dropped but before this check runs supplies no barrier the reader observes, so the reader can read a stale EMPTY slot, skip the sLog before-image probe, and serve the newly-updated on-page value to an old snapshot -> wrong query results. Either add an explicit pg_read_barrier() before probing / pg_write_barrier() before publishing the key, or document precisely which acquired lock provides the pairing barrier at every Check call site and prove no marking can occur between that acquire and the check. Confidence: moderate (depends on prune/pin interaction outside this file, but the missing-barrier + pinned-not-locked read is verified).


📄 src/backend/access/recno/recno_dirtymap.c (L267-L268)

The full flag is read on the lock-free path without any barrier, while it is written under part->mutex in Mark. Spinlock acquire/release provide barriers for the writer, but this unlocked reader has none. The false->true stickiness makes a stale-false read merely fall through to the probe loop (safe), as the comment argues -- but on weak-memory platforms this relies on the same external buffer-lock barrier as the slot reads. If you add an explicit pg_read_barrier() for the slot probe (see the other comment), the full read is covered by the same edge; please make that dependency explicit here rather than leaving it implicit.


📄 src/backend/access/recno/recno_dirtymap.c (L70-L71)

The key is composed from relid (pg_class OID, passed as RelationGetRelid(...) at all Mark/Check call sites), not relfilenode. Because the map is process-global, grow-only and never reset except on restart, this couples correctness-critical tracking to an identifier that is (a) reused after DROP/CREATE and (b) stable across table rewrites (VACUUM FULL/CLUSTER/ALTER) that change physical storage. Both cases currently only over-set bits (safe, since the failure direction is an extra sLog probe), but they permanently accumulate stale keys and push partitions toward the sticky-full state, at which point that partition degrades to always-dirty for all relations hashing into it -- silently defeating the fast path with no way to reclaim short of a restart. At minimum document this OID-reuse/rewrite behavior and the monotonic-fill consequence here; ideally key on relfilenode (or add an epoch) so recycled storage does not inherit stale entries.


📄 src/backend/access/recno/recno_dirtymap.c (L115-L115)

The >> 7 here is a hidden hard coupling to RECNO_DIRTYMAP_PARTITIONS == 128 (log2(128) = 7): it skips exactly the low bits consumed by recno_dirtymap_part's mixed & (PARTITIONS-1) mask. If someone retunes RECNO_DIRTYMAP_PARTITIONS, this 7 silently desyncs and partition/slot selection start reusing the same hash bits, degrading distribution with no error. Derive the shift from the partition count (e.g. pg_ceil_log2_32/a compile-time constant) or document the coupling. Separately, both recno_dirtymap_part (mask by PARTITIONS-1) and the slot mask assume power-of-two sizes but nothing enforces it; add StaticAssertDecl that RECNO_DIRTYMAP_PARTITIONS and RECNO_DIRTYMAP_PART_SLOTS are powers of two.


📄 src/backend/access/recno/recno_fsm.c (L161-L170)

RecnoVacuumFSM is dead code as wired up. Its only caller (recno_operations.c:6335) invokes it as RecnoVacuumFSM(onerel, RelationGetNumberOfBlocks(onerel)) after RecnoTruncateRelation -> RelationTruncate has already truncated the relation (and its FSM fork). This function then re-reads old_nblocks = RelationGetNumberOfBlocks(rel), which returns the same post-truncation value that was passed in as new_nblocks. Hence new_nblocks < old_nblocks is never true and FreeSpaceMapPrepareTruncateRel() is never called; the function is a no-op on every path.

Even if the branch did fire, the contract is not honored: FreeSpaceMapPrepareTruncateRel() returns the new FSM block count and requires the caller to then smgrtruncate() the FSM fork and call FreeSpaceMapVacuumRange() (see freespace.c). The return value is discarded here and neither follow-up is performed, so FSM upper pages could still point past the truncated heap.

Either remove this function (YAGNI - RelationTruncate already handles FSM truncation) or fix it to take the true pre-truncation block count from the caller and complete the documented truncation sequence.


📄 src/backend/access/recno/recno_fsm.c (L30-L31)

miscadmin.h is unused here (no CHECK_FOR_INTERRUPTS, MyDatabaseId, work_mem, etc. are referenced) and is also placed out of the alphabetical ordering PostgreSQL uses for system/postgres headers. Drop the include to avoid needless churn, or if kept, sort it among the other access/, storage/, utils/ headers.

💡 Suggested change

Before:

#include "utils/rel.h"
#include "miscadmin.h"

After:

#include "utils/rel.h"

📄 src/backend/access/recno/recno_lock.c (L334-L341)

RecnoHoldsTupleLock uses a LOCKMODE mapping that diverges from the acquire path. RecnoLockTuple/RecnoUnlockTuple map KeyShare->AccessShareLock, Share->RowShareLock, NoKeyExclusive->ExclusiveLock, Exclusive->AccessExclusiveLock (matching heap's tupleLockExtraInfo). But this function checks for ShareLock/ExclusiveLock instead. LockHeldByMe() therefore checks a LOCKMODE that RecnoLockTuple never acquires, so this function will always return false for locks it acquired. This is a correctness hazard (broken assertions, redundant re-acquisition). The mapping must match RecnoLockTuple's exactly.

💡 Suggested change

Before:

		case LockTupleKeyShare:
		case LockTupleShare:
			lockmode = ShareLock;
			break;
		case LockTupleNoKeyExclusive:
		case LockTupleExclusive:
			lockmode = ExclusiveLock;
			break;

After:

		case LockTupleKeyShare:
			lockmode = AccessShareLock;
			break;
		case LockTupleShare:
			lockmode = RowShareLock;
			break;
		case LockTupleNoKeyExclusive:
			lockmode = ExclusiveLock;
			break;
		case LockTupleExclusive:
			lockmode = AccessExclusiveLock;
			break;

📄 src/backend/access/recno/recno_lock.c (L326-L326)

RecnoHoldsTupleLock has no callers anywhere in the tree (only its prototype in recno.h). Combined with the wrong-mapping bug above, this is untested dead scaffolding. Per YAGNI, drop it until a caller exists; a locking primitive that never worked and is never invoked should not ship.


📄 src/backend/access/recno/recno_lock.c (L181-L181)

RecnoLockPage/RecnoUnlockPage introduce a heavyweight LOCKTAG_PAGE lock protocol but have no callers (only prototypes in recno.h, redundantly re-externed in recno_operations.c). The comment describes an aspirational 'defragmentation or cross-page tuple moves' use that does not exist yet. Page-level heavyweight locks are a deadlock footgun without a documented, deadlock-free ordering vs. buffer content locks and RecnoLockTuple. Drop these until there is a caller with a defined lock-ordering contract.


📄 src/backend/access/recno/recno_lock.c (L250-L256)

RecnoLockMultipleTuples has no callers in the tree (only a prototype). It is speculative scaffolding. Two additional problems if it were used: (1) the hand-rolled O(n^2) bubble sort duplicates existing infrastructure - use pg_qsort with an ItemPointerCompare-based comparator (DRY, and O(n log n)); the 'N is typically small' claim is unverified since there is no caller. (2) If RecnoLockTuple throws (e.g. deadlock/OOM via ereport on the wait=true path), the palloc'd 'locked' array leaks and already-acquired locks are only released on the non-error all_locked==false path - the error path relies entirely on transaction abort. Remove until needed, or fix both issues.


📄 src/backend/access/recno/recno_lock.c (L305-L305)

RecnoLockRelationForDDL is a one-line wrapper over LockRelationOid with no callers. This is premature abstraction (a helper used zero times); callers should just call LockRelationOid directly. Drop it.


📄 src/backend/access/recno/recno_lock.c (L103-L107)

The non-wait path treats every result != LOCKACQUIRE_NOT_AVAIL as success and sets *have_tuple_lock = true, including LOCKACQUIRE_ALREADY_HELD. If the transaction already held the lock, the caller will later call RecnoUnlockTuple and LockRelease will drop a lock reference the transaction still legitimately needs, causing premature lock release. The wait=true branch has the same issue (unconditionally sets have_tuple_lock even when the lock was already held). Distinguish LOCKACQUIRE_ALREADY_HELD from LOCKACQUIRE_OK so have_tuple_lock reflects whether this call took a new reference.


📄 src/backend/access/recno/recno_lock.c (L70-L86)

The 20-line LockTupleMode->LOCKMODE switch (with its multi-line comment) is duplicated verbatim in RecnoLockTuple and RecnoUnlockTuple, and a third divergent copy exists in RecnoHoldsTupleLock. That divergence is exactly how the high-severity bug crept in. Factor the mapping into a single static helper (e.g. recno_tuple_lockmode(LockTupleMode)) and call it from all three sites to prevent drift.


📄 src/backend/access/recno/recno_pvs.c (L159-L166)

Out-of-bounds read on corrupted/truncated UNDO data. This only validates the fixed RecnoDiffRecord header size against payload_size. RecnoApplyDiffReverse then trusts diff->ndiffs and each seg->ins_len and walks ptr += SizeOfRecnoDiffSegment + seg->ins_len per segment with no bound against the actual payload buffer, so a corrupt record reads past the palloc'd payload. This is exactly the corruption case the file header says the depth cap defends against, but the segment bytes are unvalidated. RecnoDiffRecord.total_size exists for this; validate it against the available payload, e.g. require payload_size >= SizeOfRelUndoDeltaUpdatePayload + diff->total_size (and diff->total_size >= SizeOfRecnoDiffRecord) before calling RecnoApplyDiffReverse.

💡 Suggested change

Before:

					if (payload_size <
						SizeOfRelUndoDeltaUpdatePayload + sizeof(RecnoDiffRecord))
					{
						elog(WARNING,
							 "RECNO PVS: DELTA_UPDATE diff truncated at %llu",
							 (unsigned long long) verptr);
						break;
					}

After:

					if (payload_size <
						SizeOfRelUndoDeltaUpdatePayload + sizeof(RecnoDiffRecord))
					{
						elog(WARNING,
							 "RECNO PVS: DELTA_UPDATE diff truncated at %llu",
							 (unsigned long long) verptr);
						break;
					}
					diff = (const RecnoDiffRecord *)
						((const char *) payload + SizeOfRelUndoDeltaUpdatePayload);
					if (diff->total_size < SizeOfRecnoDiffRecord ||
						payload_size <
						SizeOfRelUndoDeltaUpdatePayload + diff->total_size)
					{
						elog(WARNING,
							 "RECNO PVS: DELTA_UPDATE segments truncated at %llu",
							 (unsigned long long) verptr);
						break;
					}

📄 src/backend/access/recno/recno_pvs.c (L181-L182)

du is assigned but never used (the (void) du; no-op confirms it). Per YAGNI, drop the unused local and cast rather than keeping dead scaffolding.

💡 Suggested change

Before:

					(void) du;
					stepped = true;

After:

					stepped = true;

📄 src/backend/access/recno/recno_slot.c (L76-L77)

This is a bare tentative definition, not a forward declaration. recno.h (which is included above) already declares extern PGDLLIMPORT const TupleTableSlotOps TTSOpsRecnoTuple;. Redeclaring it here without extern creates a conflicting non-extern tentative definition of the same object. On MSVC this conflicts with the __declspec(dllimport) attribute (dllimport applied to a definition is an error/warning), and in general it duplicates the definition that appears at line 806. Drop this line entirely (the header prototype and the definition at line 806 are sufficient), or if a forward reference is truly needed, write extern const TupleTableSlotOps TTSOpsRecnoTuple;.

💡 Suggested change

Before:

const TupleTableSlotOps TTSOpsRecnoTuple;
static void tts_recno_deform(TupleTableSlot *slot, int natts);

After:

static void tts_recno_deform(TupleTableSlot *slot, int natts);

📄 src/backend/access/recno/recno_slot.c (L280-L283)

Correctness/performance footgun in the deform hot path. RecnoFetchOverflowColumn() palloc's its result in the current memory context (recno_overflow.c: palloc(VARHDRSZ + total_len)), which during deform is the caller's context, not slot->tts_mcxt. When the slot later materializes, tts_recno_materialize() only copies tuple_len bytes of the header and sets tts_nvalid=0 (discarding these Datums) - so on the buffer path these fetched/decompressed values leak if the surrounding context outlives the fetch, or dangle if it is reset before re-deform. Additionally, opening/closing the relation per overflow attribute per tuple via relation_open(NoLock) inside deform is a heavy relcache/syscache hit on a hot path, and NoLock reopen during scan deform assumes the relation is already locked by the caller. Prefer threading the already-open Relation through the scan (RecnoSlotStoreTuple stores no rel) instead of reopening here, and ensure fetched Datums are allocated in a context tied to the slot's lifetime.


📄 src/backend/access/recno/recno_slot.c (L458-L463)

tts_recno_getsysattr fabricates xmin/xmax. xmin is always the current backend's XID and xmax is the current XID when RECNO_TUPLE_DELETED, regardless of which transaction actually wrote or deleted the tuple. Any consumer using xmin/xmax semantics (RLS, EvalPlanQual, ctid/xmin-based logic, FDW/trigger paths) will get wrong answers. Worse, GetCurrentTransactionId() assigns an XID as a side effect (xact.c:515-516 calls AssignTransactionId), so a read-only SELECT that touches system column xmin/xmax will unexpectedly consume an XID and can no longer be treated as read-only. If a real xmin cannot be represented, this should error (as it does in the default case) rather than return a misleading value; at minimum use GetCurrentTransactionIdIfAny() to avoid forcing XID assignment.


📄 src/backend/access/recno/recno_slot.c (L644-L646)

Materialize contract violation for overflow tuples. tts_recno_materialize() copies only tuple_len header bytes and releases the buffer pin, but for a tuple with RECNO_TUPLE_HAS_OVERFLOW the actual attribute data lives on separate overflow pages, not in these bytes. After materialize, subsequent deform re-fetches overflow data via relation_open(tts_tableOid). Thus a materialized RECNO slot is NOT independent of external storage (the relation/overflow pages) - contradicting the function's stated guarantee ("the slot's data is independent of any external storage"). If the relation is dropped or the slot outlives the relation lock, overflow deref will fail. Either fully resolve overflow attributes into the materialized copy, or document/adjust the contract.


📄 src/backend/access/recno/recno_slot.c (L464-L465)

cmin and cmax both return the same value (slog_entry.cid). SLogTupleOp carries only a single cid field, so RECNO cannot distinguish cmin from cmax. Returning cid for cmax where a caller expects the deleting command id may be misleading (POLA). Confirm this is intended for RECNO semantics; if cmax genuinely has no distinct meaning here, a comment noting that cmin==cmax by design would prevent future confusion. Note the per-tuple SLog lookup itself is cheap (wait-free seqlock read bounded by SLOG_MAX_TUPLE_OPS), so the earlier performance worry does not apply.


📄 src/backend/access/recno/recno_slot.c (L849-L849)

Non-ASCII em-dash (U+2014) in source comment. PostgreSQL sources must be ASCII-only (see the comment about smart quotes/em-dashes/ellipses). Replace with a plain ASCII hyphen or ' -- '. Same issue at line 859.

💡 Suggested change

Before:

		/* Same buffer — just free any materialized data */

After:

		/* Same buffer - just free any materialized data */

📄 src/backend/access/recno/recno_slot.c (L859-L859)

Non-ASCII em-dash (U+2014) in source comment; use an ASCII hyphen instead to keep the source ASCII-only.

💡 Suggested change

Before:

		/* Different buffer — full clear (releases old pin) and acquire new */

After:

		/* Different buffer - full clear (releases old pin) and acquire new */

📄 src/backend/access/recno/recno_stats.c (L14-L15)

Dead code: neither RecnoCollectRelationStats nor RecnoLogRelationStats has any caller in the tree. RECNO's ANALYZE support already runs through the standard scan_analyze_next_block/scan_analyze_next_tuple callbacks in recno_handler.c; this full-relation scan is never invoked. Per YAGNI, drop this file (and the RecnoRelationStats declarations) until a real consumer exists, or wire it up in this same patch.

The header comment also makes a false claim: it states the stats are "made available through the RecnoCollectRelationStats() interface so that the planner can incorporate RECNO-specific cost adjustments." No planner code consumes RecnoRelationStats, and RecnoCollectRelationStats is not called at all. Comments must describe what the code does now, not aspirational behavior.


📄 src/backend/access/recno/recno_stats.c (L84-L90)

Full sequential scan of every block on top of ANALYZE's normal sampling is a serious performance footgun: it reads and share-locks every page of the relation, defeating the whole point of block sampling and potentially multiplying ANALYZE cost/buffer traffic by orders of magnitude on large tables. If this stays, it must be gated (GUC/threshold) and backed by a reproducible benchmark. As written (with no caller) it is pure cost with no benefit.


📄 src/backend/access/recno/recno_stats.c (L250-L252)

Convention: use INT64_FORMAT for int64 rather than the %lld + (long long) cast pattern; the same feature already uses UINT64_FORMAT in recno_hlc.c. Applies to the total_pages/total_live_tuples/total_dead_tuples message here.

💡 Suggested change

Before:

					(long long) stats->total_pages,
					(long long) stats->total_live_tuples,
					(long long) stats->total_dead_tuples)));

After:

					stats->total_pages,
					stats->total_live_tuples,
					stats->total_dead_tuples)));

📄 src/backend/access/recno/recno_stats.c (L282-L283)

Convention: use UINT64_FORMAT for uint64 instead of %llu + (unsigned long long) cast, consistent with recno_hlc.c which already prints HLC timestamps via UINT64_FORMAT.

💡 Suggested change

Before:

						(unsigned long long) stats->hlc_min,
						(unsigned long long) stats->hlc_max)));

After:

						stats->hlc_min,
						stats->hlc_max)));

📄 src/backend/access/recno/recno_vm.c (L122-L127)

Missing critical section around the modify + WAL + PageSetLSN sequence. Heap's canonical VM code emits WAL inside a caller-owned critical section (visibilitymap_set asserts InRecovery || CritSectionCount > 0, and the truncate path uses START_CRIT_SECTION() with the comment "NO EREPORT(ERROR) from here till changes are logged"). Here MarkBufferDirty(vmBuf) runs, then XLogInsert() can ereport(ERROR) (e.g. on a palloc failure inside WAL assembly) before PageSetLSN() executes. That leaves the VM page dirty with no WAL record and no LSN, violating the WAL-before-data rule and risking a torn/inconsistent VM (a bgwriter flush of the un-logged page followed by a crash can leave ALL_VISIBLE set with no redo record). Wrap the modify-through-PageSetLSN block in START_CRIT_SECTION()/END_CRIT_SECTION(). Same defect exists in RecnoVMClear below.


📄 src/backend/access/recno/recno_vm.c (L212-L215)

Same missing-critical-section defect as RecnoVMSet: MarkBufferDirty(vmBuf) is followed by an XLogInsert() that can ereport(ERROR) before PageSetLSN(), leaving the VM page dirty without a WAL record. Wrap the clear-through-PageSetLSN sequence in START_CRIT_SECTION()/END_CRIT_SECTION().


📄 src/backend/access/recno/recno_vm.c (L72-L79)

Dead scaffolding: RecnoVMInit is an empty exported function (body is only a comment) and has no callers anywhere in the tree. An exported no-op that takes a Relation is a footgun -- any future caller relying on it to create the VM fork gets nothing. Per YAGNI, remove RecnoVMInit (and its declaration in recno.h) until it does something. Note RecnoVMPinBuffer, RecnoVMExtend, RecnoVMTruncate, RecnoVMGetPageSize and RecnoVMMapHeapToVM are likewise never called and should also be dropped from this patch.


📄 src/backend/access/recno/recno_vm.c (L426-L429)

RecnoVMTruncate is never called (confirmed: no callers in the tree) and, if it were, it is not WAL-logged. Unlike heap, whose VM truncation is integrated into the WAL-logged smgr relation-truncate path (visibilitymap_truncate_prepare + the truncation record replayed at recovery), this bare smgrtruncate would not be replayed on a standby or after a crash. A VM that is not truncated in lockstep with the heap can leave stale ALL_VISIBLE bits for blocks that no longer exist (or get re-extended), causing scans to skip visibility checks incorrectly. Either remove this dead function or integrate VM truncation into the WAL-logged RECNO truncate path. Also, FlushRelationBuffers(rel) flushes all forks, not just the VM fork -- heavier than needed.


📄 src/backend/access/recno/recno_vm.c (L538-L540)

Speculative dead parameter. tuple is only consumed by (void) tuple; and the code unconditionally clears the VM bits regardless of its contents. Per minimalism, drop the parameter (and update the RecnoTupleHeader-passing caller in recno_operations.c) until the "future timestamp checking" is actually implemented; carrying an unused parameter plus a RecnoTupleHeader include dependency is scaffolding for a path that isn't wired yet.


📄 src/backend/access/recno/recno_vm.c (L547-L551)

Aspirational/future-tense comment describing behavior that does not exist. Comments must describe what the code does now; "A future optimization could..." and "For now, conservatively clear the bits" are TODO-style prose the project rules disallow. Reduce to a statement of current behavior (bits are always cleared on insert). Also note the -- em-dash-style separators elsewhere in this file (e.g. the FPI comment in RecnoVMSet and the RecnoVMUpdateForDelete comment) should be plain ASCII prose.


📄 src/backend/access/recno/recno_vm.c (L124-L125)

mapByte < MAPSIZE is guaranteed only by the coupling HEAPBLK_TO_MAPBYTE = (heapBlk % HEAPBLOCKS_PER_PAGE)/4 and HEAPBLOCKS_PER_PAGE = MAPSIZE*4. This invariant is undocumented and would silently corrupt adjacent page memory if HEAPBLOCKS_PER_PAGE were ever changed independently. Add an Assert(mapByte < MAPSIZE) before the array write to make the bound explicit and catch regressions. (This applies to all four sites that index map[mapByte].)


📄 src/backend/access/recno/recno_tuple.c (L605-L610)

RecnoDeformTuple does not handle overflow pointers. It only computes tuple_has_compressed and checks the compression header; there is no RECNO_INFOMASK_HASOVERFLOW/RecnoIsOverflowPtr branch (unlike RecnoTupleToSlotWithOverflow). For an overflowed column this returns the raw on-page overflow-pointer varlena (inline prefix + RecnoOverflowPtr) as the attribute Datum instead of the full value.

Two callers make this a correctness bug:

  1. RecnoXLogPrepareLogicalImage (recno_xlog.c:97) builds the logical-decoding heap image from these values -> logical replication emits the overflow-pointer bytes, not the real column data (data corruption on the subscriber).
  2. recno_compute_index_update (recno_operations.c:1767) compares old vs new indexed columns with datumIsEqual -> an overflowed indexed column is compared against its pointer, misdetecting index changes.

RecnoDeformTuple must fetch overflow columns (needs the Relation rel it already receives) exactly like RecnoTupleToSlotWithOverflow does.


📄 src/backend/access/recno/recno_tuple.c (L867-L869)

RecnoPageUpdateTuple, RecnoPageDeleteTuple and RecnoPageGetLiveTuples have no callers anywhere in the tree (only their definitions here and declarations in recno.h). The real UPDATE path in recno_operations.c reimplements the update-in-place / delete-and-re-add logic inline (with its own START_CRIT_SECTION and WAL logging). This is dead speculative code (YAGNI): it mutates shared buffer contents (memcpy over old_tuple, ItemIdSetNormal, PageAddItem) with no critical section and no WAL logging, so if ever wired up as-is it would produce torn/replica-divergent pages. Drop these three functions (and their declarations) or, if intended for future use, they cannot ship in this state.


📄 src/backend/access/recno/recno_tuple.c (L567-L568)

When a value is both compressed AND overflowed, work_values[i] is replaced twice: Phase 1 sets work_values[i] = compressed (is_compressed[i]=true), then Phase 1b overwrites work_values[i] = RecnoStoreOverflowColumn(...). This cleanup loop then frees only the final (overflow) datum, leaking the intermediate compressed varlena. Bounded to the current memory context, but still a per-tuple leak on the hot insert path for large compressible columns. Free the intermediate before overwriting in Phase 1b, or track and free both.


📄 src/backend/access/recno/recno_tuple.c (L805-L809)

The defrag-flag heuristic uses unexplained magic numbers (tuple_size * 2, FirstOffsetNumber + 5) with a WHAT comment and no rationale for the constants. Worse, this exact condition is hand-copied into the redo path (recno_xlog.c:1782-1785) to keep the replica page byte-identical, and that copy's comment already cites a stale line reference ("recno_tuple.c:513-517", which no longer matches). Any tweak to this heuristic silently diverges primary and replica. Extract the condition into a single shared helper (or macro) used by both sites, and document why 2x/+5 were chosen.


📄 src/backend/access/recno/recno_tuple.c (L999-L1002)

RecnoPageIndexTupleDelete is a near-verbatim copy of core PageIndexTupleDelete (bufpage.c), differing only by skipping LP_UNUSED items in the offset-adjustment loop. Copy-pasting core page-layout math (pd_linp shifting, tuple-data memmove, lp_off adjustment) is a maintenance hazard: it will silently drift from upstream fixes and any divergence corrupts pages. The offset condition and memmove logic currently match core exactly, so this is a DRY concern rather than a live bug. Prefer extending the core routine (or adding a shared helper) over forking it, or at minimum add a prominent note to re-sync when bufpage.c changes.


📄 src/backend/access/recno/recno_tuple.c (L231-L231)

hash_bytes() takes an int length but new_len is a Size; the (int) cast truncates for values > INT_MAX. This matches the store-side cast in recno_overflow.c:694 (so the gate is self-consistent for now), but it is a shared footgun: an over-2GB varlena would compute a mismatched/wrapped length. Since this is only a prefilter (byte-verified afterward) it cannot produce a wrong result, but guard against or document the >INT_MAX case rather than silently truncating.


📄 src/backend/access/recno/recno_tuple.c (L477-L480)

Comment inaccuracy: recno defines its own null-bitmap semantics (bit=0 means NULL, set just below), so "PostgreSQL expects all bits set to 1 initially (all NOT NULL)" is misleading -- core heap uses the opposite convention. State the recno convention directly (e.g., "recno uses bit=1 for NOT NULL; initialize all-ones, then clear bits for NULL attributes").


📄 src/backend/access/recno/recno_undo.c (L176-L184)

offnum is used to index the page's line-pointer array via PageGetItemId() without first validating it against the page bounds. If the target page shrank (e.g. VACUUM ran between abort and this apply), offnum may exceed PageGetMaxOffsetNumber(page), and PageGetItemId() will read past the valid line-pointer array (out-of-bounds read), after which ItemIdIsNormal() acts on garbage. Every other RECNO page path guards this (e.g. recno_operations.c:922: offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)). Add the same guard here (and in apply_recno_undo_restore_tuple and the DELTA_UPDATE branch) before dereferencing.

💡 Suggested change

Before:

	lp = PageGetItemId(page, offnum);
	if (!ItemIdIsNormal(lp))
	{
		/*
		 * Already cleaned up (e.g. VACUUM ran between the abort and the
		 * logical-revert worker's pass).  Nothing to do.
		 */
		return;
	}

After:

	if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page))
	{
		/* Page shrank; nothing to undo here. */
		return;
	}

	lp = PageGetItemId(page, offnum);
	if (!ItemIdIsNormal(lp))
	{
		/*
		 * Already cleaned up (e.g. VACUUM ran between the abort and the
		 * logical-revert worker's pass).  Nothing to do.
		 */
		return;
	}

📄 src/backend/access/recno/recno_undo.c (L416-L416)

The DELTA_UPDATE branch casts untrusted payload bytes directly to RecnoDiffRecord* and dereferences its uint16 fields (diff->old_total_len, and via RecnoApplyDiffReverse the RecnoDiffSegment uint16 fields). image_bytes = payload + SizeOfRecnoUndoPayloadHeader is not guaranteed to satisfy the struct's alignment on strict-alignment architectures (ARM/PPC/s390x/RISC-V), causing an unaligned access. The heap analogue in undo_xlog.c deliberately reads each field via memcpy for exactly this reason. Either copy the header/segments out with memcpy, or guarantee/assert the payload alignment.


📄 src/backend/access/recno/recno_undo.c (L393-L393)

offnum is not bounds-checked before PageGetItemId() here either. If the page shrank, this indexes past the line-pointer array. Guard with offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page) before the call, consistent with the rest of the RECNO code.


📄 src/backend/access/recno/recno_undo.c (L407-L407)

The diff is only validated with image_len < sizeof(RecnoDiffRecord), but RecnoApplyDiffReverse() then walks diff->ndiffs segments advancing ptr += SizeOfRecnoDiffSegment + seg->ins_len with no check that these reads stay within image_len (it only bounds against old_total_len/new_len, not the input diff buffer). A truncated/corrupt payload therefore reads past the end of the UNDO payload. Validate image_len >= diff->total_size (and that total_size covers the declared segments) before calling RecnoApplyDiffReverse.


📄 src/backend/access/recno/recno_undo.c (L457-L459)

The bool return of apply_recno_undo_restore_tuple() is ignored in both the UPDATE/DELETE and DELTA_UPDATE branches, and recno_undo_apply() unconditionally returns UNDO_APPLY_SUCCESS even when a branch hit a WARNING/skip path (missing before-image, slot-too-small, reverse-diff failure, or unknown subtype). undoapply.c distinguishes SUCCESS vs SKIPPED vs ERROR for its applied/skipped accounting and its 'error applying' WARNING (undoapply.c:94-117); reporting SUCCESS for a record whose physical undo was not applied silences those diagnostics and misreports rollback progress. Propagate the actual result (SKIPPED when a branch could not apply, ERROR on the invalid-payload cases).


📄 src/backend/access/recno/recno_undo.c (L127-L128)

For unlogged/temp relations this sets the page LSN to GetXLogInsertRecPtr() without emitting any WAL record for this page. The rest of RECNO (e.g. recno_handler.c:2667-2692) never touches the page LSN when !RelationNeedsWAL -- it only calls PageSetLSN inside the WAL branch after a real XLogInsert. Stamping a page LSN unrelated to any record on the page is inconsistent with that convention and can confuse LSN-based logic. Drop the PageSetLSN for the unlogged case and just return.

💡 Suggested change

Before:

		PageSetLSN(BufferGetPage(buffer), GetXLogInsertRecPtr());
		return;

After:

		return;

📄 src/backend/access/rmgrdesc/relundodesc.c (L97-L98)

Portability bug: urec_ptr is RelUndoRecPtr, which is typedef uint64 (see relundo_xlog.h line 37 / relundo.h line 80). Printing a 64-bit value with %lu and a cast to (unsigned long) truncates on every platform where long is 32-bit -- Windows/MSVC (LLP64) and all 32-bit targets -- producing wrong output in pg_waldump. Use UINT64_FORMAT as sibling rmgrdesc files do (e.g. recnodesc.c), or %llu with (unsigned long long) as undodesc.c does.

💡 Suggested change

Before:

				appendStringInfo(buf, "urec_ptr %lu",
								 (unsigned long) xlrec->urec_ptr);

After:

				appendStringInfo(buf, "urec_ptr " UINT64_FORMAT,
								 xlrec->urec_ptr);

📄 src/backend/access/rmgrdesc/relundodesc.c (L44-L64)

This hardcodes the RelUndoRecordType values as magic literals and is already stale: RelUndoRecordType (relundo.h lines 112-120) defines six values, including RELUNDO_DELTA_UPDATE = 6, which is missing here and will print "UNKNOWN" for a valid record. Reference the enum constants directly (RELUNDO_INSERT, RELUNDO_DELETE, RELUNDO_UPDATE, RELUNDO_TUPLE_LOCK, RELUNDO_DELTA_INSERT, RELUNDO_DELTA_UPDATE) so the mapping cannot silently drift from the source of truth, and add the missing case.

💡 Suggested change

Before:

				switch (xlrec->urec_type)
				{
					case 1:
						type_name = "INSERT";
						break;
					case 2:
						type_name = "DELETE";
						break;
					case 3:
						type_name = "UPDATE";
						break;
					case 4:
						type_name = "TUPLE_LOCK";
						break;
					case 5:
						type_name = "DELTA_INSERT";
						break;
					default:
						type_name = "UNKNOWN";
						break;
				}

After:

				switch (xlrec->urec_type)
				{
					case RELUNDO_INSERT:
						type_name = "INSERT";
						break;
					case RELUNDO_DELETE:
						type_name = "DELETE";
						break;
					case RELUNDO_UPDATE:
						type_name = "UPDATE";
						break;
					case RELUNDO_TUPLE_LOCK:
						type_name = "TUPLE_LOCK";
						break;
					case RELUNDO_DELTA_INSERT:
						type_name = "DELTA_INSERT";
						break;
					case RELUNDO_DELTA_UPDATE:
						type_name = "DELTA_UPDATE";
						break;
					default:
						type_name = "UNKNOWN";
						break;
				}

📄 src/backend/access/rmgrdesc/undodesc.c (L71-L84)

The operation_type decode table is incorrect and duplicates values that already have named constants. The producers fill xl_undo_apply.operation_type from these header macros:

  • recno_undo.h: RECNO_UNDO_INSERT=0x0001, RECNO_UNDO_UPDATE=0x0002, RECNO_UNDO_DELETE=0x0003, RECNO_UNDO_DELTA_UPDATE=0x0004
  • hash_undo.c: HASH_UNDO_INSERT=0x0001

So 0x0002 is UPDATE (not DELETE) and 0x0003 is DELETE (not UPDATE): this switch swaps UPDATE/DELETE and mislabels 0x0004 (DELTA_UPDATE) as PRUNE. pg_waldump will print the wrong operation name. 0x0005/0x0006 (INPLACE/HOT_UPDATE) have no corresponding producer constant. Replace the hard-coded literals with the named constants from the headers so the descriptor cannot drift from the producer side. (high confidence)


📄 src/backend/access/rmgrdesc/undodesc.c (L37-L41)

Format-specifier convention: start_ptr, discard_ptr, new_size, urec_ptr, old_seal_ptr are UndoRecPtr/uint64 (undodefs.h: typedef uint64 UndoRecPtr;). Casting to unsigned long long + %llu is portable but diverges from the tree convention of UINT64_FORMAT, while chain_prev (XLogRecPtr) is printed with LSN_FORMAT_ARGS. Recommend using UINT64_FORMAT for the uint64 fields (or LSN_FORMAT_ARGS if they represent LSN-like pointers) so this descriptor matches surrounding WAL desc code. (moderate confidence)


📄 src/backend/access/rmgrdesc/undodesc.c (L132-L133)

persistence is an enum UndoPersistenceLevel (0=PERMANENT, 1=UNLOGGED, 2=TEMP). Printing it as a bare %d emits an opaque number in pg_waldump output; consider mapping it to a readable string like the trigger/op_name cases above do, for consistency and readability. (low confidence)


📄 src/backend/access/table/tableam.c (L835-L837)

RelationAmSupportsUndo dereferences rel (via rel->rd_tableam) without checking rel for NULL. This function is meant to gate UNDO from index AMs on the parent table, and a NULL heap relation is a real case: nbtinsert.c explicitly guards its call with heaprel != NULL, while hashinsert.c calls RelationAmSupportsUndo(heapRel) with no such guard. If heapRel is NULL there (e.g. an index-only/vacuum path with no heap relation), this crashes. Since the helper's purpose is precisely to be called from these index-AM paths, it should treat a NULL relation as "no UNDO support" itself rather than relying on every caller to pre-check. (high confidence)

💡 Suggested change

Before:

	if (!rel->rd_tableam)
		return false;
	return rel->rd_tableam->am_supports_undo;

After:

	if (rel == NULL || rel->rd_tableam == NULL)
		return false;
	return rel->rd_tableam->am_supports_undo;

📄 src/backend/access/rmgrdesc/recnodesc.c (L55-L61)

Layout mismatch: xl_recno_hlc_info_fe declares 4 uint64 fields (adds a phantom commit_dvv), but the authoritative backend struct xl_recno_hlc_info (recno_xlog.h) has only 3 fields (commit_hlc, uncertainty_lower, uncertainty_upper) and no commit_dvv exists anywhere in the tree. The WAL writer appends exactly SizeOfXlRecnoHlcInfo (24 bytes) at the tail (recno_xlog.c registers SizeOfXlRecnoHlcInfo), while recno_desc_hlc() below reads SizeOfXlRecnoHlcInfoFE (32 bytes) from data + total_len - SizeOfXlRecnoHlcInfoFE. This computes the wrong tail offset (8 bytes too early) and shifts every field, so pg_waldump prints garbage HLC values. Remove commit_dvv and match the real 3-field layout (or better, include access/recno_xlog.h which already provides FRONTEND-safe structs).

💡 Suggested change

Before:

typedef struct xl_recno_hlc_info_fe
{
	uint64		commit_hlc;
	uint64		commit_dvv;
	uint64		uncertainty_lower;
	uint64		uncertainty_upper;
}			xl_recno_hlc_info_fe;

After:

typedef struct xl_recno_hlc_info_fe
{
	uint64		commit_hlc;
	uint64		uncertainty_lower;
	uint64		uncertainty_upper;
}			xl_recno_hlc_info_fe;

📄 src/backend/access/rmgrdesc/recnodesc.c (L65-L71)

Layout mismatch with the real record. The authoritative xl_recno_insert (recno_xlog.h) is {offnum, flags, uint32 tuple_len, uint64 commit_ts} (16 bytes). This copy drops tuple_len and appends a nonexistent xact_ts. Because the compiler pads the struct to 8-byte alignment before commit_ts, commit_ts happens to be read from the correct offset, but xact_ts (bytes 16-23) reads into the trailing tuple body, so pg_waldump prints garbage for xact_ts and never shows tuple_len. Match the real struct: {offnum, flags, tuple_len, commit_ts}.

💡 Suggested change

Before:

typedef struct xl_recno_insert_fe
{
	uint16		offnum;
	uint16		flags;
	uint64		commit_ts;
	uint64		xact_ts;
}			xl_recno_insert_fe;

After:

typedef struct xl_recno_insert_fe
{
	uint16		offnum;
	uint16		flags;
	uint32		tuple_len;
	uint64		commit_ts;
}			xl_recno_insert_fe;

📄 src/backend/access/rmgrdesc/recnodesc.c (L73-L79)

Layout mismatch. The real xl_recno_delete (recno_xlog.h) is {offnum, flags, uint32 tuple_len, uint64 commit_ts} (16 bytes). This copy drops tuple_len and adds a nonexistent xact_ts. The DELETE branch below prints xact_ts, which is read from bytes 16-23 (past the 16-byte record, into unrelated data) -> garbage output. Match the real struct: {offnum, flags, tuple_len, commit_ts}.

💡 Suggested change

Before:

typedef struct xl_recno_delete_fe
{
	uint16		offnum;
	uint16		flags;
	uint64		commit_ts;
	uint64		xact_ts;
}			xl_recno_delete_fe;

After:

typedef struct xl_recno_delete_fe
{
	uint16		offnum;
	uint16		flags;
	uint32		tuple_len;
	uint64		commit_ts;
}			xl_recno_delete_fe;

📄 src/backend/access/rmgrdesc/recnodesc.c (L81-L88)

Layout mismatch. The real xl_recno_update (recno_xlog.h) ends with {uint16 old_tuple_len, uint16 new_tuple_len} after new_commit_ts, not a uint64 xact_ts. The leading fields happen to line up so old/new_commit_ts print correctly, but this copy relies on undefined padding to stay compatible and diverges silently from the source. Match the real struct instead of inventing xact_ts.

💡 Suggested change

Before:

typedef struct xl_recno_update_fe
{
	uint16		offnum;
	uint16		flags;
	uint64		old_commit_ts;
	uint64		new_commit_ts;
	uint64		xact_ts;
}			xl_recno_update_fe;

After:

typedef struct xl_recno_update_fe
{
	uint16		offnum;
	uint16		flags;
	uint64		old_commit_ts;
	uint64		new_commit_ts;
	uint16		old_tuple_len;
	uint16		new_tuple_len;
}			xl_recno_update_fe;

📄 src/backend/access/rmgrdesc/recnodesc.c (L27-L28)

This entire block of duplicated structs and macros is unnecessary and is the root cause of the layout drift above. recno_xlog.h is already FRONTEND-safe (it provides #else /* FRONTEND */ copies of these structs) and it already declares recno_desc()/recno_identify(). The project convention (heapdesc.c includes access/heapam_xlog.h; nbtdesc.c includes access/nbtxlog.h) is to include the real xlog header, not re-declare structs and opcodes. Replace this file's hand-copied macros/structs and the file-scope extern prototypes by #include "access/recno_xlog.h", eliminating the dual-maintenance footgun that already produced the mismatches above.


📄 src/backend/access/rmgrdesc/recnodesc.c (L6-L7)

Inaccurate file header. recno_desc/recno_identify are NOT implemented in recno_xlog.c -- this file is their only definition (recno_xlog.c contains no such symbols, and recno_xlog.h declares them). The claim that "full implementations are in recno_xlog.c" is misleading. Fix the comment to describe what actually happens now.

💡 Suggested change

Before:

 * This provides minimal desc/identify functions for frontend tools like pg_waldump.
 * The full implementations are in recno_xlog.c for backend use.

After:

 * This provides the desc/identify functions used by both the backend rmgr
 * and frontend tools like pg_waldump.

📄 src/backend/access/rmgrdesc/recnodesc.c (L449-L449)

Inconsistent bounds-check idiom: datalen is a Size (unsigned), and every other branch compares datalen >= sizeof(...). Casting sizeof to (int) here introduces a needless signed/unsigned mix and diverges from the surrounding style. Drop the cast.

💡 Suggested change

Before:

				if (datalen >= (int) sizeof(xl_recno_cas_update_undo_fe))

After:

				if (datalen >= sizeof(xl_recno_cas_update_undo_fe))

📄 src/backend/access/transam/rmgr.c (L43-L46)

These includes break the alphabetically-sorted include block (the existing entries above run brin_xlog.h, clog.h, ... utils/relmapper.h in path order). New entries should be merged into the sorted list, not appended, so the block stays pgindent/headerscheck-clean. Confidence: high.

💡 Suggested change

Before:

#include "access/undo_xlog.h"
#include "access/atm.h"
#include "access/relundo_xlog.h"
#include "storage/fileops.h"

After:

#include "access/atm_xlog.h"
#include "access/relundo_xlog.h"
#include "access/undo_xlog.h"
#include "storage/fileops.h"

📄 src/backend/access/transam/rmgr.c (L44-L44)

rmgr.c only needs the rmgr callback declarations (redo/desc/identify), which live in access/atm_xlog.h; every other entry in this block uses the minimal *_xlog.h header. Including access/atm.h instead needlessly pulls in transam.h, datatype/timestamp.h and storage/lwlock.h. Prefer access/atm_xlog.h for consistency and a minimal footprint. Confidence: moderate.

💡 Suggested change

Before:

#include "access/atm.h"

After:

#include "access/atm_xlog.h"

📄 src/backend/access/transam/varsup.c (L203-L203)

This unconditionally extends the pg_recno_cts SLRU for every cluster and every XID, even when no RECNO table exists anywhere. Unlike ExtendCommitTs (gated by track_commit_timestamp), there is no activation toggle here (recno_cts.c: "this map is unconditional whenever RECNO is built"). Per that module's own header, this is a Stage-1 WRITE-ONLY SHADOW with no reader yet, so every cluster now pays a permanent cost -- a WAL-logged + force-written XLOG_RECNO_CTS_ZEROPAGE record on each new page boundary, plus SLRU buffers and pg_recno_cts segment files -- for a map that nothing reads. On the hot XID-allocation path under XidGenLock this is exactly where PostgreSQL is most conservative about added work. Consider gating this extend on whether the recno subsystem is actually in use, or defer wiring it into GetNewTransactionId() until the Stage-2/3 reader lands (YAGNI). Confidence: moderate on the design/perf concern; the one-line call itself is placed correctly.


📄 src/backend/access/transam/twophase.c (L1678-L1682)

Data-loss footgun: when ATMAddAborted() fails (ATM/sLog full), this path only logs a WARNING and continues. ROLLBACK PREPARED then falls through to RemoveGXact(gxact) and RemoveTwoPhaseFile() below, which drop the gxact (so TwoPhaseGetOldestUndoBatchLSN() no longer pins this LSN) and delete the 2PC state file (so last_batch_lsn is lost forever). The cluster-wide UNDO chain (FILEOPS, nbtree/hash before-images) is therefore never reverted and its WAL can be recycled, while ROLLBACK PREPARED reports success -- silent, unrecoverable corruption of the rollback.

Unlike the ordinary abort path in xactundo.c (which on ATM-full retains the per-backend WAL-retention slot until backend exit as a fallback), the preparing backend is already gone here, so there is no fallback. This must not be a soft WARNING. Either apply the UNDO synchronously in place when the ATM is full, or raise ERROR so the ROLLBACK PREPARED does not report success and the 2PC file/gxact survive for a retry.


📄 src/backend/access/transam/twophase.c (L2995-L3000)

Inconsistent defensive guarding: RecoveryTransactionIdIsPrepared() (added in this same patch) checks both max_prepared_xacts <= 0 and TwoPhaseState == NULL before dereferencing, but this function only checks the former and then dereferences TwoPhaseState->numPrepXacts unconditionally. In normal operation TwoPhaseState is non-NULL whenever max_prepared_xacts > 0, so this is not currently a crash, but the asymmetry is a trap. Add the same TwoPhaseState == NULL guard here for consistency and safety.

💡 Suggested change

Before:

	if (max_prepared_xacts <= 0)
		return InvalidXLogRecPtr;

	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);

	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)

After:

	if (max_prepared_xacts <= 0)
		return InvalidXLogRecPtr;

	if (TwoPhaseState == NULL)
		return InvalidXLogRecPtr;

	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);

	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)

📄 src/backend/access/transam/xlog.c (L7958-L7961)

This permanent per-checkpoint "phase breakdown" LOG line plus its eight instr_time/double timers is debugging scaffolding that does not belong in the hot checkpoint control path. LogCheckpointEnd() already reports write/sync/total timing when log_checkpoints is on; this adds a second, differently-formatted LOG line and interleaves timing plumbing throughout CreateCheckPoint(), which reviewers on -hackers will reject as an unrelated, non-minimal change. If per-phase timing is genuinely wanted, it should be a separate, justified patch with a benchmark and (ideally) folded into the existing checkpoint stats machinery rather than a bespoke ereport here. Recommend removing this block and the associated timer variables/INSTR_TIME calls.


📄 src/backend/access/transam/xlog.c (L7447-L7454)

Unit-labeling footgun: these accumulators are named *_ms and populated with INSTR_TIME_GET_MILLISEC() (milliseconds), but the log message divides each by 1000.0 and labels the field with " s" (seconds). The arithmetic is correct, yet the _ms name vs. the reported seconds unit is misleading for future maintainers. If this block is kept, either store/report a single consistent unit (e.g. name them *_s and drop the /1000.0, or keep ms and label "ms").


📄 src/backend/access/transam/xact.c (L3061-L3063)

This comment claims physical UNDO is "NOT needed during standard transaction abort" and frames physical UNDO as future/ZHeap-style work ("intended for", "future in-place update mechanisms"). This directly contradicts the code ~50 lines below in this same function: AtAbort_XactUndo() (added later in this diff) DOES apply per-relation UNDO synchronously in-place (ApplyPerRelUndo -> RelUndoApplyChain restores before-images), as your own BufferLockReleaseAll() comment states. Comments must describe present behavior, not aspirational behavior. Rewrite to explain that s->undoRecPtr is only the chain-head tracker being reset here, and that physical UNDO application happens via AtAbort_XactUndo() below.


📄 src/backend/access/transam/xact.c (L6635-L6635)

Non-ASCII em-dash (U+2014) introduced here. PostgreSQL source is ASCII-only; use a plain ASCII hyphen/dash (" -- " or ": "). git diff --check / pgindent conventions require ASCII. Same issue appears again below at "abort record present means".

💡 Suggested change

Before:

		 * Remove from UNDO recovery tracking — committed, no rollback

After:

		 * Remove from UNDO recovery tracking -- committed, no rollback

📄 src/backend/access/transam/xact.c (L6667-L6667)

Non-ASCII em-dash (U+2014) here as well. Replace with an ASCII dash to keep the source ASCII-only.

💡 Suggested change

Before:

		 * Remove from UNDO recovery tracking — abort record present means

After:

		 * Remove from UNDO recovery tracking -- abort record present means

📄 src/backend/access/transam/xact.c (L2775-L2778)

This guard is dead as written: XactUndoHasUnrecoverableUndo() in access/undo/xactundo.c unconditionally return false;. Per YAGNI/minimalism the community will object to an always-false choke point plus its multi-paragraph justification comment. If it must stay as an extension point, keep it terse; otherwise drop the guard. Separately, the comment asserts RECNO 2PC-safety via AtPrepare_Recno()/RECNO_2PC_RELUNDO/recno_twophase_postabort(); those do exist in-tree (recno_relundo.c wires TableAMPrepare_hook = AtPrepare_Recno), so the claim checks out, but the always-false function makes the surrounding narrative disproportionate to a return false.


📄 src/backend/access/undo/atm.c (L104-L108)

Stale/misleading doc: this comment says ATMAddAborted "Returns false if the sLog is full, signaling the caller to fall back to synchronous rollback." But the underlying SLogTxnInsert() never fails -- slog.c documents that "unlike the old fixed pool this never fails for lack of space; the return type is retained for API compatibility and is always true." So this function can only ever return true, and the documented "sLog full" fallback is dead. The corresponding elog(WARNING, "ATM full: ...") branches in xactundo.c AtAbort_XactUndo() are therefore unreachable dead code. Either drop the return value (make it void) and the dead WARNING branches, or remove the false claim from the comment. Comments must describe current behavior. (high confidence)

💡 Suggested change

Before:

 * Called from the abort path. Returns false if the sLog is full,
 * signaling the caller to fall back to synchronous rollback.
 */
bool
ATMAddAborted(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn)

After:

 * Called from the abort path.
 */
bool
ATMAddAborted(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn)

📄 src/backend/access/undo/atm.c (L118-L123)

WAL/shared-memory atomicity hazard: XLogInsert() durably records the abort before the shared-memory update in ATMAddAbortedInternal(), and there is no critical section around the pair. If an error or interrupt (e.g., CHECK_FOR_INTERRUPTS in a called path, or an OOM in shmem manipulation) fires between XLogInsert and the SLog update, the WAL claims the transaction was recorded in the ATM but the live shared-memory ATM has no entry. Crash recovery would then diverge from the live state (redo re-inserts the entry that the live path never applied). The established pattern for a WAL record that mutates shared state without pinning buffers is to wrap the XLogInsert + shared-state mutation in START_CRIT_SECTION()/END_CRIT_SECTION() so any failure in between becomes a PANIC rather than silent divergence. Same issue applies to ATMForget() below. (moderate confidence)


📄 src/backend/access/undo/atm.c (L150-L155)

Same WAL/shared-memory atomicity gap as ATMAddAborted(): the XLOG_ATM_FORGET record is made durable before ATMForgetInternal() removes the shared-memory entry, with no critical section. A failure between the two leaves WAL saying the entry was forgotten while the live ATM still holds it; recovery (which removes it) then diverges from the running state. Wrap the WAL insert plus the SLog removal in START_CRIT_SECTION()/END_CRIT_SECTION(). (moderate confidence)


📄 src/backend/access/undo/atm.c (L13-L15)

This header overstates current behavior. Per AtAbort_XactUndo() in xactundo.c, the ATM "instant abort" path is taken only for large transactions (undo bytes >= undo_instant_abort_threshold); small transactions apply their UNDO chain synchronously inline in the aborting backend. So it is not true that "at transaction abort time, the backend writes an ATM entry ... instead of performing synchronous rollback." Also, "making ROLLBACK O(1)" is a performance claim that belongs in a design doc / commit message backed by a benchmark, not a source comment (comments should explain why, not advertise complexity). Please describe the actual conditional behavior. (moderate confidence)


📄 src/backend/access/undo/README (L539-L543)

Documented GUC defaults contradict the actual C code. undolog.c sets undo_retention_time = 60000 (60s) and undo_worker_naptime = 10000 (10s), not the values documented here. (Note the code, postgresql.conf.sample at undo_retention_time = 300s/undo_worker_naptime = 60s, and this README all disagree with each other.) These stale defaults will mislead operators. Fix the README to match the registered defaults in guc_parameters.dat/undolog.c, and reconcile the three sources.

💡 Suggested change

Before:

    undo_worker_naptime     Sleep interval between discard cycles (ms)
                            Default: 60000 (1 minute)

    undo_retention_time     Minimum retention time for UNDO records (ms)
                            Default: 3600000 (1 hour)

After:

    undo_worker_naptime     Sleep interval between discard cycles (ms)
                            Default: 10000 (10 seconds)

    undo_retention_time     Minimum retention time for UNDO records (ms)
                            Default: 60000 (60 seconds)

📄 src/backend/access/undo/README (L1063-L1064)

The lock-ordering table cites undo_flush.c:61, but no undo_flush.c exists in the tree (the flush lock is not a separate file/tranche). This safety-critical table is meant to prevent deadlocks; a citation to a non-existent file undermines its authority and will misdirect maintainers. Remove the phantom level or point it at the real lock site.


📄 src/backend/access/undo/README (L1069-L1070)

LWTRANCHE_RECNO_DIRTY_MAP does not exist. recno_dirtymap.c protects each partition with a per-partition spinlock (part->mutex, via SpinLockAcquire), and lwlocklist.h only defines UNDO_LOG and UNDO_WORKER tranches. Documenting a non-existent LWLock tranche in the deadlock-avoidance hierarchy is misleading. Either drop this level or describe the actual per-partition spinlock.

💡 Suggested change

Before:

    7      LWTRANCHE_RECNO_DIRTY_MAP       recno_dirtymap.c
                                           Protects shared dirty-page bitmap

After:

    7      RECNO DirtyMap partition spinlock  recno_dirtymap.c
                                           Protects one dirty-map hash partition

📄 src/backend/access/undo/README (L881-L884)

These referenced test files do not exist. There is no src/test/regress/sql/undo.sql or undo_toast.sql in the tree (the regression tests added are recno_* and fileops), and the recovery tests are not a contiguous t/053-060 range: t/053 is the unrelated 053_standby_login_event_trigger.pl, while the UNDO-related recovery tests actually added are t/054, 061, 063, 065, 067, 068. Claiming FEATURE COMPLETE with comprehensive coverage while pointing at non-existent test files is a documentation-accuracy defect; update the paths to the tests that actually exist.


📄 src/backend/access/undo/README (L1053-L1053)

Non-ASCII characters violate the project's ASCII-only rule for source/diffs. This line uses an em-dash (U+2014). Replace with an ASCII hyphen/--.

💡 Suggested change

Before:

locks sit outside this hierarchy — they must be acquired before any

After:

locks sit outside this hierarchy -- they must be acquired before any

📄 src/backend/access/undo/README (L1075-L1075)

Non-ASCII characters violate the ASCII-only rule: this line uses rightwards-arrow (U+2192) in 2→5. Replace with ASCII (e.g. 2->5 or 2 to 5).

💡 Suggested change

Before:

- The revert worker acquires locks 2→5 in sequence during UNDO apply;

After:

- The revert worker acquires locks 2->5 in sequence during UNDO apply;

📄 src/backend/access/undo/README (L439-L442)

Section 8's WAL record-type table lists only ALLOCATE/DISCARD/EXTEND/APPLY_RECORD, but omits XLOG_UNDO_BATCH (0x60) -- the record type the rest of this README relies on most heavily (Sections 1, 4, 5, 18) as the carrier of the actual UNDO payload. Add XLOG_UNDO_BATCH to this table so the primary UNDO WAL record is documented where the WAL record types are enumerated.


📄 src/backend/access/undo/logical_revert_worker.c (L226-L233)

Starvation/liveness bug (high confidence). ATMGetNextUnreverted() (SLogTxnGetNextUnreverted in slog.c) iterates the ATM in ascending xid order and returns the first unreverted entry regardless of database; it does not filter by dboid nor advance a cursor. When that first entry belongs to database A but this worker serves database B, the worker hits goto sleep every cycle and never advances past it. Since the same A-entry is always returned first, this worker's own B-entries are never reverted until A's entry is cleared by A's worker -- classic head-of-line blocking. With max_logical_revert_workers=2 (default) and >2 databases holding aborted-xact ATM entries, entries for un-serviced databases are never reverted at all, so their aborted transactions' UNDO is never applied. Filter inside ATMGetNextUnreverted by dboid (pass MyDatabaseId), or have it skip non-matching entries and return the next matching one.


📄 src/backend/access/undo/logical_revert_worker.c (L295-L299)

Transaction-state bug in the retry path (high confidence). An elog(ERROR) in process_revert_entry() does not by itself abort the transaction; it longjmps into PG_CATCH with blockState still TBLOCK_STARTED. In the recycle branch you correctly AbortCurrentTransaction()+StartTransactionCommand(), but in the common retry branch (the else at line ~295) you only log and fall through. CommitTransactionCommand() at line 304 then sees TBLOCK_STARTED and calls CommitTransaction() on a transaction whose command errored out -- committing partial/inconsistent work instead of rolling it back, and skipping the abort cleanup the failed operation needs. The standard bgworker idiom is to call AbortCurrentTransaction() in the CATCH. Add an unconditional AbortCurrentTransaction() at the top of PG_CATCH (before the recycle-specific StartTransactionCommand()), and drop the trailing CommitTransactionCommand() out of the error path.


📄 src/backend/access/undo/logical_revert_worker.c (L403-L404)

Wrong template-database predicate (high confidence). strncmp(NameStr(db->datname), "template", 8) excludes any user database whose name starts with "template" (e.g. "template_prod"), silently denying it a revert worker so its aborted-transaction UNDO is never applied. Use the catalog flag instead: autovacuum's get_database_list tests dbForm->datistemplate || !dbForm->datallowconn (autovacuum.c:1983), and Form_pg_database exposes datistemplate. Replace the name-prefix heuristic with if (db->datistemplate) continue;.


📄 src/backend/access/undo/logical_revert_worker.c (L510-L511)

Launcher permanently under-services databases (moderate confidence). next_known is capped at max_logical_revert_workers, and any database beyond the ceiling is only ever serviced "once a tracked database is dropped". On a cluster with more connectable databases than workers (default 2), most databases silently never get a revert worker, so their aborted-transaction UNDO is never applied -- a functional gap, not just a throughput knob. Combined with the ATM head-of-line-blocking noted above, this can wedge reverts cluster-wide. Reconsider the model: either size workers to the database count, round-robin a fixed worker pool across all databases, or document this hard limitation prominently as a known correctness constraint.


📄 src/backend/access/undo/logical_revert_worker.c (L290-L293)

Silent-data-loss footgun (moderate confidence). This branch force-forgets an unrevertable ATM entry, permanently abandoning a partial rollback. WAL retention does pin these LSNs -- UndoGetOldestBatchLSN() folds ATMGetOldestUnrevertedLSN() into the checkpoint keep horizon (xlog.c:8644) -- so under normal operation this branch should be unreachable, which means it is largely defensive dead code that nonetheless silently discards data if it ever fires. At minimum this warrants an errhint/observability signal (a stat counter or dedicated log field) rather than a lone WARNING; better, assert the invariant so a reachable case surfaces in testing instead of silently corrupting visibility state.


📄 src/backend/access/undo/logical_revert_worker.c (L347-L348)

Use bounded string ops for consistency and per project convention (low). LogicalRevertLauncherRegister() below uses snprintf() for the same fields; use snprintf here too (bgw_library_name is BGW_MAXLEN). sprintf on fixed literals is not exploitable but is flagged by the project's string-safety rules and is inconsistent within this file.


📄 src/backend/access/undo/logical_revert_worker.c (L533-L536)

Deprecated postmaster-death idiom (low). The per-db worker's WaitLatch uses the modern WL_EXIT_ON_PM_DEATH, but the launcher uses WL_POSTMASTER_DEATH plus a manual proc_exit(1). Prefer WL_EXIT_ON_PM_DEATH here too for consistency with the rest of the tree and this file's own worker loop.


📄 src/backend/access/undo/logical_revert_worker.c (L529-L531)

Stale/aspirational comment (low, per project comment-accuracy rules). "A shorter interval than the original one-shot design" references removed development history rather than describing what the code does now. Comments should explain the current why, not the patch's evolution. Drop the historical comparison.


📄 src/backend/access/undo/relundo.c (L143-L143)

High-confidence bug: leaked buffer lock/pin + cross-operation state corruption on the error path.

relundo_pending_metabuf is a per-backend static set here when a new page is allocated, and it is only cleared by RelUndoStage()/RelUndoFinishWithTuple()/RelUndoCancel(). But callers can ereport(ERROR) between Reserve and Finish/Cancel without running either. Concretely, the RECNO UPDATE path (recno_operations.c: RelUndoReserve at ~4155) then calls CheckForSerializableConflictIn() (~4267) and RecnoXLogPrepareLogicalImage() (~4293), all of which can throw; its PG_CATCH only releases overflow buffers and re-throws -- it never calls RelUndoCancel nor resets this static. No abort hook resets it either (xactundo.c abort path does not touch it).

Consequences: (1) the EXCLUSIVE-locked, pinned metapage buffer is leaked (released only by ResourceOwner at abort, but the content lock is held past the throw); (2) the stale relundo_pending_metabuf survives into the next Reserve->Finish cycle in the same backend, where RelUndoStage/RelUndoFinishWithTuple will adopt an unrelated/recycled buffer, corrupting WAL registration (registering a wrong metapage as block 1) or tripping the Assert. Add a resource-owner / xact-abort reset of this static (or wrap the Reserve->Finish window so Cancel always runs).


📄 src/backend/access/undo/relundo.c (L450-L451)

The cross-backend truncation guard relies on smgrnblocks() returning the post-truncation size, but that is not guaranteed here. RelUndoDiscard (relundo_discard.c) calls smgrtruncate while holding only ShareUpdateExclusiveLock/RowExclusiveLock -- NOT AccessExclusiveLock. smgrtruncate's contract explicitly requires AccessExclusiveLock so that other backends process the smgr invalidation before their next fork access; the sinval is otherwise only applied at the next command boundary. Until then a concurrent reserver's SMgrRelation can still hold a stale (larger) smgr_cached_nblocks, this guard passes, and ReadBufferExtended() faults on the truncated block (the exact "read only 0 of 8192 bytes" the comment above warns about) or reads a recycled page and hands out overlapping UNDO offsets. The comment asserts this bound "rejects that stale entry" but the cached nblocks is not refreshed cross-backend without AccessExclusiveLock.


📄 src/backend/access/undo/relundo.c (L867-L868)

Non-portable format: RelUndoRecPtr is uint64, but this logs it as %lu with a (unsigned long) cast. On LLP64 platforms (Windows/MSVC) unsigned long is 32-bit, so the pointer is truncated in the message. Use UINT64_FORMAT with a (uint64) cast. (Also: this and the other elog(DEBUG1) calls sit on the DML hot path and evaluate their args on every operation; PostgreSQL convention keeps hot paths free of debug logging.)

💡 Suggested change

Before:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=%lu, payload_size=%zu, tuple_len=%u",
		 (unsigned long) ptr, payload_size, tuple_len);

After:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=" UINT64_FORMAT ", payload_size=%zu, tuple_len=%u",
		 (uint64) ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo.c (L862-L865)

DRY / divergence hazard: RelUndoFinishWithTuple re-implements the entire staging + WAL-registration + PageSetLSN flow that RelUndoStage()/RelUndoFinish() already provide, differing only by appending tuple_data after the payload. Any redo-relevant change (buffer flags, page_offset math, block-1 metapage registration, new-page WAL layout) must now be mirrored by hand in two places; a divergence silently corrupts the UNDO page or its prev_blkno chain on replay. Prefer expressing the tuple path through RelUndoStage (e.g. by folding tuple bytes into the staged payload) so there is a single WAL-registration code path.


📄 src/backend/access/undo/relundo.c (L154-L154)

Fragile coupling: this shift hard-codes 6 (= log2 of RELUNDO_HEAD_CACHE_SIZE == 64), but the table size is defined via RELUNDO_HEAD_CACHE_SIZE and the comment above invites bumping it to 128/256 as "straightforward". If the size is changed, this shift will not track it -- the hash keeps using only 6 high bits while the mask uses more, silently degrading distribution and increasing collisions. Derive the shift from the size macro (e.g. a RELUNDO_HEAD_CACHE_BITS constant) so they stay in sync.


📄 src/backend/access/undo/relundo.c (L1449-L1449)

Global-across-relations throttle: this function-local static is shared by every relation in the backend, so the 5s cadence is per-backend-global, not per-relation. Heavy write churn on relation A resets the timer and can starve UNDO-fork discard on relation B (and vice versa) for up to 5s regardless of B's own fork growth, allowing B's fork to grow unbounded between B's own DML calls. The comment claims parity with SLogTupleMaybeCleanupRetained but does not note this cross-relation coupling; if intended, document it, otherwise key the throttle per relation.


📄 src/backend/access/undo/relundo_apply.c (L115-L116)

Non-portable 64-bit formatting. ptr is a RelUndoRecPtr (uint64), but this logs it as %lu with (unsigned long). On LLP64 platforms (Windows/MSVC) unsigned long is 32-bit, so the high 32 bits (which encode counter/block) are truncated in the message. Use UINT64_FORMAT with a (uint64) cast.

💡 Suggested change

Before:

elog(DEBUG1, "RelUndoApplyChain: starting rollback at %lu",
	 (unsigned long) current_ptr);

After:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=" UINT64_FORMAT ", payload_size=%zu, tuple_len=%u",
		 (uint64) ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo_discard.c (L41-L42)

Static-state lifecycle hazard. relundo_pending_metabuf is set only on the new-page path in RelUndoReserve() (line 590) and is cleared only on the normal Finish/Cancel paths (lines 688, 904, 1010). There is no AtEOXact/AtAbort or ResourceOwner-based reset. If the caller throws via ereport(ERROR) between Reserve and Finish/Cancel (e.g. the RECNO UPDATE path: CheckForSerializableConflictIn() and RecnoXLogPrepareLogicalImage() both run after RelUndoReserve() at recno_operations.c:4155, and its PG_CATCH only releases overflow buffers, then re-throws), the locked+pinned metapage buffer's content lock/pin will be freed by ResourceOwner cleanup at abort, but this static keeps a now-dangling Buffer reference. The next Reserve->Stage/Finish cycle in the same backend that hits is_new_page == true will adopt the stale buffer at line 686/902, registering an unrelated (or freed) page into the WAL record or tripping Assert(BufferIsValid(...)). Reset relundo_pending_metabuf on transaction abort (register an AtEOXact/AtAbort callback, or a subxact-aware reset), so a mid-reservation error can never leak this state into a later operation.


📄 src/backend/access/undo/relundo_discard.c (L41-L42)

DRY / divergence hazard. RelUndoFinishWithTuple() re-implements the entire staging + WAL-registration flow (header/payload memcpy, max_xid bump, MarkBufferDirty, metabuf adoption, is_new_page WAL layout, PageSetLSN) that RelUndoStage() + RelUndoFinish() already provide. Since these records are WAL-logged and replayed, any redo-relevant change (buffer flags, page_offset, block ordering, new-page header prefix) must be mirrored in both copies or redo will corrupt the UNDO page/prev_blkno chain. Fold the tuple-append case into RelUndoStage() (appending tuple_data after the payload) so both finish variants share one code path.


📄 src/backend/access/undo/relundo_page.c (L80-L83)

Crash-safety/replica-consistency bug: this lazy metapage initialization mutates the page and calls MarkBufferDirty() but emits NO WAL record and never calls PageSetLSN(). Compare RelUndoInitRelation(), which logs XLOG_RELUNDO_INIT and sets the page LSN. As written, on a standby this reinitialized metapage is never reproduced (replay would see a fork with zero blocks and no INIT record), and after a subsequent crash the change can be lost or diverge from the primary. The file header itself claims metapage changes are WAL-logged via the caller's XLOG_RELUNDO_INSERT, but this happens on a read/reserve path with no accompanying XLogInsert. This init must emit an XLOG_RELUNDO_INIT record (WILL_INIT|REGBUF_STANDARD) and PageSetLSN, and must be gated on !InRecovery.


📄 src/backend/access/undo/relundo_page.c (L94-L100)

Correctness hazard on the SHARE fast path. RelUndoReserve() calls relundo_get_metapage(rel, BUFFER_LOCK_SHARE) (relundo.c:494), and RelUndoGetCurrentCounter() at relundo.c:1231 as well. The comment above states a zero-block fork can legitimately occur after crash recovery (fork created but metapage not yet written). In that state a SHARE caller falls into this else branch and errors out with ERRCODE_INDEX_CORRUPTED instead of getting a usable metapage, turning a recoverable state into a hard failure. Either upgrade to EXCLUSIVE and initialize (properly WAL-logged) regardless of the requested mode, or ensure SHARE callers never reach this path.


📄 src/backend/access/undo/relundo_page.c (L161-L166)

Same crash-safety/replica-consistency defect as the lazy-init branch: the in-place metapage reinitialization is applied with MarkBufferDirty() but no XLogInsert and no PageSetLSN. On a standby (and after a later crash) this reinit is not reproducible from WAL, so the primary and replica can diverge and the change can be lost. This reinit must emit a WAL record (e.g. XLOG_RELUNDO_INIT) and set the page LSN, and be gated on !InRecovery.


📄 src/backend/access/undo/relundo_page.c (L67-L69)

Stale comment: this block sets meta->version = RELUNDO_METAPAGE_VERSION (== 4), but the RelUndoMetaPageData.version field comment in relundo.h still reads "Format version (currently 3)". The declaration-site comment is out of date and should read version 4 to avoid confusion about the on-disk format version.


📄 src/backend/access/undo/relundo_page.c (L121-L123)

Two adjacent comment blocks with no code between them: the '...nothing to lose.' comment is immediately followed by a second '/* Upgrade to exclusive lock... */' opener. Merge these into a single comment block; keeping two back-to-back block comments is awkward and reads like a leftover edit.


📄 src/backend/access/undo/relundo_recovery.c (L344-L344)

pd_lower and urec_len are read straight from the on-disk UNDO page contents and used to drive the parse loop with no upper-bound validation. Standard page verification in ReadBufferExtended() only checks the standard PageHeader, not this custom RelUndo header living inside PageGetContents(). If a corrupt/torn page carries pd_lower larger than the contents area (contents size is BLCKSZ - MAXALIGN(SizeOfPageHeaderData), see relundo_init_page), the loop while (offset < pd_lower ...) plus memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader) reads past the buffer -- an out-of-bounds read during crash recovery, exactly when pages are most likely damaged. The chain-walk already hardens against a corrupt prev_blkno cycle; pd_lower deserves the same treatment. Validate pd_lower <= (BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) (and >= SizeOfRelUndoPageHeaderData) before parsing, and treat a violation as corruption (skip the page or ereport ERRCODE_DATA_CORRUPTED). Confidence: moderate.


📄 src/backend/access/undo/relundo_recovery.c (L380-L380)

Even with a valid pd_lower, this stride only checks offset < pd_lower at the top of the loop; it never verifies the whole record fits, i.e. offset + rhdr.urec_len <= pd_lower. A corrupt urec_len mid-page lets the final memcpy read up to SizeOfRelUndoRecordHeader - 1 bytes past pd_lower, and because offset is uint16, offset += rhdr.urec_len can wrap and re-enter the loop at a bogus small offset (only bounded by the nrecs < maxrecs guard), recording garbage offsets/xids that are then reverse-applied. Validate that the record lies fully within [SizeOfRelUndoPageHeaderData, pd_lower) before accepting it, and stop on any overrun. Confidence: moderate.


📄 src/backend/access/undo/relundo_recovery.c (L319-L320)

RelUndoRecoveryApplyPage() returns a BlockNumber (hdr->prev_blkno, whose invalid sentinel InvalidBlockNumber is 0xFFFFFFFF) but is declared to return int. The value round-trips correctly only by two's-complement accident; it is a type-correctness/portability smell and inconsistent with the BlockNumber typing used everywhere else in this file (the caller stores the result back into a BlockNumber). Declare the return type as BlockNumber here and in the forward declaration at line 75. Confidence: high.

💡 Suggested change

Before:

static int
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)

After:

static BlockNumber
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)

📄 src/backend/access/undo/relundo_worker.c (L462-L465)

Dead code: the entire launcher machinery is unreachable. RelUndoLauncherMain is never registered as a background worker -- bgworker.c's internal-worker table only lists RelUndoWorkerMain, and no RegisterBackgroundWorker/RegisterDynamicBackgroundWorker call anywhere spawns the launcher. The only wired-up path is xactundo.c -> StartRelUndoWorker(), which registers RelUndoWorkerMain directly. That makes RelUndoLauncherMain, launcher_spawn_worker, the LauncherDbSlot struct, and MAX_LAUNCHER_DB_SLOTS (~230 lines) speculative scaffolding for a path that isn't hooked up. Per the YAGNI/minimalism discipline, remove it (and its extern in relundo_worker.h) or wire it up in the same patch. (high confidence)


📄 src/backend/access/undo/relundo_worker.c (L168-L169)

Truncation/portability bug: RelUndoRecPtr is uint64 (src/include/access/relundo.h), but (unsigned long) is only 32 bits on Windows LLP64 and on 32-bit platforms, silently truncating the pointer in the log. Use UINT64_FORMAT with a uint64 cast: elog(DEBUG1, "... (ptr=" UINT64_FORMAT ")", dboid, reloid, (uint64) start_urec_ptr);. Same issue at the other two sites in this file (process_relundo_work_item's LOG message and elsewhere). (high confidence)


📄 src/backend/access/undo/relundo_worker.c (L282-L283)

Same %lu/(unsigned long) truncation of the uint64 RelUndoRecPtr as above. Use UINT64_FORMAT and a (uint64) cast. (high confidence)


📄 src/backend/access/undo/relundo_worker.c (L373-L377)

Silent data-loss footgun. process_relundo_work_item() swallows any error (EmitErrorReport/FlushErrorState) and returns normally; the caller (RelUndoWorkerMain) then unconditionally calls RelUndoQueueMarkComplete() and CommitTransactionCommand(). A UNDO chain that fails partway (e.g. RelUndoApplyChain throws after restoring some tuples) is thus marked complete and dropped, leaving the relation permanently half-reverted. There is no propagation of success/failure and no retry. This is exactly the fallback path xactundo.c:ApplyPerRelUndo() relies on when inline apply fails, so the failure mode is reachable. The worker must not mark an item complete unless the apply actually succeeded. (medium-high confidence)


📄 src/backend/access/undo/relundo_worker.c (L319-L323)

Also on the PG_CATCH error path here: table_close() is skipped, so if RelUndoApplyChain threw after table_open() succeeded, the relation reference and RowExclusiveLock are held until the surrounding transaction's ResourceOwner is reset at CommitTransactionCommand. More importantly the caught error is discarded with no signal to the caller, so the failed work is treated as done (see the MarkComplete comment). At minimum, propagate the failure so RelUndoWorkerMain does not commit-and-forget. (medium confidence)


📄 src/backend/access/undo/relundo_worker.c (L715-L716)

Stale comment / behavior drift. This header says the wait is needed "so the UNDO worker can acquire AccessExclusiveLock on the target relation", but process_relundo_work_item() (and xactundo.c's inline path) now open the relation with RowExclusiveLock -- the code was deliberately changed away from AccessExclusiveLock (see the convoy comment in process_relundo_work_item). Update this comment to match the current RowExclusiveLock behavior. (high confidence)


📄 src/backend/access/undo/relundo_worker.c (L356-L361)

Missing CHECK_FOR_INTERRUPTS() in the worker main loop. The sibling background workers (undoworker.c, logical_revert_worker.c) call CHECK_FOR_INTERRUPTS() every iteration; this loop does not. Because got_SIGTERM is only checked between iterations and never inside RelUndoApplyChain/table_open, a SIGTERM (fast shutdown) arriving during a long UNDO chain cannot be acted on promptly, risking an unresponsive worker at shutdown. Add CHECK_FOR_INTERRUPTS() at the top of the loop, consistent with the other undo workers. (medium confidence)


📄 src/backend/access/undo/relundo_worker.c (L706-L707)

Handle leak: if StartRelUndoWorker() is called twice before WaitForPendingRelUndo() drains it, the previous handle in pending_undo_handle is overwritten without pfree(), leaking the BackgroundWorkerHandle. Free (or assert NULL on) the previous handle before storing the new one.


📄 src/backend/access/undo/relundo_worker.c (L149-L151)

Message style: PostgreSQL user-facing errmsg must start with a lowercase letter, carry no trailing period, and be wrapped in _(). This WARNING starts with a capital 'P'. Suggest: errmsg("per-relation undo work queue is full, cannot queue work for relation %u", reloid). The capitalized 'Queued/Started/Per-relation' elog(DEBUG1/LOG) messages elsewhere in this file are likewise inconsistent with tree style. (low confidence)


📄 src/backend/access/undo/relundo_xlog.c (L234-L236)

The out-of-bounds guard here validates xlrec->urec_len (page_offset + urec_len > BLCKSZ), but the memcpy in the non-INIT_PAGE path below copies record_len bytes (returned by XLogRecGetBlockData) to page + page_offset. record_len equals the registered block-data length (wal_record_size/total_record_size), which is only equal to urec_len by an unenforced convention at do-time. The only guard against record_len down below is record_len > BLCKSZ, which ignores the nonzero page_offset (always >= MAXALIGN(SizeOfPageHeaderData)). A corrupt or mismatched WAL record can therefore drive a page write past BLCKSZ during crash recovery, corrupting shared buffers. Add a cross-field check on the actual copy length, e.g. verify (uint32) xlrec->page_offset + (uint32) record_len <= BLCKSZ immediately before the memcpy, and/or assert record_len == xlrec->urec_len.


📄 src/backend/access/undo/relundo_xlog.c (L342-L342)

The memcpy target length record_len is not bounded against page_offset. The earlier consistency check validated page_offset + urec_len, not page_offset + record_len. Since page_offset is always >= MAXALIGN(SizeOfPageHeaderData), a record_len up to BLCKSZ (the only guard above) can overflow the page. Guard the actual copy: if ((uint32) xlrec->page_offset + (uint32) record_len > BLCKSZ) elog(PANIC, ...); (and ideally assert record_len == xlrec->urec_len).


📄 src/backend/access/undo/relundo_xlog.c (L757-L758)

lower/upper are read directly from the page's RelUndoPageHeader and only checked with lower < upper. There is no upper bound ensuring upper <= BLCKSZ - MAXALIGN(SizeOfPageHeaderData) (the usable contents size). A corrupt or unexpectedly-typed page reaching the mask path could make memset(contents + lower, ..., upper - lower) write past the page. Masking runs only under consistency-check/wal-consistency builds so blast radius is limited, but a bound check here would match the validation rigor used throughout the redo functions in this file.


📄 src/backend/access/undo/slog_flathash.c (L521-L525)

before-image DSA leak. This reclaim loop (lines 501-519) selects any below-horizon UPDATE marker regardless of whether it still carries a valid before_image_dp -- it filters only on in_use, op_type, valid xid, and TransactionIdPrecedes(horizon). Line 525 then nulls before_image_dp WITHOUT dsa_free(). Unlike SLogTupleCleanupRetained (slog.c ~3442-3505), which collects the dp and dsa_free()s it after the seq cycle, the SLogTupleInsert caller has no collect-then-free wrapper here. The comment at lines 494-495 ("An unstamped marker never had its before-image published, so before_image_dp is invalid") only covers unstamped markers, but this loop also reclaims stamped, below-horizon markers whose before_image_dp is still valid (the retention sweeps may not have run yet). Result: the DSA SLogBeforeImage leaks. Either skip markers with a valid before_image_dp here (like flat_hash_coalesce_retained does), or return the dp to the caller so it can dsa_free() it after SLogSeqWriteEnd.


📄 src/backend/access/undo/slog_flathash.c (L106-L109)

SLogFlatHashProbe/ProbeForInsert index buckets via h & (capacity - 1), which is only a correct modulo when capacity is a power of two. The current callers (SLogFlatHashCapacity and SLogNumPartitions are both rounded to powers of two, and the /64 clamp is a power of two) satisfy this, but the invariant is silent and unenforced. A future caller passing a non-power-of-2 capacity would silently skip buckets / corrupt lookups. Add a defensive Assert to document and enforce the contract.

💡 Suggested change

Before:

	ht->capacity = capacity;
	ht->num_entries = 0;
	ht->num_tombstones = 0;
	ht->padding = 0;

After:

	Assert(capacity > 0 && (capacity & (capacity - 1)) == 0);

	ht->capacity = capacity;
	ht->num_entries = 0;
	ht->num_tombstones = 0;
	ht->padding = 0;

📄 src/backend/access/undo/undo_bufmgr.c (L38-L42)

This entire module appears to be dead code. None of its exported functions (UndoMakeBufferTag, ReadUndoBuffer, ReadUndoBufferExtended, ReleaseUndoBuffer, UnlockReleaseUndoBuffer, MarkUndoBufferDirty, InvalidateUndoBuffers, InvalidateUndoBufferRange) have any caller in the tree — every match for each symbol is limited to this file's definitions and the prototype declarations in undo_bufmgr.h.

More fundamentally, the accompanying header (undo_bufmgr.h) documents that the real architecture is "UNDO-in-WAL": "There are no separate UNDO segment files" and "UNDO data is written via pwrite() and read via pread(), bypassing shared_buffers entirely." So this file's premise (routing undo I/O through shared_buffers) contradicts the actual design and is never exercised. Per YAGNI/minimalism, drop this file (and the corresponding header declarations) rather than shipping speculative scaffolding with no callers. Confidence: high.


📄 src/backend/access/undo/undo_bufmgr.c (L6-L9)

The comment claims undo I/O is routed "through PostgreSQL's standard shared buffer pool," but the module header (undo_bufmgr.h) states the shipped architecture is UNDO-in-WAL and "UNDO data is written via pwrite() and read via pread(), bypassing shared_buffers entirely." This file-header prose describes an approach that is not how the feature actually works, and cites external documentation by line number ("ZHeap README, lines 30-40"), which is brittle and drifts. If any part of this file is kept, the header must describe what the code does now, not an abandoned design. Confidence: high.


📄 src/backend/access/undo/undo_bufmgr.c (L244-L250)

last_block is accepted but never used: the call passes only &first_block to DropRelationBuffers, which drops every buffer with blockNum >= firstDelBlock (confirmed in bufmgr.c: "removes ... all the pages ... that have block numbers >= firstDelBlock"). So this function silently ignores its documented upper bound and drops all trailing blocks, not just [first_block, last_block]. Any caller expecting blocks after last_block to survive would lose live undo buffers — a data-integrity footgun. Either enforce the range (and honor last_block) or remove the parameter and rename to reflect "drop from first_block onward." (Note: currently there are no callers at all, reinforcing that this is unused scaffolding.) Confidence: high.


📄 src/backend/access/undo/undoapply.c (L214-L215)

Silent data loss: the per-batch record array is hard-capped at max_records = 1024, but the flush threshold is a GUC (undo_batch_record_limit, valid range 100-100000, see postgresql.conf.sample). A batch may legitimately contain up to 100000 records. When nrecords_in_batch reaches 1024 the first-pass scan emits a WARNING and breaks, so records 1025..N are never collected and thus never applied during rollback. Worse, ApplyUndoChainFromWALBounded still returns true (batches_processed > 0), so the caller (inline abort in xactundo.c / logical revert worker) believes the rollback fully succeeded, leaving partially-reverted on-disk state -- lost rollback / corruption. Note the crash-recovery path in undo_xlog.c (ApplyRecoveredUndo, lines 889-947) processes the same batches with an unbounded while (pos < end) loop and no 1024 limit, so the two application paths are inconsistent and the recovery path proves an unbounded walk works here. Drop the fixed cap: either apply records directly during a reverse walk, or size the array from batch->header.nrecords, and never silently truncate.


📄 src/backend/access/undo/undoapply.c (L260-L267)

Confidence: high. When the batch exceeds max_records the code breaks out of the scan and applies only the collected prefix, then falls through to return success. Combined with the fixed cap above, this is where the remaining UNDO records are silently dropped. This branch should not exist once the array is sized to the actual record count; if any hard limit is kept it must fail the rollback (so the caller falls back to ATM / deferred processing) rather than reporting success.


📄 src/backend/access/undo/undoapply.c (L233-L233)

The record_starts array (1024 * sizeof(char*) = 8KB on 64-bit) is deliberately never freed and re-palloc'd on every batch iteration, so a long UNDO chain with many batches accumulates 8KB per batch in the current context for the whole rollback call. The two eager callers (xactundo.c) run this inside a dedicated AllocSetContext (Subxact UNDO Apply / undo_ctx), not a BumpContext, so the stated pfree/BumpContext justification does not hold there and pfree would be legal. Regardless, the array should be allocated once outside the while loop (sized to the batch), or a short-lived child context should be reset per batch. Also, commented-out code (the pfree(record_starts); line) is against PostgreSQL style; remove it.


📄 src/backend/access/undo/undoapply.c (L74-L77)

Portability: urec_ptr is UndoRecPtr (typedef uint64 in undodefs.h). Formatting a 64-bit type with %llu plus an (unsigned long long) cast violates the tree's fixed-width formatting convention; use UINT64_FORMAT. Separately, ApplyUndoChainFromWALBounded always passes InvalidUndoRecPtr (0) as urec_ptr here, so every one of these three messages prints "record at 0" -- misleading. Since UNDO records embedded in a WAL batch have no standalone UndoRecPtr, prefer reporting the batch LSN (LSN_FORMAT_ARGS) instead of a meaningless urec_ptr.


📄 src/backend/access/undo/undoapply.c (L110-L117)

Error swallowing on the rollback path: UNDO_APPLY_ERROR is downgraded to a WARNING and returns false, after which the chain walk continues and the top-level function returns true whenever batches_processed > 0. A failed rm_undo means shared on-disk state was left partially modified, yet the caller is told rollback succeeded. This masks real corruption. The crash-recovery path in undo_xlog.c only treats UNDO_APPLY_SKIPPED as needing deferral and doesn't distinguish ERROR either -- but here, an ERROR result during eager abort should propagate a failure to the caller so it can fall back to ATM/deferred processing rather than silently reporting success.


📄 src/backend/access/undo/undobuffer.c (L60-L60)

Correctness/data-integrity hazard: the process-global undo_t2buf is only ever reset via UndoBufferEnd(), which is called from table_finish_bulk_insert() (ExecEndModifyTable, copyfrom.c, etc.) on the happy path only. On an ereport(ERROR)/transaction abort, the executor End nodes do not run, so UndoBufferEnd is never reached and there is no AtAbort/AtEOXact hook here that resets this global. The stale state (active=true, aborted-txn xid, chain_prev, and un-flushed nrecords) then leaks into the next transaction on the same backend:

  • If the next txn touches the SAME relid, UndoBufferBegin returns early (below) keeping the stale xid/chain_prev, producing a corrupt UNDO chain with the wrong xid.
  • If it touches a DIFFERENT relid, UndoBufferBegin calls UndoBufferEnd(rel) which, with nrecords > 0, triggers UndoBufferFlush() -- emitting a spurious XLOG_UNDO_BATCH for the aborted transaction's records under the stale xid.

Register an abort-time reset (e.g. an AtAbort/subxact-abort callback, or hook into xact.c/ResourceOwner cleanup) that clears undo_t2buf WITHOUT flushing.


📄 src/backend/access/undo/undobuffer.c (L185-L186)

The early-return keeps the buffer active while leaving xid and chain_prev untouched. Since there is no abort-time reset (see the note on undo_t2buf), a new transaction reusing the same backend and relid inherits the aborted transaction's xid and chain_prev, corrupting the UNDO chain. UndoBufferBegin should validate that the active buffer belongs to the current transaction (compare undo_t2buf.xid against GetCurrentTransactionId()) and reinitialize otherwise.


📄 src/backend/access/undo/undobuffer.c (L327-L330)

WAL-logging correctness: UndoBufferFlush() calls XLogInsert(RM_UNDO_ID, XLOG_UNDO_BATCH) and then mutates in-memory chain state (chain_prev, UndoRegisterBatchLSN, XActUndoUpdateLastBatchLSN, SetCurrentTransactionUndoRecPtr) outside any critical section. The sibling UndoRecordSetInsert() in undoinsert.c documents that it "always runs inside the caller's critical section (it emits a WAL record)". An ERROR or interrupt between the XLogInsert and the subsequent state updates would leave the in-memory UNDO chain desynchronized from the WAL that was already emitted. Wrap the XLogInsert and the dependent state updates in START_CRIT_SECTION()/END_CRIT_SECTION(). Note the DEBUG2 ereport() below allocates memory and must be moved out of the critical section.


📄 src/backend/access/undo/undobuffer.c (L325-L325)

persistence is hardcoded to UNDOPERSISTENCE_PERMANENT, but recno_begin_bulk_insert activates this buffer unconditionally for any recno relation, including TEMP and UNLOGGED tables (RelationAmSupportsUndo only checks the AM capability flag, not relation persistence). Emitting permanent XLOG_UNDO_BATCH WAL for a temporary/unlogged relation is incorrect (temp tables must not WAL-log) and a crash-recovery inconsistency hazard. Thread the relation's actual persistence (RelationNeedsWAL / rel->rd_rel->relpersistence) through to the buffer and skip the WAL flush for non-permanent relations.


📄 src/backend/access/undo/undoinsert.c (L108-L109)

total_len and nrecords are truncated from Size/int to uint32. uset->buffer_size is a 64-bit Size and the record set has no flush threshold, so a large batch silently truncates these header fields. During redo, undo_redo/UndoReadBatchFromWAL consume total_len/nrecords, so a truncated value yields the wrong payload length and record count -> replica divergence or crash-recovery failure. Enforce an upper bound on buffer_size/nrecords (or assert they fit in uint32) before building the header.


📄 src/backend/access/undo/undoinsert.c (L56-L59)

These are pure no-op stubs for a brand-new feature (not a real legacy migration). Per YAGNI/minimalism, empty exported functions plus "kept for callers that haven't been updated yet" framing are dead scaffolding. There are still live callers in xactundo.c (e.g. lines 783/795/845/858), so removing these requires also deleting those call sites in the same patch. Prefer removing the stubs and their callers rather than shipping empty exports with aspirational "legacy"/"for safety" comments.


📄 src/backend/access/undo/undorecord.c (L316-L320)

Integer truncation footgun: record_size and payload_len are Size (64-bit) but are stored into the 32-bit urec_len/urec_payload_len via unchecked casts. If a payload pushes the record past UINT32_MAX, urec_len/urec_payload_len silently truncate, yet uset->buffer_size below advances by the full 64-bit record_size. The batch is then written to WAL with a length field that disagrees with the actual bytes, and the recovery-side walk in undoapply.c trusts the truncated hdr.urec_len, causing misparsed/corrupt records. Add an explicit bound check (e.g. if (record_size > UINT32_MAX) elog(ERROR, ...)) before the casts. The same applies to the identical block in UndoRecordAddPayloadParts.


📄 src/backend/access/undo/undorecord.c (L223-L227)

Overflow in the capacity-doubling loop: new_capacity *= 2 on a Size can wrap to a value smaller than uset->buffer_size + additional for very large requests, so the while exits (or never enters) with an undersized buffer, and the subsequent header/payload memcpy writes past the allocation (heap overflow). Guard against overflow, e.g. cap/validate new_capacity and error out if it would exceed a sane maximum (or MaxAllocSize).


📄 src/backend/access/undo/undorecord.c (L54-L55)

UndoRecordSerialize is dead code: it has no callers anywhere in the tree. The actual write path assembles records in-buffer in UndoRecordAddPayload/UndoRecordAddPayloadParts, not via this function. Per YAGNI/minimalism, remove it (and its header declaration) unless a caller is added.


📄 src/backend/access/undo/undorecord.c (L74-L75)

UndoRecordDeserialize is dead code: it has no callers. The recovery/apply path in undoapply.c reads records with a direct memcpy(&hdr, pos, SizeOfUndoRecordHeader) rather than this function. The returned bool also never becomes false except on NULL args. Remove it (and its declaration) unless it is actually wired up.


📄 src/backend/access/undo/undorecord.c (L364-L377)

DRY: this 11-line header-assembly block (memset + 10 field assignments) is duplicated verbatim in UndoRecordAddPayload and UndoRecordAddPayloadParts. Factor it into a shared static helper (e.g. fill_undo_record_header(header, uset, rmid, info, reloid, payload_len, record_size)). Otherwise the two paths can drift if the on-disk header format changes (a field added in one but not the other silently corrupts one record type).


📄 src/backend/access/undo/undolog.c (L180-L185)

Data race: every other accessor of discard_ptr takes log->lock (see UndoLogDiscard LW_EXCLUSIVE, CheckPointUndoLog/UndoLogGetDiscardPtr LW_SHARED), but here log->in_use and log->discard_ptr are read with no lock held. discard_ptr is a plain (non-atomic) 64-bit UndoRecPtr, so a concurrent writer under LW_EXCLUSIVE can produce a torn/stale read and an incorrect retention horizon. Since the returned value can drive WAL retention, this is a silent-corruption footgun (premature WAL recycling). Also note this function currently has no callers in the tree, so it is dead code -- prefer removing it, or make it consistent by taking log->lock (LW_SHARED) around the reads.


📄 src/backend/access/undo/undolog.c (L475-L477)

This constructs a base/undo/<log> path even though the file header states these segment files no longer exist under UNDO-in-WAL. Returning a plausible-looking path for a nonexistent file is a footgun: any caller that opens the result will fail confusingly or silently misbehave. Either drop this function (as with the other removed segment-file APIs) or have it error/assert rather than fabricate a path.


📄 src/backend/access/undo/undolog.c (L154-L156)

UndoRecPtrGetOffset returns uint64. Per project convention use UINT64_FORMAT rather than %llu with an explicit (unsigned long long) cast (which is non-portable for uint64 across platforms).


📄 src/backend/access/undo/undostats.c (L39-L41)

These three functions (pg_stat_get_undo_logs, pg_stat_get_undo_buffers, pg_undo_force_discard) are declared with PG_FUNCTION_INFO_V1 but have no entries in src/include/catalog/pg_proc.dat. The header comment at line 14 claims they are 'registered in pg_proc.dat', and README/examples (examples/05-undo-monitoring.sql) call them via SQL, but without a pg_proc catalog entry SELECT * FROM pg_stat_get_undo_logs() will fail with 'function does not exist' at runtime. Add pg_proc.dat entries using developer-range OIDs (8000-9999; see src/include/catalog/unused_oids) so the functions are actually reachable from SQL. (high confidence)


📄 src/backend/access/undo/undostats.c (L115-L122)

pg_stat_get_undo_buffers unconditionally returns zeros (GetUndoBufferStats hard-codes all counters to 0 with the comment that per-buffer stats are 'no longer tracked'), so cache_hits/cache_misses/hit_ratio always report 0 and a 0% hit ratio. This is a footgun: operators reading the documented columns will be misled into believing the UNDO buffer cache never hits. Since UNDO pages now live in shared_buffers, this function and GetUndoBufferStats are dead scaffolding (YAGNI) that should be removed rather than shipped returning constant zeros. (moderate confidence)


📄 src/backend/access/undo/undostats.c (L285-L287)

This ereport lacks an explicit errcode, so it defaults to ERRCODE_INTERNAL_ERROR for a user-reachable, user-facing condition. Add a proper errcode (e.g. ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE) so clients can classify the error consistently with the privilege-check ereport just above. (moderate confidence)

💡 Suggested change

Before:

	if (UndoLogShared == NULL)
+		ereport(ERROR,
+				(errmsg("UNDO subsystem is not initialized")));

After:

	if (UndoLogShared == NULL)
		ereport(ERROR,
				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
				 errmsg("UNDO subsystem is not initialized")));

📄 src/backend/access/undo/undoworker.c (L346-L347)

u32 underflow: sealed_log_count is only ever decremented here (fetch_sub) and initialized to 0 in UndoWorkerShmemInit; there is no increment anywhere in the tree (verified by searching the whole codebase). The comment above claims it is incremented "on SEAL", but the SEALED state is only ever set in undo_xlog.c redo, which never touches this counter. Consequently the first DISCARDABLE log freed does fetch_sub_u32(x, 1) on 0, wrapping to UINT32_MAX. The adaptive-sleep check sealed_count > 0 in the main loop then stays permanently true, forcing the worker to busy-spin every 200ms forever (needless wakeups/CPU) instead of using undo_worker_naptime. Either wire up the increment on seal, or drop this counter and the adaptive-sleep entirely (it is dead scaffolding: with UNDO-in-WAL the ACTIVE->SEALED->DISCARDABLE rotation "no longer occurs" per undolog.h). Confidence: high.


📄 src/backend/access/undo/undoworker.c (L242-L245)

TOCTOU race clearing a live backend's slot. The pid == 0 liveness check is done without ProcArrayLock, then the atomic write happens later. Between the check and the write, the PGPROC slot can be reused by a newly-started backend (InitProcess sets pid, then the backend runs UndoRegisterBatchLSN on the same MyProcNumber index). The worker would then clobber a live backend's registered batch LSN with InvalidXLogRecPtr, allowing UndoGetOldestBatchLSN()/UndoSetDiscardHorizon() to advance the discard horizon past WAL that is still needed for rollback -- a recovery/data-loss hazard. A per-slot re-check under the appropriate lock, or a compare_exchange that only clears the exact stale value, is needed. Confidence: moderate (depends on slot-reuse timing, but the pattern is unsafe).


📄 src/backend/access/undo/undoworker.c (L273-L278)

Phase 1 discards up to insert_ptr (the next insertion point), not a computed retention boundary. oldest_undo_ptr is read from insert_ptr under LW_SHARED, the lock is released, and only then is UndoLogDiscard() called. This over-discards: everything up to the current end of the log is marked discardable whenever log->oldest_xid precedes the global oldest_xid, ignoring the retention_time the comment claims to honor (it is never computed here). Worse, the lock is dropped before UndoLogDiscard(), so a concurrent writer can advance insert_ptr / change oldest_xid, and the debug ereport below reads log->log_number after the lock is released (unsynchronized). Compute the actual oldest-needed pointer and hold consistent state across the discard. Confidence: high on the missing-retention logic; moderate on the concurrent-writer window.


📄 src/backend/access/undo/undoworker.c (L128-L130)

SIGTERM handler writes a non-async-signal-safe field. shutdown_requested is a plain bool (see undoworker.h), not volatile sig_atomic_t. Writing it from signal context here, reading it locklessly in the main loop, and also writing it under an LWLock in UndoWorkerRequestShutdown() is a data race and violates async-signal-safety; a torn/stale read can miss the shutdown request. Use the standard pattern: SignalHandlerForShutdownRequest() setting the volatile sig_atomic_t ShutdownRequestPending, checked via CHECK_FOR_INTERRUPTS/ProcessMainLoopInterrupts. Confidence: high.


📄 src/backend/access/undo/undoworker.c (L395-L397)

Portability: retained_mb is uint64 but is printed with %lu after casting to (unsigned long). On LLP64 (Windows/MSVC) and 32-bit platforms unsigned long is 32 bits, truncating the value. Use UINT64_FORMAT for the uint64 and pass it unmodified. (The %d for undo_max_wal_retention_size is fine since it is declared int.) Confidence: high.

💡 Suggested change

Before:

					ereport(WARNING,
							(errmsg("UNDO WAL retention (%lu MB) exceeds undo_max_wal_retention_size (%d MB)",
									(unsigned long) retained_mb, undo_max_wal_retention_size),

After:

					ereport(WARNING,
							(errmsg("UNDO WAL retention (" UINT64_FORMAT " MB) exceeds undo_max_wal_retention_size (%d MB)",
									retained_mb, undo_max_wal_retention_size),

📄 src/backend/access/undo/undoworker.c (L285-L287)

Convention/portability: UndoRecPtr is a 64-bit type; the tree prints these with UINT64_FORMAT rather than casting to unsigned long long and using the non-standard %llu. Use UINT64_FORMAT here. Confidence: high.


📄 src/backend/access/undo/undoworker.c (L636-L638)

Inconsistent shutdown signalling: UndoWorkerRequestShutdown() takes UndoWorkerShmem->lock LW_EXCLUSIVE to set the same shutdown_requested bool that the SIGTERM handler sets locklessly and the main loop reads locklessly. The lock here provides no real protection (the other writer bypasses it) and just adds a false sense of safety. Standardize on the ShutdownRequestPending / sig_atomic_t pattern and drop the lock. Confidence: high.


📄 src/backend/access/undo/undoworker.c (L620-L621)

Project convention (and the security checklist) requires bounded copies; use snprintf/strlcpy instead of sprintf. Note the adjacent bgw_name/bgw_type already use snprintf, so this is also internally inconsistent. Low corruption risk given the fixed literals, but flag for consistency. Confidence: high.

💡 Suggested change

Before:

	sprintf(worker.bgw_library_name, "postgres");
	sprintf(worker.bgw_function_name, "UndoWorkerMain");

After:

	snprintf(worker.bgw_library_name, BGW_MAXLEN, "postgres");
	snprintf(worker.bgw_function_name, BGW_MAXLEN, "UndoWorkerMain");

📄 src/backend/commands/tablecmds.c (L999-L1000)

This added break; is a no-op: default is the last (terminal) label in the switch, so control falls out regardless. This is an unrelated cosmetic change with no functional effect and does not belong in this feature patch. Per minimal-diff discipline, drop this hunk so the file is untouched. (high confidence)

💡 Suggested change

Before:

			(void) heap_reloptions(relkind, reloptions, true);
+			break;

After:

			(void) heap_reloptions(relkind, reloptions, true);

Comment thread .local-gitignore
Comment on lines +1 to +3
# Local development ignores (not tracked in .gitignore)
# To enable: git config core.excludesFile .local-gitignore
.local-gitignore

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should not be part of the patch. .local-gitignore is a personal, local-development convenience file (note the comment: "not tracked in .gitignore" and the intent to be wired up via git config core.excludesFile). Committing it violates the minimal-diff discipline: it is unrelated to the stated purpose of the change and would draw an immediate rejection on pgsql-hackers.

Several concrete problems compound this:

  • The file ignores itself (.local-gitignore), which is self-contradictory once it is actually tracked in the repository.
  • It references CLAUDE.md, an AI-assistant artifact that has no business being referenced from the PostgreSQL source tree.
  • Ignore entries like log/, results/, regression.diffs, regression.out, build/, install/ are developer-environment concerns; anything genuinely worth ignoring for all contributors belongs in the tree's real .gitignore files, not a personal excludes file checked into the repo.

Remove this file from the commit entirely and keep it out of version control (e.g., add it to your global/local excludes instead).

Comment on lines +17 to +18
DATA = pageinspect--1.13--1.14.sql \
pageinspect--1.12--1.13.sql \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bumps pageinspect to 1.14, adding three new SQL-callable functions (recno_page_items, recno_page_stats, recno_tuple_infomask_flags) in recnofuncs.c, but REGRESS is not extended and there is no new sql/recno*.sql test. Per PostgreSQL patch standards, a user-visible change needs regression coverage. Add a recno pageinspect regress test to REGRESS here (and the mirror list in meson.build), and update sql/oldextversions.sql to exercise the 1.13->1.14 upgrade path (matching the existing 1.11/1.12 pattern). (moderate confidence)

heapfuncs.o \
rawpage.o
rawpage.o \
recnofuncs.o

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bumps pageinspect to 1.14 and wires in recnofuncs.o, adding three new SQL-callable functions (recno_page_items, recno_page_stats, recno_tuple_infomask_flags). However, REGRESS is not extended and there is no sql/recno*.sql test for these functions, so the new behavior is entirely untested. Per PostgreSQL patch standards a user-visible change requires regression coverage. Add a recno pageinspect regress test to REGRESS (and the mirror list in meson.build), and update sql/oldextversions.sql to exercise the 1.13->1.14 upgrade path (matching the existing 1.11/1.12 pattern). (moderate confidence)

--
-- recno_page_items()
--
CREATE FUNCTION recno_page_items(IN page bytea,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three new SQL-callable functions are user-visible additions but have no accompanying documentation. doc/src/sgml/pageinspect.sgml was not updated (verified: no references to recno_page_items/recno_page_stats/recno_tuple_infomask_flags in the SGML), and there are no regression tests under contrib/pageinspect/sql/ and expected/ (verified: no matches). By pgsql-hackers standards a user-visible feature without docs AND tests is WIP, not commit-ready. Add an SGML section documenting the three functions and their output columns, and a regression test that exercises them (including the invalid/unused line-pointer and empty-infomask paths).

COMMIT;

-- STEP 6: Check UNDO log statistics
SELECT * FROM pg_stat_get_undo_logs();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example calls pg_stat_get_undo_logs() and pg_stat_get_undo_buffers(), but neither function is registered in the catalog. The pg_proc.dat diff only adds recno_tableam_handler (OID 9400) and build_zstd_dict_for_attribute (OID 9401); there is no CREATE FUNCTION in any SQL file for these either. The C functions exist in undostats.c (whose header comment claims they are "registered in pg_proc.dat"), but without a catalog entry these queries fail with function pg_stat_get_undo_logs() does not exist. Either add pg_proc.dat entries (SETOF record for logs, record for buffers, OIDs in 8000-9999) or the example is broken. Same problem in examples/05-undo-monitoring.sql. (high confidence)

Comment on lines +195 to +198
elog(DEBUG1, "UNDO discard: pruned %lu index entries across %d indexes (counter %u)",
(unsigned long) total_entries_pruned,
num_indexes_pruned,
discard_counter);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-portable format specifier: %lu with (unsigned long) cast truncates uint64 on LLP64 (Windows). Use UINT64_FORMAT.

Suggested change
elog(DEBUG1, "UNDO discard: pruned %lu index entries across %d indexes (counter %u)",
(unsigned long) total_entries_pruned,
num_indexes_pruned,
discard_counter);
elog(DEBUG1, "UNDO discard: pruned " UINT64_FORMAT " index entries across %d indexes (counter %u)",
total_entries_pruned,
num_indexes_pruned,
discard_counter);

Comment on lines +333 to +335
elog(DEBUG2, "targeted prune: index %s, %lu entries pruned",
RelationGetRelationName(indexrel),
(unsigned long) pruned);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-portable format specifier: %lu with (unsigned long) cast truncates uint64 on LLP64 (Windows). Use UINT64_FORMAT.

Suggested change
elog(DEBUG2, "targeted prune: index %s, %lu entries pruned",
RelationGetRelationName(indexrel),
(unsigned long) pruned);
elog(DEBUG2, "targeted prune: index %s, " UINT64_FORMAT " entries pruned",
RelationGetRelationName(indexrel),
pruned);

Comment on lines +72 to +73
elog(ERROR, "too many index pruning handlers registered");
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: elog(ERROR) does not return, so the following return; is unreachable. Remove it. (Same pattern in IndexPruneRegisterTargetedHandler below.)

Suggested change
elog(ERROR, "too many index pruning handlers registered");
return;
elog(ERROR, "too many index pruning handlers registered");

Comment on lines +238 to +239
elog(ERROR, "too many targeted index pruning handlers registered");
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead code: elog(ERROR) does not return; the return; is unreachable and should be removed.

Suggested change
elog(ERROR, "too many targeted index pruning handlers registered");
return;
elog(ERROR, "too many targeted index pruning handlers registered");

Comment on lines +296 to +299
* Simple approach: sort targets by index_oid, then process each group.
* For the common case (small number of targets), linear scan is fine.
*
* We batch targets by index_oid and dispatch to the targeted callback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment claims targets are sorted ("sort targets by index_oid, then process each group"), but the code never sorts; it only coalesces runs of adjacent equal index_oid. If a caller passes unsorted targets, the same index is opened/closed and its callback invoked multiple times, defeating the advertised O(N_dead) grouping and doing duplicate work. Either sort the array here, or document the precondition and Assert that the input is pre-sorted by index_oid. (Note: there is currently no caller, so the intended precondition cannot be verified.)

@github-actions github-actions Bot force-pushed the master branch 2 times, most recently from e495986 to d7cd5d6 Compare July 14, 2026 10:50

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 267 issue(s).

  • 25 inline, 242 in summary (inline capped at 25)
  • ⚠️ 216 warning(s) during review

📄 src/backend/access/recno/recno_compress.c

Stale/incorrect tuple-header layout. The actual RecnoTupleHeader in recno.h contains a restored t_xid_hint (TransactionId, 4 bytes) field and is documented there as 35 bytes raw / MAXALIGN'd to 40 bytes. This section claims the header is "32 bytes after MAXALIGN (31 bytes raw)" and that "there is no transaction-id / xact-timestamp field ... Both were removed." Both statements contradict the shipped struct and mislead anyone reasoning about on-disk format. Update the size and the field list to include t_xid_hint.


📄 src/backend/access/recno/recno_compress.c

Incorrect claim: the tuple header still carries an XID-related field (t_xid_hint) in the actual recno.h struct. The blanket statement "There is no transaction-id / xact-timestamp field ... Both were removed" is factually wrong and should be reconciled with the code.


📄 src/backend/access/recno/recno_clock.c

Wrong line reference: heap_toast_insert_or_update is at heapam.c:3877, while lines 3993-4050 contain HOT-update / replica-identity logic, not the TOAST pin-reuse pattern. Line-number citations in a README are fragile and already stale here; cite the function name only.


📄 src/backend/access/recno/recno_compress.c

Wrong symbol reference: no RecnoHLCNow() exists in the codebase. The actual function in recno_hlc.c is HLCNow(). Fix the symbol name.


📄 src/backend/access/recno/recno_compress.c

Dangling cross-reference: CLOCK_BOUND_DESIGN.md does not exist in the change set; only a DESIGN file is added under src/backend/access/recno/. Point this reference at the actual DESIGN document.


📄 src/backend/access/recno/recno_compress.c

Incorrect line reference. heap_toast_insert_or_update is defined around line 3877 in heapam.c; lines 3993-4050 contain HOT-update logic, not the TOAST pin-reuse pattern. Hard-coded line numbers rot quickly and are already wrong here; reference the function by name only and drop the line range.


📄 src/backend/access/recno/recno_compress.c

Incorrect line reference. The Assert(entry->data.lockmode == BUFFER_LOCK_UNLOCK) assertion is at line 6068 in bufmgr.c, not 5892. Drop the fragile line number (it will drift again) and reference the mechanism without a hard-coded line.


📄 src/backend/access/recno/recno_hlc.c

Mutating shared sLog state (SLogTupleRemove takes the seqlock writer path with writer-lock serialization) inside a read-only visibility probe is a hazard. During hot standby this path is reached by read-only queries while recovery concurrently applies WAL, and there is no RecoveryInProgress() guard here -- a scan-time writer against the sLog can race the applier and take partition locks in an order inconsistent with the DML paths. Guard this cleanup out of the read path (or at minimum skip it during recovery).


📄 src/backend/access/recno/recno_hlc.c

Comments should not cite volatile absolute line numbers (e.g. 'recno_mvcc.c lines 1824-1868', 'lines 1831-1930'); they drift the moment any line is added above them and become misleading. Describe the referenced logic by name instead.


📄 examples/02-undo-rollback.sql (L35-L39)

This explanatory sequence is confusing and self-contradictory for a teaching example: it lists "item 3 re-inserted" as an undo step, then "all 3 original inserts deleted", which nets to item 3 being removed anyway. Readers trying to understand UNDO ordering will be misled. Consider describing the undo as a strict LIFO reversal of the logged operations (undo DELETE of item 3 -> undo UPDATE of item 2 -> undo UPDATE of item 1 -> undo the 3 INSERTs), or simply state the net effect. Confidence: moderate (documentation clarity).

💡 Suggested change

Before:

-- UNDO records will be applied automatically:
-- - item 3 re-inserted
-- - item 2 price restored to 49.99
-- - item 1 quantity restored to 5
-- - all 3 original inserts deleted

After:

-- UNDO records are applied automatically in LIFO order, reversing each
-- logged operation until the transaction's state is fully undone.
-- Net effect: the table returns to empty (0 rows).

📄 contrib/pageinspect/recnofuncs.c (L18-L18)

Copyright header uses the range 2007-2026, which was copied verbatim from an existing pageinspect file (e.g. heapfuncs.c). For a brand-new file the convention is to use only the creation year. Change to Copyright (c) 2026, PostgreSQL Global Development Group.

💡 Suggested change

Before:

 * Copyright (c) 2007-2026, PostgreSQL Global Development Group

After:

 * Copyright (c) 2026, PostgreSQL Global Development Group

📄 examples/05-undo-monitoring.sql (L13-L13)

This calls pg_undo_force_discard() with no arguments, but the function's implementation in src/backend/access/undo/undostats.c reads a mandatory force_rotate boolean argument via PG_GETARG_BOOL(0). As written this example will fail at runtime (wrong number of arguments / function does not exist). Pass an explicit argument, e.g. SELECT pg_undo_force_discard(false); (or true to also rotate the active segment). [high confidence]

💡 Suggested change

Before:

+SELECT pg_undo_force_discard();

After:

+SELECT pg_undo_force_discard(false);

📄 src/backend/access/Makefile (L26-L30)

The ifdef USE_RECNO guard here is dead/misleading scaffolding and is out of sync with meson.

  • USE_RECNO is hardcoded to 1 in src/Makefile.global.in (USE_RECNO = 1) with no path to unset it, so this ifdef is always true.
  • The disable path it implies is documented as broken: both meson.build and Makefile.global.in state RECNO "cannot be conditionally disabled" because guc_parameters.dat/pg_proc.dat reference recno symbols unconditionally, so a build with USE_RECNO unset fails to link.
  • The meson counterpart src/backend/access/meson.build adds subdir('recno') unconditionally (no guard). This Makefile therefore diverges from meson, which the review rules require to stay in sync.

Add recno unconditionally alongside undo to match meson and avoid a conditional that can never be false. (high confidence)

💡 Suggested change

Before:

	undo

ifdef USE_RECNO
SUBDIRS += recno
endif

After:

	undo \
	recno

📄 examples/04-transactional-fileops.sql (L15-L16)

The example's central premise is false. CREATE TABLE does not emit a XLOG_FILEOPS_CREATE record: there is no FileOps* call anywhere in the relation-storage path (catalog/storage.c / smgrcreate). FileOpsCreate() is only called for the database version file (dbcommands.c) and the tablespace symlink (tablespace.c). A user running this example and inspecting WAL (e.g. via pg_waldump) will find no FILEOPS record for this table, contradicting the comment. Either wire regular relation creation through FILEOPS, or rewrite the example to demonstrate an operation that actually uses FILEOPS (CREATE DATABASE / tablespace symlink). Confidence: high.


📄 examples/04-transactional-fileops.sql (L29-L30)

DROP TABLE on a heap relation does not write a XLOG_FILEOPS_DELETE record and is not routed through FILEOPS. Heap file removal uses the existing smgrDoPendingDeletes mechanism in catalog/storage.c; FileOpsDelete() is only invoked from tablespace.c and internally in fileops.c. The "deletion deferred / XLOG_FILEOPS_DELETE has been written" and "deleted atomically with the transaction commit" narrative describes behavior that does not exist for this table. Confidence: high.


📄 examples/04-transactional-fileops.sql (L46-L47)

The rollback here is cleaned up by the standard pending-delete/abort machinery, not by "FILEOPS cleanup on abort". Plain heap tables never register a FILEOPS delete-on-abort undo action, so attributing this cleanup to FILEOPS is misleading. Confidence: high.


📄 examples/04-transactional-fileops.sql (L4-L5)

The header claims FILEOPS applies to ordinary table creation/deletion, but FILEOPS is not wired into the standard heap CREATE/DROP TABLE path (see catalog/storage.c). As written, none of the three examples below actually exercises FILEOPS, so the whole file demonstrates behavior that does not occur. Additionally, an SQL example that only asserts internal WAL behavior in comments cannot be verified by the reader and is not a regression test. Consider demonstrating a FILEOPS-backed operation instead, and add a TAP test (cf. src/test/recovery/t/054_fileops_recovery.pl) that actually checks the WAL records. Confidence: high.


📄 src/backend/access/common/reloptions.c (L2183-L2188)

This change is functionally a no-op and violates the minimal-diff discipline. default_reloptions() already returns bytea *, which the original one-liner returned directly. The new block casts the result to StdRdOptions *, stores it in rdopts, then casts it back to bytea * — an identical round-trip with no purpose. Unlike the TOAST branch above, this branch never touches rdopts (no field is adjusted), so the local assignment and extra braces add pure churn. Revert to the original one-line return.

(high confidence)

💡 Suggested change

Before:

			{
				rdopts = (StdRdOptions *)
					default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

				return (bytea *) rdopts;
			}

After:

			return default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

📄 src/backend/access/hash/hashinsert.c (L248-L249)

This UNDO gate dereferences heapRel via RelationAmSupportsUndo() (which reads rel->rd_tableam) without first checking for NULL. The sibling nbtree integration guards both of its identical call sites with heaprel != NULL && RelationAmSupportsUndo(heaprel) and documents that "heaprel is NULL during index builds and recovery" (see nbtinsert.c lines 1255 and 1454). Passing a NULL heapRel here is a NULL-pointer dereference. Mirror the nbtree guard.

💡 Suggested change

Before:

	if (RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

After:

	if (heapRel != NULL && RelationAmSupportsUndo(heapRel) &&
		UndoBufferIsActive(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

📄 src/backend/access/common/index_prune.c (L115-L117)

This entire module is dead code. Nothing in the tree includes access/index_prune.h except this file, and there are no callers of IndexPruneNotifyDiscard, IndexPruneNotifyTargeted, or the register/stats functions anywhere. Moreover, the AM callbacks the header declares (_bt_prune_by_undo_counter, _bt_prune_by_targets, hash_prune_by_undo_counter, etc.) are never implemented, and nothing ever calls the register functions -- so even if IndexPruneNotifyDiscard were invoked, IndexPruneFindHandler would always return NULL and the whole path would silently no-op. This is speculative scaffolding for a path that is not wired up. Per pgsql-hackers hygiene (YAGNI/minimal diff), drop this file (and its header) until it has a live caller and real callbacks. (high confidence)


📄 src/backend/access/common/index_prune.c (L72-L76)

elog(ERROR, ...) does not return, so this return; is unreachable dead code. Remove it.

💡 Suggested change

Before:

		elog(ERROR, "too many index pruning handlers registered");
		return;
	}

	handlers[num_handlers].indexam_oid = indexam_oid;

After:

		elog(ERROR, "too many index pruning handlers registered");
	}

	handlers[num_handlers].indexam_oid = indexam_oid;

📄 src/backend/access/common/index_prune.c (L238-L240)

Same unreachable return; after elog(ERROR, ...) here. Remove it.

💡 Suggested change

Before:

		elog(ERROR, "too many targeted index pruning handlers registered");
		return;
	}

After:

		elog(ERROR, "too many targeted index pruning handlers registered");
	}

📄 src/backend/access/common/index_prune.c (L162-L165)

Portability: uint64 values must be printed with UINT64_FORMAT, not %lu with an (unsigned long) cast. On LLP64 platforms (Windows/MSVC) unsigned long is 32-bit, so this cast truncates the 64-bit entries_pruned. Use e.g. elog(DEBUG2, "index %s: marked " UINT64_FORMAT " entries as dead for counter %u", RelationGetRelationName(indexrel), entries_pruned, discard_counter);. (high confidence)


📄 src/backend/access/common/index_prune.c (L195-L198)

Same %lu+(unsigned long) truncation of a uint64 on LLP64 platforms. Use UINT64_FORMAT with the raw total_entries_pruned.


📄 src/backend/access/common/index_prune.c (L333-L335)

Same %lu+(unsigned long) truncation of a uint64 on LLP64 platforms. Use UINT64_FORMAT with the raw pruned.


📄 src/backend/access/common/index_prune.c (L295-L300)

Comment-vs-code mismatch: the comment says "sort targets by index_oid, then process each group", but no sort is performed. The while-loop only groups contiguous entries with the same index_oid. If the caller does not pre-sort by index_oid, non-contiguous entries for the same index will be opened and processed multiple times (redundant work, and the batch passed to the callback is incomplete per index). Either sort the array here (respecting that targets is caller-owned -- sort a copy or document that the caller must pass a sorted array) or fix the comment and enforce/assert the precondition. (high confidence on the mismatch; caller behavior unverified since there is no caller)


📄 src/backend/access/common/index_prune.c (L76-L78)

Registration appends unconditionally without checking whether indexam_oid is already registered. A double registration silently creates duplicate entries, wastes MAX_INDEX_HANDLERS slots, and leaves later duplicates as dead entries (lookup returns the first match). Consider rejecting or replacing an existing registration for the same OID. The same applies to IndexPruneRegisterTargetedHandler.


📄 src/backend/access/common/index_prune.c (L39-L40)

The handlers[]/num_handlers, targeted_handlers[]/num_targeted_handlers, and prune_stats are plain process-local file-scope statics. If registration is intended to happen once at postmaster startup, forked backends will not inherit runtime registrations done after fork; if it is per-backend, every backend must re-register or lookups return NULL and pruning silently no-ops. Likewise, prune_stats is per-process, so the "global" statistics returned by IndexPruneGetStats are not aggregated across backends and are misleading; if they were meant to be cluster-wide they must live in shared memory with proper synchronization. Clarify the intended process model before this is wired up. (moderate confidence -- no caller/registration site exists yet to confirm intent)


📄 src/backend/access/heap/heapam.c (L42-L43)

These three includes are unrelated churn. Nothing added in this diff to heapam.c uses a new symbol from access/xact.h, access/xlog.h, or utils/memutils.h (the only functional changes below are added blank lines). The symbols already used here (GetCurrentTransactionId, XLogInsert, palloc, etc.) were available before this patch. Adding includes not required by the change is unrelated modification and should be dropped to keep the diff minimal. (high confidence)


📄 src/backend/access/heap/heapam.c (L60-L60)

Whitespace-only churn on an untouched line. The upstream file intentionally uses a double blank line separating the include block from the static forward declarations. Removing it is unrelated reformatting and will show up as noise in git diff --check review; revert it. (high confidence)


📄 src/backend/access/heap/heapam.c (L115-L115)

Whitespace-only churn on an untouched line. This double blank line before the lock-mode table comment is the original formatting; removing it is unrelated reformatting and should be reverted to keep the patch minimal. (high confidence)


📄 src/backend/access/heap/heapam.c (L2140-L2141)

Cosmetic blank line inserted after XLogBeginInsert(); in heap_insert with no functional purpose. This reformatting of untouched code is unrelated to any change and should be dropped. (high confidence)

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

📄 src/backend/access/heap/heapam.c (L3069-L3070)

Cosmetic blank line inserted after XLogBeginInsert(); in heap_delete with no functional purpose. Same unrelated reformatting as in heap_insert; drop it. (high confidence)

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

📄 src/backend/access/heap/heapam_handler.c (L156-L159)

The doc comment describes UNDO behavior that this function does not perform: the body is empty and the heap AM sets am_supports_undo = false. Comments must describe what the code does now (not aspirational RECNO-AM behavior). Either drop this no-op stub entirely -- begin_bulk_insert is an optional callback and the table_begin_bulk_insert() wrapper already NULL-checks it, so heap need not register it -- or, if kept, replace the comment with an accurate one stating heap does no bulk-insert setup.

Confidence: high.


📄 src/backend/access/heap/heapam_handler.c (L166-L169)

Same issue: the comment claims this "Flushes any pending UNDO records and deactivates the write buffer", but the body is empty and heap does not use UNDO. This is a dead no-op stub for an optional callback (the table_finish_bulk_insert() wrapper NULL-checks it). Prefer not registering it in heap AM at all; if retained, the comment must reflect that heap does nothing here.

Confidence: high.


📄 src/backend/access/hash/hash_undo.c (L202-L209)

Critical correctness bug: undo apply marks LP_DEAD purely by the stored physical (blkno, offset), with no validation that the item there is still the tuple this transaction inserted. Hash offsets are NOT stable: _hash_vacuum_one_page, hashbucketcleanup, and _hash_squeezebucket all call PageIndexMultiDelete, which compacts the line-pointer array and shifts surviving tuples' offsets (and squeeze moves tuples across pages entirely). Between insert and abort, a committed row's index entry can occupy the stored offset, so this will silently mark a live entry dead -> index corruption / wrong query results.

The nbtree undo apply (nbtree_undo.c) deliberately avoids this by storing the full IndexTuple in the payload and locating the entry via BTreeTupleGetHeapTID() before marking it dead. The hash payload here carries no tuple/TID, so it cannot validate. This needs a TID-based lookup (store the heap TID or index tuple in the undo payload and search the bucket chain for the matching live entry) rather than trusting the stale offset.


📄 src/backend/access/hash/hash_undo.c (L222-L223)

The CLR carries the stale stored offset (hdr.offset) as target_offset. On crash recovery the redo handler (undo_xlog.c: UNDO_CLR_LP_DEAD case) re-applies ItemIdMarkDead(PageGetItemId(page, xlrec->target_offset)) at exactly this offset -- so replay reproduces the same wrong-offset marking on primaries and replicas. nbtree sets target_offset = kill_offset (the offset it actually modified). Once the apply path is fixed to locate the real entry, target_block/target_offset here must name the actually-killed location, not the stored (blkno, offset).


📄 src/backend/access/hash/hash_undo.c (L62-L63)

Portability hazard: HashUndoInsert is memcpy'd whole into the WAL-persisted undo payload and read back the same way, so the on-disk layout depends on struct padding/alignment. With Oid(4)+BlockNumber(4)+OffsetNumber(2) there is trailing padding; more importantly this couples a persisted format to the compiler's struct layout. SizeOfHashUndoInsert avoids the trailing pad, but relying on offsetof for a cross-architecture WAL format is fragile. Prefer explicit field-by-field serialization (as other undo payloads effectively require by appending variable data).


📄 src/backend/access/hash/hash_undo.c (L95-L98)

The file header claims "All hooks are gated by RelationAmSupportsUndo(heapRel)", but this function performs no such gating -- it relies entirely on the caller (_hash_doinsert) to check RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel). State this as a caller precondition to avoid the misleading impression that the gating happens here.


📄 src/backend/access/heap/pruneheap.c (L21-L21)

This file uses no symbols from access/parallel.h (no IsParallelWorker, ParallelContext, ParallelWorkerNumber, etc. appear anywhere in pruneheap.c) or from access/xact.h (the file only uses TransactionId* macros from the already-included access/transam.h, not xact.h functions like GetCurrentTransactionId/RegisterXactCallback). These two includes are unused; adding them with no corresponding code change violates the minimal-diff rule and should be dropped.

Separately, access/visibilitymapdefs.h is already pulled in transitively by access/visibilitymap.h (included just above, and unchanged by this patch), so it is redundant here. Confidence: high for the two unused includes; moderate for the redundant one.


📄 src/backend/access/heap/vacuumlazy.c (L156-L156)

Minimal-diff concern: this added include is the only change in this file, but no new code in vacuumlazy.c requires it — all uses of Relation* macros (RelationGetRelid, RelationNeedsWAL, RelationGetNumberOfBlocks, etc.) are pre-existing and already compiled without a direct rel.h include (it was pulled in transitively). If this line is not actually required to build, it is unrelated churn from the larger feature work and should be dropped so the file reads as it always did. If it IS now required (e.g. a transitive include was removed elsewhere in this series), that dependency should be made explicit in the commit message. (moderate confidence)


📄 src/backend/access/index/indexam.c (L306-L309)

These NULL guards are dead code. Just above at line 289, table_index_fetch_begin(heapRelation, flags) unconditionally dereferences heapRelation->rd_tableam->index_fetch_begin, and at line 270 RelationIsAccessibleInLogicalDecoding(heapRelation) also dereferences heapRelation. So by the time control reaches here, both heapRelation and heapRelation->rd_tableam are guaranteed non-NULL. The extra checks are inconsistent with the surrounding code and add noise; drop them so the condition reads as if it had always been written that way. (low confidence on impact, high confidence the checks are unreachable)

💡 Suggested change

Before:

	if (heapRelation != NULL &&
		heapRelation->rd_tableam != NULL &&
		heapRelation->rd_tableam->am_inplace_update_keeps_tid)
		scan->xs_want_itup = true;

After:

	if (heapRelation->rd_tableam->am_inplace_update_keeps_tid)
		scan->xs_want_itup = true;

📄 src/backend/access/nbtree/nbtinsert.c (L1454-L1459)

This UNDO-logging block is byte-for-byte identical to the split-path block added above (same guard, same NbtreeUndoLogInsert(rel, heaprel, buf, postingoff == 0 ? itup : origitup, itemsz, newitemoff, isleaf) call). Consider extracting a tiny static helper so the two call sites cannot drift apart. Note also that this branch wraps the single call in braces while the split-path branch does not; keep the two consistent. (moderate confidence)


📄 src/backend/access/recno/Makefile (L38-L38)

Missing newline at end of file. The include $(top_srcdir)/src/backend/common.mk line is not terminated by a newline (\ No newline at end of file). Every other backend subdirectory Makefile in the tree ends with a trailing newline, and git diff --check / POSIX conventions expect text files to end with one. Add a trailing newline. (high confidence)

💡 Suggested change

Before:

+include $(top_srcdir)/src/backend/common.mk

After:

+include $(top_srcdir)/src/backend/common.mk


📄 src/backend/access/nbtree/nbtree_undo.c (L602-L605)

Missing bounds validation before restoring the full page image. payload_len is only checked against SizeOfNbtreeUndoDedup, but hdr.page_len (a uint16 read from the payload) is used directly in the memcpy without verifying:

  • payload_len >= SizeOfNbtreeUndoDedup + hdr.page_len (a truncated payload causes an out-of-bounds read past the payload buffer), and
  • hdr.page_len <= BLCKSZ / hdr.page_len == PageGetPageSize(page) (a corrupt/oversized page_len overruns the target page buffer, corrupting adjacent shared-buffer memory).

The INSERT_LEAF path validates payload_len < SizeOfNbtreeUndoInsertLeaf + hdr.itup_sz; the DEDUP path must do the equivalent. On CLR replay of a malformed record this is a data-corruption / crash hazard.

💡 Suggested change

Before:

				if (payload_len < SizeOfNbtreeUndoDedup)
					return UNDO_APPLY_ERROR;

				memcpy(&hdr, payload, SizeOfNbtreeUndoDedup);

After:

				if (payload_len < SizeOfNbtreeUndoDedup)
					return UNDO_APPLY_ERROR;

				memcpy(&hdr, payload, SizeOfNbtreeUndoDedup);

				/* The saved page image follows the fixed header. */
				if (hdr.page_len > BLCKSZ ||
					payload_len < SizeOfNbtreeUndoDedup + hdr.page_len)
					return UNDO_APPLY_ERROR;

📄 src/backend/access/nbtree/nbtree_undo.c (L635-L638)

The full page image is copied without checking that hdr.page_len matches the target buffer's actual page size. Combined with the missing payload bound check above, hdr.page_len > BLCKSZ overruns the shared-buffer page. Add the validation (see comment on the header memcpy) before this restore.


📄 src/backend/access/nbtree/nbtree_undo.c (L88-L88)

These SizeOf* macros are not fully parenthesized, unlike every other SizeOf* macro in the tree (e.g. SizeOfHeapDelete (offsetof(...) + sizeof(uint8))). An unparenthesized offsetof(...) + sizeof(Size) binds incorrectly in pointer arithmetic or any higher-precedence context, e.g. payload + SizeOfNbtreeUndoInsertLeaf or a future x * SizeOf.... Wrap the whole expression in parentheses. All four macros (InsertLeaf, InsertUpper, Dedup, Delete) share this defect.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertLeaf	offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertLeaf	(offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L103-L103)

Unparenthesized SizeOf* macro (see note on SizeOfNbtreeUndoInsertLeaf). Wrap in parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertUpper	offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertUpper	(offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L116-L116)

Unparenthesized SizeOf* macro (see note on SizeOfNbtreeUndoInsertLeaf). Wrap in parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDedup	offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDedup	(offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L129-L129)

NbtreeUndoDelete and SizeOfNbtreeUndoDelete are dead code: nothing in the tree writes or reads a DELETE payload (only INSERT_LEAF/INSERT_UPPER/DEDUP are logged), and the DELETE undo case is a no-op. Per YAGNI, drop the struct, the macro, and the unparenthesized-macro fix becomes moot. If retained, the macro still needs parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDelete	offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDelete	(offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L65-L69)

These subtypes are speculative scaffolding: no code path anywhere writes INSERT_POST, SPLIT_L, SPLIT_R, NEWROOT, DELETE, or VACUUM undo records (only NbtreeUndoLogInsert and NbtreeUndoLogDedup exist, producing INSERT_LEAF/INSERT_UPPER/DEDUP). Since every one of these cases only returns UNDO_APPLY_SKIPPED and can never be reached, they are dead code. Per PG minimalism, remove the unreachable subtypes, their #defines, and their switch/desc arms until an actual writer exists.


📄 src/backend/access/nbtree/nbtree_undo.c (L691-L694)

Aspirational/future-tense comment: "For now, skip and let the entries be re-created...". PG comments must describe current behavior, not intent. Reword to state what the code does now (DELETE undo is not applied; entries are handled by the reverted heap operation) without "for now".


📄 src/backend/access/recno/DESIGN (L286-L287)

Doc-vs-code drift (documentation accuracy). Section 1.7 states the durable commit-HLC map is "Stage 1 (write-only shadow)" and that "nothing about observable visibility changes ... Stage 1 writes a shadow copy that no read path consults yet." This no longer matches the code. recno_mvcc.c's RecnoTupleVisible() and RecnoTupleVisibleHLC() already call RecnoCtsResolveWriter() (which reads the map via RecnoTxidGetCommitData()) inside the visibility path and treat it as "authoritative for a non-self writer" -- the code's own comments label this the "Stage-2 read-cutover". So the read path DOES consult the map and visibility HAS changed. Per PostgreSQL review discipline, comments/design docs must describe what the code does now, not a superseded plan. Update section 1.7 to reflect that the map is now read on the visibility path (Stage 2), or remove the emphatic write-only-shadow framing. Note this also contradicts recno_cts.c's own header comment claiming no visibility-path code calls RecnoTxidGetCommitData() yet.


📄 src/backend/access/recno/DESIGN (L331-L332)

Consequence of the same Stage-1/Stage-2 drift: this paragraph is written as guidance "for whoever picks up Stage 2/3" and asserts "Stage 1 has no reader." Since RecnoTupleVisible()/RecnoTupleVisibleHLC() already read the map (RecnoCtsResolveWriter), the "map entry lost, CLOG entry present" residual described here is no longer a purely-future concern -- it is a live correctness constraint on the current read path. The doc should state whether the current read cutover already handles the "unknown, fall back to CLOG" case, rather than deferring it to a hypothetical future stage.


📄 src/backend/access/recno/DESIGN (L52-L53)

Identity/accuracy nit: section 1.2 presents RecnoTupleVisible() / RecnoTupleVisibleHLC() as "the actual entry points, not illustrative placeholders" for the visibility path. In the code these two are lower-level checks; the actual entry point invoked throughout recno_handler.c and recno_operations.c is RecnoTupleVisibleToSnapshotDual(), which dispatches to one of the two based on HLC mode. Consider naming the dispatcher as the true entry point to avoid misdirecting a reviewer tracing the visibility path.


📄 src/backend/access/recno/README (L261-L262)

This tuple-header description is stale and now factually wrong. RecnoTupleHeader in recno.h contains a t_xid_hint (TransactionId, 4B) field that was restored (per the header's own comment, commit 52ddb3f7839 was reverted to fix a dirty-read hole), and the struct is documented there as "35 bytes raw (MAXALIGN'd to 40 bytes)", not 31/32. The claim "There is no transaction-id... field... Both were removed" contradicts the actual struct. A README describing on-disk format must match the code; this misstates both the per-tuple size and the field set.

💡 Suggested change

Before:

The fixed header (`RecnoTupleHeader`, `recno.h`) is 32 bytes after
+MAXALIGN (31 bytes raw), compared to heap's 23-27 bytes:

After:

The fixed header (`RecnoTupleHeader`, `recno.h`) is 40 bytes after
+MAXALIGN (35 bytes raw), compared to heap's 23-27 bytes:

📄 src/backend/access/recno/README (L280-L282)

Contradicts recno.h: the header still carries t_xid_hint (the inserting XID, valid while RECNO_TUPLE_UNCOMMITTED). Only t_cid was removed; the transaction-id field was re-added. Update the layout table and this paragraph, and reconcile the downstream size claims at lines ~241 ("32 bytes"), ~976, ~1038 ("32 bytes per tuple header"), and ~1093, which all repeat the incorrect 32-byte figure.


📄 src/backend/access/recno/README (L117-L117)

Wrong symbol name: there is no RecnoHLCNow(). The HLC generation function in recno_hlc.c is HLCNow(). A README that names a nonexistent function misleads anyone grepping the source.

💡 Suggested change

Before:

  implementation in `recno_hlc.c:RecnoHLCNow()` detects this and

After:

  implementation in `recno_hlc.c:HLCNow()` detects this and

📄 src/backend/access/recno/README (L137-L137)

Dangling cross-reference: CLOCK_BOUND_DESIGN.md is not in the tree. The change set only adds src/backend/access/recno/DESIGN, which the "Additional Documentation" section itself lists. Point at the existing DESIGN file (or add the referenced document).

💡 Suggested change

Before:

  CLOCK_BOUND_DESIGN.md for the integration with clock-bound daemons

After:

  DESIGN for the integration with clock-bound daemons

📄 src/backend/access/recno/README (L548-L549)

Fragile and already-incorrect hard-coded line reference. heap_toast_insert_or_update is defined around heapam.c:3877; lines 3993-4050 are HOT-update logic, not the TOAST pin-reuse pattern. Line-number citations rot immediately across rebases/backpatches; drop the range and reference the function by name only.

💡 Suggested change

Before:

See src/backend/access/heap/heapam.c:3993-4050 for the analogous pattern
+in heap's TOAST handling (heap_toast_insert_or_update).

After:

See heap's TOAST handling (heap_toast_insert_or_update in
+src/backend/access/heap/heapam.c) for the analogous pattern.

📄 src/backend/access/recno/README (L561-L561)

Incorrect line reference: Assert(entry->data.lockmode == BUFFER_LOCK_UNLOCK) is at bufmgr.c:6068 in this tree, not 5892. As with the heapam.c citation, a hard-coded line number in a README is guaranteed to drift; cite the assertion by content only and drop the line number.

💡 Suggested change

Before:

in bufmgr.c:5892 enforces this.

After:

in bufmgr.c enforces this.

📄 src/backend/access/recno/README (L130-L130)

Non-ASCII character: this line contains a Unicode em-dash (U+2014). PostgreSQL source/docs must be ASCII-only; replace it with " -- ". A git diff --check / ASCII scan will flag this on -hackers.

💡 Suggested change

Before:

on logical-replication messages (see `recno_clock.c`) — a receiver

After:

on logical-replication messages (see `recno_clock.c`) -- a receiver

📄 src/backend/access/recno/recno_clock.c (L207-L207)

This tuple-header description is stale and contradicts the actual struct in recno.h. The real RecnoTupleHeader includes a restored t_xid_hint (TransactionId, 4B) field, and recno.h documents the fixed size as "35 bytes raw (MAXALIGN'd to 40 bytes)", not 32/31 bytes. The README both understates the on-disk tuple size and denies a field that exists. Because this documents an on-disk binary layout, the mismatch is misleading for anyone reasoning about storage size or format. Update the byte counts (35 raw / 40 MAXALIGN'd) and the field table to include t_xid_hint.


📄 src/backend/access/recno/recno_clock.c (L286-L286)

The claim "There is no transaction-id ... field ... Both were removed" is false for the current code. recno.h restored t_xid_hint (TransactionId, valid while RECNO_TUPLE_UNCOMMITTED is set) specifically to close a dirty-read hole for local-only INSERTs. The README should describe t_xid_hint rather than assert it was removed.


📄 src/backend/access/recno/recno_clock.c (L115-L115)

CLOCK_BOUND_DESIGN.md does not exist in the change set; only a file named DESIGN (src/backend/access/recno/DESIGN) is present. This is a dangling cross-reference. Point to the actual DESIGN document (or the specific section) instead.


📄 src/backend/access/recno/recno_clock.c (L548-L548)

Wrong line reference: the Assert(entry->data.lockmode == BUFFER_LOCK_UNLOCK) assertion is at bufmgr.c:6068, not 5892. Hard-coded line numbers rot quickly; prefer citing the function name only (or drop the line number) so the reference doesn't become stale on the next edit.


📄 src/backend/access/recno/recno_clock.c (L369-L372)

Non-ASCII character (em-dash U+2014) present. PostgreSQL source/docs must be ASCII-only; replace it with " -- " to keep git diff --check and the tree's ASCII conventions clean.


📄 src/backend/access/recno/recno_diff.c (L211-L220)

Out-of-bounds read on the undo/recovery path. This walk trusts diff->ndiffs and each seg->ins_len from the (potentially corrupt) diff record: seg is dereferenced and ptr is advanced by SizeOfRecnoDiffSegment + seg->ins_len per iteration, but nothing bounds ptr against the actual record buffer. diff->total_size is carried in the header precisely for this but is never used here. The callers only validate the fixed header size before calling this (recno_undo.c checks image_len < sizeof(RecnoDiffRecord); recno_pvs.c checks payload_size < SizeOfRelUndoDeltaUpdatePayload + sizeof(RecnoDiffRecord)), so a truncated/damaged UNDO record drives ptr past the buffer and reads out of bounds during undo apply / crash recovery. Add a caller-supplied buffer length (or validate against diff->total_size) and bounds-check ptr before every seg/old_bytes read. (high confidence)


📄 src/backend/access/recno/recno_diff.c (L227-L230)

Reconstruction failures here are effectively data corruption of the UNDO/before-image record, yet they are logged at DEBUG1 and return false. Callers (recno_undo.c, recno_pvs.c) turn this into a WARNING and skip the rollback, silently leaving the tuple in its post-update state -- a silent-data-corruption footgun on the rollback/recovery path. Consider elevating the severity so a corrupt diff is not quietly ignored. (moderate confidence)


📄 src/backend/access/recno/recno_cts.c (L18-L19)

This comment is factually wrong and will mislead future readers. RecnoTxidGetCommitData() IS wired into the visibility path: recno_mvcc.c's RecnoCtsResolveWriter() calls it (recno_mvcc.c:1035) and is in turn invoked from at least six MVCC visibility sites (recno_mvcc.c:1149, 1210, 1322, 2135, 2190, 2312). The claim that the reader is "provided, tested, and correct, but unwired" and that "No visibility-path code calls RecnoTxidGetCommitData() yet" describes a state the code is no longer in. Update this header (and the analogous claims in recno_cts.h) to reflect that the reader is live on the visibility path.


📄 src/backend/access/recno/recno_diff.c (L10-L12)

Comment does not match the format. The diff fields (offset, del_len, ins_len) are uint16 (2 bytes each), and RecnoDiffRecord's header is 3x uint16 = 6 bytes; there is no 4-byte-per-field layout. The '4 bytes offset + 4 bytes header' breakdown misleads readers about the on-disk format. Fix the arithmetic to reflect the actual uint16 widths. (high confidence)


📄 src/backend/access/recno/recno_dict.c (L54-L54)

Critical buffer overflow: RecnoDictMeta does not fit in one page. sizeof(RecnoDictMeta) = 24 (header, padded to 8-byte alignment for the uint64-containing entries[]) + RECNO_DICT_MAX_DIRECTORY(256) * sizeof(RecnoDictDirEntry)(32) = 8216 bytes, but PageGetContents() only yields BLCKSZ - MAXALIGN(SizeOfPageHeaderData) = 8168 bytes. Writing meta->entries[meta->count] as the directory fills (recno_dict_append) overruns the shared-buffer page by ~48 bytes, corrupting adjacent buffers; recno_dict_read's loop over meta->entries[i] reads past the page too. Reduce RECNO_DICT_MAX_DIRECTORY so the struct fits, and add a StaticAssertDecl(RecnoDictMetaSize <= BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) to catch this at compile time.


📄 src/backend/access/recno/recno_dict.c (L87-L92)

Concurrency hazard in the metapage-creation path. The confirmed caller build_zstd_dict_for_attribute() (recno_compress.c) opens the relation with only AccessShareLock before calling recno_dict_append()/recno_dict_set_active(). The smgrexists()/smgrnblocks() check here is unlocked, so two concurrent backends can both observe an empty fork and both run smgrcreate() + log_smgrcreate() + ExtendBufferedRel(). Nothing serializes this (no relation-extension lock is taken for the create decision). The result is double smgrcreate WAL, duplicate block-0 extension, or a torn directory. Serialize dictionary creation with an exclusive relation lock or the relation-extension lock before deciding to create/extend the fork.


📄 src/backend/access/recno/recno_dict.c (L376-L377)

Unbounded on-disk length used directly for allocation. entry.length is read from the metapage on disk and passed straight to palloc(entry.length) with no upper-bound sanity check. A corrupted directory entry with a huge length triggers a very large allocation (possible OOM/crash) before any per-page overrun check can fire. Validate entry.length against a sane maximum (e.g. a bounded multiple of RecnoDictPagePayload, or the known max training blob size) before allocating.


📄 src/backend/access/recno/recno_dict.c (L402-L403)

Out-of-bounds read from an unvalidated on-disk field. ph->bytes_on_page is read from disk and used directly in memcpy(result + copied, payload, ph->bytes_on_page) without checking it against RecnoDictPagePayload. A corrupt page header with bytes_on_page > RecnoDictPagePayload reads past the page payload region. The existing check only guards the destination overrun (copied + bytes_on_page > entry.length), not the source page bounds. Add a check that ph->bytes_on_page <= RecnoDictPagePayload before the memcpy.


📄 src/backend/access/recno/recno_dict.c (L130-L135)

Magic-mismatch is silently treated as "uninitialized" and reinitialized. Since the metapage write is WAL-logged with a forced FPI, a magic mismatch on a properly-recovered fork indicates corruption, not a fresh page. Silently re-running recno_dict_init_metapage() on a torn/corrupt (but non-empty) block masks corruption and destroys the existing directory (losing all dictid mappings, making previously-compressed datums undecompressable). The empty-fork case is already handled above by the smgrnblocks()==0 branch; a non-empty block-0 with a bad magic should ereport(ERROR, ERRCODE_DATA_CORRUPTED) rather than reinitialize.


📄 src/backend/access/recno/recno_dict.c (L325-L325)

On-disk format relies on compiler struct padding and host endianness. RecnoDictMeta/RecnoDictDirEntry are written to and read from the page via raw pointer casts. The uint64 trained_ts imposes 8-byte struct alignment (adding 4 bytes of implicit padding after the 20-byte header before entries[]), and multi-byte fields are stored in host byte order. This is non-portable across the architectures PostgreSQL supports and breaks physical replication/pg_upgrade between hosts with different endianness or alignment. The explicit _pad[3] handles only the codec field; the header/entries padding and endianness are still implicit. Document/enforce the exact on-disk layout (fixed field order, explicit padding, defined byte order) or note the intended portability constraints.


📄 src/backend/access/recno/recno_dict.c (L270-L274)

Missing CHECK_FOR_INTERRUPTS() in the page-writing loop. For a large blob spanning many pages this do/while runs without an interrupt check, making the operation non-cancellable. Add CHECK_FOR_INTERRUPTS() at the top of the loop body, outside any START/END_CRIT_SECTION.


📄 src/backend/access/recno/recno_dirtymap.c (L184-L184)

Correctness contract violated at the call sites. This module's central invariant (documented in both this file's header and recno_dirtymap.h) is: set the page bit BEFORE releasing the buffer lock that covers the modification, so any scanner that can observe the modification also observes the bit. But every caller does the opposite: recno_operations.c:1662 UnlockReleaseBuffer() then Mark at 1702 (DELETE); recno_operations.c:4590 Mark after buffer release (UPDATE); recno_handler.c:3016 LockBuffer(UNLOCK) then Mark at 3041 (LOCK). The on-page RECNO_TUPLE_UPDATED flag is published under the buffer lock, but the dirtymap bit is published after unlock. A scanner can therefore observe RECNO_TUPLE_UPDATED set (via the page) while RecnoDirtyMapCheck still returns false (Mark not yet run), skip the sLog before-image probe, and serve the wrong on-page value to an older snapshot -> silent MVCC-incorrect results. Either move every Mark to before the buffer-lock release (matching the documented contract), or revise the contract and add the correct happens-before ordering. This must be proven, not asserted.


📄 src/backend/access/recno/recno_dirtymap.c (L224-L224)

Missing memory barriers for the lock-free reader/writer handshake. On common platforms pg_atomic_write_u64/pg_atomic_read_u64 compile to plain aligned store/load with NO barrier (see port/atomics/generic.h; the barrier variants are pg_atomic_write_membarrier_u64 / pg_atomic_read_membarrier_u64). Publishing the key with pg_atomic_write_u64 while holding only the spinlock does not, by itself, establish release/acquire ordering with a lock-free reader on weakly-ordered ISAs (ARM64, PPC64, RISC-V). Even if the intended ordering is buffer-lock-based, the writer publishes the bit after the buffer lock is released, so the LWLock release barrier does not cover this store. Use pg_atomic_write_membarrier_u64 on publish and pg_atomic_read_membarrier_u64 on the hot read (or insert explicit pg_write_barrier()/pg_read_barrier()) and document the exact pairing.


📄 src/backend/access/recno/recno_dirtymap.c (L233-L235)

Sticky 'full' latch is an irreversible, silent performance cliff. nentries is only ever incremented (never decremented) and the map is never reset during cluster uptime, so a single partition monotonically approaches RECNO_DIRTYMAP_MAX_LOAD and, once latched, RecnoDirtyMapCheck returns dirty for every page in that partition forever -- forcing the per-tuple sLog probe on the whole hot scan path with no recovery until restart. Combined with grow-only semantics and no discard/reset integration this is a guaranteed unbounded-degradation footgun on a long-lived database. At minimum this needs a bounded reclaim/reset path tied to the sLog retained-entry horizon, and the fixed 8 MB sizing should not be a hardcoded policy without a benchmark-backed rationale (or a GUC).


📄 src/backend/access/recno/recno_dirtymap.c (L70-L71)

OID-reuse / cross-relation aliasing hazard. All callers compose the key from RelationGetRelid() (a pg_class OID), not a stable relfilenode. Because the map is grow-only and never reset, a stale set bit for a dropped relation persists and is applied to a new relation that reuses the same OID after OID wraparound (possible over a long-lived cluster) at the same blkno. That direction is only conservative (extra probe), but if the reused page is classified via a still-set bit against a before-image that no longer exists, behavior depends on the discard interaction. Document and verify the key is collision-free across relation lifetime; the 'relid 0 is never a user relation' note does not establish that.


📄 src/backend/access/recno/recno_dirtymap.c (L112-L116)

Weak independence between partition index and initial slot. recno_dirtymap_part() uses mixed & 127 (low 7 bits) and recno_dirtymap_slot0() uses (mixed >> 7) & MASK -- adjacent, non-overlapping bit ranges of the same multiplicative mix. For sequential (relid, blkno) scan keys this can increase clustering and cause the probe chain to be exhausted (latching 'full') below MAX_LOAD. Consider deriving partition and slot from independently-mixed words (or two separate hashes) and add a distribution check.


📄 src/backend/access/recno/recno_dirtymap.c (L158-L162)

RecnoDirtyMapShmemInit() is an empty no-op whose only body is a comment. Initialization is done by RecnoDirtyMapShmemInit_cb via the registered callback, so this exported function does nothing. It is either dead code (remove the declaration and definition) or a trap for a caller that expects it to initialize the map. Per YAGNI, remove it unless a real caller depends on the symbol.


📄 src/backend/access/recno/recno_dirtymap.c (L188-L192)

Mark/Check dereference the global DirtyMap without any NULL guard (the only Assert(DirtyMap != NULL) is in the init callback). If either is reached before shmem attach (early bootstrap, a process that did not attach, or EXEC_BACKEND child re-establishment on Windows), this is a NULL-deref crash. Add Assert(DirtyMap != NULL) at the top of RecnoDirtyMapMark and RecnoDirtyMapCheck, and confirm the pointer is re-established in EXEC_BACKEND children.


📄 src/backend/access/recno/recno_fsm.c (L161-L170)

RecnoVacuumFSM's truncation branch is dead code as invoked. The only caller (recno_operations.c:6335) passes RelationGetNumberOfBlocks(onerel) as new_nblocks, while this function recomputes old_nblocks = RelationGetNumberOfBlocks(rel) -- the same current on-disk size. Thus new_nblocks < old_nblocks is never true and FreeSpaceMapPrepareTruncateRel() is never called.

Even if it did fire, it would be redundant and unsafe: RecnoTruncateRelation() already calls RelationTruncate(), which truncates the FSM fork correctly (and WAL-logs it via the smgr truncate record). Calling FreeSpaceMapPrepareTruncateRel() here in isolation does not perform the physical smgrtruncate of the FSM fork nor WAL-log it. Recommend removing this function (and its caller) or wiring the pre/post block counts so the intent is actually exercised and non-redundant with RelationTruncate.


📄 src/backend/access/recno/recno_fsm.c (L95-L95)

RecnoGetCommitTimestamp() is called inside the critical section (after START_CRIT_SECTION). Its implementation (recno_mvcc.c:454) does elog(ERROR, ...) when RecnoMvccShmem == NULL; any ereport(ERROR) inside a critical section escalates to PANIC. While shmem being NULL is effectively a can't-happen state during normal operation, calling error-capable code inside a crit section is against convention. Move the RecnoGetCommitTimestamp() call (and read of init_commit_ts) before START_CRIT_SECTION() to be safe.


📄 src/backend/access/recno/recno_fsm.c (L31-L31)

miscadmin.h is included but no symbol from it (e.g. CHECK_FOR_INTERRUPTS, MyProcPid, MyDatabaseId) is used in this file. Remove the unused include. Also, per PostgreSQL convention, includes after postgres.h should be grouped/ordered (system headers, then project headers alphabetically); miscadmin.h is out of order relative to the access/ and storage/ headers above it.


📄 src/backend/access/recno/recno_hlc.c (L76-L76)

node_id is dead state: it is written here (and in RecnoHLCShmemInit_cb and assign_recno_node_id) but never read anywhere in the tree. A codebase search for ->node_id finds only these three writes and no reader. HLCNow() never folds the node id into the generated timestamp, despite the "replica ID" narrative in the header comment. Either incorporate node_id into the HLC (as the multi-node story implies) or drop the field, the recno_node_id GUC, and assign_recno_node_id entirely. Unused fields/GUCs are speculative scaffolding.


📄 src/backend/access/recno/recno_hlc.c (L125-L125)

RecnoGetPhysicalTimeMs() casts a signed TimestampTz (int64 microseconds from the 2000 epoch) to uint64 before dividing. For any pre-2000 wall clock, now is negative; (uint64) now wraps to a huge value that is then clamped to HLC_MAX_PHYSICAL, silently pinning the clock to ~year 8925. Guard the negative case (clamp to 0) before the unsigned cast. The identical unsafe cast appears in HLCFromTimestampTz.

💡 Suggested change

Before:

	ms = (uint64) now / 1000;

After:

	if (now < 0)
		now = 0;
	ms = (uint64) now / 1000;

📄 src/backend/access/recno/recno_hlc.c (L144-L147)

RecnoHLCShmemInit() is dead code: the actual shared-memory init path is the RecnoHLCShmemCallbacks registered via PG_SHMEM_SUBSYSTEM(RecnoHLCShmemCallbacks) in subsystemlist.h, and RecnoHLCShmemInit_cb duplicates this function's body verbatim. A codebase search finds no caller of RecnoHLCShmemInit other than its own header declaration. Remove the unused function (and its recno.h prototype) so the initialization logic lives in exactly one place and cannot drift.


📄 src/backend/access/recno/recno_hlc.c (L406-L409)

This WARNING is emitted from HLCNow(), which is called on the transaction commit path (e.g. xact_commit_hlc = HLCNow(...) in recno_mvcc.c). Under sustained clock skew this logs one WARNING per commit, flooding the server log on a hot path. The drift is already tracked losslessly via max_drift_ms; demote this to that counter, or rate-limit the warning (e.g. log at most once per interval).


📄 src/backend/access/recno/recno_hlc.c (L313-L313)

Non-ASCII em-dash characters (U+2014) appear in these comments. PostgreSQL source must be ASCII-only; replace each em-dash with -- or -. The same character also appears in the CAS-failure comment ("CAS failed -- old_hlc ...") and in the file header. Run the diff through a check for non-ASCII bytes before submitting.

💡 Suggested change

Before:

	 * another backend advanced the clock concurrently — we simply retry

After:

	 * another backend advanced the clock concurrently -- we simply retry

📄 src/backend/access/recno/recno_hlc.c (L20-L20)

Stale comment: RecnoGetCommitHLC, RecnoGetCommitTimestamp, and RecnoGetSnapshotHLC are defined in recno_mvcc.c, not "at the bottom of this file" -- this file contains none of them. Fix the reference so the comment describes reality.


📄 src/backend/access/recno/recno_hlc.c (L52-L53)

Redefining a header macro inside a .c file is fragile. recno.h defines HLC_MAX_LOGICAL as 0xFFFF while HLC_LOGICAL_MASK is ((1 << HLC_LOGICAL_BITS) - 1); this #undef/#define silently makes this translation unit disagree with every other file if the two ever diverge. Since both currently equal 0xFFFF, this override buys nothing but risk. Drop it and rely on the header definition (or fix the header if the intent is that they must be identical).


📄 src/backend/access/recno/recno_lock.c (L331-L344)

Bug: the mode->LOCKMODE mapping here diverges from what RecnoLockTuple/RecnoUnlockTuple actually acquire. Those functions (correctly matching heap's tupleLockExtraInfo) acquire LockTupleKeyShare->AccessShareLock, LockTupleShare->RowShareLock, LockTupleNoKeyExclusive->ExclusiveLock, LockTupleExclusive->AccessExclusiveLock. Here KeyShare/Share map to ShareLock and NoKeyExclusive/Exclusive to ExclusiveLock. So a lock taken as AccessShareLock (KeyShare), RowShareLock (Share), or AccessExclusiveLock (Exclusive) will never be found by LockHeldByMe, which queries ShareLock/ExclusiveLock. RecnoHoldsTupleLock therefore returns a wrong (false) answer for 3 of the 4 modes. Reuse the same mapping as RecnoLockTuple (ideally factor the switch into a single shared helper so acquire/release/check can never diverge).


📄 src/backend/access/recno/recno_lock.c (L70-L86)

The LockTupleMode->LOCKMODE switch is duplicated verbatim in RecnoLockTuple and RecnoUnlockTuple, and a third, divergent copy exists in RecnoHoldsTupleLock (the source of the correctness bug flagged there). Factor this into a single static helper (e.g. recno_tuple_lockmode(LockTupleMode)) and call it from all three sites so acquire/release/check can never drift apart.


📄 src/backend/access/recno/recno_lock.c (L180-L182)

RecnoLockPage/RecnoUnlockPage have no callers anywhere in the tree (only extern declarations in recno.h and recno_operations.c). This is speculative scaffolding (YAGNI). Beyond being dead code, LOCKTAG_PAGE heavyweight page locks are essentially unused in modern PostgreSQL; introducing them as a general recno mechanism with no caller needs justification or removal.


📄 src/backend/access/recno/recno_lock.c (L304-L309)

RecnoLockRelationForDDL has no callers in the tree and is a one-line pass-through to LockRelationOid(RelationGetRelid(rel), lockmode). This is dead speculative code / premature abstraction; drop it and call LockRelationOid directly where needed.


📄 src/backend/access/recno/recno_lock.c (L252-L265)

RecnoLockMultipleTuples is unused anywhere in the tree (only referenced in the DESIGN doc and declared in recno.h). If it is intended to be wired in, the O(n^2) bubble sort should be replaced with qsort using a comparator wrapping ItemPointerCompare, per PostgreSQL convention; the 'N is typically small' justification is unverifiable without a caller. If it is not going to be used, remove it as speculative scaffolding.


📄 src/backend/access/recno/recno_hlc.c (L34-L34)

These externs are redundant: recno.h (already included above) declares all of these at lines 820-851 -- HLCGetGlobal, HLCGetUncertaintyInterval, HLCInUncertaintyWindow, HLCToString, and recno_uncertainty_wait. Redeclaring them here bypasses header type-checking and risks silent signature drift; for the cross-module GUC it also misses the PGDLLIMPORT that the header carries (Windows/MSVC). Remove this block and rely on the header.


📄 src/backend/access/recno/recno_hlc.c (L118-L125)

now is a signed TimestampTz; casting to uint64 with no guard is a footgun. If the wall clock is set before the Postgres epoch (2000-01-01) or is otherwise negative, this produces a huge uint64 that permanently poisons global_commit_ts (the CAS below will never lower it), corrupting all subsequent visibility/vacuum-horizon comparisons. Guard against now <= 0 (clamp to the existing counter) before the cast.


📄 src/backend/access/recno/recno_hlc.c (L645-L648)

Dead scaffolding (YAGNI): needs_restart, restart_reason, restart_hlc, restart_count, and max_uncertainty_end are only written inside RecnoTupleVisibleWithUncertainty (which itself has no caller) and never read to drive an actual restart. is_read_only is set to true and never flipped or read. Drop the unused fields and the dead function.


📄 src/backend/access/recno/recno_hlc.c (L609-L626)

RecnoTupleVisibleWithUncertainty has no caller anywhere in the backend -- it is dead code (and the sole writer of the unused restart/uncertainty fields). Remove it along with those fields.


📄 src/backend/access/recno/recno_hlc.c (L371-L371)

Non-ASCII characters in comments violate the ASCII-only rule and will trip whitespace/pgindent checks. Examples in this block: the U+2192 arrows ('->' should be used) in 'We deleted this tuple ->', 'ABORTED entry and return false', 'in-place update -- the data', etc. These arrows and em-dashes appear throughout the file (e.g. 'Our own operation --', 'no lock needed --') and must be replaced with plain ASCII '->' and '--'.


📄 src/backend/access/recno/recno_mvcc.c (L661-L666)

Non-atomic 64-bit slot store/load is a portability hazard. xact_start_ts_slots[] is a plain uint64 array read lockless by RecnoGetOldestActiveTimestamp() (after a single pg_read_barrier()) while written here without a lock. On 32-bit platforms a uint64 store/load is not guaranteed atomic, so a reader can observe a torn value. A torn oldest-active timestamp feeds VACUUM's prune horizon and could cause tuples still visible to a running/prepared transaction to be reclaimed (data corruption). Use pg_atomic_uint64 (pg_atomic_write_u64/pg_atomic_read_u64) for the slot array. Also note the barrier is misplaced: pg_write_barrier() before the store orders prior writes, but to publish the payload the barrier should sit between the payload write and the slot store, or the whole thing should rely on the atomic. (moderate confidence)


📄 src/backend/access/recno/recno_mvcc.c (L457-L459)

GetCurrentTimestamp() returns TimestampTz (signed int64, microseconds since the 2000-01-01 epoch). Casting to uint64 without guarding: if the system clock is set before the PG epoch the value is negative and the cast yields a huge uint64 that permanently pins global_commit_ts at the top of the range, breaking every subsequent visibility/vacuum-horizon comparison and starving RecnoGetCommitTimestamp() into always returning old_ts + 1. Since this is the legacy authority for commit ordering and the vacuum-horizon fallback, guard against negative/regressed clock values explicitly. (moderate confidence)


📄 src/backend/access/recno/recno_mvcc.c (L2339-L2342)

Mutating shared state (SLogTupleRemove) inside a read-only visibility check is a lock-ordering / recovery hazard. SLogTupleRemove takes the sLog seqlock writer path (external writer_lock), yet it is invoked here from the visibility hot path with no RecoveryInProgress() guard. The comment itself states this is for "recovery-inserted entries ... on hot standbys" -- standbys are read-only, and acquiring sLog writer locks from a scan-time visibility probe can (a) run during recovery where mutation is inappropriate and (b) acquire sLog partition/writer locks in an order inconsistent with the DML paths, risking deadlock, plus removing an entry another backend still needs to resolve visibility (TOCTOU). This side effect should not live in a visibility check; if lazy cleanup is required it belongs on the write path or a dedicated maintenance path, guarded by !RecoveryInProgress(). (moderate confidence)


📄 src/backend/access/recno/recno_mvcc.c (L1154-L1159)

Bare tuple->t_flags &= ~RECNO_TUPLE_UNCOMMITTED when the buffer is invalid is a data hazard. Unlike BufferSetHintBits16() -- which coordinates with the buffer manager (SharedBufferBeginSetHintBits, marks the buffer dirty, and declines when hint bits aren't allowed) -- this fallback does an unsynchronized write directly into what is presumably shared-buffer page memory with no pin/dirty-marking. If tuple points into a shared buffer this races other backends mutating t_flags (no atomicity, torn flag write) and the hint is silently lost on eviction; if the page's lifetime truly isn't guaranteed, this is a write into memory that may be reused. This exact pattern is repeated at every BufferIsValid(buffer) else-branch in both RecnoTupleVisible and RecnoTupleVisibleHLC. Verify no caller passes an Invalid buffer while tuple points into a shared page. (moderate confidence)


📄 src/backend/access/recno/recno_mvcc.c (L371-L371)

Purpose-specific lock registered under an unrelated built-in tranche. mvcc_lock is initialized with LWTRANCHE_BUFFER_MAPPING, which misattributes its wait-events and lock statistics to the buffer-mapping subsystem, making contention diagnosis misleading. Register a dedicated named tranche (via RequestNamedLWLockTranche, or a lwlocklist.h entry) instead of borrowing the buffer-mapping id. This appears in both initializers. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L370-L373)

Duplicated shmem-init block and double cleanup registration. RecnoMvccShmemInit and RecnoMvccShmemInit_cb copy the identical field-initialization block verbatim and both call on_shmem_exit(RecnoShmemExit, 0). Per subsystemlist.h the active path is the RecnoMvccShmemCallbacks (.init_fn = RecnoMvccShmemInit_cb), so the legacy RecnoMvccShmemInit is a dead second initializer. If both ever run in one process the exit callback is registered twice. Collapse to a single initializer (extract the common init into a helper) and drop the unused path. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L287-L289)

Non-ASCII characters throughout the new file violate the ASCII-only source rule and will trip whitespace/pgindent checks. This block alone uses U+2192 ('→') and em-dashes ('—'); the same occurs at ~29 sites across the file (e.g. box-drawing '── Our own operation ──'). Replace '→' with '->' and '—'/'──' with '--'. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L215-L220)

Dead/unused scaffolding on RecnoTransactionState (YAGNI). needs_restart, restart_reason, restart_hlc, restart_count, and max_uncertainty_end are only ever written inside RecnoTupleVisibleWithUncertainty and never read to actually drive a restart; is_read_only is set to true ("Until first write") but nothing ever flips or reads it. RecnoTupleVisibleWithUncertainty itself has no caller. Remove the unwired restart machinery and the dead function, or wire them, so the struct reflects behavior that actually ships. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L109-L110)

Redundant extern redeclarations in the .c file. HLCGetGlobal, HLCInUncertaintyWindow, HLCToString, etc. and extern bool recno_uncertainty_wait are already declared in recno.h (which this file includes). Redeclaring them here bypasses header type-checking and, for the cross-module GUC on Windows/MSVC, sidesteps the PGDLLIMPORT annotation carried by the header. Drop these local externs and rely on the header. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L407-L408)

Comments cite volatile absolute line numbers ('recno_mvcc.c lines 1824-1868'), which drift the instant any line is added above and become misleading. Describe the referenced logic by name/behavior instead of by line number. Same issue recurs (e.g. 'lines 1831-1930'). (high confidence)


📄 src/backend/access/recno/recno_slot.c (L458-L463)

xmin/xmax are wrong here on two counts. (1) Correctness: returning GetCurrentTransactionId() for every tuple reports the current backend's XID as xmin regardless of who actually inserted the row. The real inserter XID is available in the header (RecnoTupleHeader.t_xid_hint, valid while RECNO_TUPLE_UNCOMMITTED); a committed tuple should not report the reader's XID. Consumers of xmin/xmax (EPQ, RI, ctid-independent MVCC queries) will observe an incorrect MVCC identity. (2) Footgun: GetCurrentTransactionId() assigns an XID if none exists (AssignTransactionId). On a hot standby / read-only transaction this ereport(ERROR)s ("cannot assign TransactionIds during recovery"), so any query selecting xmin/xmax from a RECNO table on a standby will fail. Derive xmin from t_xid_hint / commit state instead, and never force XID assignment on a read path.


📄 src/backend/access/recno/recno_slot.c (L849-L849)

Non-ASCII em-dash characters ('\u2014') here violate the ASCII-only source rule for PostgreSQL. Replace with an ASCII hyphen or reword.

💡 Suggested change

Before:

		/* Same buffer — just free any materialized data */

After:

		/* Same buffer - just free any materialized data */

📄 src/backend/access/recno/recno_slot.c (L859-L859)

Non-ASCII em-dash character ('\u2014') violates the ASCII-only source rule. Replace with an ASCII hyphen.

💡 Suggested change

Before:

		/* Different buffer — full clear (releases old pin) and acquire new */

After:

		/* Different buffer - full clear (releases old pin) and acquire new */

📄 src/backend/access/recno/recno_stats.c (L159-L162)

Confirmed incorrect pointer arithmetic. The compression header is NOT a whole-tuple prefix at the start of the data area; it is a per-attribute varlena prefix ([VARHDRSZ][RecnoCompressionHeader][payload]) located at VARDATA_ANY(attr_ptr) for a specific compressed varlena attribute (see recno_compress.c line ~212 and recno_tuple.c lines 682-691).

Two problems here:

  1. (char *) hdr + RECNO_TUPLE_OVERHEAD points at the start of the null bitmap, not the data. The real data starts at hdr + RECNO_TUPLE_OVERHEAD + MAXALIGN(BITMAPLEN(hdr->t_natts)) (see RecnoFormTuple/RecnoDeformTuple: data_ptr = (char *) header + RECNO_TUPLE_OVERHEAD + MAXALIGN(bitmap_len)).
  2. Even at the correct data offset, the first bytes are a varlena header (and possibly fixed-length attrs), not a bare RecnoCompressionHeader. Reading comp_hdr->orig_size/comp_size here yields garbage, so total_uncompressed_size/total_compressed_size and the reported compression_ratio are meaningless.

The length guard prevents reading past the item end, so this is not an out-of-bounds read, but the computed statistic is wrong. To measure per-tuple compression you must deform the tuple (or walk attributes with the null bitmap + att_align) and inspect each compressed varlena at VARDATA_ANY, mirroring recno_tuple.c. (high confidence)


📄 src/backend/access/recno/recno_stats.c (L49-L51)

Dead / unwired code (YAGNI). Both RecnoCollectRelationStats() and RecnoLogRelationStats() have no callers anywhere in the backend or frontend (verified across the whole tree; the only reference is prose in access/recno/README). Nothing in the ANALYZE path (recno_handler / heapam analyze callbacks) or the planner invokes them, yet the header comment claims they are "called during ANALYZE" and consumed by the planner -- an aspirational comment describing behavior that does not exist.

As written this is speculative scaffolding that, if it were wired in, would add a full-relation sequential scan to every ANALYZE (which is otherwise sample-based) -- a significant regression on large tables. Additionally, RecnoGetOverflowStats() (recno_overflow.c) already implements a full-relation overflow-stats scan under shared buffer locks; this file duplicates much of that infrastructure (DRY). Either wire this into the AM's analyze callback (with tests and docs) in this patch, or drop it. (high confidence)


📄 src/backend/access/recno/recno_stats.c (L94-L95)

The full-relation ReadBufferExtended loop passes strategy NULL, so this pollutes shared_buffers with every page of the relation. Bulk scans of this kind must use a BufferAccessStrategy (GetAccessStrategy(BAS_BULKREAD)) like vacuum/ANALYZE do, to avoid evicting the working set. (moderate confidence)


📄 src/backend/access/recno/recno_stats.c (L248-L252)

Portability/convention: int64 values must be printed with INT64_FORMAT, not %lld with a (long long) cast, per PostgreSQL coding conventions (the latter is not guaranteed portable across all supported compilers). Use INT64_FORMAT in the format string and pass the int64 fields directly (removing the long long casts). Also wrap user-facing text in _() if these are intended as translatable. (moderate confidence)


📄 src/backend/access/recno/recno_stats.c (L280-L283)

Portability/convention: uint64 must be printed with UINT64_FORMAT, not %llu with an (unsigned long long) cast. Use UINT64_FORMAT and pass stats->hlc_min / stats->hlc_max directly. (moderate confidence)


📄 src/backend/access/recno/recno_tuple.c (L867-L870)

RecnoPageUpdateTuple has no callers (the only in-place UPDATE path in recno_operations.c overwrites the item / delete+re-add and emits its own WAL directly), yet it is declared in recno.h and defined here. As written it is a footgun: (1) old_commit_ts is never used; (2) it mutates page contents (memcpy over old_tuple, ItemIdSetNormal, RecnoPageIndexTupleDelete + PageAddItem) with no START_CRIT_SECTION and no WAL emission, so if any future caller uses it the on-page state will not be reproduced by redo -> silent corruption on crash/replica; and (3) PageAddItem is passed offnum but may relocate the tuple, so the two physical paths (in-place overwrite vs delete+re-add) diverge and any redo would have to reproduce whichever ran. Per YAGNI, drop this unused API (and its declaration) rather than ship an un-WAL'd, easy-to-misuse mutator.


📄 src/backend/access/recno/recno_tuple.c (L437-L440)

Leak when an attribute is both compressed (Phase 1) and overflowed (Phase 1b). After Phase 1 sets work_values[i] to a freshly palloc'd compressed datum with is_compressed[i]=true, this line (and the COW branch above) overwrites work_values[i] with the overflow-pointer datum without pfree'ing the compressed datum first. The final cleanup loop only frees the current work_values[i], so the intermediate compressed varlena is leaked in the caller's memory context for every wide, compressible column pushed off-page. Free the previous work_values[i] before overwriting when is_compressed[i] is set.


📄 src/backend/access/rmgrdesc/Makefile (L39-L41)

This diverges from meson. src/backend/access/rmgrdesc/meson.build (lines 33-36) compiles recnodesc.c/recnoctsdesc.c unconditionally, with a comment stating RECNO is always built and "cannot be conditionally disabled ... a build with USE_RECNO unset fails to link" (top-level meson.build lines 1598-1605; USE_RECNO=1 is hardcoded in src/Makefile.global.in). Guarding these objects behind ifdef USE_RECNO here therefore (a) violates the make/meson sync requirement, and (b) is dead conditional scaffolding for a USE_RECNO-unset path that the project explicitly states cannot link. Compile them unconditionally to match meson. (high confidence)

💡 Suggested change

Before:

ifdef USE_RECNO
OBJS += recnodesc.o recnoctsdesc.o
endif

After:

OBJS += \
	recnoctsdesc.o \
	recnodesc.o

📄 src/backend/access/rmgrdesc/recnoctsdesc.c (L36-L36)

Use the tree's int64 format macro instead of %lld with a (long long) cast. The three sibling zero-page descriptors that use this exact memcpy(&pageno, ...) pattern — clogdesc.c, committsdesc.c, and mxactdesc.c — all format the int64 pageno with "%" PRId64. %lld relies on long long matching int64, which is not guaranteed across all target platforms/compilers and is inconsistent with the CTS_SET branch above that correctly uses UINT64_FORMAT. This is the portability convention (int64 -> INT64_FORMAT/PRId64, never %ld/%lld).

💡 Suggested change

Before:

		appendStringInfo(buf, "pageno %lld", (long long) pageno);

After:

		appendStringInfo(buf, "pageno %" PRId64, pageno);

📄 src/backend/access/rmgrdesc/relundodesc.c (L97-L98)

urec_ptr is RelUndoRecPtr (== uint64, per relundo_xlog.h line 37). Casting to unsigned long and formatting with %lu truncates the pointer to 32 bits on LLP64 (Windows/MSVC) and ILP32 (32-bit) platforms, printing garbage in pg_waldump. Use UINT64_FORMAT on the value directly (do not cast).

💡 Suggested change

Before:

				appendStringInfo(buf, "urec_ptr %lu",
								 (unsigned long) xlrec->urec_ptr);

After:

				appendStringInfo(buf, "urec_ptr " UINT64_FORMAT,
								 xlrec->urec_ptr);

📄 src/backend/access/rmgrdesc/relundodesc.c (L44-L64)

The urec_type-to-name mapping hardcodes literals 1..5 instead of the RelUndoRecordType enum defined in access/relundo.h (RELUNDO_INSERT=1 .. RELUNDO_DELTA_INSERT=5). This is a maintainability/correctness hazard: the enum also defines RELUNDO_DELTA_UPDATE = 6, which is missing here and will be mislabeled "UNKNOWN" in pg_waldump. Switch on the enum symbols and add the DELTA_UPDATE case so the mapping cannot silently drift when the enum changes.

💡 Suggested change

Before:

				switch (xlrec->urec_type)
				{
					case 1:
						type_name = "INSERT";
						break;
					case 2:
						type_name = "DELETE";
						break;
					case 3:
						type_name = "UPDATE";
						break;
					case 4:
						type_name = "TUPLE_LOCK";
						break;
					case 5:
						type_name = "DELTA_INSERT";
						break;
					default:
						type_name = "UNKNOWN";
						break;
				}

After:

				switch (xlrec->urec_type)
				{
					case RELUNDO_INSERT:
						type_name = "INSERT";
						break;
					case RELUNDO_DELETE:
						type_name = "DELETE";
						break;
					case RELUNDO_UPDATE:
						type_name = "UPDATE";
						break;
					case RELUNDO_TUPLE_LOCK:
						type_name = "TUPLE_LOCK";
						break;
					case RELUNDO_DELTA_INSERT:
						type_name = "DELTA_INSERT";
						break;
					case RELUNDO_DELTA_UPDATE:
						type_name = "DELTA_UPDATE";
						break;
					default:
						type_name = "UNKNOWN";
						break;
				}

📄 src/backend/access/rmgrdesc/relundodesc.c (L102-L109)

The outer switch has no default case. relundo_redo() (relundo_xlog.c:644) switches on the same masked info and PANICs on unknown op codes; here an unrecognized info value (corrupt or future-version WAL) leaves buf empty with no diagnostic. Add a default that reports the unknown info byte, consistent with the redo path.

💡 Suggested change

Before:

		case XLOG_RELUNDO_TRUNCATE:
			{
				xl_relundo_truncate *xlrec = (xl_relundo_truncate *) data;

				appendStringInfo(buf, "new_nblocks %u", xlrec->new_nblocks);
			}
			break;
	}

After:

		case XLOG_RELUNDO_TRUNCATE:
			{
				xl_relundo_truncate *xlrec = (xl_relundo_truncate *) data;

				appendStringInfo(buf, "new_nblocks %u", xlrec->new_nblocks);
			}
			break;

		default:
			appendStringInfo(buf, "unknown op code %u", info);
			break;
	}

📄 src/backend/access/rmgrdesc/recnodesc.c (L50-L55)

These structs (and the opcode/flag macros below) hand-duplicate definitions that recno_xlog.h ALREADY provides in frontend-safe form (see its #else /* FRONTEND */ block, e.g. xl_recno_insert). pg_waldump's rmgrdesc.c even #includes access/recno_xlog.h, so this whole _fe layer is redundant and, worse, has drifted out of sync (verified below). Drop the duplication and #include "access/recno_xlog.h" here, using the shared structs and macros. As written this is the exact "keep in sync" footgun the comment admits to.


📄 src/backend/access/rmgrdesc/recnodesc.c (L55-L61)

Layout mismatch (data-corruption footgun, high confidence). The authoritative on-disk record xl_recno_hlc_info in recno_xlog.h has THREE uint64 fields (commit_hlc, uncertainty_lower, uncertainty_upper = 24 bytes) and the writer registers exactly SizeOfXlRecnoHlcInfo (recno_xlog.c:563). This _fe copy adds a bogus 4th field commit_dvv, so SizeOfXlRecnoHlcInfoFE is 32 bytes. recno_desc_hlc() reads the LAST 32 bytes of the payload, so it reads 8 bytes of preceding tuple data and shifts every field: it prints garbage for hlc/dvv/uncertainty. Remove commit_dvv (or better, use the shared struct from recno_xlog.h).

💡 Suggested change

Before:

typedef struct xl_recno_hlc_info_fe
{
	uint64		commit_hlc;
	uint64		commit_dvv;
	uint64		uncertainty_lower;
	uint64		uncertainty_upper;
}			xl_recno_hlc_info_fe;

After:

typedef struct xl_recno_hlc_info_fe
{
	uint64		commit_hlc;
	uint64		uncertainty_lower;
	uint64		uncertainty_upper;
}			xl_recno_hlc_info_fe;

📄 src/backend/access/rmgrdesc/recnodesc.c (L65-L71)

Layout mismatch with the on-disk record (high confidence). The record is written from xl_recno_insert = {offnum, flags, tuple_len(uint32), commit_ts(uint64)} (recno_xlog.h; writer at recno_xlog.c:432-435). This _fe copy drops tuple_len and invents a nonexistent xact_ts. Consequences in recno_desc(): the datalen >= sizeof(xl_recno_insert_fe) guard demands 24 bytes but the header is only 16, and the printed xact_ts is actually tuple-body bytes. The same defect exists in xl_recno_delete_fe and xl_recno_update_fe (both invent xact_ts and omit the real tuple_len/old_tuple_len/new_tuple_len fields). Match the authoritative structs (or include recno_xlog.h and use them).


📄 src/backend/access/rmgrdesc/recnodesc.c (L155-L163)

xl_recno_lock_fe does not match the on-disk xl_recno_lock in recno_xlog.h, which is only {offnum, flags, infomask(uint8), lock_mode(uint8)} (4 fields, ~6 bytes). This copy adds xmax(uint32), widens infomask to uint16, and invents infomask2, so the guard datalen >= sizeof(xl_recno_lock_fe) will almost always fail (falling back to "lock (truncated)") and, when it doesn't, prints misaligned garbage. Mirror the authoritative struct exactly.


📄 src/backend/access/rmgrdesc/recnodesc.c (L23-L25)

recno_desc/recno_identify are already declared in access/recno_xlog.h (lines 458-459), which pg_waldump includes. Re-declaring them here as loose externs is redundant and risks silent signature drift from the canonical prototypes. Include the header instead of hand-copying the declarations.


📄 src/backend/access/rmgrdesc/recnodesc.c (L431-L431)

The datalen >= 14 guard and the "- 2 padding" comment are wrong/misleading. xl_recno_cas_update is {offnum(2), flags(2), data_offset(2), data_len(2), new_commit_ts(8)} = 16 bytes with natural alignment and no trailing padding needed for these fields; there is no 2-byte padding to subtract. The magic byte offsets (data+0/+4/+6) happen to be correct but are fragile. Parse via a shared struct (from recno_xlog.h) rather than hardcoded offsets and a bespoke length.


📄 src/backend/access/rmgrdesc/undodesc.c (L37-L39)

xlrec->start_ptr is UndoRecPtr (a uint64, per undodefs.h). PostgreSQL convention is to print 64-bit values with UINT64_FORMAT, not a (unsigned long long) cast with %llu. The neighboring desc files added in this same patch (recnodesc.c, recnoctsdesc.c) already use UINT64_FORMAT. Use it here for consistency and portability.

💡 Suggested change

Before:

				appendStringInfo(buf, "log %u, start %llu, len %u, xid %u",
								 xlrec->log_number,
								 (unsigned long long) xlrec->start_ptr,

After:

				appendStringInfo(buf, "log %u, start " UINT64_FORMAT ", len %u, xid %u",
								 xlrec->log_number,
								 (uint64) xlrec->start_ptr,

📄 src/backend/access/rmgrdesc/undodesc.c (L49-L51)

Same UINT64_FORMAT convention: discard_ptr is a UndoRecPtr (uint64). Print it with UINT64_FORMAT rather than (unsigned long long)/%llu.

💡 Suggested change

Before:

				appendStringInfo(buf, "log %u, discard_ptr %llu, oldest_xid %u",
								 xlrec->log_number,
								 (unsigned long long) xlrec->discard_ptr,

After:

				appendStringInfo(buf, "log %u, discard_ptr " UINT64_FORMAT ", oldest_xid %u",
								 xlrec->log_number,
								 (uint64) xlrec->discard_ptr,

📄 src/backend/access/rmgrdesc/undodesc.c (L60-L62)

new_size is declared as uint64 in xl_undo_extend. Use UINT64_FORMAT instead of (unsigned long long)/%llu per PostgreSQL convention.

💡 Suggested change

Before:

				appendStringInfo(buf, "log %u, new_size %llu",
								 xlrec->log_number,
								 (unsigned long long) xlrec->new_size);

After:

				appendStringInfo(buf, "log %u, new_size " UINT64_FORMAT,
								 xlrec->log_number,
								 (uint64) xlrec->new_size);

📄 src/backend/access/rmgrdesc/undodesc.c (L101-L101)

urec_ptr is UndoRecPtr (uint64). Use UINT64_FORMAT here instead of (unsigned long long)/%llu.

💡 Suggested change

Before:

								 (unsigned long long) xlrec->urec_ptr,

After:

								 (uint64) xlrec->urec_ptr,

📄 src/backend/access/rmgrdesc/undodesc.c (L165-L165)

old_seal_ptr is UndoRecPtr (uint64). Use UINT64_FORMAT instead of (unsigned long long)/%llu for consistency with the rest of the tree.

💡 Suggested change

Before:

								 (unsigned long long) xlrec->old_seal_ptr,

After:

								 (uint64) xlrec->old_seal_ptr,

📄 src/backend/access/rmgrdesc/undodesc.c (L71-L75)

The operation_type cases use bare magic numbers (0x0001..0x0006). No named constants exist for these values -- they are only documented in prose in src/backend/access/undo/README, and the xl_undo_apply header comment even references a non-existent HEAP_UNDO_INSERT. This is a drift hazard: the desc mapping can silently diverge from whatever code actually emits these values. Define named constants (e.g. UNDO_OP_INSERT/DELETE/UPDATE/PRUNE/INPLACE/HOT_UPDATE) in a shared header and use them both here and at the emit sites.


📄 src/backend/access/table/tableam.c (L835-L837)

RelationAmSupportsUndo dereferences rel before any NULL check; it only guards rel->rd_tableam. The documented purpose is gating UNDO on the parent table relation, which is NULL during index builds and recovery (see the comment at nbtinsert.c: "heaprel is NULL during index builds and recovery"). The nbtree callers work around this with an external heaprel != NULL guard, but the hash caller (hashinsert.c) calls RelationAmSupportsUndo(heapRel) with no such guard, so the contract is inconsistent and this is a crash footgun. Make the helper NULL-tolerant so callers don't each have to remember the guard; this also matches the defensive style used for optional callbacks (e.g. begin_bulk_insert) elsewhere in tableam.h. [high confidence]

💡 Suggested change

Before:

	if (!rel->rd_tableam)
		return false;
	return rel->rd_tableam->am_supports_undo;

After:

	if (rel == NULL || !rel->rd_tableam)
		return false;
	return rel->rd_tableam->am_supports_undo;

📄 src/backend/access/transam/rmgr.c (L43-L46)

The existing includes in this IWYU keep-block are alphabetically sorted (from access/brin_xlog.h through utils/relmapper.h). These new includes are appended out of order, breaking that convention. For minimal-diff/hygiene reasons on -hackers, merge them into the sorted list (e.g. access/atm.h after access/nbtxlog.h, access/undo_xlog.h/access/relundo_xlog.h in their alphabetical slots, storage/fileops.h after replication/*), keeping the USE_RECNO-guarded pair together at an appropriate spot. Low confidence / low severity - style only, no functional impact.


📄 src/backend/access/transam/twophase.c (L1678-L1682)

ATM-full handling on ROLLBACK PREPARED is a silent-corruption footgun. When ATMAddAborted() returns false (sLog full), this only logs a WARNING, then execution falls through to ProcArrayRemove()/RemoveGXact() and the ROLLBACK PREPARED reports success to the client. The permanent-level UNDO chain (FILEOPS/nbtree/hash before-images) is then never reverted, leaving the cluster partially rolled back with no record that revert is still owed. Unlike an in-progress abort (which can retry synchronously), the gxact is gone after this point so there is no second chance. This mirrors the WARNING-only handling in xactundo.c, but here the consequence is a completed 2PC rollback that swallowed the failure. Consider making a full sLog an ERROR (so the ROLLBACK PREPARED does not report success and can be retried) or performing a synchronous inline revert as a fallback before dropping the gxact.


📄 src/backend/access/transam/twophase.c (L2995-L3002)

TwoPhaseGetOldestUndoBatchLSN() dereferences TwoPhaseState->numPrepXacts / prepXacts guarded only by max_prepared_xacts <= 0, but does not check TwoPhaseState == NULL. RecoveryTransactionIdIsPrepared() added in this same diff does add that guard. This function feeds UndoGetOldestBatchLSN(), which runs on the WAL-retention/checkpoint path (xlog.c) and the undo worker; while in the current call paths shmem is already initialized, the inconsistency is a latent NULL-deref if this ever runs earlier (e.g. bootstrap/early startup). Add the same if (TwoPhaseState == NULL) return InvalidXLogRecPtr; guard for consistency and safety.

💡 Suggested change

Before:

	if (max_prepared_xacts <= 0)
		return InvalidXLogRecPtr;

	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);

	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)
	{
		XLogRecPtr	lsn = TwoPhaseState->prepXacts[i]->undo_batch_lsn;

After:

	if (max_prepared_xacts <= 0)
		return InvalidXLogRecPtr;

	if (TwoPhaseState == NULL)
		return InvalidXLogRecPtr;

	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);

	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)
	{
		XLogRecPtr	lsn = TwoPhaseState->prepXacts[i]->undo_batch_lsn;

📄 src/backend/access/transam/twophase.c (L997-L1001)

The TWOPHASE_MAGIC comment hardcodes drift-prone specifics: the literal '(24 bytes)' byte count and the exact 'from 0x57F94534 to 0x57F94535' transition. Per project comment-accuracy discipline, comments should explain WHY concisely and not encode values that will silently go stale on the next layout change. The value 24 also assumes 3 levels x 8 bytes, duplicating the invariant that is already compile-time enforced by the StaticAssertDecl in xactundo.c. Suggest trimming to the rationale (layout of xl_xact_prepare changed, so old servers must reject the file) without the specific byte count and version literals.


📄 src/backend/access/transam/xlog.c (L7445-L7454)

This eight-timer checkpoint phase instrumentation and the new "checkpoint phase breakdown" LOG message are unrelated to the recno/UNDO feature this patch implements. It should be split into a separate patch (a pgsql-hackers patch must do one logical thing). Substantive problems even as a standalone change: (1) it largely duplicates the timing already emitted by LogCheckpointEnd() (write/sync/total), so it fights existing infrastructure (DRY); (2) the INSTR_TIME_SET_CURRENT/SUBTRACT calls run unconditionally on every checkpoint even when log_checkpoints is off, adding needless work to the checkpoint path; (3) the variable names are misleading -- they hold milliseconds (INSTR_TIME_GET_MILLISEC) yet are divided by 1000.0 and printed with an 's' suffix. Recommend removing this from the feature patch.


📄 src/backend/access/transam/xlog.c (L8639-L8641)

Comment drift: the comment claims retention is driven solely by scanning "live per-backend UNDO batch LSN slots". UndoGetOldestBatchLSN() actually folds in three sources -- per-backend slots, ATM entries for aborted xacts awaiting logical revert, and 2PC prepared transactions (TwoPhaseGetOldestUndoBatchLSN). The ATM/2PC contributions are the crucial ones for correctness (a cleared backend slot would otherwise let needed UNDO WAL be recycled). Reword so the comment matches what the function actually scans.

💡 Suggested change

Before:

	 * Scan live per-backend UNDO batch LSN slots at every checkpoint rather
	 * than using the worker-updated cached horizon, to ensure WAL retention
	 * is accurate even when the UNDO worker lags.

After:

	 * UndoGetOldestBatchLSN() scans per-backend UNDO batch LSN slots, ATM
	 * entries for aborted xacts awaiting logical revert, and prepared (2PC)
	 * transactions, returning the minimum.  This is done at every checkpoint
	 * rather than using the worker-updated cached horizon, to ensure WAL
	 * retention is accurate even when the UNDO worker lags.

📄 src/backend/access/transam/xact.c (L6699-L6701)

This comment claims XLOG_PAGE_MAGIC was bumped 0xD120 -> 0xD121, but the actual value in src/include/access/xlog_internal.h is still 0xD120 (unchanged). This is a serious inconsistency:

  • If the magic bump is genuinely required (as this comment and the header comment on xl_xact_prepare in xact.h both assert, to stop older replicas from misinterpreting the 24-byte-larger struct), then it is MISSING and this is a crash-recovery/replica-compatibility correctness bug: a mismatched WAL stream will misread the record.
  • If the bump is not actually required, the comment is false.

Either way this must be resolved. Verify whether the extended xl_xact_prepare layout truly needs the magic bump; if so, bump XLOG_PAGE_MAGIC (and TWOPHASE_MAGIC per the xact.h note), otherwise correct this comment to not claim a bump that did not happen. (high confidence on the mismatch; the correctness impact depends on whether a bump is required.)


📄 src/backend/access/transam/xact.c (L3061-L3063)

This comment contradicts the code that ships in this same change. It states physical UNDO application is "NOT needed during standard transaction abort" and frames it as only for "future in-place update mechanisms" (ZHeap-style). But AtAbort_XactUndo() is called ~50 lines below and applies physical per-relation (RECNO) UNDO synchronously on abort (inline apply via ApplyUndoChainFromWAL/RelUndoApplyChain), and WaitForPendingRelUndo() blocks for it. The comment describes aspirational/absent behavior that does not match the shipped code and will mislead future readers. Rewrite it to describe what actually happens now: MVCC/CLOG handles heap rollback, while per-relation UNDO subsystems (RECNO/FILEOPS) apply physical compensation via AtAbort_XactUndo. (high confidence)


📄 src/backend/access/transam/xlogrecovery.c (L1886-L1888)

The phrase "Like PerformUndoRecovery() above, this runs before WAL insertion is enabled, so it writes no WAL" mischaracterizes PerformUndoRecovery(). That function's own docstring (undo_xlog.c) states "CLRs are generated during this phase to ensure idempotency," and its record loop dispatches to rm_undo handlers that emit CLRs on the normal abort path. The 'writes no WAL' property is true for PerformRelUndoRecovery() itself (WAL insertion is disabled until LocalSetXLogInsertAllowed() runs later in StartupXLOG), but the comparison to PerformUndoRecovery() as also writing no WAL is inaccurate and will mislead a future reader. Drop the comparison or restate it precisely (e.g. "runs before WAL insertion is enabled") without asserting the two phases have identical WAL behavior. (moderate confidence)


📄 src/backend/access/undo/README (L291-L292)

This init-flow description is inaccurate. The UNDO shmem is not wired through ipci.c: undo.c registers it via the shmem hook framework (init_fn = UndoShmemInit_internal) and there is no reference to UndoShmemInit in src/backend/storage/ipc/ipci.c. The documented aggregation formula is also incomplete: undo.c includes ATM/sLog shmem (see slog.c: "Called from UndoShmemInit()") which is omitted here. Correct the described caller and the sub-init list to match undo.c.


📄 src/backend/access/undo/README (L175-L175)

This idempotency description directly contradicts the code it documents. undorecord.h defines UNDO_INFO_HAS_CLR (0x20) "CLR has been written for this record (urec_clr_ptr is valid)" and documents urec_clr_ptr as storing the CLR's XLogRecPtr when the record is applied, "enabling crash recovery to determine which UNDO records have already been applied." The README asserts the opposite ("NOT the idempotency mechanism", "always InvalidXLogRecPtr", "never updated"). Either the header/flag is dead and should be removed, or this README is wrong; reconcile the two before this can be trusted as a design reference.


📄 src/backend/access/undo/README (L169-L169)

Header-size claim is wrong. undorecord.h documents the UndoRecordHeader as "Size: 48 bytes" and SizeOfUndoRecordHeader = offsetof(urec_clr_ptr) + sizeof(XLogRecPtr) = 48. This table (ending urec_clr_ptr at offset 32, total 40) and the repeated "~40 bytes (SizeOfUndoRecordHeader)" / "40-byte header" in section 11 are off by 8 bytes.


📄 src/backend/access/undo/README (L539-L543)

Documented GUC defaults do not match the code. In undolog.c, undo_worker_naptime defaults to 10000 (not 60000) and undo_retention_time defaults to 60000 (not 3600000). Update these defaults to match guc_parameters.dat / undolog.c.


📄 src/backend/access/undo/README (L882-L883)

These test files do not exist in the tree. There is no src/test/regress/sql/undo.sql or undo_toast.sql, and no recovery tests numbered 053-060; the actual added tests are recno_*.sql and recovery t/054,061,063,065,067,068. Claiming "FEATURE COMPLETE" with "comprehensive test coverage" while citing nonexistent test paths is a patch-hygiene/trust problem. Cite the real test files.


📄 src/backend/access/undo/README (L1063-L1063)

The 'flush' lock is attributed to undo_flush.c:61, but no file named undo_flush.c exists in this change. Hardcoded file:line citations in a lock-ordering hierarchy are fragile and here are already wrong (also note undo_xlog.c comments say MAX_UNDO_LOGS=64 while the header defines 100). An incorrect lock hierarchy in a concurrency design doc invites deadlocks. Point at the real file and drop the line numbers.


📄 src/backend/access/undo/README (L1053-L1053)

Non-ASCII character (em-dash) in source; PostgreSQL requires ASCII-only in source/docs. Replace with '--'. Several other em-dashes appear throughout this README and should be converted as well.

💡 Suggested change

Before:

locks sit outside this hierarchy — they must be acquired before any

After:

locks sit outside this hierarchy -- they must be acquired before any

📄 src/backend/access/undo/README (L1075-L1075)

Non-ASCII arrow character; use ASCII (e.g. "2 -> 5") to comply with the ASCII-only source rule.

💡 Suggested change

Before:

- The revert worker acquires locks 2→5 in sequence during UNDO apply;

After:

- The revert worker acquires locks 2->5 in sequence during UNDO apply;

📄 src/backend/access/undo/README (L586-L586)

Section numbering skips 12 (jumps from '## 11. Performance Characteristics' to '## 13. Monitoring and Troubleshooting'), indicating editing drift. Renumber the sections to be contiguous.


📄 src/backend/access/undo/atm.c (L110-L116)

The xl_atm_abort struct has a 4-byte padding hole between xid (offset 0-3) and last_batch_lsn (uint64, 8-byte aligned at offset 8). Since the struct is initialized field-by-field (not zeroed) and then registered whole with SizeOfXlAtmAbort (24 bytes), the padding bytes are written to WAL uninitialized. This makes the emitted WAL non-deterministic for logically identical operations, which breaks wal_consistency_checking comparisons and is contrary to PostgreSQL WAL-hygiene convention. Zero the struct first via MemSet(&xlrec, 0, sizeof(xlrec)) before setting fields. (moderate confidence)

💡 Suggested change

Before:

	xl_atm_abort xlrec;

	/* Write WAL first */
	xlrec.xid = xid;
	xlrec.last_batch_lsn = last_batch_lsn;
	xlrec.dboid = dboid;
	xlrec.reloid = InvalidOid;

After:

	xl_atm_abort xlrec;

	/* Write WAL first (zero struct to avoid non-deterministic padding bytes) */
	MemSet(&xlrec, 0, sizeof(xlrec));
	xlrec.xid = xid;
	xlrec.last_batch_lsn = last_batch_lsn;
	xlrec.dboid = dboid;
	xlrec.reloid = InvalidOid;

📄 src/backend/access/undo/atm.c (L17-L19)

The file header comment refers to replacing "the old fixed-size linear array". In a newly added file there is no "old" implementation in-tree; this is historical narrative that describes a prior design rather than what the code does now, which contradicts comment-accuracy discipline. Drop the comparison to the removed array and describe only the current sLog-backed behavior. (high confidence)

💡 Suggested change

Before:

 * Implementation: All ATM functions are thin wrappers around the sLog
 * (Secondary Log) hash tables defined in access/slog.h.  The sLog
 * provides O(1) lookups, replacing the old fixed-size linear array.

After:

 * Implementation: All ATM functions are thin wrappers around the sLog
 * (Secondary Log) hash tables defined in access/slog.h, which provide
 * O(1) lookups.

📄 src/backend/access/undo/logical_revert_worker.c (L295-L299)

Broken transaction state on the retry path. After PG_CATCH the transaction is left in TBLOCK_ABORT. In this else branch you do NOT call AbortCurrentTransaction(), so control falls through PG_END_TRY() to the unconditional CommitTransactionCommand() below. CommitTransactionCommand() on a TBLOCK_ABORT transaction is a no-op (xact.c: case TBLOCK_ABORT: break;) -- it does NOT clean up the aborted transaction. continue then loops back and the next StartTransactionCommand() is also a no-op in TBLOCK_ABORT, so process_revert_entry()/ATMMarkReverted()/ATMForget() run inside a stuck aborted-transaction block. The worker is wedged from then on. You must abort the failed transaction here, mirroring the canonical bgworker idiom (autovacuum calls AbortCurrentTransaction() in its error recovery).

💡 Suggested change

Before:

					else
					{
						elog(LOG, "logical revert worker: failed to revert "
							 "xid %u, will retry", xid);
					}

After:

					else
					{
						elog(LOG, "logical revert worker: failed to revert "
							 "xid %u, will retry", xid);
						AbortCurrentTransaction();
					}

📄 src/backend/access/undo/logical_revert_worker.c (L185-L191)

SIGTERM cannot interrupt in-progress work. SIGTERM is wired to a custom handler that only sets got_SIGTERM, which is tested solely as the while() loop guard. CHECK_FOR_INTERRUPTS() at the loop top will NOT trigger shutdown because ProcDiePending is never set (that requires the standard die handler). Consequently a long-running ApplyUndoChainFromWAL(), or a tight back-to-back continue work loop, can only be terminated between iterations and never mid-work -- the very hang the CHECK_FOR_INTERRUPTS comment claims to prevent. Follow the standard bgworker convention: pqsignal(SIGTERM, die) (and SignalHandlerForConfigReload for SIGHUP), so CHECK_FOR_INTERRUPTS honors shutdown promptly.


📄 src/backend/access/undo/logical_revert_worker.c (L312-L320)

Dead postmaster-death check. The WaitLatch here requests WL_EXIT_ON_PM_DEATH, which makes WaitLatch exit the process internally on postmaster death; it therefore never returns the WL_POSTMASTER_DEATH bit, so if (rc & WL_POSTMASTER_DEATH) proc_exit(1) is unreachable. Pick one convention: either WL_EXIT_ON_PM_DEATH and drop the manual check, or WL_POSTMASTER_DEATH and keep the check (as LogicalRevertLauncherMain does below). Note the two loops in this same file are inconsistent.


📄 src/backend/access/undo/logical_revert_worker.c (L232-L233)

Cross-database starvation / ATM never drains. ATMGetNextUnreverted() returns entries for any database, and this worker skips (goto sleep) entries whose dboid != MyDatabaseId. Combined with the launcher capping the number of workers at max_logical_revert_workers (below the database count on larger clusters), ATM entries belonging to un-serviced databases are handed to whichever worker scans, cause it to sleep, and never get reverted -- a silent liveness/data-consistency gap for always-on UNDO infrastructure. The per-database filter should be pushed into the scan API (ATMGetNextUnreverted taking a target dboid) so each worker only receives its own entries, and every connectable database must be guaranteed a worker.


📄 src/backend/access/undo/logical_revert_worker.c (L347-L348)

Use bounded string ops. Project convention (and the rules for this repo) require snprintf/strlcpy over sprintf for bgw_library_name/bgw_function_name; LogicalRevertLauncherRegister() below already uses snprintf. Use snprintf with the proper size limits (MAXPGPATH for bgw_library_name, BGW_MAXLEN for bgw_function_name) here too for consistency.

💡 Suggested change

Before:

	sprintf(worker.bgw_library_name, "postgres");
	sprintf(worker.bgw_function_name, "LogicalRevertWorkerMain");

After:

	snprintf(worker.bgw_library_name, MAXPGPATH, "postgres");
	snprintf(worker.bgw_function_name, BGW_MAXLEN, "LogicalRevertWorkerMain");

📄 src/backend/access/undo/logical_revert_worker.c (L355-L360)

The BackgroundWorkerHandle is obtained but immediately discarded, so the launcher has no way to detect that a per-database worker terminated. Combined with the launcher unconditionally keeping the OID in known_dbs after a call to StartLogicalRevertWorker() (even when RegisterDynamicBackgroundWorker returns false and only emits a WARNING), a database whose worker fails to register or exits without a postmaster restart is treated as tracked forever and never re-spawned -- its ATM never drains. Track the handle (or check liveness via GetBackgroundWorkerPid) and only mark the OID tracked on successful registration.


📄 src/backend/access/undo/logical_revert_worker.c (L403-L404)

Template-database detection uses a name prefix, which over-matches. strncmp(NameStr(db->datname), "template", 8) skips any user database whose name begins with "template" (e.g. "templatedb"), leaving its ATM entries unreverted. The correct discriminator is the pg_database.datistemplate flag, as autovacuum's get_database_list() uses.

💡 Suggested change

Before:

		if (strncmp(NameStr(db->datname), "template", 8) == 0)
			continue;

After:

		if (db->datistemplate)
			continue;

📄 src/backend/access/undo/relundo_discard.c (L296-L296)

discarded_records is documented in relundo.h as a "Cumulative count of discarded records" with the invariant (total - discarded) = live records, and total_records counts records. Incrementing it by npages_freed (a page count) breaks that invariant: subtracting a page count from a record count yields a meaningless "live records" value reported by any stats consumer. The redo path (relundo_xlog.c) mirrors this same increment, so replay is self-consistent but equally wrong. Either count actual discarded records here (accumulate per-page record counts during the Pass-2 walk) or rename/redocument the field as a page count and fix its consumers. The bare /* approximate */ is a what-comment that hides a real semantic mismatch rather than explaining it.


📄 src/backend/access/undo/relundo_discard.c (L200-L202)

This head->tail chain walk (Pass 1), and the Pass-2 counting walk below, run over an unbounded page chain while holding the metapage BUFFER_LOCK_EXCLUSIVE, with no CHECK_FOR_INTERRUPTS(). For a large UNDO fork this makes VACUUM unresponsive to cancellation and holds the metapage exclusive lock (which serializes every reserver/allocator) across two full O(n) I/O passes. Other relundo/recno loops call CHECK_FOR_INTERRUPTS() during traversal; add one to each walk. Note the metapage buffer lock is released automatically by the resource owner if an interrupt ERRORs out.


📄 src/backend/access/undo/relundo.c (L590-L590)

relundo_pending_metabuf holds an EXCLUSIVE-locked, pinned metapage buffer between RelUndoReserve() (this new-page path) and the matching RelUndoFinish()/RelUndoStage()/RelUndoCancel(). It is only ever reset on those happy paths. If an ERROR is raised in the window between reserve and finish (e.g. in the RECNO INSERT path, code between END_CRIT_SECTION() and RelUndoFinish() runs outside any crit section, and RelUndoReserve in the UPDATE path is inside a PG_TRY whose PG_CATCH does not touch this static), ResourceOwner cleanup at abort releases the buffer pin/lock, but this static pointer is NOT reset. The next new-page RelUndoReserve() in the same backend then stores over — or RelUndoStage/Finish Assert(BufferIsValid(relundo_pending_metabuf)) and operate on — a released/stale buffer handle, causing an assertion failure or use-after-free. No abort hook resets this (RecnoRelUndoAbortCleanup only drops sLog markers). Register an at-abort/at-subabort reset (e.g. via an AbortCleanup path) that releases and clears relundo_pending_metabuf if still set.


📄 src/backend/access/undo/relundo.c (L868-L868)

RelUndoRecPtr is typedef uint64 (see access/relundo.h). Casting it to (unsigned long) and formatting with %lu truncates the value on LLP64 (Windows/MSVC) and ILP32 platforms where unsigned long is 32 bits — a portability defect. Use UINT64_FORMAT and pass the value directly (no cast). Better yet, this DEBUG1 scaffolding on the hot insert path should be removed before commit.

💡 Suggested change

Before:

		 (unsigned long) ptr, payload_size, tuple_len);

After:

		 ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo_page.c (L80-L85)

This metapage initialization path modifies shared on-disk state (PageInit + field writes + MarkBufferDirty) but performs NO WAL logging. Compare RelUndoInitRelation() in relundo.c, which correctly emits XLOG_RELUNDO_INIT for exactly this purpose. Two of the four callers (RelUndoGetCurrentCounter, and the Tier-2 fast path of RelUndoReserve) invoke this with BUFFER_LOCK_SHARE and may release the buffer without ever emitting a WAL record, so a crash after this dirty page reaches disk (or shipping to a replica) yields a metapage on disk with no describing WAL: the FPI is never generated, PageSetLSN is never called, and recovery/replica state diverges (magic missing or stale counter). Any mutation of shared on-disk state must be WAL-logged and replayed. WAL-log this init (mirroring RelUndoInitRelation) or make callers responsible for logging. (high confidence)


📄 src/backend/access/undo/relundo_page.c (L94-L100)

Functional gap: a SHARE caller on an uninitialized fork (0 blocks) falls into this branch and errors out with ERRCODE_INDEX_CORRUPTED. Callers RelUndoGetCurrentCounter() (relundo.c:1231) and the Tier-2 fast path in RelUndoReserve() (relundo.c:494) pass BUFFER_LOCK_SHARE, so a legitimate freshly-created-but-not-yet-initialized fork (e.g., early in a startup/recovery sequence, or before the first record is written) produces a spurious corruption error rather than transparently initializing. Consider upgrading the lock to EXCLUSIVE and initializing here as well, rather than erroring. (moderate confidence)


📄 src/backend/access/undo/relundo_page.c (L65-L69)

This reinitialization path also modifies shared on-disk state without WAL logging (PageInit + field writes + MarkBufferDirty, no XLogInsert/PageSetLSN). Same crash/replica divergence hazard as the init branch above. Additionally, the safety justification ("an uninitialized metapage means no UNDO records were ever written, so there's nothing to lose") is asserted, not verified: a torn/partial write of a previously-valid metapage would clear the magic while live UNDO records still exist on data pages (blocks 1+). Silently wiping the metapage in that case discards the head/tail chain pointers and the counter, orphaning live UNDO chains and risking incorrect visibility/rollback (data corruption). This overwrite should be gated on evidence that no data pages exist, and must be WAL-logged if performed. (high confidence)


📄 src/backend/access/undo/relundo_page.c (L120-L123)

Two consecutive comment blocks with no code between them; fold into a single block. Also, the three near-identical metapage-initialization sequences (this reinit, the init branch above, and RelUndoInitRelation() in relundo.c) are copy-pasted; per DRY, extract a shared relundo_init_metapage(Page) helper so future field additions to RelUndoMetaPageData cannot drift between sites. (low confidence)


📄 src/backend/access/undo/relundo_worker.c (L319-L323)

The error handling here is unsafe and can silently drop UNDO work. On ANY error from RelUndoApplyChain (lock timeout, OOM, deadlock, disk error, serialization) the catch block just calls EmitErrorReport/FlushErrorState and continues, but it never aborts the transaction. Two problems follow: (1) A mid-apply error may leave buffer content locks held. The inline path in xactundo.c calls BufferLockReleaseAll() in its PG_CATCH for exactly this reason; this path omits it. (2) RelUndoWorkerMain then unconditionally calls CommitTransactionCommand() on a transaction whose statement errored, and RelUndoQueueMarkComplete() removes the item -- so a transient failure permanently drops the UNDO work, leaving the relation's aborted changes un-reverted and sLog entries uncleaned (silent logical corruption). Errors must be distinguished: 'relation dropped' can be skipped, but transient errors must AbortCurrentTransaction and leave the item queued for retry (reset in_progress) rather than MarkComplete.


📄 src/backend/access/undo/relundo_worker.c (L130-L138)

TOCTOU / lost-work race in the queue. RelUndoQueueGetNext copies an item and marks it in_progress, but RelUndoQueueAdd coalesces by (dboid, reloid) and will OVERWRITE start_urec_ptr/xid of an item that is currently in_progress. If a new abort for the same relation arrives while a worker is applying the previously fetched copy, the queue entry's urec_ptr is updated, but when the worker finishes it calls RelUndoQueueMarkComplete(dboid, reloid, worker_id) which deletes the (now-updated) entry -- silently losing the newly queued UNDO work. RelUndoQueueAdd must not coalesce into an in_progress item; add a new item (or refuse to update in_progress entries) instead.


📄 src/backend/access/undo/relundo_worker.c (L168-L169)

RelUndoRecPtr is typedef'd as uint64 (see relundo.h). Casting to (unsigned long) and printing with %lu truncates on LLP64 platforms (Windows/MSVC, where long is 32-bit) and violates the tree's rule to use UINT64_FORMAT for 64-bit values. Use UINT64_FORMAT and pass the value as uint64. Same defect appears in process_relundo_work_item's LOG message.

💡 Suggested change

Before:

	elog(DEBUG1, "Queued per-relation UNDO work for database %u, relation %u (ptr=%lu)",
		 dboid, reloid, (unsigned long) start_urec_ptr);

After:

	elog(DEBUG1, "Queued per-relation UNDO work for database %u, relation %u (ptr=" UINT64_FORMAT ")",
		 dboid, reloid, (uint64) start_urec_ptr);

📄 src/backend/access/undo/relundo_worker.c (L282-L283)

Same 64-bit truncation/format defect as in RelUndoQueueAdd: RelUndoRecPtr is uint64, so (unsigned long) + %lu truncates on LLP64 (Windows/MSVC). Use UINT64_FORMAT.

💡 Suggested change

Before:

	elog(LOG, "Per-relation UNDO worker processing: database %u, relation %u, UNDO ptr %lu",
		 item->dboid, item->reloid, (unsigned long) item->start_urec_ptr);

After:

	elog(LOG, "Per-relation UNDO worker processing: database %u, relation %u, UNDO ptr " UINT64_FORMAT,
		 item->dboid, item->reloid, (uint64) item->start_urec_ptr);

📄 src/backend/access/undo/relundo_worker.c (L57-L58)

max_relundo_workers and relundo_worker_naptime are documented as 'GUC parameters' but are never registered in guc_tables.c (verified: no reference to either variable in guc_tables.c). They are plain globals that no user can set and that reload on SIGHUP does nothing for -- dead knobs. Either register them as real GUCs (with proper min/max bounds; the WaitLatch timeout 'relundo_worker_naptime * 2' and the backoff both need bounded input) or drop the GUC framing and the ProcessConfigFile handling that pretends to reconfigure them.


📄 src/backend/access/undo/relundo_worker.c (L338-L346)

Unresponsive/mid-item shutdown. RelUndoApplyChain has no CHECK_FOR_INTERRUPTS (verified: none in relundo_apply.c), and got_SIGTERM is only checked at the top of the loop, so a SIGTERM arriving during a long apply is not honored promptly. Standard bgworkers route SIGTERM to die() so ProcDiePending is processed at the next CHECK_FOR_INTERRUPTS; here a custom got_SIGTERM handler defeats that. Use the standard die handler (or ensure CHECK_FOR_INTERRUPTS is reachable in the apply path) so the worker can be stopped mid-item -- WaitForBackgroundWorkerShutdown() callers may otherwise block indefinitely.


📄 src/backend/access/undo/relundo_worker.c (L223-L225)

worker_id matching is unsafe for RelUndoQueueMarkComplete. next_worker_id is a monotonically-increasing counter that is never reset, and RelUndoQueueMarkComplete's array-compaction (memcpy shift) reorders items on every completion. Because MarkComplete matches on (dboid, reloid, worker_id) after the array may have been shifted by other workers' completions, and RelUndoQueueGetNext only matches on in_progress+dboid, there is no guarantee the worker removes the exact slot it took. Coupled with the coalescing race above, this can drop or duplicate UNDO application. Consider matching on a stable item identity (e.g. xid + reloid) or storing an index/handle rather than a reused monotonic id.


📄 src/backend/access/undo/relundo_worker.c (L715-L719)

Stale/contradictory comment. This header states the worker acquires AccessExclusiveLock, but process_relundo_work_item was changed to RowExclusiveLock (with a comment explaining why AccessExclusiveLock was removed). Update this comment so it does not claim behavior the code no longer has.

💡 Suggested change

Before:

 * Called from AbortTransaction() AFTER locks have been released so the
 * UNDO worker can acquire AccessExclusiveLock on the target relation.
 * This makes the "sync rollback" path truly synchronous: the aborting
 * backend does not return to the client until the UNDO is applied and
 * the original tuple data is restored.

After:

 * Called from AbortTransaction() AFTER locks have been released so the
 * UNDO worker can acquire RowExclusiveLock on the target relation.
 * This makes the "sync rollback" path truly synchronous: the aborting
 * backend does not return to the client until the UNDO is applied and
 * the original tuple data is restored.

📄 src/backend/access/undo/relundo_worker.c (L149-L151)

errmsg/ereport convention: primary messages must start lowercase, have no trailing period, and user-facing text should be wrapped in _(). This WARNING starts with a capital 'P' and is not translatable. Rephrase to lowercase, e.g. errmsg("per-relation UNDO work queue is full, cannot queue work for relation %u", reloid).


📄 src/backend/access/undo/relundo_recovery.c (L362-L366)

Out-of-bounds read hazard on a corrupt/truncated UNDO page. This loop is a defensive parser (per the comment on line 368), yet the header read is guarded only by offset < pd_lower. SizeOfRelUndoRecordHeader is 16 bytes; if offset lands within 16 bytes of pd_lower (which on a full page approaches pd_upper ~ BLCKSZ), the memcpy reads past the written region and can read past the buffer page. Guard the header size explicitly.

💡 Suggested change

Before:

	while (offset < pd_lower && nrecs < maxrecs)
	{
		RelUndoRecordHeader rhdr;

		memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader);

After:

	while (offset + SizeOfRelUndoRecordHeader <= pd_lower && nrecs < maxrecs)
	{
		RelUndoRecordHeader rhdr;

		memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader);

📄 src/backend/access/undo/relundo_recovery.c (L380-L381)

urec_len is read from the page but the stride is not validated to keep the record body within the written region: a corrupt urec_len can advance offset past pd_lower (or, combined with the unbounded header read above, into the next iteration's over-read). The only lower bound checked is urec_len < SizeOfRelUndoRecordHeader. Add an upper-bound check that offset + rhdr.urec_len <= pd_lower before recording the entry / striding, and break defensively otherwise.

💡 Suggested change

Before:

		offset += rhdr.urec_len;
	}

After:

		if (rhdr.urec_len > pd_lower - offset)
			break;

		offset += rhdr.urec_len;
	}

📄 src/backend/access/undo/relundo_recovery.c (L319-L320)

Return type is int but the value returned is prev (a BlockNumber, i.e. uint32) and this function legitimately returns InvalidBlockNumber (0xFFFFFFFF). The declared int return truncates/sign-confuses the unsigned 32-bit block-number domain; it round-trips only because the caller reassigns to a BlockNumber. Declare the return type as BlockNumber here and in the forward declaration for type correctness and to avoid sign-related warnings.

💡 Suggested change

Before:

static int
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)

After:

static BlockNumber
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)

📄 src/backend/access/undo/relundo_recovery.c (L75-L75)

The forward declaration's return type (int) is inconsistent with the block-number value actually returned. Match the corrected definition and use BlockNumber.

💡 Suggested change

Before:

static int	RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno);

After:

static BlockNumber RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno);

📄 src/backend/access/undo/relundo_recovery.c (L156-L157)

This comment claims to "Mirror ResetUnloggedRelations" on the ENOENT path, but reinit.c's ResetUnloggedRelationsInTablespaceDir emits an ereport(LOG, (errcode_for_file_access(), errmsg("could not open directory ..."))) before returning, whereas this returns silently. Either drop the misleading "Mirror ResetUnloggedRelations" claim or add the matching LOG so a crashed DROP TABLESPACE leaving a dangling symlink is diagnosable.


📄 src/backend/access/undo/relundo_xlog.c (L455-L458)

xlrec->slot (a uint16 from the WAL record) is used to index meta->tail_blkno[] / meta->head_blkno[] without any bounds check against RELUNDO_NUM_HEADS (16). A slot value >= RELUNDO_NUM_HEADS from a corrupt or forged WAL record produces an out-of-bounds write into the metapage during redo. Add a validation before indexing, e.g. if (xlrec->slot >= RELUNDO_NUM_HEADS) elog(PANIC, ...), consistent with the other consistency checks in this file.


📄 src/backend/access/undo/relundo_xlog.c (L342-L342)

The bounds checks above validate xlrec->urec_len against page_offset, but the actual copy below uses record_len (from XLogRecGetBlockData), which is only checked against > BLCKSZ. The record_len > BLCKSZ check does not account for page_offset, so if record_len ever exceeds urec_len (e.g. on a corrupt WAL record), memcpy((char *) page + xlrec->page_offset, record_data, record_len) can overrun the shared buffer. Validate record_len against page_offset in the same coordinate system (i.e. page_offset + record_len <= BLCKSZ) before the memcpy, rather than relying on urec_len.


📄 src/backend/access/undo/relundo_xlog.c (L481-L483)

This BLK_NEEDS_REDO branch is effectively unreachable: the do-time path (RelUndoDiscard) registers block 1 with flag 0 (forced full-page image), so XLogReadBufferForRedo can only return BLK_RESTORED or BLK_DONE for this block, never BLK_NEEDS_REDO. The manual prev_blkno re-application here (and the equivalent for block 2) is dead code. Either drop it in favor of relying on the FPI, or register the block REGBUF_STANDARD at do-time and re-apply here as done for the metapage; the current mix is inconsistent with the file header comment which only calls out the metapage as image-free.


📄 src/backend/access/undo/slog_flathash.c (L524-L526)

DSA before-image leak on horizon eviction. This path evicts the oldest below-horizon UPDATE marker to make room, but it selects ANY below-horizon UPDATE (preferring the smallest commit_hlc at line 514) without excluding markers that still carry a valid before_image_dp. When the evicted marker holds a valid DSA before-image, this line overwrites the pointer with InvalidDsaPointer without ever calling dsa_free()/SLogDsaFreeBeforeImage(), so the DSA chunk is leaked permanently.

Contrast flat_hash_coalesce_retained(), which deliberately skips markers with a valid before_image_dp (see the if (DsaPointerIsValid(...)) continue; at the image-less filter), and every caller-driven removal path (SLogTupleCleanupRetained / SLogTupleRemoveByXidGlobal) which collects the dp and dsa_free()s it after the seqlock cycle closes. This internal reclamation has no such caller and no free, so it is the one path that drops a live before-image on the floor.

The adjacent comment's claim that "an unstamped marker never had its before-image published, so before_image_dp is invalid and nothing is leaked" only covers the commit_hlc == 0 subset; a stamped, below-horizon, committed marker whose before-image has not yet been swept by SLogTupleCleanupRetained can be selected here and its dp leaked. Free the before-image before nulling it (note dsa_free may take an LWLock, so keep it outside any spinlock; the partition writer lock is held here). Confidence: high.

💡 Suggested change

Before:

			entry->ops[oldest_idx].in_use = false;
			entry->ops[oldest_idx].before_image_dp = InvalidDsaPointer;
			entry->ops[oldest_idx].commit_hlc = 0;

After:

			entry->ops[oldest_idx].in_use = false;
			if (DsaPointerIsValid(entry->ops[oldest_idx].before_image_dp))
				SLogDsaFreeBeforeImage(entry->ops[oldest_idx].before_image_dp);
			entry->ops[oldest_idx].before_image_dp = InvalidDsaPointer;
			entry->ops[oldest_idx].commit_hlc = 0;

📄 src/backend/access/undo/undo_bufmgr.c (L102-L107)

This whole module is dead code. An exhaustive search finds no caller of any function defined here (ReadUndoBuffer, ReadUndoBufferExtended, ReleaseUndoBuffer, UnlockReleaseUndoBuffer, MarkUndoBufferDirty, UndoMakeBufferTag, InvalidateUndoBuffers, InvalidateUndoBufferRange) anywhere in the tree. undo_bufmgr.h itself states the read/write paths use pread/pwrite and bypass shared_buffers, and that the buffer-pool integration is "retained only for the buffer invalidation API used during segment discard" plus "Legacy backward compatibility" -- but even the invalidation API has zero callers. Per the YAGNI/minimalism discipline, this speculative scaffolding should be removed until an actual consumer is wired up; a patch posted to -hackers will be rejected for shipping an unused subsystem. (high confidence)


📄 src/backend/access/undo/undo_bufmgr.c (L235-L247)

The last_block parameter is silently ignored: DropRelationBuffers() drops ALL blocks with blockNum >= firstDelBlock for the fork (see bufmgr.c: "removes ... all the pages ... that have block numbers >= firstDelBlock") and discards dirty pages WITHOUT writing them back. So a caller intending to invalidate only [first_block, last_block] will also evict every buffer beyond last_block, which may still be valid/needed -- a silent data-loss/corruption footgun for the undo log. The last_block parameter is dead and misleading API surface. Either honor the upper bound (iterate/scan the bounded range) or drop the parameter and rename the function to reflect "from first_block onward" semantics. (high confidence)


📄 src/backend/access/undo/undo_bufmgr.c (L117-L119)

This comment ("Undo logs are always permanent ... we pass permanent=true") contradicts the architecture documented in undo_bufmgr.h, which states UNDO records are embedded in the WAL stream, there are no separate UNDO segment files, and the write path uses pwrite bypassing shared_buffers. Since this buffer-pool read path is not actually used by the current design, the rationale here is stale/misleading. Comments must describe what the code does now. (moderate confidence)


📄 src/backend/access/undo/undo_bufmgr.c (L209-L212)

smgropen()/smgrclose() (storage/smgr.h) and INVALID_PROC_NUMBER (storage/procnumber.h) are used here but only reach this file transitively through storage/buf_internals.h. Per PostgreSQL include discipline, include the header you directly depend on (add #include "storage/smgr.h"). Relying on transitive includes is fragile if buf_internals.h's includes change. (low confidence)


📄 src/backend/access/undo/undo_xlog.c (L994-L996)

High confidence, critical correctness/data-integrity bug. During crash recovery the startup process has no attached database, so MyDatabaseId is InvalidOid. The deferred entry's target database is not derived from the UNDO batch/record — it is set to MyDatabaseId. FlushDeferredUndoXacts() feeds this into ATMAddAborted(deferred->xid, deferred->dboid, ...), and the logical revert worker selects work with if (entry_dboid != MyDatabaseId) goto sleep; (logical_revert_worker.c:232). An InvalidOid dboid will never match any worker's connected database, so the deferred rollback is silently dropped forever, leaving uncommitted data permanently visible. The database OID must be carried in the UNDO batch/record and used here, not MyDatabaseId.


📄 src/backend/access/undo/undo_xlog.c (L254-L262)

High confidence, buffer-overrun / page-corruption hazard on the crash-recovery redo path. xlrec->tuple_len is a uint32 taken directly from the WAL record, and the only guard before the memcpy is Assert(datalen >= xlrec->tuple_len), which is compiled out in production builds. A corrupt or short record (e.g. WAL segment recycled/overwritten so the LSN now maps to different data) makes this memcpy read past the block-data buffer AND overwrite htup on the page, corrupting the buffer and potentially making the database unrecoverable. Unlike the UNDO_CLR_HAS_DELTA branch (which does explicit ereport(ERROR) length validation), this branch has no production-time bounds check. Add an explicit runtime check: if (datalen < xlrec->tuple_len) ereport(ERROR, ...).


📄 src/backend/access/undo/undo_xlog.c (L385-L388)

High confidence, same buffer-overrun class as the tuple branch. vis_datalen is only checked via Assert(vis_datalen >= SizeOfUndoApplyVisibility), which is a no-op in production. If the block data is shorter than SizeOfUndoApplyVisibility (corrupt/recycled WAL), the memcpy(&vis_rec, vis_data, SizeOfUndoApplyVisibility) reads past the buffer during recovery. Replace the Assert with a runtime ereport(ERROR) bounds check, consistent with the DELTA branch.


📄 src/backend/access/undo/undo_xlog.c (L420-L423)

High confidence, same buffer-overrun class. datalen is only guarded by Assert(datalen >= SizeOfUndoApplyHot), compiled out in production. A short/corrupt record makes memcpy(&hot_data, data, SizeOfUndoApplyHot) read past the buffer during recovery, and hot_data.new_offset is then used to index a line pointer. Add a runtime ereport(ERROR) bounds check instead of relying on Assert.


📄 src/backend/access/undo/undo_xlog.c (L106-L115)

High confidence, concurrency/portability. undo_redo() mutates UndoLogShared fields without any LWLock or memory barrier. insert_ptr, seal_ptr, and active_log_idx use pg_atomic_*, but discard_ptr, oldest_xid, log_number, in_use, and state are plain scalar writes. During redo (especially hot standby) other processes read UndoLogShared concurrently; plain writes without a barrier produce torn/stale reads on weakly-ordered architectures (ARM64/PPC64) and violate the tree's balanced-lock discipline. Worse, the free-slot allocation loop writes log_number/insert_ptr/in_use non-atomically — a concurrent reader can observe in_use==true with a half-initialized slot. This should take the same LWLock the normal (non-redo) writers in undolog.c use.


📄 src/backend/access/undo/undo_xlog.c (L1204-L1210)

Medium confidence, TOCTOU / SIGBUS still reachable. This recycled-segment guard is a time-of-check/time-of-use race: the segment can be recycled between access() and the later XLogReadRecord(). More importantly the check only runs when batch_lsn < redo_ptr; for batch_lsn >= redo_ptr the segment may still have been recycled/renamed and reading it can SIGBUS. The access() probe cannot make the subsequent read safe. Prefer relying on the graceful error handling in XLogReadRecord() (returning NULL) plus the caller's reader reset, rather than a pre-check that gives a false sense of safety.


📄 src/backend/access/undo/undo_xlog.c (L639-L647)

Medium confidence, recovery-time performance (O(n^2)). Both UndoRecoveryTrackBatch() and UndoRecoveryRemoveXid() do a linear scan of undo_recovery_entries for every XLOG_UNDO_BATCH and every commit/abort record during redo. With the cap at 1,048,576 in-flight XIDs, per-record scanning makes crash recovery effectively quadratic. The checklist mandates avoiding O(n^2) where feasible; use dynahash/simplehash keyed by xid (the standard PostgreSQL pattern for XID tracking). Additionally, UndoRecoveryRemoveXid tombstones entries by zeroing the xid but never compacts, so undo_recovery_nentries keeps growing and tombstones count against UNDO_RECOVERY_MAX_ENTRIES.


📄 src/backend/access/undo/undo_xlog.c (L462-L465)

Medium confidence, dead scaffolding (minimalism). This is a brand-new, never-shipped WAL record type, so there is no pre-transition on-disk WAL to be 'backward compatible' with. The XLOG_UNDO_PAGE_WRITE opcode is never emitted anywhere in the tree (only referenced here and in undodesc.c). Keeping a no-op redo case plus the aspirational 'backward compatibility with WAL from before the transition' comment is dead code that adds ambiguity to the redo dispatcher. Remove the opcode and this case, or if the opcode must remain, drop the misleading comment.


📄 src/backend/access/undo/undo_xlog.c (L617-L617)

Low confidence but a hard style rule per the review context: source must be ASCII only. This comment uses a non-ASCII multiplication sign and non-ASCII characters. Replace with ASCII, e.g. '1 million entries * ~40 bytes each ~= 40 MB'.

💡 Suggested change

Before:

 * 1 million entries × ~40 bytes each ≈ 40 MB, which is reasonable for

After:

 * 1 million entries * ~40 bytes each ~= 40 MB, which is reasonable for

📄 src/backend/access/undo/undo_xlog.c (L603-L603)

Low confidence, dead field. UndoRecoveryEntry.status is declared with comment '/* in use */' but is never read or written anywhere in this file (entries are tracked via xid == InvalidTransactionId tombstones instead). Per the minimalism discipline, remove the unused field.


📄 src/backend/access/undo/undoapply.c (L214-L215)

Silent truncation of UNDO records is a data-corruption hazard. A batch's record count is xl_undo_batch.nrecords (uint32); it can legitimately exceed 1024: UndoBufferFlush() flushes at undo_batch_record_limit (a GUC-backed int defaulting to 1000, configurable higher), and UndoRecordSetInsert() imposes no per-batch record-count cap at all (only a ~256KB size threshold). When nrecords_in_batch >= max_records, the first pass breaks after collecting only the first (OLDEST) 1024 records. The second pass then applies those oldest-first-1024 newest-first, but the records that were serialized after index 1024 -- i.e. the genuinely newest records that ARIES requires be undone FIRST -- are never collected and never reverted. Rollback silently leaves on-disk state partially reverted with only a WARNING. Grow the array dynamically instead (or walk in a way that has no fixed cap); the batch header's nrecords should also be used to assert/verify all records were collected.


📄 src/backend/access/undo/undoapply.c (L260-L267)

Overflowing this fixed cap silently drops the newest records (see comment on max_records). At minimum this must not silently continue: either grow the array or ereport(ERROR) so the caller falls back to the ATM/deferred path instead of committing a partial rollback. Because records are applied newest-first for ARIES correctness, capping the first-pass collection at the oldest 1024 records is precisely wrong -- the dropped records are the ones that had to be undone first.


📄 src/backend/access/undo/undoapply.c (L110-L117)

A failed rm_undo callback (UNDO_APPLY_ERROR) is swallowed into a WARNING and the chain walk continues, silently leaving the database partially rolled back. This is reachable: heap/nbtree/hash/recno/fileops undo appliers all return UNDO_APPLY_ERROR on real failures (e.g. truncated payload, missing file, buffer read failure). The inline-abort caller in xactundo.c relies on an error PROPAGATING out (PG_TRY/PG_CATCH) to set undo_applied=false and fall back to ATM; but because ApplyUndoChainFromWALBounded returns batches_processed > 0, a batch whose records all errored still reports success and the ATM fallback is skipped. The subxact-abort caller ignores the return entirely. An UNDO application error during rollback must halt loudly (ereport(ERROR), or return a failure the caller acts on), not WARNING-and-continue.


📄 src/backend/access/undo/undoapply.c (L288-L289)

urec_ptr is always InvalidUndoRecPtr here (the second pass calls ApplyOneUndoRecord(..., InvalidUndoRecPtr) unconditionally). Every WARNING/DEBUG2 message in ApplyOneUndoRecord that prints (unsigned long long) urec_ptr therefore always prints 0, making these messages useless for diagnosing which record failed during a rollback. Either thread the real record location through (each record's UndoRecPtr is derivable during the walk) or drop the misleading address from the message.


📄 src/backend/access/undo/undoapply.c (L233-L233)

This palloc is intentionally never freed and is issued once per batch inside the while (XLogRecPtrIsValid(batch_lsn)) loop. For a long UNDO chain (many batches during a large rollback or recovery), 1024sizeof(char) = 8KB accumulates per batch in CurrentMemoryContext and is not reclaimed until that context resets -- unbounded growth proportional to chain length. The idiomatic fix is a dedicated short-lived MemoryContext created once before the loop and MemoryContextReset()'d per batch (which also sidesteps the BumpContext pfree concern entirely), or moving this allocation outside the loop and reusing it.


📄 src/backend/access/undo/undobuffer.c (L183-L186)

Correctness/data-integrity: the global undo_t2buf is never reset on transaction (or subtransaction) abort. UndoBufferEnd() is only invoked from recno_finish_bulk_insert() on the success path; if an ereport(ERROR) unwinds after UndoBufferBegin() but before end, active stays true with a stale xid/chain_prev. On the next statement, UndoBufferBegin() for the same relid short-circuits at the early return below and reuses the stale xid/chain_prev from the aborted transaction, mixing records across transactions and corrupting the UNDO chain. Register an AtEOXact/AtAbort/subxact-abort callback (or reset via ResourceOwner) that forces undo_t2buf.active = false and clears xid/len/nrecords/chain_prev.


📄 src/backend/access/undo/undobuffer.c (L234-L238)

Speculative scaffolding: UndoBufferAddRecord, UndoBufferHasPendingData, UndoBufferTakePayload, and UndoBufferReset have no callers anywhere in the tree. The module/header advertise the WAL-embedding path ("embedded directly inside the AM's DML WAL record ... eliminating a separate XLOG_UNDO_BATCH record for single-tuple operations") as the primary purpose, but only the overflow-flush path (UndoBufferFlush, which emits a standalone XLOG_UNDO_BATCH) is actually wired up. Either wire up the TakePayload/Reset embedding path with tests, or drop the dead API and correct the header comment to describe what the code does now (YAGNI).


📄 src/backend/access/undo/undobuffer.c (L136-L140)

DRY: UndoTier2AddRecordParts is a near-verbatim copy of UndoTier2AddRecord, differing only in payload assembly. Implement UndoTier2AddRecord as a single-part call into UndoTier2AddRecordParts(..., payload, payload_len, NULL, 0) (or vice versa) to eliminate the duplicated header-serialization logic.


📄 src/backend/access/undo/undobuffer.c (L201-L202)

Portability/convention: use INT64_FORMAT for the int64 nrows instead of %lld with a (long long) cast, per PostgreSQL formatting conventions.


📄 src/backend/access/undo/undobuffer.c (L119-L126)

Silent truncation: record_size (Size) is stored into header->urec_len (uint32) and payload_len into header->urec_payload_len (uint32) via bare casts. For a payload >= 4GB these truncate and corrupt the serialized record read back during rollback/replay. Add an explicit bound check (e.g. reject/elog(ERROR) if record_size > PG_UINT32_MAX) before the cast, since payload_len is caller-supplied and unvalidated here.


📄 src/backend/access/undo/undoinsert.c (L126-L127)

This rationale is factually wrong for the nbtree/hash direct-insert callers. NbtreeUndoLogInsert() (nbtinsert.c:1454, and the split path at 1256) and HashUndoLogInsert() (hashinsert.c:249) call UndoRecordSetInsert() after END_CRIT_SECTION() -- see the nbtree comment "Emitted here, after _bt_split's critical section has ended". So UndoRecordSetInsert() does NOT always run inside a critical section. Since INJECTION_POINT_CACHED is being chosen based on this incorrect invariant, either the comment must be corrected to describe the actual (mixed) calling context, or the invariant must be enforced. As written the comment will mislead future readers about when this code runs. (high confidence)


📄 src/backend/access/undo/undoinsert.c (L108-L109)

uset->buffer_size is a Size (64-bit) and uset->nrecords is an int, but they are silently downcast to the uint32 fields total_len/nrecords. The full buffer_size is then registered via XLogRegisterData(uset->buffer, uset->buffer_size) just below, while the redo/read path (UndoReadBatchFromWAL, undo_xlog.c:1367-1386) trusts xlrec->total_len for the payload length. If a batch ever exceeds UINT32_MAX bytes (or INT_MAX records) the header and the registered payload disagree, and replay reads the wrong length -- silent UNDO corruption. There is no cap or assertion guarding this cast in this path. Add an overflow guard (e.g. Assert(uset->buffer_size <= PG_UINT32_MAX) or an ereport) before the cast. (moderate confidence)


📄 src/backend/access/undo/undolog.c (L473-L478)

UndoLogPath() has no callers anywhere in the tree, yet it fabricates a base/undo/%012u path for segment files that this rewrite has removed. This is a footgun: any future caller would operate on a non-existent file, and the comment itself admits the path is bogus. It also assumes the caller-supplied path is at least MAXPGPATH bytes with no way to enforce that. Remove the function (and its header declaration) rather than shipping dead code that manufactures obsolete paths.


📄 src/backend/access/undo/undolog.c (L171-L174)

This function has no callers in the tree. discard_ptr here is read without holding log->lock, whereas UndoLogGetDiscardPtr() and CheckPointUndoLog() read the same field under LW_SHARED and UndoLogDiscard() writes it under LW_EXCLUSIVE. Since it is dead code, drop it; if a caller is intended, make the locking consistent with the other readers.


📄 src/backend/access/undo/undolog.c (L249-L255)

UndoGetDiscardHorizon() has no callers: the actual WAL-retention gate in xlog.c uses the live UndoGetOldestBatchLSN() scan instead. The undo_discard_horizon field is written by the worker via UndoSetDiscardHorizon() but never read for its stated purpose. This getter is dead code and should be removed along with its header declaration.


📄 src/backend/access/undo/undolog.c (L429-L432)

Several of these no-op stubs (UndoLogSync, UndoFlushGetMaxWritePtr, UndoLogTryPressureDiscard, ExtendUndoLogSmgrFile) have no callers anywhere in the tree, so the stated justification ('they still have live callers ... rather than sprinkling mode checks') does not apply to them. Per YAGNI, empty stubs kept only to preserve an obsolete interface are a rejection risk; drop the callerless ones and their header declarations. (The ones with real callers -- UndoLogCloseFiles, UndoFlushResetMaxWritePtr, UndoLogSealAndRotate, UndoLogDeleteSegmentFile, ExtendUndoLogFile -- are the only ones this comment actually describes.)


📄 src/backend/access/undo/undolog.c (L495-L498)

UndoLogGetInsertPtr() and UndoLogGetDiscardPtr() have no callers in the tree. In this WAL-integrated rewrite insert_ptr/discard_ptr are never advanced by any real write path, so these getters return a value that is always effectively invalid/zero. They are dead accessors and should be removed together with their header declarations.


📄 src/backend/access/undo/undolog.c (L16-L18)

Comment-hygiene: this is a newly ADDED file, so there is no 'previous version' in the tree to contrast against. Describing removed functions ('The previous functions ... are removed. Callers now use ...') is migration-note narrative rather than a description of what the code does now. Rewrite the header to state what this file provides today; drop the retrospective list of deleted functions.


📄 src/backend/access/undo/undoinsert.c (L126-L127)

Inaccurate comment. This claims UndoRecordSetInsert() "always runs inside the caller's critical section," but the reachable nbtree callers emit it after END_CRIT_SECTION(): nbtinsert.c:1443 (END_CRIT_SECTION() then NbtreeUndoLogInsert at 1456), and the split-path comment at nbtinsert.c:1248 ("Emitted here, after _bt_split's critical section has ended"). The hash caller in hashinsert.c likewise emits after the crit section. Since this comment is the stated justification for choosing INJECTION_POINT_CACHED, the rationale is contradicted by the actual call sites. Rewrite the comment to reflect that this may run outside a critical section, and re-justify the CACHED choice on the real invariant (e.g. it is still called inside a resource-owner/nesting context where palloc during injection is undesirable), or drop the incorrect claim.


📄 src/backend/access/undo/undoinsert.c (L108-L109)

Unchecked narrowing casts. uset->nrecords is int and uset->buffer_size is Size (see undorecord.h:149,156), but xl_undo_batch.nrecords/total_len are uint32 (undo_xlog.h:263-264). If a batch's serialized size exceeds UINT32_MAX, total_len silently truncates while the full uset->buffer_size is still registered via XLogRegisterData(uset->buffer, uset->buffer_size). The read/redo path (UndoReadBatchFromWAL) derives payload_len from xlrec->total_len, so a header/payload length mismatch would mis-parse the batch and risk corruption. Add an Assert/bounds check that buffer_size and nrecords fit in uint32 before the cast.


📄 src/backend/access/undo/undoinsert.c (L55-L59)

Migration scaffolding shipped as dead no-ops. UndoWalBatchFlush/UndoWalBatchReset are pure no-ops whose only remaining callers are also stale (xactundo.c:783,795,845,858, one even labeled "no-op, kept for safety"). Per YAGNI, either finish the migration by removing these functions and their call sites, or keep them only if they still perform meaningful work. Shipping empty stubs with "haven't been updated yet" framing is WIP, not commit-ready.


📄 src/backend/access/undo/undoinsert.c (L28-L29)

Aspirational/transitional comment language. "Legacy support: ... kept as no-ops for callers that haven't been updated yet" describes a migration state rather than current behavior. Comments should explain why the code is the way it is now, not narrate an incomplete transition. Remove or rephrase once the no-op functions are resolved.


📄 src/backend/access/undo/undorecord.c (L223-L227)

Unbounded capacity growth: the doubling loop has no overflow guard. For a large additional, new_capacity *= 2 can overflow Size and wrap to a small value, so MemoryContextAlloc allocates far less than needed and the subsequent memcpy in UndoRecordAddPayload*/serialize overruns the buffer (heap overflow). There is also no MaxAllocSize check. Add an overflow-safe bound (and reject sizes above MaxAllocSize with ereport), or reuse the existing enlargeStringInfo growth pattern. (moderate confidence)


📄 src/backend/access/undo/undorecord.c (L229-L233)

Reinvents repalloc: MemoryContextAlloc + memcpy + pfree in the same context is exactly what repalloc() does, and it transiently doubles peak memory. Prefer uset->buffer = repalloc(uset->buffer, new_capacity) in uset->mctx for a smaller, DRY implementation. (moderate confidence)


📄 src/backend/access/undo/undorecord.c (L316-L323)

Silent uint32 truncation of on-disk header fields: record_size and payload_len are Size (64-bit) but are cast to uint32 for urec_len/urec_payload_len with no bound check. If a payload ever makes record_size exceed UINT32_MAX, the truncated length is written to the WAL-bound header while buffer_size grows by the full record_size; deserialize then trusts urec_payload_len, producing a length mismatch and potential out-of-bounds read during replay. Add an explicit size check (ereport(ERROR) if record_size/payload_len exceeds the header field width) before writing. Same issue in UndoRecordAddPayloadParts. (moderate confidence)


📄 src/backend/access/undo/undorecord.c (L243-L244)

Unsubstantiated performance claim: "~5 cycles vs ~300 cycles" is stated without a reproducible benchmark reference. Per project discipline, drop the specific cycle numbers or cite the benchmark that produced them; comments should explain why, not assert unverified micro-costs. (low confidence)


📄 src/backend/access/undo/undormgr.c (L38-L40)

Inconsistent NULL-handling of rm_name within this function: the rm_undo == NULL check below guards with rmgr->rm_name ? rmgr->rm_name : "(null)", but this duplicate-registration branch dereferences UndoRmgrs[rmid]->rm_name unguarded. If any prior registration ever passed an UndoRmgrData with a NULL rm_name (nothing here forbids it), this elog would dereference NULL. Either require a non-NULL rm_name explicitly at registration, or apply the same guard here for consistency. Low confidence / minor: all current in-tree callers use static structs with names.


📄 src/backend/access/undo/undostats.c (L39-L41)

These three functions are declared via PG_FUNCTION_INFO_V1 but have no corresponding entry in src/include/catalog/pg_proc.dat (verified: undo matches nothing there, only recno OIDs 9400/9401 were added). Without a pg_proc.dat entry these functions are unreachable from SQL, so the example scripts that SELECT * FROM pg_stat_get_undo_logs() / pg_undo_force_discard() will fail. The header comment above ("registered in pg_proc.dat") and undostats.h ("Catalog references go through pg_proc by name") are therefore inaccurate. Add pg_proc.dat entries with developer-range OIDs (8000-9999), a proretset='t' for the SRFs, correct prorettype/proargtypes, and proacl/permission as appropriate. Do NOT hand-edit generated fmgr files.


📄 src/backend/access/undo/undostats.c (L313-L318)

pg_undo_force_discard() re-implements perform_undo_discard() (undoworker.c) but diverges from it, which is both a DRY violation and a correctness bug. Two concrete divergences: (1) Phase 1 here sets log->discard_ptr = insert_ptr directly instead of calling UndoLogDiscard() and does not update UndoLogShared->total_discarded, so the cumulative discard counter drifts. (2) Phase 2 here frees a DISCARDABLE slot but, unlike the worker, does NOT pg_atomic_fetch_sub_u32(&UndoWorkerShmem->sealed_log_count, 1). Since a log only becomes DISCARDABLE after being SEALED (which incremented sealed_log_count), freeing it via this path permanently leaks the counter upward, which keeps the worker in its 200ms fast-naptime forever. Factor the shared two-phase logic into a common helper called by both the worker and this function.


📄 src/backend/access/undo/undostats.c (L114-L122)

GetUndoBufferStats() and pg_stat_get_undo_buffers() are dead scaffolding: they unconditionally return all zeros because UNDO pages now live in shared_buffers. Exposing a permanently-zero SQL function (and its UndoBufferStat struct) violates YAGNI and will confuse users. Consider removing this function entirely and directing users to pg_buffercache, rather than shipping a hardcoded all-zero SRF.


📄 src/backend/access/undo/undostats.c (L94-L96)

size_bytes is an unsigned subtraction of two 40-bit offsets. discard_ptr is read under the log lock but insert_ptr is captured via pg_atomic_read_u64 before the lock is taken (in the caller it is stored into stats[count].insert_ptr, then discard_ptr is read); if discard_ptr transiently exceeds the captured insert offset, or the two ever refer to a rotated log, this underflows to a huge uint64. Guard against underflow (report 0 when discard_ptr > insert_ptr) so the reported size is not garbage.

💡 Suggested change

Before:

		stats[count].size_bytes =
			UndoRecPtrGetOffset(stats[count].insert_ptr) -
			UndoRecPtrGetOffset(log->discard_ptr);

After:

		{
			uint64		ins = UndoRecPtrGetOffset(stats[count].insert_ptr);
			uint64		disc = UndoRecPtrGetOffset(log->discard_ptr);

			stats[count].size_bytes = (ins > disc) ? (ins - disc) : 0;
		}

📄 src/backend/access/undo/undostats.c (L285-L287)

This ereport(ERROR) omits errcode(), defaulting to ERRCODE_INTERNAL_ERROR for a user-triggerable condition. Use a specific errcode such as ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE, and wrap user-facing errmsg text in _() for translation (also applies to the pg_maintain message above).

Comment thread .github/ocr/litellm.yaml
@@ -0,0 +1,41 @@
# LiteLLM proxy config — bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-ASCII em-dash character (U+2014) present. The project requires ASCII-only in source and diffs (no smart quotes, em-dashes, or ellipsis characters). Replace the em-dash with an ASCII hyphen/dash. This appears on line 1 and line 24 (Set it DIRECTLY here — NOT via).

Confidence: high.

Suggested change
# LiteLLM proxy config bridges Open Code Review (OpenAI protocol) to AWS Bedrock.
# LiteLLM proxy config - bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Comment thread .github/ocr/litellm.yaml
aws_region_name: os.environ/AWS_REGION

# "High effort" review. Claude Opus 4.8 on Bedrock uses *adaptive* thinking
# controlled by output_config.effort. Set it DIRECTLY here — NOT via

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-ASCII em-dash character (U+2014) present in this comment. Project rule: ASCII-only in source and diffs. Replace with an ASCII hyphen/dash.

Confidence: high.

Suggested change
# controlled by output_config.effort. Set it DIRECTLY here NOT via
# controlled by output_config.effort. Set it DIRECTLY here - NOT via

Comment thread .github/ocr/pg-history.py
Comment on lines +51 to +54
resp = urllib.request.urlopen(req, timeout=60)
sid_out = resp.headers.get("Mcp-Session-Id")
result = None
for line in resp.read().decode().splitlines():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource leak: the response from urllib.request.urlopen() is never closed. On any exception between here and the end of the function the socket leaks, and even on the happy path the connection is only reclaimed by GC. _mcp_post runs once per tool call across up to MAX_ROUNDS rounds, so leaked connections accumulate over a run. Wrap the call in with.

Suggested:

    req = urllib.request.Request(MCP_URL, data=json.dumps(body).encode(), headers=headers, method="POST")
    with urllib.request.urlopen(req, timeout=60) as resp:
        sid_out = resp.headers.get("Mcp-Session-Id")
        payload = resp.read().decode()
    result = None
    for line in payload.splitlines():

Comment thread .github/ocr/pg-history.py
final_text = "_pg-history: no related history found._"
body = "## 📜 Change history & discussion (Agora / pg.ddx.io)\n\n" + final_text + \
"\n\n<sub>Generated by pg-history via the Agora MCP server (pg.ddx.io).</sub>\n"
open(OUT, "w").write(body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open(OUT, "w").write(...) never closes the handle explicitly; it relies on refcount GC to flush. The output file is read back by a separate cat/github-script step in ocr-review.yml, and the tree's portability rules forbid depending on CPython-specific finalization (must run on the BSDs/Windows/non-CPython). On an interpreter with deferred finalization the artifact can be truncated or empty. Use with open(OUT, "w") as f: f.write(body). Same pattern applies to the other four open(OUT, "w").write(...) sites (lines ~158, 168, 172, 212).

Comment on lines +39 to +43
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AWS CLI subprocess return code is never checked. If aws bedrock list-inference-profiles fails (expired/misconfigured OIDC role, region issue, throttling, or a version mismatch that changes the JSON shape), out.stdout is empty, json.loads("[]") yields no profiles, and the script emits newer=false and exits 0. A broken model check would masquerade as "nothing newer" and silently produce no alert for weeks -- the exact failure this workflow exists to catch. Check the return code and fail loudly on error so a broken run surfaces instead of being swallowed.

Suggested change
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
if out.returncode != 0:
import sys
print(out.stderr, file=sys.stderr)
raise SystemExit(f"aws bedrock list-inference-profiles failed (exit {out.returncode})")
ids = json.loads(out.stdout or "[]")

Comment on lines +5 to +6
# Run hourly every day
- cron: '0 * * * *'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cron is 0 * * * *, which runs hourly, but the comment says "Run hourly every day" and, more importantly, the user-facing messages describe it as a "daily" sync (issue body line 155: "The daily sync ... failed", and summary line 242: "### Daily Sync Summary"). This drifted wording will mislead maintainers reading a failure issue about how often the sync runs. Align the comment and the messages with the actual hourly schedule.

Suggested change
# Run hourly every day
- cron: '0 * * * *'
# Run hourly
- cron: '0 * * * *'

Comment on lines +18 to +19
-- STEP 2: Insert sample data
INSERT INTO customer_data (name, email) VALUES

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing BEGIN before the modifications. This script runs in psql's default autocommit mode, so the INSERT (STEP 2), UPDATE (STEP 3), and DELETE (STEP 4) each commit as their own separate transactions. Consequently the COMMIT at STEP 5 executes outside any transaction block and emits WARNING: there is no transaction in progress — there is nothing to commit. The comment "Commit the transaction" is misleading. Add a BEGIN; before STEP 2 to actually group the modifications into one transaction, matching the pattern in 02-undo-rollback.sql. (moderate confidence)

Suggested change
-- STEP 2: Insert sample data
INSERT INTO customer_data (name, email) VALUES
-- STEP 2: Begin a transaction and insert sample data
BEGIN;
INSERT INTO customer_data (name, email) VALUES

Comment on lines +40 to +42
SELECT pid, backend_type, state
FROM pg_stat_activity
WHERE backend_type = 'undo worker';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state column of pg_stat_activity is not meaningful for this background worker: UndoWorkerMain never calls pgstat_report_activity, so state stays NULL for the 'undo worker' row. Selecting state here to "verify the UNDO worker is running" is misleading — the row's presence proves it is running, but state will be empty. Consider dropping the state column or noting that it is NULL for this worker. (moderate confidence)

Suggested change
SELECT pid, backend_type, state
FROM pg_stat_activity
WHERE backend_type = 'undo worker';
SELECT pid, backend_type
FROM pg_stat_activity
WHERE backend_type = 'undo worker';

Comment on lines +3 to +4
-- complain if script is sourced in psql, rather than via ALTER EXTENSION
\echo Use "ALTER EXTENSION pageinspect UPDATE TO '1.14'" to load this file. \quit

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new pageinspect release adds three user-visible SQL functions but ships no documentation and no regression tests. Per PostgreSQL contribution standards a user-visible pageinspect addition requires (1) an entry in doc/src/sgml/pageinspect.sgml describing each function and its columns, and (2) regression coverage under contrib/pageinspect/sql/ + expected/ exercising happy path and edge/error cases (e.g. a corrupt/short page, an empty page, unused line pointers). I verified neither exists in this change set: pageinspect.sgml has no reference to these functions and there is no contrib/pageinspect/sql directory. Without tests and docs this is WIP, not commit-ready. (high confidence)

Comment on lines +48 to +51
CREATE FUNCTION recno_tuple_infomask_flags(
IN t_infomask integer,
OUT raw_flags text[],
OUT combined_flags text[])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recno_tuple_infomask_flags exposes two array columns, but the C implementation (recnofuncs.c) assigns the identical array to both raw_flags and combined_flags (values[0] = values[1] = PointerGetDatum(a);, commented "same as combined for RECNO (no separate raw names)"). A second column that is guaranteed to duplicate the first is dead scaffolding and a POLA violation for anyone comparing this against heap's tuple_data_split/heap_tuple_infomask_flags, where raw and combined genuinely differ. Either drop combined_flags (returning a single text[]) or make it carry distinct information; do not ship a column that is always a copy. (moderate confidence)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 255 issue(s).

  • 25 inline, 230 in summary (inline capped at 25)
  • ⚠️ 219 warning(s) during review

📄 src/backend/access/undo/logical_revert_worker.c

Non-ASCII arrow glyph. 2→5 uses a Unicode arrow character; replace with the ASCII '->' to comply with the project's ASCII-only rule for source files.

💡 Suggested change

Before:

- The revert worker acquires locks 2→5 in sequence during UNDO apply;

After:

- The revert worker acquires locks 2->5 in sequence during UNDO apply;

📄 src/backend/access/undo/logical_revert_worker.c

False test-coverage references. Neither src/test/regress/sql/undo.sql nor undo_toast.sql exists in the tree, and there is no contiguous 053-060 recovery test range for UNDO (053 exists but is unrelated; 060 does not exist; the actual UNDO/fileops recovery tests are 054/061/063/065/067/068/069/070). Citing nonexistent files to substantiate a 'FEATURE COMPLETE' / 'comprehensive test coverage' claim overstates coverage and misleads reviewers. Update these references to the tests that actually exist.


📄 src/backend/access/undo/logical_revert_worker.c

Internal contradiction on TOAST support. Section 17 declares 'TOAST UNDO integration: Complete' and this block claims pg_toast_NNN relations are 'automatically enrolled in the same UNDO log', yet Section 15 'Current Limitations' lists 'No TOAST-aware UNDO (large tuples stored inline)' and 'Planned Future Work' lists 'TOAST-aware UNDO storage'. These cannot both be true. Moreover RECNO -- the primary UNDO-enabled AM -- disables TOAST entirely (recno_handler.c), so the described auto-enrollment mechanism has no code basis. Reconcile these statements to reflect what actually ships.


📄 src/backend/access/undo/logical_revert_worker.c

Section numbering skips 12: the document jumps from '## 11. Performance Characteristics' directly to '## 13. Monitoring and Troubleshooting'. Renumber the sections (or restore the missing Section 12) so the table-of-contents-style numbering is consistent.


📄 src/backend/access/undo/relundo_apply.c

Buffer/lock leak and stale-state hazard on the error path. When RelUndoReserve() allocates a new page it stashes the still-locked metapage in this per-backend static. It is only cleared by RelUndoStage/RelUndoFinish[WithTuple]/RelUndoCancel. If the caller hits an ereport(ERROR) between Reserve and Finish/Cancel (e.g. CheckForSerializableConflictIn, the diff-preview palloc, or RecnoXLogPrepareLogicalImage on the UPDATE path, whose PG_CATCH re-throws without calling RelUndoCancel), ResourceOwner cleanup releases the buffer pin/lock but leaves relundo_pending_metabuf pointing at a now-released (and possibly recycled) buffer. The next RelUndoReserve->RelUndoStage in this backend then hits Assert(BufferIsValid(relundo_pending_metabuf)) with a stale buffer and calls MarkBufferDirty()/WAL-registers a buffer this backend no longer holds -> corruption. Reset this static on transaction/subtransaction abort (e.g. via an AtEOXact/AtEOSubXact hook or ResourceOwner callback), since a caller error between the two phases is possible.


📄 src/backend/access/undo/relundo_apply.c

The hash shift hard-codes 6 = log2(RELUNDO_HEAD_CACHE_SIZE=64). The comment on the size macro explicitly invites raising it to 128/256, but this shift would not follow, silently masking to only 64 slots and breaking the power-of-two guarantee. Derive the shift from the size macro instead of hard-coding 6, e.g. (32 - pg_ceil_log2_32(RELUNDO_HEAD_CACHE_SIZE)) or a dedicated RELUNDO_HEAD_CACHE_SHIFT macro tied to the size.


📄 src/backend/access/undo/relundo_apply.c

This truncate-to-zero of a pre-existing UNDO fork is not WAL-logged; only smgrcreate (via log_smgrcreate) and the metapage init (XLOG_RELUNDO_INIT) are. relundo_redo_init only reinitializes the metapage (block 0) and never truncates the fork, so if this branch ever fires on a fork that already holds >0 blocks, a crash-recovered/standby instance can retain stale UNDO blocks past block 0 that the primary discarded, diverging on-disk state. In the current wiring RelUndoInitRelation is only reached via the set-new-filelocator path (a fresh relfilenumber -> zero-block fork), so the branch is effectively unreachable today; either remove the branch (and its idempotency comment) as dead defensive code, or WAL-log the truncation so redo reproduces it.


📄 src/backend/access/undo/relundo_page.c

Fragile hash: the shift >> (32 - 6) hard-codes 6 = log2(RELUNDO_HEAD_CACHE_SIZE=64). The header comment explicitly invites bumping the size to 128/256, but that would silently leave this shift at 6, masking to only 64 of the larger table's slots and breaking the power-of-two masking guarantee elsewhere. Derive the shift from the size macro so it can never desync.


📄 src/backend/access/undo/relundo_page.c

Magic-OID contract with no guard. RELUNDO_CACHE_TOMBSTONE is (Oid) 1 and the safety argument ('OID 1 never appears as a user relation OID passed here') lives only in a comment. If a relation with relid 1 ever reaches the cache, update() would treat it as a free/tombstone slot (entry silently lost) and invalidate() would corrupt the probe chain. Add a defensive Assert(relid != RELUNDO_CACHE_TOMBSTONE) in the lookup/update/invalidate entry points so the invariant is enforced rather than merely documented.


📄 src/backend/access/undo/relundo_page.c

The safety contract ('must never be called while holding a data page lock', metapage-after-datapage lock ordering to avoid a writer convoy/deadlock) is comment-only, with no enforcement. Also the throttle interval 5000000 is an unexplained magic number; extract it to a named constant (matching the SLog cadence you reference) so the 5-second intent is self-documenting and kept in sync.


📄 src/backend/commands/dbcommands.c

Control-flow bug: goto inline_undo_done jumps past MemoryContextSwitchTo(old_ctx) and MemoryContextDelete(undo_ctx). On this path the backend never switches back to old_ctx and never deletes undo_ctx (which is parented to TopMemoryContext, not a transaction context). Consequences: (1) the following ATMAddAborted(), and after the enclosing block ApplyPerRelUndo(), UndoRecordSetFree(), ResetXactUndo() all run with undo_ctx as CurrentMemoryContext; (2) undo_ctx leaks until process exit, accumulating on every abort where the batch LSN fails validation. Restore and delete the context before the goto (or restructure to avoid the goto).

💡 Suggested change

Before:

						undo_applied = false;
						goto inline_undo_done;

After:

						undo_applied = false;
						MemoryContextSwitchTo(old_ctx);
						MemoryContextDelete(undo_ctx);
						goto inline_undo_done;

📄 src/backend/commands/dbcommands.c

PrepareXactUndoDataParts omits the INJECTION_POINT_LOAD("undo-batch-before-wal-insert") / INJECTION_POINT_LOAD("undo-batch-after-wal-insert") preloads that PrepareXactUndoData performs. UndoRecordSetInsert() (called from InsertXactUndoData in the caller's critical section) only fires these via the palloc-free cached variant, so records prepared through the scatter-gather path will not trigger the injection points during insert. This is a test-observability gap and a copy-paste divergence between the two near-identical Prepare functions; add the same LOAD calls (and consider factoring the shared subxact-push / record-set-create logic into a helper to avoid future divergence).


📄 src/backend/commands/dbcommands.c

Latent subxact-stack corruption: this push does a single subxact_depth++ guarded only by nestingLevel > cur->nestingLevel. If the current transaction nesting level ever jumps by more than one relative to the tracked top (i.e. UNDO is first generated at a level deeper than tracked_top+1), the intermediate stack slots are left with stale nestingLevel/start_location/last_batch_lsn from a prior transaction, and CURRENT_SUBXACT() ends up pointing at a slot whose nestingLevel does not match. That can make AtSubAbort_XactUndo/AtSubCommit_XactUndo no-op (early return on level mismatch) and skip UNDO application. Consider looping to push all intermediate levels, or asserting the invariant. Same pattern is duplicated in PrepareXactUndoData.


📄 .github/workflows/sync-upstream.yml (L82-L85)

The check_commits step only writes non_github_changes and dev_setup_commits inside the DIVERGED > 0 / else branches. If git checkout master or the git rev-list calls behave unexpectedly (detached HEAD, missing origin/master/upstream/master ref), these outputs may be empty, and the subsequent numeric comparisons in the merge step ([ "$COMMITS_AHEAD" -gt 0 ], etc.) will error on empty strings. Consider defaulting the shell variables (e.g., ${VAR:-0}) in the merge step to make the arithmetic robust.

💡 Suggested change

Before:

          COMMITS_AHEAD=${{ steps.check_commits.outputs.commits_ahead }}
          COMMITS_BEHIND=${{ steps.check_commits.outputs.commits_behind }}
          NON_GITHUB_CHANGES=${{ steps.check_commits.outputs.non_github_changes }}
          DEV_SETUP_COMMITS=${{ steps.check_commits.outputs.dev_setup_commits }}

After:

          COMMITS_AHEAD=${{ steps.check_commits.outputs.commits_ahead }}
          COMMITS_BEHIND=${{ steps.check_commits.outputs.commits_behind }}
          NON_GITHUB_CHANGES=${{ steps.check_commits.outputs.non_github_changes }}
          DEV_SETUP_COMMITS=${{ steps.check_commits.outputs.dev_setup_commits }}
          COMMITS_AHEAD=${COMMITS_AHEAD:-0}
          COMMITS_BEHIND=${COMMITS_BEHIND:-0}
          NON_GITHUB_CHANGES=${NON_GITHUB_CHANGES:-0}
          DEV_SETUP_COMMITS=${DEV_SETUP_COMMITS:-0}

📄 .local-gitignore (L1-L3)

This entire file should not be committed. It is a developer-local, personal ignore file (as its own header admits: "Local development ignores (not tracked in .gitignore)") meant to be wired up via git config core.excludesFile. Such a file is per-developer configuration and has no place in the tree that ships to pgsql-hackers/commitfest. It is also unrelated to the stated purpose of this change (the recno/undo storage work). A patch that bundles this kind of personal tooling will draw an immediate rejection. Two options: (1) drop the file entirely, or (2) if these ignore patterns are genuinely useful for the project (e.g. build/, install/, regression.diffs), fold the relevant, project-wide entries into the tracked .gitignore instead of adding a separate opt-in file. Note the file even ignores itself (line 3, .local-gitignore), which only makes sense for an untracked local file, further confirming it should not be in the repo.


📄 .local-gitignore (L20-L20)

CLAUDE.md is an AI-assistant scratch/instruction file specific to your local workflow. Ignoring it via a committed file leaks personal tooling into the project and is unrelated to this change. This is another reason this file does not belong in the patch.


📄 examples/01-basic-undo-setup.sql (L30-L31)

STEP 5 issues COMMIT; but no transaction was ever opened with BEGIN;. Under psql's default autocommit mode, STEPs 2-4 each run and commit as separate transactions, so this bare COMMIT; emits WARNING: there is no transaction in progress and commits nothing. This contradicts the comment and the example's stated purpose of demonstrating a transaction whose changes are protected by UNDO. Wrap the DML in an explicit transaction block (add BEGIN; before STEP 2). Confidence: high.

💡 Suggested change

Before:

-- STEP 5: Commit the transaction
+COMMIT;

After:

-- STEP 5: Commit the transaction
COMMIT;

📄 examples/01-basic-undo-setup.sql (L34-L34)

These SELECTs call pg_stat_get_undo_logs() and pg_stat_get_undo_buffers(), but neither function is registered in the catalog: the C implementations in undostats.c exist (PG_FUNCTION_INFO_V1) yet there is no matching entry in src/include/catalog/pg_proc.dat and no extension SQL creating them. As written, both statements will fail with ERROR: function pg_stat_get_undo_logs() does not exist, so this example cannot run to completion. Register the functions in pg_proc.dat (with OIDs in the 8000-9999 range) before shipping examples that depend on them. Confidence: high.


📄 contrib/pageinspect/pageinspect--1.13--1.14.sql (L9-L9)

These three new SQL-callable functions are user-visible but undocumented. doc/src/sgml/pageinspect.sgml exists in the tree but is not part of this change, so recno_page_items(), recno_page_stats(), and recno_tuple_infomask_flags() are not described anywhere. By PostgreSQL contribution standards, a user-visible feature without documentation is WIP, not commit-ready. Add entries to pageinspect.sgml describing each function, its arguments, and output columns. (high confidence)


📄 contrib/pageinspect/pageinspect--1.13--1.14.sql (L53-L54)

No regression tests accompany these functions. There is no contrib/pageinspect/sql/recno.sql + expected/ pair in the change, so the SQL-to-C column layout (10/13/2 columns, order, and types) is entirely untested and will silently drift on any future edit. pageinspect verifies each inspection function against real pages in its regression suite; add equivalent coverage (including the corrupt/short-page and empty-infomask edge paths the C code explicitly handles). (high confidence)


📄 contrib/pageinspect/pageinspect--1.13--1.14.sql (L17-L17)

t_commit_ts is a uint64 in C (RecnoTupleHeader.t_commit_ts, recno.h:117) that packs xmax in the low 32 bits and additional data in the high 32 bits, so its high bit can be set. Exposing it through bigint via Int64GetDatum will render such values as negative, which is a POLA violation for an inspection function. Note that recno_page_stats.pd_commit_ts does not have this issue because it is masked to <=61 bits. Consider documenting the raw/packed nature of this column, or expose it in a form that does not misrepresent large unsigned values. (moderate confidence)


📄 contrib/pageinspect/recnofuncs.c (L232-L235)

This opaque-space guard diverges from every other pageinspect function (brinfuncs.c, btreefuncs.c, ginfuncs.c, gistfuncs.c, hashfuncs.c), which all do a strict PageGetSpecialSize(page) != MAXALIGN(sizeof(XxxPageOpaqueData)) check and ereport(ERROR). Two problems here:

  1. The loose >= check accepts any special region of at least 8 bytes, so a corrupt/attacker-supplied raw page whose pd_special is not MAXALIGN'd still passes. RecnoPageGetOpaque expands to PageGetSpecialPointer(page) (= page + pd_special), and the struct's sole field pd_commit_ts_and_flags is a uint64. Dereferencing it via RecnoPageGetCommitTs/RecnoPageGetFlags is then an unaligned 8-byte read, which is a portability hard-gate violation on strict-alignment architectures (SPARC, older ARM, etc.). This file's stated goal is to never crash on a corrupt page.

  2. Silently returning NULLs on a mis-sized special space hides corruption, unlike the sibling functions that error out with a clear message.

Match the established idiom: require PageGetSpecialSize(page) == MAXALIGN(sizeof(RecnoPageOpaqueData)) and ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), errmsg("input page is not a valid RECNO page"), errdetail(...))). Confidence: high.

💡 Suggested change

Before:

	if (phdr->pd_special <= BLCKSZ &&
		(BLCKSZ - phdr->pd_special) >= sizeof(RecnoPageOpaqueData))
	{
		opaque = RecnoPageGetOpaque(page);

After:

	if (PageGetSpecialSize(page) == MAXALIGN(sizeof(RecnoPageOpaqueData)))
	{
		opaque = RecnoPageGetOpaque(page);

📄 contrib/pageinspect/recnofuncs.c (L319-L321)

The same array pointer a is stored into both raw_flags (values[0]) and combined_flags (values[1]), so the two output columns are always byte-for-byte identical. This diverges from the heap_tuple_infomask_flags contract, which deliberately builds two distinct arrays (raw single-bit names vs. combined multi-bit masks). Exposing two columns that can never differ is a misleading API. Either drop combined_flags from the SQL signature (returning only the decoded single-bit flags), or populate it with the RECNO combined masks if any exist. As written the combined_flags column carries no information. Confidence: high.


📄 contrib/pageinspect/recnofuncs.c (L144-L144)

The hdr_size <= lp_len sub-condition is dead. The enclosing branch already required lp_len >= MAXALIGN(sizeof(RecnoTupleHeader)), and hdr_size is exactly MAXALIGN(sizeof(RecnoTupleHeader)), so hdr_size <= lp_len is unconditionally true here; tuple_data_len > 0 alone is sufficient. Drop the redundant term. Confidence: high.

💡 Suggested change

Before:

				if (tuple_data_len > 0 && hdr_size <= lp_len)

After:

				if (tuple_data_len > 0)

📄 examples/02-undo-rollback.sql (L36-L37)

The rollback narration is misleading. "item 3 re-inserted" then "all 3 original inserts deleted" describes item 3 as if it were re-added and then removed, which is confusing. What actually happens during UNDO (in LIFO order) is: the DELETE is undone (item 3's visibility is restored), the two UPDATEs are undone (values restored), and finally all three INSERTs are undone (rows removed). Item 3 is never "re-inserted" in any user-observable sense. Since this is a teaching example, consider rewording to describe the undo of each operation rather than the net physical effect, so readers aren't misled about the mechanism. [low confidence]

💡 Suggested change

Before:

-- - item 3 re-inserted
+-- - item 2 price restored to 49.99

After:

-- - DELETE of item 3 undone (row visible again)
-- - UPDATE of item 2 undone (price restored to 49.99)
-- - UPDATE of item 1 undone (quantity restored to 5)
-- - all 3 original INSERTs undone (rows removed)

📄 examples/05-undo-monitoring.sql (L18-L18)

table is a fully reserved SQL keyword and cannot be used as a bare column alias — PostgreSQL raises a syntax error at parse time (e.g. ERROR: syntax error at or near "table"). Since this is a copy-paste example, the query fails as written. Quote the alias (AS "table") or rename it (e.g. AS table_name). Note AS schema on the line above is accepted because schema is not fully reserved.

💡 Suggested change

Before:

    c.relname AS table,

After:

    c.relname AS "table",

📄 examples/04-transactional-fileops.sql (L30-L30)

This comment is incorrect and contradicts the FILEOPS implementation. FileOpsDelete() in src/backend/storage/file/fileops.c explicitly states "no WAL is emitted at registration time" (lines 384-392); the DROP only registers a PENDING_FILEOP_DELETE. The XLOG_FILEOPS_DELETE record is emitted later in FileOpsDoPendingOps() at commit-execution time (only when isCommit && XLogIsNeeded()), NOT here after DROP TABLE and before COMMIT. As written, the example teaches the wrong mental model of the deferred-delete design. Fix the wording to say the deletion has been scheduled (a pending op registered), and that the XLOG_FILEOPS_DELETE record is written at COMMIT. Confidence: high.

💡 Suggested change

Before:

-- A XLOG_FILEOPS_DELETE WAL record has been written.

After:

-- The deletion has been scheduled (a pending delete op is registered).
-- The XLOG_FILEOPS_DELETE WAL record is written at COMMIT, not now.

📄 src/backend/access/Makefile (L28-L30)

This ifdef USE_RECNO guard is a misleading dead conditional and diverges from meson. Per this patch's own meson.build (lines 1598-1605) and src/Makefile.global.in (USE_RECNO = 1), USE_RECNO is always defined and cannot be disabled: pg_proc.dat/guc_parameters.dat reference recno symbols unconditionally, so a build with USE_RECNO unset fails to link. Meanwhile meson builds recno unconditionally (subdir('recno') in src/backend/access/meson.build, no guard). The guard therefore (a) never actually excludes recno, and (b) is a footgun: if someone did unset USE_RECNO, this recursive make would silently skip building the recno directory while the backend still links against recno symbols, producing a confusing link error rather than a clean "disabled" build. Either drop the conditional and add recno to SUBDIRS unconditionally to match meson, or make both build systems honor the same (real) toggle. Confidence: high.

💡 Suggested change

Before:

ifdef USE_RECNO
SUBDIRS += recno
endif

After:

	recno \
	transam \
	undo

📄 src/backend/access/common/reloptions.c (L2183-L2188)

This hunk is a no-op refactor with no functional change: default_reloptions() already returns a bytea *, and this just casts it to StdRdOptions *, assigns it to rdopts, then casts it back to bytea * to return it. The original one-line return default_reloptions(...) is equivalent and clearer. Non-minimal diffs like this (extra block scope, redundant intermediate assignment on untouched code) are a common rejection reason on -hackers: they add nothing, churn git blame, and waste reviewer time. Revert to the original line unless a later change in the series actually needs rdopts here (nothing in this diff does). Confidence: high.

💡 Suggested change

Before:

			{
				rdopts = (StdRdOptions *)
					default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

				return (bytea *) rdopts;
			}

After:

			return default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

📄 src/backend/access/hash/hashinsert.c (L248-L249)

NULL-pointer dereference: heapRel can be NULL here (e.g. during index builds and recovery), and both RelationAmSupportsUndo() (dereferences heapRel->rd_tableam) and UndoBufferIsActive() (calls RelationGetRelid(heapRel)) dereference it unconditionally. The sibling nbtree change in this same patch (nbtinsert.c:1255, 1454) guards with heaprel != NULL && RelationAmSupportsUndo(heaprel) and documents "heaprel is NULL during index builds and recovery." This branch must add the same NULL guard to avoid a crash. [high confidence]

💡 Suggested change

Before:

	if (RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

After:

	if (heapRel != NULL && RelationAmSupportsUndo(heapRel) &&
		UndoBufferIsActive(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

📄 src/backend/access/common/index_prune.c (L14-L19)

This entire module is dead scaffolding. None of the public APIs (IndexPruneRegisterHandler, IndexPruneRegisterTargetedHandler, IndexPruneNotifyDiscard, IndexPruneNotifyTargeted, IndexPruneGetStats, IndexPruneResetStats) has any caller anywhere in the tree, the callback registries are therefore never populated, and the AM-specific implementation functions declared in index_prune.h (_bt_prune_by_undo_counter, _bt_prune_by_targets, hash_prune_by_undo_counter, etc.) are never defined. The actual nbtree/hash UNDO apply path uses a completely separate mechanism (UndoRmgrData / nbtree_undo_apply / hash_undo_apply), not this registry. Per YAGNI, an unwired module with empty registries and never-invoked dispatchers should not be part of the patch; remove it (and the header/Makefile/meson entries) until there is a real caller, or wire it in fully with tests. (high confidence)


📄 src/backend/access/common/index_prune.c (L162-L165)

Casting a uint64 to (unsigned long) and printing with %lu truncates on platforms where long is 32-bit (Windows/MSVC LLP64, and all 32-bit platforms). Use UINT64_FORMAT, which is the established tree convention for printing uint64. Same defect applies to the other (unsigned long) casts on total_entries_pruned below and in IndexPruneNotifyTargeted. (high confidence)

💡 Suggested change

Before:

				elog(DEBUG2, "index %s: marked %lu entries as dead for counter %u",
					 RelationGetRelationName(indexrel),
					 (unsigned long) entries_pruned,
					 discard_counter);

After:

				elog(DEBUG2, "index %s: marked " UINT64_FORMAT " entries as dead for counter %u",
					 RelationGetRelationName(indexrel),
					 entries_pruned,
					 discard_counter);

📄 src/backend/access/common/index_prune.c (L195-L198)

uint64 truncation: same portability issue with (unsigned long) + %lu. Use UINT64_FORMAT. (high confidence)

💡 Suggested change

Before:

		elog(DEBUG1, "UNDO discard: pruned %lu index entries across %d indexes (counter %u)",
			 (unsigned long) total_entries_pruned,
			 num_indexes_pruned,
			 discard_counter);

After:

		elog(DEBUG1, "UNDO discard: pruned " UINT64_FORMAT " index entries across %d indexes (counter %u)",
			 total_entries_pruned,
			 num_indexes_pruned,
			 discard_counter);

📄 src/backend/access/common/index_prune.c (L146-L150)

Resource-leak / lock-leak on error: this loop opens each index with index_open() and invokes an arbitrary AM callback, but there is no PG_TRY/PG_FINALLY. If a callback ereport(ERROR)s, the just-opened index lock/refcount is not released here and the indexoidlist List is leaked. The callback contract in the header ("mark dead entries", "use buffer locking") clearly allows callbacks that can error mid-operation. Either document that the caller must run inside a resource-owner/subtransaction that guarantees cleanup, or wrap the open/callback/close in PG_TRY/PG_FINALLY. (moderate confidence)


📄 src/backend/access/common/index_prune.c (L72-L74)

return; after elog(ERROR, ...) is unreachable dead code: elog(ERROR) does not return. Drop the trailing return to match tree convention. Same issue in IndexPruneRegisterTargetedHandler below. (high confidence)

💡 Suggested change

Before:

		elog(ERROR, "too many index pruning handlers registered");
		return;
	}

After:

		elog(ERROR, "too many index pruning handlers registered");
	}

📄 src/backend/access/common/index_prune.c (L238-L240)

Unreachable return; after elog(ERROR). Remove it. (high confidence)

💡 Suggested change

Before:

		elog(ERROR, "too many targeted index pruning handlers registered");
		return;
	}

After:

		elog(ERROR, "too many targeted index pruning handlers registered");
	}

📄 src/backend/access/common/index_prune.c (L295-L300)

Comment-vs-code drift and an unstated precondition. The comment says "sort targets by index_oid, then process each group", but the code never sorts; it only groups consecutive equal index_oid values. If the caller passes unsorted targets, the same index is opened/closed repeatedly and callbacks receive partial groups (extra lock churn, degraded grouping). Either sort here, or state explicitly that callers must pass targets pre-sorted by index_oid and fix the misleading comment. (moderate confidence)


📄 src/backend/access/hash/hash_undo.c (L202-L209)

Silent index-corruption hazard: this marks LP_DEAD by the raw stored (blkno, offset) with no identity check that the entry is still the one this transaction inserted. Hash pages relocate tuples between insert and abort -- _hash_splitbucket() moves tuples across buckets, _hash_squeezebucket() and the kill-dead path (_hash_vacuum_one_page -> PageIndexMultiDelete) compact pages and shift offsets. A committed, unrelated tuple can end up at (blkno, offset), and marking it LP_DEAD silently drops committed rows / returns wrong query results.

The sibling nbtree_undo.c INSERT_LEAF path deliberately avoids exactly this: it stores the full IndexTuple, extracts the heap TID (BTreeTupleGetHeapTID), and searches for the matching entry before killing it ("concurrent inserts and leaf splits can shift a committed entry onto that offset, and marking it LP_DEAD would silently drop committed rows"). Hash undo must apply the same heap-TID identity check rather than trusting the physical offset.


📄 src/backend/access/hash/hash_undo.c (L208-L214)

WAL-replay divergence: MarkBufferDirty() and the CLR (clr_flags = UNDO_CLR_LP_DEAD) are emitted unconditionally whenever hdr.offset <= PageGetMaxOffsetNumber(page), but ItemIdMarkDead() only runs when ItemIdIsNormal(lp). When the item is not normal (already dead/unused/redirected), the primary makes no change yet still writes a CLR that names target_offset with UNDO_CLR_LP_DEAD. The redo handler in undo_xlog.c (XLOG_UNDO_APPLY_RECORD, BLK_NEEDS_REDO) will PageGetItemId(target_offset) and, if by then it IS normal on the standby, ItemIdMarkDead it -- so the standby marks an item dead the primary never touched. Only emit the CLR (and MarkBufferDirty) when ItemIdMarkDead actually ran; keep the WAL record consistent with the applied change, as nbtree_undo.c does.


📄 src/backend/access/hash/hash_undo.c (L272-L278)

hash_undo_desc decodes only index_oid, but blkno and offset are present in every HASH_UNDO_INSERT payload (SizeOfHashUndoInsert). Decode them too for usable pg_waldump output, matching the information the record actually carries.


📄 src/backend/access/heap/heapam.c (L60-L62)

Whitespace-only churn on an untouched line, unrelated to the patch's purpose. Removing this pre-existing blank line touches code the change does not need. Keep the original blank line to avoid a noisy diff.


📄 src/backend/access/heap/heapam.c (L115-L117)

Whitespace-only churn on an untouched line. Removing this pre-existing blank line is unrelated to the change and adds noise to the diff. Leave it as-is.


📄 src/backend/access/heap/heapam.c (L2140-L2141)

Cosmetic-only insertion of a blank line between XLogBeginInsert() and XLogRegisterData(). This adds no value and is unrelated to any functional change; it is pure diff noise. Remove it.

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

📄 src/backend/access/heap/heapam.c (L3069-L3070)

Cosmetic-only blank line inserted between XLogBeginInsert() and XLogRegisterData(). Unrelated diff noise; remove it.

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

📄 src/backend/access/heap/heapam_handler.c (L149-L159)

These comments describe behavior the functions do not implement. heapam_begin_bulk_insert and heapam_finish_bulk_insert are empty no-op stubs, yet the comments claim they activate/flush an UNDO write buffer. The heap AM explicitly sets am_supports_undo = false and never uses UNDO, so this behavior never happens here. Comments must describe what the code does now, not aspirational behavior belonging to another AM (RECNO). Either remove these stubs entirely or replace the comments with an accurate note that heap ignores these hints.


📄 src/backend/access/heap/heapam_handler.c (L166-L169)

The empty stub bodies are dead code. The table_begin_bulk_insert/table_finish_bulk_insert wrappers already NULL-check the callback pointers, so registering empty no-op functions for heap adds nothing behaviorally. Upstream heap does not register finish_bulk_insert at all (see tableam.h: "In-tree access methods ceased to use this."). Registering these empty callbacks in heapam_methods is speculative scaffolding for the RECNO/UNDO feature and should not touch the heap AM. Prefer leaving heap's method table unchanged.


📄 src/backend/access/heap/heapam_handler.c (L157-L159)

The nrows and options parameters are unused in these empty bodies. On compilers with -Wunused-parameter this is harmless for PostgreSQL's default flags, but combined with the empty bodies it confirms these are pure scaffolding. If the stubs are kept, this reinforces that they carry no logic; removing them is preferable.


📄 src/backend/access/hash/hash_undo.c (L180-L187)

After try_relation_open(hdr.index_oid), there is no check that the reopened relation is actually a hash index (rd_rel->relkind == RELKIND_INDEX && rd_rel->relam == HASH_AM_OID). If the OID was dropped and reused for a different relation between insert and abort, the subsequent ReadBuffer + PageGetItemId + ItemIdMarkDead would reinterpret and corrupt an unrelated relation's page. Validate relkind/relam and return UNDO_APPLY_SKIPPED on mismatch.


📄 src/backend/access/heap/pruneheap.c (L21-L21)

These four added includes appear to be unused in this file, and none of pruneheap.c's actual code is otherwise changed in this diff:

  • access/parallel.h: no symbol from it is referenced anywhere in the file (the only parallel match is this include line itself).
  • access/visibilitymapdefs.h: the VISIBILITYMAP_* macros are used, but they are already provided transitively by access/visibilitymap.h (which #includes visibilitymapdefs.h) that was already present before this patch. This is redundant.
  • access/xact.h: no transaction/snapshot symbol from it is referenced in the file.

Unless a companion change in this patch series introduces uses of these headers in pruneheap.c, this is churn that violates the minimal-diff rule. Please drop the includes that aren't needed by the code as it stands (moderate confidence, verified via search of the full file). (access/parallel.h, access/xact.h)


📄 src/backend/access/heap/vacuumlazy.c (L156-L156)

Include ordering is broken. Within the utils/ group the includes are alphabetical (injection_point, lsyscache, pg_rusage, ..., timestamp, wait_event); utils/rel.h was inserted between injection_point.h and lsyscache.h, which violates that order. It belongs between pg_rusage.h and timestamp.h.

More importantly, this is a lone include added to a file with no other functional change in this diff. vacuumlazy.c already compiled without an explicit utils/rel.h (the Relation* macros were pulled in transitively). If nothing in this patch requires it, drop the hunk to keep the diff minimal; if it is a deliberate cleanup, it does not belong in this feature patch. (moderate confidence)


📄 src/backend/access/index/indexam.c (L306-L309)

Forcing xs_want_itup = true here affects every caller of index_beginscan against a RECNO table, not just the executor index-scan / index-only-scan paths that actually consult xs_inplace_recheck. Internal callers -- FK checks (ri_triggers.c), replica-identity/unique lookups (execReplication.c, execIndexing.c), CLUSTER/repack, and get_actual_variable_range (selfuncs.c) -- never read xs_inplace_recheck, so they gain nothing from xs_itup being populated, yet they still pay the cost: as the comment below states, xs_want_itup makes nbtree's _bt_first clear so->dropPin, retaining the leaf pin and making VACUUM block on cleanup locks for all RECNO index scans (moderate confidence).

Consider forcing the flag only in the executor nodes that perform the recheck (nodeIndexscan.c / nodeIndexonlyscan.c already set xs_want_itup for index-only scans), rather than unconditionally in the generic entry point. That keeps the pin-retention regression scoped to the paths that need it and avoids surprising the internal callers.


📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

In the split path this branch is also reached for internal (non-leaf) page insertions via _bt_insert_parent() -> _bt_insertonpg(), where isleaf == false. In that case NbtreeUndoLogInsert() emits an NBTREE_UNDO_INSERT_UPPER record, but nbtree_undo_apply()'s NBTREE_UNDO_INSERT_UPPER case is an intentional no-op (returns UNDO_APPLY_SKIPPED). So every internal split on an undo-supporting table now writes undo volume that can never be applied. Consider gating this call on isleaf (only leaf entries are reversible via heap-TID re-descent) to avoid the wasted undo I/O. Confidence: high.

💡 Suggested change

Before:

		if (heaprel != NULL && RelationAmSupportsUndo(heaprel))
			NbtreeUndoLogInsert(rel, heaprel, buf,
								postingoff == 0 ? itup : origitup,
								itemsz, newitemoff, isleaf);

After:

		if (isleaf && heaprel != NULL && RelationAmSupportsUndo(heaprel))
			NbtreeUndoLogInsert(rel, heaprel, buf,
								postingoff == 0 ? itup : origitup,
								itemsz, newitemoff, isleaf);

📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

This split-path undo write happens before _bt_insert_parent() completes the split. If NbtreeUndoLogInsert() throws (it allocates and may do buffer I/O / UndoLogAllocate), the transaction aborts with the page split already WAL-logged but the parent downlink not yet inserted, i.e. an incomplete split. nbtree recovers incomplete splits lazily, so this is not data loss, but it is a new, easily-overlooked failure window that the no-split branch does not have. Worth a note in the comment (and consider whether the leaf undo could instead be written by the recursion level that actually placed the tuple). Confidence: moderate.


📄 src/backend/access/nbtree/nbtinsert.c (L1454-L1459)

These two added blocks are identical NbtreeUndoLogInsert() calls but differ only in brace style (the split-path one omits braces, this one uses them). Pick one style for consistency; per PostgreSQL convention a single-statement body typically omits braces. Minor. Confidence: high.


📄 src/backend/access/recno/Makefile (L35-L35)

Missing newline at the end of the file. Every other PostgreSQL backend Makefile (e.g. src/backend/access/hash/Makefile) ends with a newline after the final include line. git diff --check and the tree's file conventions treat a missing trailing newline as a defect. Add a trailing newline.

💡 Suggested change

Before:

include $(top_srcdir)/src/backend/common.mk

After:

include $(top_srcdir)/src/backend/common.mk


📄 src/backend/access/nbtree/nbtree_undo.c (L635-L638)

Buffer overflow / out-of-bounds read: this DEDUP path validates only payload_len < SizeOfNbtreeUndoDedup above, but never checks that the trailing page image actually fits. hdr.page_len is an attacker-/corruption-controlled uint16 read from the payload. This memcpy into the shared buffer page can (a) read past the end of payload when payload_len < SizeOfNbtreeUndoDedup + hdr.page_len, and (b) write past BLCKSZ into the buffer pool when hdr.page_len > BLCKSZ, corrupting shared memory inside a critical section. Validate both bounds before the copy (and treat a mismatch as UNDO_APPLY_ERROR). Contrast the INSERT_LEAF path, which does validate payload_len >= SizeOfNbtreeUndoInsertLeaf + hdr.itup_sz.

💡 Suggested change

Before:

				/* Restore the full pre-dedup page image */
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

After:

				/* Restore the full pre-dedup page image */
				if (hdr.page_len > BufferGetPageSize(buffer) ||
					payload_len < SizeOfNbtreeUndoDedup + hdr.page_len)
				{
					END_CRIT_SECTION();
					UnlockReleaseBuffer(buffer);
					relation_close(indexrel, RowExclusiveLock);
					return UNDO_APPLY_ERROR;
				}
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

📄 src/backend/access/nbtree/nbtree_undo.c (L88-L88)

Macro-hygiene footgun: unlike every other SizeOf* macro in the tree (e.g. SizeOfHeapInsert, SizeOfBtreeInsert all wrap the definition in parentheses), these expand without wrapping parens. Current uses like payload_len < SizeOfNbtreeUndoInsertLeaf + hdr.itup_sz happen to be correct only due to operator precedence, but any future use such as x - SizeOfNbtreeUndoInsertLeaf or SizeOfNbtreeUndoDedup * 2 would silently miscompute. Wrap in parentheses to match the tree convention.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertLeaf	offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertLeaf	(offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L103-L103)

Same missing-parentheses issue as the other SizeOf* macros; wrap in parentheses for macro hygiene and consistency with the tree convention.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertUpper	offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertUpper	(offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L116-L116)

Same missing-parentheses issue; wrap in parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDedup	offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDedup	(offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L129-L129)

Same missing-parentheses issue; wrap in parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDelete	offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDelete	(offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L65-L69)

Speculative scaffolding (YAGNI): the NbtreeUndoDelete struct and its SizeOf macro, plus the subtype constants NBTREE_UNDO_INSERT_POST, NBTREE_UNDO_DELETE, NBTREE_UNDO_SPLIT_L/R, and NBTREE_UNDO_NEWROOT, are defined but never emitted by any WAL-logging hook in this file (only INSERT_LEAF, INSERT_UPPER, and DEDUP are ever written). Their undo-apply branches are hardcoded UNDO_APPLY_SKIPPED no-ops. Since these values are persisted in undo records, shipping unused-but-reserved subtypes is a maintenance hazard. Consider removing the unwired constants/struct until a producer exists.


📄 src/backend/access/nbtree/nbtree_undo.c (L692-L694)

Comment discipline: 'For now, skip' is a forward-looking placeholder for behavior that is not implemented. Since this subtype is never emitted (see the unused-constant note), either drop the case entirely or state the current behavior without the aspirational 'For now'.


📄 src/backend/access/recno/README (L500-L500)

This line number is wrong and violates PostgreSQL's documentation convention of not citing source line numbers. The assertion Assert(entry->data.lockmode == BUFFER_LOCK_UNLOCK) actually lives at bufmgr.c:6068 (line 5892 contains no BUFFER_LOCK_UNLOCK reference at all). Since bufmgr.c is itself modified in this same change, the pinned number is guaranteed to drift. Cite the function/mechanism by name instead of a line number.

💡 Suggested change

Before:

in bufmgr.c:5892 enforces this.

After:

in bufmgr.c (LockBuffer / CheckBufferIsPinnedOnce) enforces this.

📄 src/backend/access/recno/README (L487-L488)

This citation is inaccurate on two counts and violates the convention of not hard-coding source line numbers. heap_toast_insert_or_update() is not defined in heapam.c (it is only called there, at lines 2241 and 3877); the function lives in src/backend/access/heap/heaptoast.c. The line range 3993-4050 does not correspond to the referenced code. Because heapam.c is modified in this same change, the pinned line range will drift immediately. Reference the file/function by name only.

💡 Suggested change

Before:

See src/backend/access/heap/heapam.c:3993-4050 for the analogous pattern
+in heap's TOAST handling (heap_toast_insert_or_update).

After:

See heap_toast_insert_or_update() in src/backend/access/heap/heaptoast.c
+for the analogous buffer-pinning pattern in heap's TOAST handling.

📄 src/backend/access/recno/recno_compress.c (L1018-L1023)

Out-of-bounds read/write on the decompress path. bytes_stored is taken directly from the on-disk byte input[1] (range 0..255) with no validation, then memcpy(&abs_val, input + 2, bytes_stored) copies up to 255 bytes into an 8-byte uint64 stack variable — a stack buffer overflow, plus an over-read of input.

This is reachable from corrupt/crafted on-disk data: RecnoDecompressAttribute dispatches on header->comp_type read from disk, and the guard in recno_tuple.c only checks comp_type <= RECNO_COMP_DICTIONARY, so a stored RECNO_COMP_DELTA tuple reaches this code with attacker/corruption-controlled input[1]. Validate bytes_stored is in 1..8 (and that comp_size covers 2 + bytes_stored) and raise ERRCODE_DATA_CORRUPTED otherwise. The 0x81-0x88 positive path is bounded by construction, but tag 0x89 below (stored_size = input[1], memcpy into a buffer sized orig_size) has the same unchecked-length hazard against orig_size.

💡 Suggested change

Before:

	if (tag == 0x80)
	{
		int			bytes_stored = input[1];
		uint64		abs_val = 0;

		memcpy(&abs_val, input + 2, bytes_stored);

After:

	if (tag == 0x80)
	{
		int			bytes_stored = input[1];
		uint64		abs_val = 0;

		if (bytes_stored < 1 || bytes_stored > 8)
			ereport(ERROR,
					(errcode(ERRCODE_DATA_CORRUPTED),
					 errmsg("invalid delta compression length: %d", bytes_stored)));

		memcpy(&abs_val, input + 2, bytes_stored);

📄 src/backend/access/recno/recno_compress.c (L1354-L1357)

Relcache-reference leak on the error path and unsafe NoLock open. recno_dict_read() can ereport(ERROR) (unknown dictid, blob overrun, truncation). Because the relation_open(relid, NoLock) above is not paired with a PG_TRY/PG_FINALLY, an error thrown from recno_dict_read leaks the relcache reference opened here. Wrap the read in PG_TRY/PG_FINALLY (or close before it can throw).

Separately, opening with NoLock is only safe if the caller already holds a lock on the relation. On the decompress path (RecnoDecompressAttribute) there is no such guarantee, so the relation could be dropped/altered concurrently. Take at least AccessShareLock, matching the lock the callers already hold on scans.


📄 src/backend/access/recno/recno_compress.c (L377-L377)

Silent truncation of a uint32 dictid into a uint16 on-disk field. dictid comes from recno_dict_get_active()/recno_dict_append() which return uint32 (RecnoDictMeta.next_dictid is a monotonic uint32), while header->dict_id is uint16. Today RECNO_DICT_MAX_DIRECTORY (256) bounds ids so this is latent, but the cast is a footgun: if the directory ever grows or ids become sparse/>65535, the stored id will silently mismatch and route decompression to the wrong (or a nonexistent) dictionary. Either widen the header field to uint32 to match the id space, or assert dictid <= UINT16_MAX here.


📄 src/backend/access/recno/recno_compress.c (L855-L857)

On-disk endianness dependence. The compressed datum (RecnoCompressionHeader + payload) is persisted on-disk and read back on scan, so its byte layout must be portable. This delta encoding writes native-endian integer bytes via memcpy(out_ptr + 2, &abs_val, bytes_needed) (and the 0x89 marker path), and RecnoDecompressDelta reads them back the same way. Data written on a little-endian host cannot be decoded on a big-endian host, breaking cross-platform physical replication / dump-file portability of RECNO relations. The dictionary codec's *((int *) output) = dict_id has the same defect. This DELTA/DICTIONARY path is not reachable from the current sole caller (varlena-only), but since decompression dispatches on the stored comp_type, encode fixed-width integers with pg_hton*/store fixed little-endian bytes before this can be enabled.


📄 src/backend/access/recno/recno_compress.c (L1767-L1768)

Raw ZSTD library handles leak on ereport. ZSTD_createCCtx/ZSTD_createCDict allocations are not tracked by MemoryContext/ResourceOwner. Here cdict (and cctx) are only freed at the normal end of the function; if any ereport(ERROR) fires in between (e.g. an OOM from a palloc in a nested call), these C-library allocations leak for the session. The loop body itself is error-safe, but consider guarding the cctx/cdict lifetime with PG_TRY/PG_FINALLY, consistent with how the backend wraps external compressor handles elsewhere.


📄 src/backend/access/recno/recno_compress.c (L1631-L1635)

Stale/aspirational comment: this function takes void and unconditionally resets every cached dictionary, but the header comment claims it can reset "a specific relation ... if relid is InvalidOid" — there is no relid parameter. Update the comment to describe what the code does now.

💡 Suggested change

Before:

 * Reset compression dictionary for a specific relation, or all dictionaries
 * if relid is InvalidOid.
 */
void
RecnoResetCompressionDict(void)

After:

/*
 * Reset (free) all cached compression dictionaries for this backend.
 */
void
RecnoResetCompressionDict(void)

📄 src/backend/access/recno/recno_compress.c (L1928-L1929)

Missing privilege check on a SQL-callable function that reads arbitrary relation contents. build_zstd_dict_for_attribute opens any regclass and scans all its rows to train a dictionary, relying only on relation_open. A user who can call this function could sample data from tables they cannot otherwise read. Add a table-level ACL check (e.g. pg_class_aclcheck for ACL_SELECT) after opening the relation, and error out with ERRCODE_INSUFFICIENT_PRIVILEGE on failure.


📄 src/backend/access/recno/recno_diff.c (L218-L220)

Out-of-bounds read on untrusted input: the segment header and its ins_len old bytes are read (ptr advanced, old_bytes dereferenced by the memcpy at line 246) before any check that this data lies within the diff record. diff->ndiffs and each seg->ins_len come from on-disk UNDO/version storage; callers (recno_undo.c only checks image_len >= sizeof(RecnoDiffRecord), recno_pvs.c only checks header + one sizeof(RecnoDiffRecord)) never validate diff->total_size nor pass the buffer length here. A corrupted/short record makes ptr walk past the allocation, causing OOB reads and a potential crash/memory disclosure on the redo/UNDO-apply path. Pass the input buffer length into this function and validate that SizeOfRecnoDiffSegment + seg->ins_len stays within it (and cross-check diff->total_size) before each read.


📄 src/backend/access/recno/recno_diff.c (L227-L230)

On a corrupted diff during WAL redo / UNDO apply this reports at DEBUG1 and returns false; the caller in recno_undo.c then emits a WARNING and leaves the tuple in place, silently skipping before-image restoration. Skipping restoration on corruption can diverge a standby / hide data corruption. PostgreSQL convention is to treat corruption detected during recovery as a hard error via ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), ...)). DEBUG1 is far too quiet for a data-integrity fault.


📄 src/backend/access/recno/recno_diff.c (L162-L164)

total_size is truncated to uint16 without a guard that result_size <= PG_UINT16_MAX. It happens to be safe today only because the final threshold check bounds result_size below old_len/2 (<= ~32767 given old_len <= PG_UINT16_MAX), but that is an implicit, unasserted dependency between two unrelated constants. If RECNO_DIFF_THRESHOLD_PCT ever changes, this silently wraps and produces a diff record whose total_size under-states its real size, which the apply path then trusts. Add an explicit if (result_size > PG_UINT16_MAX) return NULL; before the cast.


📄 src/backend/access/recno/recno_dict.c (L392-L400)

Out-of-bounds read on corrupted on-disk data. ph->bytes_on_page is trusted here and validated only against the logical entry.length, never against the physical per-page capacity RecnoDictPagePayload. The write path guarantees bytes_on_page = Min(remaining, RecnoDictPagePayload), so a value exceeding RecnoDictPagePayload can only come from corruption. If such a corrupted bytes_on_page still keeps copied + bytes_on_page <= entry.length (e.g. a corrupted-but-consistent single-page blob), the memcpy below reads past the page buffer end. Since this function otherwise carefully hardens against corruption (it raises ERRCODE_DATA_CORRUPTED), it should also reject ph->bytes_on_page > RecnoDictPagePayload before the memcpy.

💡 Suggested change

Before:

		if (copied + ph->bytes_on_page > entry.length)
		{
			UnlockReleaseBuffer(buf);
			pfree(result);
			ereport(ERROR,
					(errcode(ERRCODE_DATA_CORRUPTED),
					 errmsg("RECNO dictionary id %u blob overruns recorded length",
							dictid)));
		}

After:

		if (ph->bytes_on_page > (uint32) RecnoDictPagePayload ||
			copied + ph->bytes_on_page > entry.length)
		{
			UnlockReleaseBuffer(buf);
			pfree(result);
			ereport(ERROR,
					(errcode(ERRCODE_DATA_CORRUPTED),
					 errmsg("RECNO dictionary id %u blob overruns recorded length",
							dictid)));
		}

📄 src/backend/access/recno/recno_dict.c (L116-L128)

This SHARE-mode branch upgrades to EXCLUSIVE and, if the magic is still wrong, falls through to re-initialize (and WAL-log) the metapage even though the caller requested a read-only SHARE lock. recno_dict_count()/recno_dict_get_active() only reach here when the fork already has a block-0, so a wrong magic there means a genuinely corrupt/uninitialized metapage; silently re-initializing it discards any existing directory contents from a read path (POLA violation, potential silent data loss). Consider having SHARE callers ereport(ERROR) on a persistently-invalid magic rather than re-init, or restrict re-init to a dedicated init path.


📄 src/backend/access/recno/recno_dirtymap.c (L178-L181)

The lock-free correctness argument here is broken by the actual call sites. This contract requires the key be published "while the page's buffer is exclusively locked, before that lock is released," but every RecnoDirtyMapMark caller publishes AFTER unlocking:

  • recno_operations.c:1659 (comment above it: "Register ... AFTER releasing the buffer lock")
  • recno_operations.c:2613 (ReleaseBuffer at 2607, then Mark)
  • recno_operations.c:4510 ("sLog registration was done above (before buffer release)", Mark after)
  • recno_handler.c:3106 (LockBuffer(...UNLOCK) at 3081, then Mark)

Because the Mark happens after the writer drops the buffer lock, a concurrent scanner can pin+lock the same page, read the already-modified on-page tuple, and call RecnoDirtyMapCheck before the writer's Mark completes -> false-clean -> the per-tuple sLog before-image probe is skipped -> a snapshot that should see the before-image serves the new value (silent wrong results / lost update). Either move every Mark inside the writer's exclusive buffer-lock scope (as documented) or redesign the ordering so the published key is guaranteed visible to any scanner that can observe the modification. (high confidence)


📄 src/backend/access/recno/recno_dirtymap.c (L247-L248)

The lock-free reader has no memory-barrier pairing. pg_atomic_read_u64/pg_atomic_write_u64 do NOT imply full barriers on weakly-ordered CPUs (ARM64/PPC64/RISC-V). This rationale relies on "mark-before-buffer-unlock ordering" to pair the publish with the read, but (a) the callers violate that ordering (see RecnoDirtyMapMark), and (b) at the index fetch-by-TID call site (recno_handler.c:1943) the buffer content lock is already released before RecnoDirtyMapCheck runs, so the buffer lock provides no acquire barrier here either. Without an explicit acquire barrier on the read (e.g. pg_atomic_read_membarrier / pg_read_barrier) paired with a release on the publish, a reader on a weakly-ordered core can observe the modified tuple bytes but a stale-empty slot (false-clean), or vice versa. Add explicit barriers or use the membarrier atomic variants; the current code is only correct on strongly-ordered x86. (high confidence)


📄 src/backend/access/recno/recno_dirtymap.c (L267-L268)

full is a plain bool written under part->mutex in RecnoDirtyMapMark but read here without any lock or barrier. Likewise nentries is read/written as a plain int. Per the project rule that all cross-process shared-memory visibility must go through port/atomics, a plain-bool read concurrent with a plain-bool write is a data race; the C standard does not guarantee it is benign, and the compiler may reorder it relative to the atomic slot loads. Make full a pg_atomic_flag / pg_atomic_uint32 (read with the appropriate barrier), or document precisely why the buffer-lock ordering makes the plain access safe (which, as noted above, it does not at all call sites). (moderate confidence)


📄 src/backend/access/recno/recno_fsm.c (L163-L169)

Dead truncation logic: the FSM entries for truncated pages are never removed. The only caller (recno_operations.c) invokes RecnoVacuumFSM(onerel, RelationGetNumberOfBlocks(onerel)) AFTER RecnoTruncateRelation() has already truncated the relation via RelationTruncate(). So new_nblocks is the already-truncated (current) block count. Here old_nblocks = RelationGetNumberOfBlocks(rel) reads that same current count, making new_nblocks < old_nblocks always false. FreeSpaceMapPrepareTruncateRel() therefore never runs, leaving stale FSM entries pointing at blocks that no longer exist. Either the caller must pass the pre-truncation block count as a separate argument, or (matching heap) the FSM truncation should be driven off the actual old/new sizes captured before RelationTruncate(). (high confidence)


📄 src/backend/access/recno/recno_fsm.c (L128-L128)

FreeSpaceMapVacuumRange() runs on every relation extension, i.e. on the hot insert path. It is not redundant (RecordPageWithFreeSpace only sets the leaf via fsm_set_and_search and does not reliably propagate up the tree), but walking/pinning the FSM upper pages on each single-page extend is more expensive than what heap does (heap batches FSM vacuuming and relies on block-target caching). Consider whether the upper-level propagation can be deferred/batched to avoid a per-extend FSM tree walk. (moderate confidence)


📄 src/backend/access/recno/recno_fsm.c (L31-L31)

miscadmin.h is included but nothing from it is used in this file (no CHECK_FOR_INTERRUPTS, MyProc, etc.). Drop the unused include to keep the diff minimal. (high confidence)


📄 src/backend/access/recno/recno_lock.c (L332-L344)

This mode->LOCKMODE mapping is inconsistent with what RecnoLockTuple/RecnoUnlockTuple actually acquire. RecnoLockTuple takes AccessShareLock (KeyShare), RowShareLock (Share), ExclusiveLock (NoKeyExclusive), AccessExclusiveLock (Exclusive) -- matching heap's tupleLockExtraInfo. But here KeyShare/Share both map to ShareLock and Exclusive maps to ExclusiveLock, so LockHeldByMe() will never find a lock actually held via RecnoLockTuple for KeyShare, Share, or Exclusive (only NoKeyExclusive happens to coincide on ExclusiveLock). The function's own header says it is for 'avoiding redundant lock acquisitions and for assertions in debug builds' -- with this mapping it silently returns false for genuinely-held locks, defeating that purpose. Use the same four-way mapping as RecnoLockTuple.

💡 Suggested change

Before:

	switch (mode)
	{
		case LockTupleKeyShare:
		case LockTupleShare:
			lockmode = ShareLock;
			break;
		case LockTupleNoKeyExclusive:
		case LockTupleExclusive:
			lockmode = ExclusiveLock;
			break;
		default:
			return false;
	}

After:

	switch (mode)
	{
		case LockTupleKeyShare:
			lockmode = AccessShareLock;
			break;
		case LockTupleShare:
			lockmode = RowShareLock;
			break;
		case LockTupleNoKeyExclusive:
			lockmode = ExclusiveLock;
			break;
		case LockTupleExclusive:
			lockmode = AccessExclusiveLock;
			break;
		default:
			return false;
	}

📄 src/backend/access/recno/recno_lock.c (L267-L278)

Lock leak on the error path. RecnoLockTuple calls LockAcquire/LockAcquireExtended, which can ereport(ERROR) (deadlock detected, out of shared memory) or process a CHECK_FOR_INTERRUPTS. On such a longjmp out of this loop, the tuple locks already acquired in earlier iterations are NOT released here -- only the explicit wait=false !all_locked path releases them. They will linger until transaction/ResourceOwner cleanup, which contradicts the fine-grained all-or-nothing release the function advertises. The pfree(locked) is also skipped, though MemoryContext reset reclaims it. If all-or-nothing release semantics on error are required, wrap the acquisition loop in PG_TRY/PG_CATCH (or document that error unwinding relies on ResourceOwner). Note this function currently has no callers.


📄 src/backend/access/recno/recno_lock.c (L304-L309)

RecnoLockMultipleTuples, RecnoLockRelationForDDL, RecnoHoldsTupleLock, and RecnoLockPage/RecnoUnlockPage have no callers anywhere in the tree (only prototypes in recno.h and a redundant re-declaration in recno_operations.c; RecnoLockMultipleTuples is merely mentioned in DESIGN). Per YAGNI/minimalism, speculative lock helpers with zero callers are premature abstraction and should be removed until an actual caller exists. RecnoLockRelationForDDL in particular is a one-line pass-through over LockRelationOid() that adds no value.


📄 src/backend/access/recno/recno_lock.c (L70-L86)

The identical LockTupleMode->LOCKMODE switch is copy-pasted in RecnoLockTuple, RecnoUnlockTuple, and (with a diverging, buggy variant) RecnoHoldsTupleLock. Extract a single static helper (e.g. recno_tuple_lockmode(LockTupleMode)) and call it from all three sites to keep the mapping in one place and prevent exactly the KeyShare/Share divergence seen in RecnoHoldsTupleLock.


📄 src/backend/access/recno/recno_lock.c (L252-L265)

O(n^2) bubble sort. Although the header claims N is typically small, if this ever runs on nontrivial tid arrays it becomes a quadratic hot-path cost. Use qsort() with an ItemPointerCompare-based comparator (existing infrastructure) instead of a hand-rolled bubble sort.


📄 src/backend/access/recno/recno_mvcc.c (L149-L150)

Portability/correctness: xact_start_ts_slots[] is a plain uint64, written here with only pg_write_barrier() and read in RecnoGetOldestActiveTimestamp() with a single pg_read_barrier(). On 32-bit platforms a uint64 load/store is not atomic, so a reader (VACUUM) can observe a torn timestamp and compute a wrong oldest-active horizon, causing premature reclamation of still-visible tuples (the exact phantom-row-after-TID-reuse hazard the code's own comments warn about). Note global_commit_ts in this same struct correctly uses pg_atomic_uint64. Make the slots pg_atomic_uint64 and use pg_atomic_read/write_u64 at every access site (init, cleanup, prepare-reassign, resolve, and the horizon rescan). The comment claiming "just a compiler barrier via volatile access" is also inaccurate: these are plain (non-volatile) uint64.

💡 Suggested change

Before:

	int			num_xact_slots; /* Number of slots (== RECNO_TOTAL_PROCS) */
	uint64		xact_start_ts_slots[FLEXIBLE_ARRAY_MEMBER];

After:

	int			num_xact_slots; /* Number of slots (== RECNO_TOTAL_PROCS) */
	pg_atomic_uint64 xact_start_ts_slots[FLEXIBLE_ARRAY_MEMBER];

📄 src/backend/access/recno/recno_mvcc.c (L276-L279)

Dead code: RecnoMvccShmemInit() is declared in recno.h but has no caller anywhere in the tree — shmem setup goes exclusively through RecnoMvccShmemCallbacks (wired via PG_SHMEM_SUBSYSTEM in subsystemlist.h), whose .init_fn is RecnoMvccShmemInit_cb. This function and its duplicated init/on_shmem_exit body are dead scaffolding. Remove RecnoMvccShmemInit() (and its header decl) so the classic and callback init paths don't diverge.


📄 src/backend/access/recno/recno_mvcc.c (L291-L291)

mvcc_lock is initialized with LWTRANCHE_BUFFER_MAPPING, an unrelated tranche. This mislabels the lock in wait-event/pg_stat_activity output. Register a dedicated tranche for RECNO MVCC (via lwlocklist.txt / a LWTRANCHE_RECNO_* entry) rather than borrowing the buffer-mapping tranche.


📄 src/backend/access/recno/recno_mvcc.c (L817-L821)

Dead/vestigial state: serializable_horizon is initialized to 1, only ever decreased via Min() on serializable commit, and has no reader anywhere in the tree (it is a leftover of the removed private SSI graph). As written it monotonically pins at 1 and can never be used. Worse, maintaining it takes an exclusive mvcc_lock on every serializable commit — pure wasted contention. Remove serializable_horizon, this LWLock acquisition, and (if it becomes the only user) mvcc_lock itself.


📄 src/backend/access/recno/recno_mvcc.c (L842-L844)

Dead code: RecnoGetSnapshotTimestamp() has no callers in the tree (only its definition and the header extern). Its READ COMMITTED branch also allocates a fresh timestamp via RecnoGetCommitTimestamp() per call, which the file's own comments say is not used for visibility (CLOG decides). Remove this function and its header declaration.


📄 src/backend/access/recno/recno_mvcc.c (L147-L147)

Non-ASCII characters in source: PostgreSQL requires ASCII-only comments. This line uses an em-dash. Replace with -- or a comma/hyphen.

💡 Suggested change

Before:

	 * no lock is needed — just a compiler barrier via volatile access.

After:

	 * no lock is needed -- just a compiler barrier via volatile access.

📄 src/backend/access/recno/recno_mvcc.c (L552-L552)

Non-ASCII characters: em-dash. Replace with ASCII --.

💡 Suggested change

Before:

		 * reads it), so no lock is needed — just a write barrier.

After:

		 * reads it), so no lock is needed -- just a write barrier.

📄 src/backend/access/recno/recno_mvcc.c (L742-L742)

Non-ASCII characters: em-dash. Replace with ASCII --.

💡 Suggested change

Before:

	 * XIDs — we get them from the sLog.

After:

	 * XIDs -- we get them from the sLog.

📄 src/backend/access/recno/recno_mvcc.c (L753-L753)

Non-ASCII characters: em-dash. Replace with ASCII --.

💡 Suggested change

Before:

		 * conflict to report — analogous to heap's HEAPTUPLE_DEAD case.

After:

		 * conflict to report -- analogous to heap's HEAPTUPLE_DEAD case.

📄 src/backend/access/recno/recno_mvcc.c (L791-L791)

Non-ASCII characters: two em-dashes and an approx sign () across these lines. Replace em-dashes with -- and with ~ (or reword). ASCII-only is a hard requirement for -hackers submission.

💡 Suggested change

Before:

	 * RecnoGetCommitTimestamp() — the only other updater — is

After:

	 * RecnoGetCommitTimestamp() -- the only other updater -- is

📄 src/backend/access/recno/recno_mvcc.c (L793-L793)

Non-ASCII character: . Replace with ~ or reword.

💡 Suggested change

Before:

	 * oldest_ts ≈ 1 and treats every dead tuple as "recently dead",

After:

	 * oldest_ts ~ 1 and treats every dead tuple as "recently dead",

📄 src/backend/access/recno/recno_overflow.c (L1593-L1595)

Critical WAL/redo corruption. RecnoPageIndexTupleDelete() above deletes only the orphan overflow items, but this logs the change with RecnoXLogInitPage(). Its redo handler (recno_xlog_init_page_redo) reads the block with RBM_ZERO_AND_LOCK and calls RecnoInitPage(), i.e. it ZEROES and reinitializes the ENTIRE page. On crash recovery or on a physical standby, replaying this record wipes out every live tuple and non-orphan overflow record on the page, not just the deleted orphans. This is silent, catastrophic data loss on the redo path. You need a WAL record that replays the specific per-offset deletions (like the cross-page-defrag record replays a specific tuple), or register a full-page image of the post-deletion page contents rather than a WILL_INIT/reinit record.


📄 src/backend/access/recno/recno_overflow.c (L953-L954)

Crash/replica inconsistency: this removes overflow records from a shared buffer (RecnoPageIndexTupleDelete + MarkBufferDirty) but deliberately emits no WAL. On a primary this dirties an on-disk page whose modification is never replayed; after crash recovery, or on a physical standby, the page can diverge (orphan records reappear, or FSM disagrees with page contents), and combined with checkpoint timing this risks torn/divergent pages. "Idempotent, deferred to VACUUM" does not satisfy the rule that any change to a shared buffer must be WAL-logged and replayable. This path is reached from VACUUM (recno_operations.c:5979); it must log the deletion.


📄 src/backend/access/recno/recno_overflow.c (L692-L694)

Silent truncation / data corruption for large values. ov_total_length is uint32 (recno.h), so (uint32) data_len truncates any value >= 4 GiB, and the hash length (int) data_len becomes negative/undefined past INT_MAX. RecnoFetchOverflowColumn later allocates and expects exactly ov_total_length bytes, so a truncated length yields an incorrect reconstruction with no error raised. Add an explicit guard that data_len fits in uint32 (ereport ERRCODE_PROGRAM_LIMIT_EXCEEDED) before storing, and bound the hash length to the same.


📄 src/backend/access/recno/recno_overflow.c (L1609-L1611)

Portability: int64 must be printed with INT64_FORMAT, not %lld/(long long) casts (project hard rule). Use errmsg("RECNO overflow vacuum: found " INT64_FORMAT " overflow records, removed " INT64_FORMAT " orphans", overflow_records_found, orphans_removed).


📄 src/backend/access/recno/recno_overflow.c (L79-L79)

Misleading comment / dead configuration. recno_overflow_inline_prefix is a plain C global, not a registered GUC: it does not appear in guc_tables.c and the regression test recno_overflow.out shows SHOW recno_overflow_inline_prefix errors with "unrecognized configuration parameter". Callers cannot actually configure it. Either register it as a real GUC in guc_tables.c (and add PGDLLIMPORT to the header extern for MSVC cross-module use) or drop the "GUC"/"configurable via GUC" wording from the comments and README so it does not claim behavior that does not exist.


📄 src/backend/access/recno/recno_overflow.c (L1141-L1144)

Dead code (YAGNI). RecnoDeleteTupleOverflows() has no callers anywhere in the tree (the delete/vacuum paths use RecnoCollectOverflowPtrs + RecnoDeleteOverflowChain instead). Remove it, or wire it into a real caller; committing unused exported functions is unnecessary surface and violates the minimalism discipline for pgsql-hackers patches.


📄 src/backend/access/recno/recno_overflow.c (L1013-L1016)

DRY: the varlena-attribute walk (bitmap_len, att_align_nominal, attlen -1/-2/>0 dispatch, RecnoIsOverflowPtr check) is copy-pasted verbatim across RecnoFindOverflowPageForReuse, RecnoCollectOverflowPtrs, RecnoCollectOverflowPtrsByAttr, RecnoDeleteTupleOverflows and RecnoVacuumOverflowRecords Pass 1. All five hard-code the same on-page layout assumption (RECNO_TUPLE_OVERHEAD + MAXALIGN(bitmap_len)); a future layout change or bug fix must be applied in five places and any miss silently misparses tuple data. Extract a single iterator/callback helper and reuse it.


📄 src/backend/access/recno/recno_overflow.c (L1288-L1290)

Misleading diagnostic (POLA). This is not an average chain length at all: it is bytes/chunk_size+1, i.e. an estimate of total chunks, and the comment admits it is "approximate". Reporting this via an output parameter named avg_chain_length will mislead any monitoring consumer. Either compute the real value (records / number-of-chains, which Pass 1 of the vacuum already knows how to trace) or rename the output and document what it actually is.


📄 src/backend/access/recno/recno_overflow.c (L34-L34)

Aspirational/future-tense header comment. pgsql-hackers convention is that comments describe what the code does now; this large "FUTURE ENHANCEMENTS (deferred)" block (lazy streaming, row-level overflow) is speculative design notes that belong on the mailing-list thread or a README, not in committed source. Remove it from the file header.


📄 src/backend/access/recno/recno_overflow.c (L384-L384)

"WORKAROUND" heuristic on a guessed invariant. Special-casing target_block == 0 as "probably the main tuple's page" is fragile: block 0 is a legitimate data block and can hold overflow records for other tuples, so this can needlessly skip a block with free space, and the guess ("probably") is not a real invariant. If the main-tuple page must be excluded, pass its block number in explicitly and compare against it; do not infer it from block==0. Also remove the "WORKAROUND"/aspirational wording per the comment-accuracy rule.


📄 src/backend/access/recno/recno_pvs.c (L159-L166)

Out-of-bounds read on a corrupt/truncated UNDO record. This only checks that the fixed RecnoDiffRecord header (6 bytes) is present, not that the full diff fits in the payload. RecnoApplyDiffReverse walks diff->ndiffs segments (ptr += SizeOfRecnoDiffSegment; ptr += seg->ins_len) with no bound against the end of payload -- it only bounds writes against old_total_len and reads against new_data. A record whose ndiffs/ins_len claims more bytes than were actually stored will read past the palloc'd payload buffer. The RelUndoDeltaUpdatePayload you just parsed carries diff_len (currently discarded via (void) du); validate the whole diff region against payload_size before dereferencing the segments.

💡 Suggested change

Before:

					if (payload_size <
						SizeOfRelUndoDeltaUpdatePayload + sizeof(RecnoDiffRecord))
					{
						elog(WARNING,
							 "RECNO PVS: DELTA_UPDATE diff truncated at %llu",
							 (unsigned long long) verptr);
						break;
					}

After:

					du = (const RelUndoDeltaUpdatePayload *) payload;
					if (payload_size <
						SizeOfRelUndoDeltaUpdatePayload + du->diff_len ||
						du->diff_len < SizeOfRecnoDiffRecord)
					{
						elog(WARNING,
							 "RECNO PVS: DELTA_UPDATE diff truncated at " UINT64_FORMAT,
							 verptr);
						break;
					}

📄 src/backend/access/recno/recno_pvs.c (L181-L183)

du is parsed only to be discarded with (void) du, which is dead code. It carries diff_len, the exact length needed to bound the diff region against payload_size (see the truncation check above). Either use du->diff_len for that validation or drop the cast entirely.

💡 Suggested change

Before:

					(void) du;
					stepped = true;
					break;

After:

					stepped = true;
					break;

📄 src/backend/access/recno/recno_pvs.c (L6-L7)

Stale header comment. This describes the version pointer as a "trailing RelUndoRecPtr (verptr) into the new on-page image" and refers to the "trailing verptr ... preserved verbatim through the reverse-apply". Per recno.h the pointer now lives in the fixed header field t_verptr (formerly a trailer located by item_len - 8), and RecnoTupleGetVersionPtr ignores item_len. Update the comment to match the current header-field representation so it doesn't mislead readers.


📄 src/backend/access/recno/recno_pvs.c (L154-L155)

RelUndoRecPtr is a uint64 typedef, so printing it with (unsigned long long)/%llu deviates from the tree convention of UINT64_FORMAT (used by the sibling recnodesc.c). While the explicit cast keeps %llu technically portable, prefer UINT64_FORMAT for consistency. Applies to all four WARNING messages in this file.

💡 Suggested change

Before:

							 "RECNO PVS: truncated DELTA_UPDATE payload at %llu",
							 (unsigned long long) verptr);

After:

							 "RECNO PVS: truncated DELTA_UPDATE payload at " UINT64_FORMAT,
							 verptr);

📄 src/backend/access/recno/recno_slot.c (L173-L175)

This is a second, byte-layout-critical copy of the RECNO deform logic. RecnoDeformTuple() in recno_tuple.c already deforms the identical on-disk format (same RECNO_TUPLE_OVERHEAD + MAXALIGN(bitmap_len) start, same align/compression/overflow handling). Maintaining two independent implementations of format-critical offset math is a silent-corruption footgun: any future change to the form path (recno_tuple.c) must be mirrored here by hand or reads will desync. Please factor the shared deform core into one routine that both RecnoDeformTuple and this slot deform call, rather than duplicating it. (Confidence: high that this is a near-verbatim duplicate; the two must stay byte-identical.)


📄 src/backend/access/recno/recno_slot.c (L849-L849)

Non-ASCII em-dash (U+2014) here and at the "Different buffer" comment below. The project source is ASCII-only (no em-dashes/smart quotes). Replace with an ASCII hyphen.

💡 Suggested change

Before:

		/* Same buffer — just free any materialized data */

After:

		/* Same buffer - just free any materialized data */

📄 src/backend/access/recno/recno_slot.c (L859-L859)

Non-ASCII em-dash (U+2014). Replace with an ASCII hyphen to satisfy the ASCII-only source rule.

💡 Suggested change

Before:

		/* Different buffer — full clear (releases old pin) and acquire new */

After:

		/* Different buffer - full clear (releases old pin) and acquire new */

📄 src/backend/access/recno/recno_slot.c (L76-L76)

Redundant (and malformed) declaration. TTSOpsRecnoTuple is already declared in recno.h (line 595) as extern PGDLLIMPORT const TupleTableSlotOps TTSOpsRecnoTuple;, and that header is included above. This line lacks extern, so it is a tentative definition of a const object rather than a forward declaration, and it duplicates the header decl. Drop it; the actual definition later in this file plus the header declaration are sufficient.


📄 src/backend/access/recno/recno_slot.c (L850-L855)

Potential values_block leak in the same-buffer fast path. When this branch runs with rslot->buffer == buffer and TTS_SHOULDFREE set, only rslot->tuple is freed; rslot->values_block is not. A materialized values-only slot has buffer == InvalidBuffer and tuple == NULL; if a caller invokes RecnoSlotStoreTuple with buffer == InvalidBuffer on such a slot, rslot->buffer (InvalidBuffer) == buffer is true and the owned values_block leaks. Free values_block here too (mirroring tts_recno_clear).

💡 Suggested change

Before:

		if (unlikely(TTS_SHOULDFREE(slot)))
		{
			if (rslot->tuple)
				pfree(rslot->tuple);
			slot->tts_flags &= ~TTS_FLAG_SHOULDFREE;
		}

After:

		if (unlikely(TTS_SHOULDFREE(slot)))
		{
			if (rslot->tuple)
				pfree(rslot->tuple);
			if (rslot->values_block)
				pfree(rslot->values_block);
			rslot->values_block = NULL;
			slot->tts_flags &= ~TTS_FLAG_SHOULDFREE;
		}

📄 src/backend/access/recno/recno_slot.c (L256-L258)

Unbounded read on corrupt/truncated tuples. attr_len = VARSIZE_ANY(data_ptr) is read before any check that data_ptr (and data_ptr + attr_len) stays within rslot->tuple_len. Since this parses raw on-disk bytes, a corrupt page or any offset-math desync can make VARSIZE_ANY read past the tuple/page and advance data_ptr out of bounds. For a storage AM, add a defensive bounds check of the running offset against rslot->tuple_len before dereferencing, so corruption raises ERRCODE_DATA_CORRUPTED instead of crashing. (Confidence: moderate -- confirm no upstream verify pass already guarantees this.)


📄 src/backend/access/recno/recno_slot.c (L278-L282)

Per-attribute relation_open/relation_close inside the deform hot loop. For every overflowed varlena of every tuple this opens and closes the relation via the relcache. On wide tables with several overflow columns under a large scan this multiplies relcache lookups on the read path. Open the Relation once per tuple (or thread the scan's already-open Relation down) instead of once per attribute.


📄 src/backend/access/recno/recno_stats.c (L50-L52)

Dead code: neither RecnoCollectRelationStats() nor RecnoLogRelationStats() has any caller anywhere in the tree. The RECNO ANALYZE path uses recno_scan_analyze_next_block/recno_scan_analyze_next_tuple (standard sampling); it never invokes these. The header comment claims the results are "made available ... so that the planner can incorporate RECNO-specific cost adjustments" and are "called during ANALYZE", but nothing consumes them. This is speculative scaffolding (YAGNI) and should not be committed. Note that recno_overflow.c already has RecnoGetOverflowStats() doing a similar full-relation overflow scan, so this also duplicates existing infrastructure. If a real caller is intended, wire it in and justify the extra full-table scan (see below); otherwise drop the file, the header struct/decls, and the typedefs.list/README entries. [high confidence]


📄 src/backend/access/recno/recno_stats.c (L156-L164)

Out-of-bounds / meaningless read. RECNO_TUPLE_COMPRESSED is a whole-tuple flag, but the RecnoCompressionHeader does NOT live at (char *) hdr + RECNO_TUPLE_OVERHEAD. Per recno_compress.c the layout is per-attribute: "[varlena header][RecnoCompressionHeader (8 bytes)][compressed data]", i.e. the header sits inside a compressed attribute's varlena body (VARDATA_ANY), reached only after decoding the tuple's attributes/bitmap. Casting hdr + RECNO_TUPLE_OVERHEAD to RecnoCompressionHeader reads the tuple's attribute data as if it were a compression header, so orig_size/comp_size are garbage and compression_ratio is bogus. The item_len guard does not make the read correct. [high confidence]


📄 src/backend/access/recno/recno_stats.c (L247-L253)

Portability/convention: int64 values must be formatted with INT64_FORMAT and uint64 with UINT64_FORMAT rather than casting to (long long)/%lld and (unsigned long long)/%llu. Also, errmsg text should start lowercase and should not be split into multiple ereport() calls for one logical event; "RECNO stats" repeated as a capitalized prefix violates the errmsg lowercase-start convention. Since this text is user-facing it should also be wrapped in _(). [moderate confidence]


📄 src/backend/access/recno/recno_stats.c (L85-L86)

Unbounded cost: this performs a full sequential scan of every page (reading and locking each buffer) on top of ANALYZE's standard random sampling. On large relations that is an O(nblocks) I/O and buffer-cache pressure cost that defeats the purpose of sampling. If this is ever wired into ANALYZE it needs a bounded/sampling approach and justification with a reproducible benchmark. [moderate confidence]


📄 src/backend/access/recno/recno_stats.c (L202-L204)

Incoherent metric: total_overflow_chains counts standalone overflow records (items where RecnoIsOverflowRecord() is true), while total_overflow_tuples counts main tuples flagged RECNO_TUPLE_HAS_OVERFLOW. avg_overflow_chain_len = total_overflow_chains / total_overflow_tuples divides two independently-counted populations, so it is not an average chain length. Additionally bloat_factor uses total_tuple_bytes, which excludes overflow-record bytes (they are 'continue'd before accumulation), overstating bloat for overflow-heavy relations. Clarify the counting model. [moderate confidence]


📄 src/backend/access/recno/recno_stats.c (L8-L10)

Comment reads like a merge artifact: the sentence is awkwardly split ("the standard\n * per-column statistics"). Reflow to a single clean sentence. Also prefer avoiding the '--' dash style inside comments to stay ASCII-clean and consistent with sibling recno files. [low confidence]

💡 Suggested change

Before:

 * distribution of the per-tuple commit-ts word.  These statistics supplement
 * the standard
 * per-column statistics (MCV, histograms, NULL fractions, etc.) that

After:

 * distribution of the per-tuple commit-ts word.  These statistics supplement
 * the standard per-column statistics (MCV, histograms, NULL fractions, etc.)
 * that

📄 src/backend/access/recno/recno_tuple.c (L225-L225)

Unaligned access hazard (portability, strict-alignment platforms). ovbuf is a char[], which has only 1-byte alignment. VARDATA(ovbuf) is ovbuf + VARHDRSZ (i.e. +4), so the resulting RecnoOverflowPtr * is not guaranteed to satisfy the struct's alignment. Writing ((RecnoOverflowPtr *) VARDATA(ovbuf))->ov_inline_prefix = 0 and then passing ovbuf into RecnoFetchOverflowColumn() (which dereferences ov_first_block, ov_total_length, etc.) performs unaligned reads/writes that fault or are slow on ARM/PPC64/s390x/RISC-V. Declare the backing buffer with a properly-aligned type, e.g. union { char c[VARHDRSZ + sizeof(RecnoOverflowPtr)]; RecnoOverflowPtr ptr; int32 vl; } u; (or use pg_attribute_aligned) and copy the pointer contents into a separate stack RecnoOverflowPtr before setting fields.


📄 src/backend/access/recno/recno_tuple.c (L868-L869)

Dead/speculative code (YAGNI). RecnoPageUpdateTuple has no callers anywhere in the tree -- the in-place UPDATE path in recno_operations.c implements its own page mutation and does not call this. As written it also mutates a shared buffer page (in-place memcpy, then RecnoPageIndexTupleDelete + PageAddItem) with no START_CRIT_SECTION/WAL logging, so if it were ever wired up it would cause crash/replica divergence. Remove it (and its prototype in recno.h) until a caller exists, or move the page-mutation into a WAL-logged critical section owned by a real caller.


📄 src/backend/access/recno/recno_tuple.c (L828-L828)

Dead/speculative code (YAGNI). RecnoPageDeleteTuple has no callers in the tree (delete is handled elsewhere). It also mutates the page (t_flags, t_commit_ts, opaque commit_ts) with no critical section or WAL logging, so wiring it in later would break crash/replica consistency. Remove it (and its recno.h prototype) until it is actually needed.


📄 src/backend/access/recno/recno_tuple.c (L939-L939)

Dead code confirmed by its own comment ("no current callers", "snapshot_ts is unused"). The (void) snapshot_ts; cast and the unused snapshot_ts parameter are a footgun: the name promises a snapshot visibility check but the body only tests RECNO_TUPLE_DELETED. Remove RecnoPageGetLiveTuples (and its recno.h prototype) until a caller exists; if kept, drop the misleading snapshot_ts parameter.


📄 src/backend/access/recno/recno_tuple.c (L1186-L1187)

Memory-ownership footgun / potential leak. This function returns Datums with mixed ownership: for a plain varlena or fixed-length column it hands back PointerGetDatum(data_ptr) pointing directly into the pinned page, but for overflow columns it stores a freshly palloc'd result from RecnoFetchOverflowColumn() and for compressed columns a palloc'd result from RecnoDecompressAttribute(). These allocations land in the current memory context and are not tracked by the slot; ExecClearTuple/slot teardown will not free them, so on a repeated seqscan they accumulate until the surrounding context resets. Callers also cannot uniformly pfree the result (some Datums point into the page and must not be freed). Document the ownership contract explicitly and ensure a per-tuple/per-scan short-lived context is reset between rows, or materialize the slot so ownership is uniform.


📄 src/backend/access/recno/recno_vm.c (L344-L351)

RecnoVMCheckCached takes BUFFER_LOCK_SHARE on the sequential-scan hot path for every heap page. Heap's equivalent (visibilitymap_get_status) reads the single VM byte lock-free, since a byte read is atomic and a stale bit is harmless (the scan rechecks visibility). The share lock/unlock per page is needless overhead here; consider dropping it to match heap.


📄 src/backend/access/recno/recno_vm.c (L373-L376)

RecnoVMPinBuffer calls recno_vm_readbuf(rel, mapBlock, true), so a mere "pin" request can silently extend/create the VM fork (allocating and zeroing a new block, invalidating smgr caches). A pin operation that grows the relation as a side effect is a footgun and can cause unexpected relation growth during read-oriented paths. This function also has no callers anywhere in the tree. Either pass extend=false (return early if the block doesn't exist, like RecnoVMCheck does) or remove the function until it is actually used.


📄 src/backend/access/recno/recno_vm.c (L384-L387)

Dead scaffolding (YAGNI): RecnoVMExtend has no callers anywhere in the backend or headers -- only its definition and prototype exist. Speculative, unwired code is a common rejection reason on -hackers. Remove it until a real caller exists.


📄 src/backend/access/recno/recno_vm.c (L428-L429)

RecnoVMTruncate has no callers in the tree (dead scaffolding), and as written it is also incorrect: it performs smgrtruncate() on the VM fork with no WAL logging. Unlike heap's truncation path, a bare smgrtruncate here will not be replayed on a standby / after crash, leaving the VM fork longer than the heap and permitting VM reads for blocks that no longer map to heap pages. Remove this function until it is wired into a properly WAL-logged truncate path.


📄 src/backend/access/recno/recno_vm.c (L436-L440)

Dead scaffolding (YAGNI): RecnoVMGetPageSize and RecnoVMMapHeapToVM have no callers anywhere. Remove until needed.


📄 src/backend/access/recno/recno_vm.c (L73-L79)

RecnoVMInit is an empty body with only a comment -- dead scaffolding. Drop it (and its prototype) until it actually does something; the VM fork is already created on demand by recno_vm_extend().


📄 src/backend/access/recno/recno_vm.c (L551-L551)

Unused parameter and aspirational comment. tuple is only cast to (void) and documented as "reserved for future timestamp checking"; the project rejects future-tense/speculative comments and unused parameters. Drop the parameter until the timestamp check is actually implemented, and remove the future-tense comment.


📄 src/backend/access/recno/recno_vm.c (L595-L597)

ASCII/style: these comments use '--' as em-dash-like separators (e.g. "cleaned, eventually" ... "critically -- its index entries"), which draws hygiene nits on -hackers. Reword to plain ASCII sentences.


📄 src/backend/access/transam/xact.c (L6698-L6701)

This comment claims a WAL magic bump that did not happen. XLOG_PAGE_MAGIC in src/include/access/xlog_internal.h is still 0xD120 and that file is not touched by this change. Extending xl_xact_prepare (a struct written into XLOG_XACT_PREPARE records) without bumping XLOG_PAGE_MAGIC is exactly the WAL-compatibility hazard this comment purports to prevent: an older/newer server replaying a mismatched record cannot detect the format change. Either actually bump XLOG_PAGE_MAGIC (and TWOPHASE_MAGIC, as the xact.h comment demands) or correct this false statement. As written it is an aspirational comment describing a safeguard that does not exist. (high confidence)


📄 src/backend/access/transam/xact.c (L3075-L3075)

Use the named sentinel InvalidUndoRecPtr (defined in access/undodefs.h) instead of a raw literal 0. The doc comment on GetCurrentTransactionUndoRecPtr itself says "Returns InvalidUndoRecPtr (0)", so the hardcoded 0 here (and in the StartTransaction init) diverges from the subsystem convention and will silently break if the invalid sentinel ever changes. Relatedly, the undoRecPtr field and the get/set helper signatures use raw uint64 rather than the subsystem's UndoRecPtr typedef, defeating type documentation. (medium confidence)


📄 src/backend/access/transam/xact.c (L3061-L3063)

This comment is contradicted by the code a few lines below in this same function. It asserts "Physical UNDO application is NOT needed during standard transaction abort" and "UNDO records ... are preserved ... for use by the undo worker, crash recovery, or future in-place update mechanisms." But the newly added AtAbort_XactUndo() call below does apply per-relation UNDO synchronously in-line during abort (xactundo.c: ApplyPerRelUndo -> RelUndoApplyChain restores in-place before-images), and WaitForPendingRelUndo() makes it synchronous from the client's view. A committer cannot tell from these two comments whether inline UNDO runs during abort. Reconcile this correctness-critical comment with the actual behavior. (high confidence)


📄 src/backend/access/transam/xact.c (L6635-L6635)

Non-ASCII character (em-dash) in source. PostgreSQL source must be ASCII-only; replace the em-dash with an ASCII hyphen/dash. The same issue appears in the abort-record branch comment ("Remove from UNDO recovery tracking - abort record present means..."). Run the file through a non-ASCII check. (high confidence)

💡 Suggested change

Before:

		 * Remove from UNDO recovery tracking — committed, no rollback

After:

		 * Remove from UNDO recovery tracking - committed, no rollback

📄 src/backend/access/transam/xact.c (L6667-L6667)

Non-ASCII character (em-dash) in source; replace with an ASCII hyphen to comply with the ASCII-only source rule. (high confidence)

💡 Suggested change

Before:

		 * Remove from UNDO recovery tracking — abort record present means

After:

		 * Remove from UNDO recovery tracking - abort record present means

📄 src/backend/access/transam/twophase.c (L1674-L1679)

Crash-consistency gap on ROLLBACK PREPARED. RecordTransactionAbortPrepared() calls XLogFlush() on the abort record and marks clog aborted (see line 2584-2591, "Always flush, since we're about to remove the 2PC state file") before this ATMAddAborted() runs. ATMAddAborted() writes its own XLOG_ATM_ABORT record but does not force it durable here. If a crash occurs after the abort record is flushed but before the ATM record reaches disk, recovery replays the 2PC-file removal, and the crash-recovery UNDO phase in undo_xlog.c explicitly skips prepared transactions (undo_xlog.c:821). The permanent-level UNDO chain (FILEOPS/nbtree/hash before-images) is then never reverted, leaving on-disk state permanently inconsistent while ROLLBACK PREPARED already reported success. The ATM registration must be made crash-consistent with the flushed abort record (e.g. register into the ATM inside the same critical section before the flush, or re-derive the ATM entry from the abort WAL record during recovery), not written afterwards.


📄 src/backend/access/transam/twophase.c (L1678-L1682)

Silent data-loss footgun: on ATM-full this only warns and continues to ProcArrayRemove() and recycles the gxact. Once the gxact is gone, TwoPhaseGetOldestUndoBatchLSN() no longer pins the batch WAL, so the before-images become unrecoverable and the on-disk UNDO chain is never reverted -- yet ROLLBACK PREPARED reports success. Unlike the ordinary-abort ATM-full path in xactundo.c (which can fall back to inline/synchronous rollback), there is no fallback here. This should fail the ROLLBACK PREPARED with ereport(ERROR) (so the operator can retry after the ATM drains) or drive synchronous UNDO application, rather than dropping required UNDO work.


📄 src/backend/access/transam/xlog.c (L7446-L7455)

This checkpoint phase-breakdown instrumentation is unrelated to the UNDO/ATM/recno feature this patch implements. It adds eight timing locals plus INSTR_TIME calls scattered across CreateCheckPoint() and a new LOG line, and it overlaps with the existing LogCheckpointEnd() reporting (which already emits write/sync/total timings gated on the same log_checkpoints). Per patch hygiene, an atomic patch should do one thing; this diagnostic scaffolding should be split into its own patch (or dropped/folded into CheckpointStats). Bundling it here enlarges the diff and mixes concerns, which is a common -hackers rejection reason.


📄 src/backend/access/transam/xlog.c (L7966-L7969)

Values are computed in milliseconds (INSTR_TIME_GET_MILLISEC) then divided by 1000.0 to print as "%.3f s", which is needless indirection. The existing checkpoint LOG line in LogCheckpointEnd() uses the established "write=%ld.%03d s" idiom; either follow that idiom or format the milliseconds directly. As-is this is inconsistent with the surrounding conventions.


📄 src/backend/access/transam/xlog.c (L8646-L8648)

Comment inaccuracy: KeepLogSeg() is not called only "at every checkpoint" -- it is also invoked from CreateRestartPoint() during recovery (and from the WAL-availability check). Calling UndoGetOldestBatchLSN() there is harmless (a plain shmem scan), but the comment's "at every checkpoint" framing is misleading. Reword to describe the retention decision rather than asserting the call site is a checkpoint.


📄 src/backend/access/transam/xlogrecovery.c (L1903-L1903)

PerformRelUndoRecovery() is unreachable in exactly the case its own comment says it must handle. It sits inside the if (record != NULL) block (redo happened); when the last WAL record is the checkpoint itself, record == NULL and control takes the else ("redo is not required") branch, so the on-disk UNDO-fork scan never runs.

The comment states: "a CHECKPOINT taken after an uncommitted in-place UPDATE advances the redo start past that UPDATE's WAL, so nothing is replayed for it even though its uncommitted value is durable." If that checkpoint is also the last WAL record, redo is skipped entirely and the durable uncommitted before-image is never rolled back -> silent data corruption after crash recovery.

Unlike PerformUndoRecovery() (driven by redo-time UndoRecoveryTrackBatch tracking, correctly no-op when no records follow the checkpoint), PerformRelUndoRecovery() is explicitly an end-of-redo on-disk scan and must run regardless of whether any WAL followed the checkpoint. Move this call out of the if (record != NULL) block so it runs on both paths (e.g. after the if/else, before the recovery-target check). Confidence: high.


📄 src/backend/access/undo/README (L1063-L1063)

The lock-ordering table cites undo_flush.c:61, but no such source file exists in this change (the module list in this same README enumerates undo.c/undolog.c/undorecord.c/undoinsert.c/undoapply.c/xactundo.c/undo_xlog.c/undo_bufmgr.c/undoworker.c/undostats.c/undormgr.c/undobuffer.c and never mentions undo_flush.c). A documented lock hierarchy that points at a nonexistent file is a deadlock footgun. Additionally, hard-coded line-number citations (undolog.c:97, undolog.c:105, logical_revert_worker.c:109) drift the moment any of those files change; prefer citing the function/lock name only.


📄 src/backend/access/undo/README (L1069-L1069)

LWTRANCHE_RECNO_DIRTY_MAP does not exist anywhere in the codebase (grep finds it only in this README). recno_dirtymap.c does not define such a tranche. This is a fabricated entry in the lock hierarchy; documenting a lock level that has no corresponding tranche misleads anyone reasoning about deadlock avoidance. Verify the real tranche name (if any) and correct or remove this level.


📄 src/backend/access/undo/README (L539-L543)

Both defaults here are wrong. Per guc_parameters.dat, undo_worker_naptime boot_val is 10000 (10 seconds), not 60000; and undo_retention_time boot_val is 60000 (60 seconds), not 3600000 (1 hour). These stated defaults will mislead operators tuning the system, and they also contradict the Upgrade Guide further down which uses 1000 and 60000 respectively.

💡 Suggested change

Before:

    undo_worker_naptime     Sleep interval between discard cycles (ms)
                            Default: 60000 (1 minute)

    undo_retention_time     Minimum retention time for UNDO records (ms)
                            Default: 3600000 (1 hour)

After:

    undo_worker_naptime     Sleep interval between discard cycles (ms)
                            Default: 10000 (10 seconds)

    undo_retention_time     Minimum retention time for UNDO records (ms)
                            Default: 60000 (60 seconds)

📄 src/backend/access/undo/README (L882-L884)

These test references are stale/false and overstate coverage. There are no src/test/regress/sql/undo.sql or undo_toast.sql files (the regress tests added here are recno_*.sql and fileops.sql), and there is no contiguous 053-060 UNDO recovery range: 053_standby_login_event_trigger.pl is unrelated, and the UNDO/fileops recovery tests actually added are 054/061/063/065/067/068/069/070. Citing nonexistent files as evidence for 'FEATURE COMPLETE' with 'comprehensive test coverage' is misleading; point at the tests that actually exist.


📄 src/backend/access/undo/README (L786-L792)

This TOAST claim contradicts Section 15, which lists both 'No TOAST-aware UNDO (large tuples stored inline)' under Current Limitations and 'TOAST-aware UNDO storage' under Planned Future Work. It also does not hold for RECNO, the primary UNDO-enabled AM: recno_relation_needs_toast_table() returns false, so RECNO never creates a pg_toast_NNN relation (it uses integrated overflow pages), and there is no code enrolling a toast relation into 'the same UNDO log'. Reconcile these sections so the README states a single, accurate TOAST status.


📄 src/backend/access/undo/README (L914-L916)

This 'UNDO now fully protects TOAST tables ... automatic TOAST table enrollment' claim directly contradicts Section 15 which still lists 'No TOAST-aware UNDO' as a current limitation and 'TOAST-aware UNDO storage' as future work. Only one can be true; reconcile Sections 15, 17, and 18 to a single accurate statement.


📄 src/backend/access/undo/README (L1053-L1053)

PostgreSQL requires ASCII-only content in source and diffs. This line contains a Unicode em-dash (U+2014). Replace it with the ASCII '--'. (Note: em-dashes appear in many other lines of this README as well and should all be converted.)

💡 Suggested change

Before:

locks sit outside this hierarchy — they must be acquired before any

After:

locks sit outside this hierarchy -- they must be acquired before any

📄 src/backend/access/undo/README (L1075-L1075)

Non-ASCII arrow glyph (U+2192) violates the ASCII-only rule. Replace with '->'.

💡 Suggested change

Before:

- The revert worker acquires locks 2→5 in sequence during UNDO apply;

After:

- The revert worker acquires locks 2->5 in sequence during UNDO apply;

📄 src/backend/access/undo/logical_revert_worker.c (L59-L59)

Wrong GUC defaults. The authoritative registration in guc_parameters.dat sets undo_worker_naptime boot_val = 10000 (10 seconds) and undo_retention_time boot_val = 60000 (60 seconds). The README states 60000 (1 minute) and 3600000 (1 hour) respectively -- both are wrong and will mislead operators. Also note the internal contradiction: the Upgrade Guide (Section 19) recommends undo_worker_naptime = 1000 and undo_retention_time = 60000, which further diverges from these stated defaults.

💡 Suggested change

Before:

int			logical_revert_naptime = 1000;

After:

    undo_worker_naptime     Sleep interval between discard cycles (ms)
                            Default: 10000 (10 seconds)

    undo_retention_time     Minimum retention time for UNDO records (ms)
                            Default: 60000 (60 seconds)

📄 src/backend/access/undo/logical_revert_worker.c (L114-L114)

Stale/incorrect lock-table entry: undo_flush.c does not exist anywhere in the tree (the module list earlier in this same README enumerates undo*.c files and never mentions it). A documented lock hierarchy that cites a nonexistent file is a deadlock footgun -- maintainers cannot verify or maintain the ordering against real code. The :61 line citation compounds the problem: line-number citations in prose drift immediately and should be dropped. Point to the actual flush-lock acquisition site (or remove level 4 if no such lock exists).


📄 src/backend/access/undo/logical_revert_worker.c (L114-L114)

Nonexistent symbol/file in the lock hierarchy: LWTRANCHE_RECNO_DIRTY_MAP and recno_dirtymap.c produce no matches anywhere in the codebase except this line. A lock-ordering table is only useful if every entry maps to a real, greppable lock; a phantom level-7 lock undermines trust in the whole hierarchy and can hide actual ordering bugs. Correct the tranche name to the one actually defined in recno_dirtymap.c, or remove the entry.


📄 src/backend/access/undo/relundo.c (L589-L589)

Buffer/lock leak on the error path between Reserve and Finish/Cancel. When relundo_allocate_page allocates a new page, the locked metapage is stashed in the per-backend static relundo_pending_metabuf. It is only cleared by RelUndoStage/RelUndoFinish(WithTuple) or RelUndoCancel. But the RECNO UPDATE caller (recno_operations.c ~4056-4222) wraps RelUndoReserve in a PG_TRY whose PG_CATCH only releases overflow buffers and re-throws -- it never calls RelUndoCancel. If CheckForSerializableConflictIn() or RecnoXLogPrepareLogicalImage() (OOM) throws after a new-page reserve, ResourceOwner cleanup releases the buffer pin/lock but this static pointer stays set. The next new-page Reserve in this backend then Assert()s BufferIsValid(relundo_pending_metabuf) on a released buffer and MarkBufferDirty()s it -- a use-after-free. Nothing in RelUndoAbortCleanup_hook (RecnoRelUndoAbortCleanup) resets it. Reset relundo_pending_metabuf on transaction/subtransaction abort (e.g. via an AtAbort/AtEOXact hook), or have callers guarantee Cancel on every error path.


📄 src/backend/access/undo/relundo.c (L866-L867)

Truncating RelUndoRecPtr with %lu is a portability defect. RelUndoRecPtr is uint64 (relundo.h:80). On LLP64 (Windows/MSVC) unsigned long is 32-bit, so (unsigned long) ptr silently truncates the upper 32 bits of the pointer (which encode the generation counter). Use UINT64_FORMAT with a (uint64) cast, per the tree's 64-bit formatting rule.

💡 Suggested change

Before:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=%lu, payload_size=%zu, tuple_len=%u",
		 (unsigned long) ptr, payload_size, tuple_len);

After:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=" UINT64_FORMAT ", payload_size=%zu, tuple_len=%u",
		 (uint64) ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo.c (L497-L498)

Numerous elog(DEBUG1, ...) calls litter the reservation/finish hot path. Even at DEBUG1 the format string and arguments are evaluated on every call on a contended write path (the whole point of the head cache is to make this path cheap). These read like leftover development tracing. Remove them, or gate behind a compile-time debug macro, before this is commit-ready.


📄 src/backend/access/undo/relundo.c (L1291-L1291)

The truncate-to-zero of a pre-existing UNDO fork is not WAL-logged. Only smgrcreate (via log_smgrcreate) and the metapage init (XLOG_RELUNDO_INIT) are logged; the redo handler relundo_redo_init (relundo_xlog.c) only reinitializes the metapage and never truncates the fork. If a crash occurs after this smgrtruncate but the replay only reinitializes block 0, a crash-recovered/standby instance can retain stale UNDO blocks beyond block 0 that the primary discarded, diverging on-disk state. In the normal TRUNCATE path the new relfilenumber is fresh so this branch is not hit, but the branch exists precisely for the not-fresh case; either WAL-log the truncation or document/Assert that this branch is unreachable outside recovery.


📄 src/backend/access/undo/relundo.c (L110-L110)

RELUNDO_CACHE_TOMBSTONE == (Oid) 1 relies on an unenforced invariant that relid 1 is never passed to these cache functions. If a relation OID of 1 ever reaches the cache, update() treats the slot as reusable/free and the entry is silently lost, and invalidate() corrupts probe chains. The safety argument lives only in a comment. Add a defensive Assert(relid != RELUNDO_CACHE_TOMBSTONE) at the top of the lookup/update/invalidate functions so a violation trips in assert builds rather than silently corrupting the cache.


📄 src/backend/access/undo/relundo.c (L153-L153)

The hash shift >> (32 - 6) hard-codes 6 = log2(RELUNDO_HEAD_CACHE_SIZE=64). The header comment for RELUNDO_HEAD_CACHE_SIZE explicitly invites changing it to 128/256; if that happens this shift is not updated automatically and will mask to the wrong slot count, silently breaking the power-of-two masking guarantee (index out of the used slot range). Derive the shift from the size macro, e.g. use & RELUNDO_HEAD_CACHE_MASK on the multiplied hash instead of the magic shift.

💡 Suggested change

Before:

	return (int) (((uint32) relid * UINT32_C(2654435761)) >> (32 - 6));

After:

	return (int) (((uint32) relid * UINT32_C(2654435761)) >> 16) & RELUNDO_HEAD_CACHE_MASK;

📄 src/backend/access/undo/relundo.c (L1453-L1454)

The 5s throttle interval is a bare magic number 5000000. Define a named constant (e.g. RELUNDO_MAYBE_VACUUM_INTERVAL_US) alongside RELUNDO_MAYBE_VACUUM_MIN_BLOCKS so the cadence is self-documenting and greppable, matching the style of the block-count threshold just above.


📄 src/backend/access/undo/relundo.c (L1446-L1446)

RelUndoMaybeVacuum() documents (comment) that callers must invoke it only after releasing all buffer/tuple locks, because RelUndoDiscard takes the metapage lock and acquiring it under a data-page lock would convoy writers / risk deadlock (metapage-after-datapage ordering). This safety contract is comment-only with no enforcement. Consider an Assert(!HoldingAnyBufferLock...)-style guard if feasible, or at minimum keep this invariant prominent; a future caller that violates it silently reintroduces the lock-ordering hazard.


📄 src/backend/access/undo/relundo_discard.c (L41-L42)

Footgun / error-path leak: RelUndoReserve() stashes the EXCLUSIVE-locked metapage in the per-backend static relundo_pending_metabuf, released only by RelUndoFinish/RelUndoFinishWithTuple/RelUndoCancel. If the caller hits ereport(ERROR) between Reserve and Finish/Cancel, the pin/lock leaks and the static stays set, so the next RelUndoReserve in this backend that allocates a page overwrites a stale (already ResourceOwner-released) buffer handle and later MarkBufferDirty()'s garbage. There is no AtAbort/subxact reset of this static (RelUndoAbortCleanup_hook does not touch it). This makes correct use depend entirely on every caller wrapping Reserve in PG_TRY/PG_FINALLY with RelUndoCancel on error -- an easy-to-misuse contract. Recommend an xact/subxact abort callback that clears relundo_pending_metabuf (UnlockReleaseBuffer if still valid), or documenting the mandatory cleanup contract at the declaration.


📄 src/backend/access/undo/relundo_discard.c (L477-L477)

WAL divergence: on the primary, the re-init branch physically shrinks a pre-existing UNDO fork via smgrtruncate() before re-extending the metapage, but this truncation is NOT WAL-logged. The INIT redo handler (relundo_redo_init) only re-initializes the metapage (block 0) via XLogInitBufferForRedo and never truncates the fork. A crash-recovered instance or standby that had stale blocks [1..N] from a prior incarnation would retain them, diverging from the primary. Either WAL-log the truncate (as the discard path does with xl_relundo_truncate) or document why this branch can only be reached in an idempotent recovery corner case.


📄 src/backend/access/undo/relundo_discard.c (L86-L86)

Lock-ordering contract is comment-only. RelUndoMaybeVacuum -> RelUndoVacuum -> RelUndoDiscard acquires the UNDO metapage lock; the comment states this must be called only after releasing all buffer/tuple locks, but nothing enforces it. A caller that invokes it while holding a data-page lock would convoy writers or violate the metapage-after-datapage ordering. Consider Assert(!held-page-locks) via a debug check, at minimum. Also the '5000000' throttle should be a named constant (e.g. RELUNDO_MAYBE_VACUUM_INTERVAL_US) consistent with the 5-second comment.


📄 src/backend/access/undo/relundo_discard.c (L477-L477)

Dead scaffolding + aspirational comment. RelUndoDropRelation() does nothing beyond an smgrexists() early-return that is then ignored; the real work is delegated to smgrdounlinkall(). The trailing 'If in the future we need explicit fork removal...' is future-tense speculation. Per the minimalism/comment-accuracy discipline, either remove this no-op function (and its call sites) or drop the speculative comment and keep only a one-line rationale.


📄 src/backend/access/undo/relundo_apply.c (L115-L116)

Portability defect: RelUndoRecPtr is uint64 (relundo.h:80). Casting it to unsigned long and printing with %lu truncates the high 32 bits on LLP64 platforms (Windows 64-bit), producing a misleading value. Use UINT64_FORMAT.

💡 Suggested change

Before:

elog(DEBUG1, "RelUndoApplyChain: starting rollback at %lu",
	 (unsigned long) current_ptr);

After:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=" UINT64_FORMAT ", payload_size=%zu, tuple_len=%u",
		 (uint64) ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo_apply.c (L729-L729)

Hot-path debug scaffolding: these elog(DEBUG1) calls sit on the reservation fast path (once per DML). Even at DEBUG1 the arguments are always evaluated and the elog machinery is invoked (a function call + errstart check) on a contended write path. This is developer instrumentation that should be removed before commit; per-record DEBUG logging on the reserve path is not something committers accept.


📄 src/backend/access/undo/relundo_page.c (L57-L58)

Portability defect: ptr is a RelUndoRecPtr, which is typedef uint64 (relundo.h:80). Printing it with %lu/(unsigned long) truncates on LLP64 platforms (Windows 64-bit, where unsigned long is 32-bit) and is undefined-behavior format/argument mismatch elsewhere. The tree requires UINT64_FORMAT for 64-bit values. Also, this is one of several leftover DEBUG1 scaffolding lines on a hot write path (see also the block in RelUndoReserve) that should be removed before commit -- they evaluate format args on a contended path and read as WIP debug noise.

💡 Suggested change

Before:

elog(LOG, "UNDO fork for relation \"%s\" has no blocks, initializing metapage",
     RelationGetRelationName(rel));

After:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=" UINT64_FORMAT ", payload_size=%zu, tuple_len=%u",
		 (uint64) ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo_page.c (L53-L53)

Dead scaffolding with an aspirational comment. RelUndoDropRelation does nothing beyond an early return: after the smgrexists check it falls through with only a future-tense comment ('If in the future we need explicit fork removal...'). Per the project's minimalism/comment discipline this is a no-op with speculative commentary -- either remove the function (and its call site) or give it a real body. Aspirational 'in the future' comments describe behavior that does not exist and should not ship.


📄 src/backend/access/undo/relundo_page.c (L287-L287)

Abort-path leak of per-backend static state. relundo_pending_metabuf is set here when a new page is allocated, and is only ever cleared by RelUndoStage/RelUndoFinish(WithTuple)/RelUndoCancel. There is no abort-time reset (the RelUndoAbortCleanup_hook does not touch it). If the caller throws between RelUndoReserve() and Finish/Cancel -- e.g. the RECNO UPDATE path's PG_CATCH at recno_operations.c:4217 releases only overflow buffers and re-throws without calling RelUndoCancel() -- ResourceOwner cleanup releases the pin/lock but leaves this static pointing at a now-invalid buffer. The next RelUndoReserve+Finish in this backend would then hit Assert(BufferIsValid(relundo_pending_metabuf)) with a stale buffer or (with asserts off) WAL-register a released/reused buffer. The reservation should register a resource-owner/xact-abort callback that clears this static, so an error between Reserve and Finish cannot leave dangling state.


📄 src/backend/access/undo/relundo_recovery.c (L344-L344)

pd_lower is read verbatim from the on-disk page header and never bounded before it drives the record-striding loop below. On a corrupt or torn UNDO page, pd_lower (a uint16) can be as large as 65535 while the page buffer is only BLCKSZ (typically 8192) bytes; memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader) then reads past the page buffer -- an out-of-bounds read during crash recovery, exactly when the code is expected to be defensive against damaged pages. Additionally, even for a benign page the final iteration where offset < pd_lower but offset + SizeOfRelUndoRecordHeader > pd_lower reads slightly past the intended record region. Bound pd_lower to [SizeOfRelUndoPageHeaderData, hdr->pd_upper] (the contents area is BLCKSZ - MAXALIGN(SizeOfPageHeaderData) per relundo_init_page) before using it, e.g. treat an out-of-range pd_lower as an empty page. This also fixes the maxrecs/palloc sizing below, which is currently derived from the same unvalidated value.

💡 Suggested change

Before:

	pd_lower = hdr->pd_lower;

After:

	pd_lower = hdr->pd_lower;

	/*
	 * Defensively clamp a corrupt pd_lower: the record region lives in the
	 * page contents area, so a value below the page header or beyond
	 * pd_upper cannot be trusted.  Treat such a page as having no records
	 * rather than striding off the end of the buffer.
	 */
	if (pd_lower < SizeOfRelUndoPageHeaderData || pd_lower > hdr->pd_upper)
		pd_lower = SizeOfRelUndoPageHeaderData;

📄 src/backend/access/undo/relundo_recovery.c (L319-L321)

RelUndoRecoveryApplyPage() returns int but the value returned is prev (a BlockNumber, i.e. uint32) and, at the caller, is assigned to a BlockNumber blkno. For blocks > INT_MAX and for InvalidBlockNumber (0xFFFFFFFF) this round-trips only by implementation-defined signed/unsigned conversion. Return BlockNumber directly to match the codebase convention and avoid relying on int width/conversion behavior (portability rule). Update the forward declaration accordingly.

💡 Suggested change

Before:

static int
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)
{

After:

static BlockNumber
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)
{

📄 src/backend/access/undo/relundo_recovery.c (L75-L75)

Forward declaration uses int return type but the function returns a BlockNumber (uint32). Update this prototype to BlockNumber to match the corrected definition and avoid relying on implementation-defined int/uint32 conversion for values such as InvalidBlockNumber (0xFFFFFFFF).

💡 Suggested change

Before:

static int	RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno);

After:

static BlockNumber RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno);

📄 src/backend/access/undo/relundo_xlog.c (L334-L336)

Out-of-bounds read on a corrupt/torn page during crash recovery. pd_lower is a uint16 read directly from the on-disk page header (RelUndoPageHeaderData.pd_lower) with no upper-bound validation. The valid contents area is only BLCKSZ - MAXALIGN(SizeOfPageHeaderData) bytes (~8168 for BLCKSZ=8192), but a bogus pd_lower can be as large as 65535. The loop guard offset < pd_lower then lets memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader) read far past the ~8KB page buffer. Additionally, maxrecs and the two palloc() calls are sized from the same unvalidated pd_lower, allocating more than a page's worth. Recovery must be defensive against corrupt pages. Bound pd_lower to [SizeOfRelUndoPageHeaderData, BLCKSZ - MAXALIGN(SizeOfPageHeaderData)] before use, and additionally guard the final header so it does not straddle pd_lower (i.e. require offset + SizeOfRelUndoRecordHeader <= pd_lower).

💡 Suggested change

Before:

if (undohdr->pd_lower > BLCKSZ)
	elog(PANIC, "relundo_redo_insert: existing pd_lower %u exceeds page size",
		 undohdr->pd_lower);

After:

	prev = hdr->prev_blkno;
	page_counter = hdr->counter;
	pd_lower = hdr->pd_lower;

	/*
	 * pd_lower comes straight off a possibly torn/corrupt page; clamp it to
	 * the usable contents area before striding records, so a bogus value
	 * cannot drive the memcpy or the palloc sizing past the page buffer.
	 */
	if (pd_lower < SizeOfRelUndoPageHeaderData ||
		pd_lower > (uint16) (BLCKSZ - MAXALIGN(SizeOfPageHeaderData)))
	{
		UnlockReleaseBuffer(buf);
		return prev;
	}

📄 src/backend/access/undo/slog_flathash.c (L524-L527)

DSA leak: this reclaim path nulls before_image_dp without freeing the DSA chunk it may hold. Everywhere else in the sLog layer the before-image is collected into a local array before the apply op nulls it, then dsa_free()'d after the seq cycle closes (see SLogTupleAbortByXid at slog.c:2153-2292 and SLogTupleCleanupRetained at slog.c:3454-3517). But the caller of this INSERT op, SLogTupleInsert (slog.c:1459-1468), does NOT collect or free the reclaimed marker's before_image_dp. A below-horizon retained UPDATE marker can legitimately still carry a valid before_image_dp (the cleanup-retained scan explicitly handles that case), so on a hot row this orphans a DSA allocation on every reclaim -- a repeated shared-memory leak. Free the pointer before nulling (subject to the same seq-cycle ordering as the other paths), or have SLogTupleInsert collect and free it.


📄 src/backend/access/undo/slog_flathash.c (L106-L109)

The probe/insert index math (h & (uint32)(ht->capacity - 1) and idx = (idx + 1) & (uint32)(ht->capacity - 1)) depends on capacity being a power of 2 and > 0, and the struct comment documents it, but SLogFlatHashInit never enforces it. If capacity is 0, capacity-1 becomes 0xFFFFFFFF and the probe loops read out of bounds; if it is not a power of 2 the mask silently probes wrong buckets. Callers currently derive capacity from power-of-2 values, but the invariant is load-bearing and unchecked. Add a defensive Assert (e.g. Assert(capacity > 0 && (capacity & (capacity - 1)) == 0)) here to catch any future caller that violates it.

💡 Suggested change

Before:

	ht->capacity = capacity;
	ht->num_entries = 0;
	ht->num_tombstones = 0;
	ht->padding = 0;

After:

	Assert(capacity > 0 && (capacity & (capacity - 1)) == 0);

	ht->capacity = capacity;
	ht->num_entries = 0;
	ht->num_tombstones = 0;
	ht->padding = 0;

📄 src/backend/access/undo/slog_flathash.c (L57-L61)

Dead parameter (YAGNI): max_backends is accepted only to be discarded via (void) max_backends, and SLogFlatHashPartitionedShmemSize threads it through solely to pass it here. Both call sites (slog.c:551,585 and slog.c:635) pass MaxBackends but the value is never used. This kind of "retained for call-site compatibility" scaffolding draws objection on -hackers. Drop the parameter from both functions and update the three call sites and the header prototypes.


📄 src/backend/access/undo/slog_flathash.c (L377-L378)

These WARNING strings do not follow PostgreSQL message conventions: an errmsg should start lowercase and read as prose, not carry ALL_CAPS machine tokens like "SLOG_LOST_OP table_full". If a machine-parseable marker is genuinely needed, prefer ereport() with an appropriate errcode and a conventional message. Also note TransactionId prints elsewhere in this subsystem use %u consistently, which is fine here.


📄 src/backend/access/undo/undo_bufmgr.c (L38-L42)

This entire file is dead code. None of its eight exported functions (UndoMakeBufferTag, ReadUndoBuffer, ReadUndoBufferExtended, ReleaseUndoBuffer, UnlockReleaseUndoBuffer, MarkUndoBufferDirty, InvalidateUndoBuffers, InvalidateUndoBufferRange) has any caller anywhere in the tree. The header undo_bufmgr.h (lines 128-134) confirms this: undo I/O uses pwrite()/pread() and "bypasses shared_buffers entirely", and the buffer-pool integration "is retained only for the buffer invalidation API used during segment discard" -- but even the two invalidation functions are never called. This is speculative scaffolding (YAGNI); on pgsql-hackers this whole file should be dropped until a real caller exists. [high confidence]


📄 src/backend/access/undo/undo_bufmgr.c (L23-L28)

The file header claims "WAL integration for crash safety" and routing undo I/O "through PostgreSQL's standard shared buffer pool", but the accompanying header (undo_bufmgr.h:128-134) states the read/write path uses pwrite()/pread() and "bypasses shared_buffers entirely", and this file performs no WAL logging at all. The comment overstates and contradicts the actual design, and will mislead reviewers (POLA violation). Comments must describe what the code does now. [high confidence]


📄 src/backend/access/undo/undo_bufmgr.c (L235-L242)

last_block is accepted but never used to bound the operation. As the comment admits, DropRelationBuffers() drops ALL buffers with blockNum >= firstDelBlock (see bufmgr.c:4830-4835, "Dirty pages are simply dropped, without bothering to write them out first ... NOT rollback-able"). So a caller intending to invalidate only [first_block, last_block] would silently have every block from first_block to end-of-log dropped, discarding dirty undo pages without write-back. This is a data-loss footgun: the signature promises range semantics it does not deliver. Either implement true range invalidation or remove last_block and rename to reflect "from first_block onward". [high confidence]


📄 src/backend/access/undo/undo.c (L119-L127)

EXEC_BACKEND hazard: this static guard does not prevent a spurious error on every child startup. Under EXEC_BACKEND, each exec'd child re-runs ShmemCallRequestCallbacks() (launch_backend.c), which re-invokes this request_fn. In the fresh child process image undo_worker_registered is reset to false, so UndoWorkerRegister() -> RegisterBackgroundWorker() runs. But by then IsUnderPostmaster is true and process_shared_preload_libraries_in_progress is false, so RegisterBackgroundWorker() (bgworker.c) takes the early-return path and logs background worker "undo worker": must be registered in "shared_preload_libraries" on every child spawn.

The static flag only works because it happens to survive across the postmaster's own reinitialize cycle in the fork() model; it provides no protection in EXEC_BACKEND. The established convention is to register builtin workers directly in PostmasterMain (as ApplyLauncherRegister()/LogicalRevertLauncherRegister() do at postmaster.c:928/935), which is not re-run in children or on reinitialize, rather than from a request_fn callback. Confidence: high.


📄 src/backend/access/undo/undo_xlog.c (L203-L205)

Missing critical section in the CLR redo path. This entire BLK_NEEDS_REDO branch modifies page contents and then calls PageSetLSN() + MarkBufferDirty() (below) with no START_CRIT_SECTION()/END_CRIT_SECTION() around it. Every other redo/apply routine in this tree (heapam.c, recno_undo.c apply_recno_undo_insert/restore_tuple, relundo_xlog.c) wraps the page mutation + PageSetLSN/MarkBufferDirty in a critical section so that a failure between dirtying the buffer and completing the change PANICs rather than leaving a torn, WAL-inconsistent page. Without it, an ERROR partway through (e.g. the delta-length checks below) leaves a dirtied-but-uncommitted page and breaks crash consistency. Wrap the mutation in START_CRIT_SECTION()/END_CRIT_SECTION().


📄 src/backend/access/undo/undo_xlog.c (L305-L310)

ereport(ERROR) during WAL redo is fatal for the startup process (PANIC-equivalent) and turns a single corrupt/mismatched CLR into an unrecoverable crash loop. This directly contradicts the design used elsewhere in this same file (UndoReadBatchFromWAL and PerformUndoRecovery deliberately WARN-and-skip rather than PANIC precisely to avoid making the database permanently unrecoverable). A corrupt or recycled-and-overwritten CLR reaching this branch should be handled defensively (skip the block) rather than ERROR/PANIC. Same applies to the second delta-length ereport(ERROR) below.


📄 src/backend/access/undo/undo_xlog.c (L265-L266)

Dead code path with no WAL producer. UNDO_CLR_HAS_DELTA is referenced only here in the redo path; a repository-wide search finds no producer that ever sets this flag in an emitted CLR (recno emits UNDO_CLR_HAS_TUPLE; hash/nbtree emit UNDO_CLR_LP_DEAD). The same is true for UNDO_CLR_LP_UNUSED, UNDO_CLR_HAS_VISIBILITY, and UNDO_CLR_HOT_RESTORE handled in this switch. Per the minimalism/YAGNI discipline, redo handling for flags that are never written is speculative scaffolding and a correctness hazard (untested paths that can corrupt pages). Remove these branches or wire up and test the producers.


📄 src/backend/access/undo/undo_xlog.c (L994-L996)

MyDatabaseId is InvalidOid here. PerformUndoRecovery() runs in the startup process during crash recovery (called from xlogrecovery.c before InRedo is cleared), which has no database attached, so MyDatabaseId is InvalidOid. Recording the deferred transaction with dboid = InvalidOid means the logical revert worker cannot later connect to the correct database to finish the rollback, so the required undo is silently dropped -> logical data inconsistency. The database OID must be derived from the transaction's relation/undo record, not from MyDatabaseId.


📄 src/backend/access/undo/undo_xlog.c (L639-L647)

O(n^2) recovery cost. UndoRecoveryTrackBatch performs a linear scan over all tracked entries for every XLOG_UNDO_BATCH record, and UndoRecoveryRemoveXid does the same on every commit/abort redo. With the cap at ~1M distinct XIDs this is O(n^2) and can dominate recovery time on workloads with many in-flight transactions. The idiomatic solution is a hash table (simplehash.h or dynahash) keyed by xid; reuse it rather than a linear array.


📄 src/backend/access/undo/undo_xlog.c (L1206-L1208)

Wrong timeline for the recycle-detection check. This function runs during recovery/replay, where the relevant timeline for locating past WAL segments is the replay timeline, not the WAL insertion timeline. GetWALInsertionTimeLine() may return a different TLI, producing either a false 'segment recycled' warning (skipping legitimate rollback) or failing to detect a genuinely missing segment (the SIGBUS this guard is meant to prevent). Also note the access(F_OK) + subsequent read is inherently racy against a concurrent checkpoint. Use the replay timeline.


📄 src/backend/access/undo/undo_xlog.c (L617-L617)

Non-ASCII characters in a comment. The user rules require ASCII-only in source. This line contains the multiplication sign, the approximately-equal sign, and an em dash. Replace with ASCII (e.g. "1 million entries x ~40 bytes each ~= 40 MB").

💡 Suggested change

Before:

 * 1 million entries × ~40 bytes each ≈ 40 MB, which is reasonable for

After:

 * 1 million entries x ~40 bytes each ~= 40 MB, which is reasonable for

📄 src/backend/access/undo/undo_xlog.c (L1195-L1195)

Non-ASCII em dash in comment; replace with ASCII (e.g. " -- if our target ...").

💡 Suggested change

Before:

	 * Compare against GetRedoRecPtr() — if our target is well behind the

After:

	 * Compare against GetRedoRecPtr() -- if our target is well behind the

📄 src/backend/access/undo/undoapply.c (L214-L215)

Data-corruption hazard: a batch can legitimately contain more than max_records (1024) records. The uset-based write path (UndoRecordSetInsert) has no per-batch record cap and flushes all uset->nrecords (a uint32) as a single XLOG_UNDO_BATCH; the undobuffer path defaults to 1000 (undo_batch_record_limit) but is GUC-configurable higher. When a batch exceeds 1024 records, the forward scan below breaks after collecting record_starts[0..1023] (the OLDEST records), then the reverse-apply loop applies only those and silently SKIPS the NEWEST records (indexes >= 1024). This is the exact opposite of the ARIES newest-first requirement and leaves on-disk state partially/incorrectly un-reverted. Only a WARNING is emitted and the function still returns success. Collect all record starts with a growable structure (e.g. a List, or a two-pass count-then-alloc) instead of a fixed cap, so every record in the batch is applied.


📄 src/backend/access/undo/undoapply.c (L171-L175)

The chain-walk loop can iterate over an arbitrarily long UNDO chain, and neither this loop nor UndoReadBatchFromWAL()/the rm_undo callbacks issue CHECK_FOR_INTERRUPTS(). During a large rollback the backend becomes uninterruptible and cannot respond to query cancel / SIGTERM. Add a CHECK_FOR_INTERRUPTS() at the top of the while loop.

💡 Suggested change

Before:

	while (XLogRecPtrIsValid(batch_lsn))
	{
		UndoBatchData *batch;
		char	   *pos;
		char	   *end;

After:

	while (XLogRecPtrIsValid(batch_lsn))
	{
		UndoBatchData *batch;
		char	   *pos;
		char	   *end;

		CHECK_FOR_INTERRUPTS();

📄 src/backend/access/undo/undoapply.c (L250-L257)

urec_payload_len is not validated against urec_len. The scan checks hdr.urec_len >= SizeOfUndoRecordHeader and that urec_len fits within (end - pos), but never checks that urec_payload_len <= urec_len - SizeOfUndoRecordHeader. In the apply pass, payload is set to record_starts[i] + SizeOfUndoRecordHeader and header.urec_payload_len is handed to rm_undo, whose implementations (e.g. hash_undo/nbtree_undo) memcpy from payload trusting payload_len. On a corrupt/mismatched WAL record during recovery this permits an out-of-bounds read past the record within the batch buffer. Add a bound check here (reject the record if urec_payload_len > urec_len - SizeOfUndoRecordHeader).

💡 Suggested change

Before:

				if (hdr.urec_len < SizeOfUndoRecordHeader ||
					(Size) (end - pos) < hdr.urec_len)
				{
					ereport(WARNING,
							(errmsg("UNDO rollback: invalid record size %u in batch at %X/%X",
									hdr.urec_len, LSN_FORMAT_ARGS(batch_lsn))));
					break;
				}

After:

				if (hdr.urec_len < SizeOfUndoRecordHeader ||
					(Size) (end - pos) < hdr.urec_len ||
					hdr.urec_payload_len > hdr.urec_len - SizeOfUndoRecordHeader)
				{
					ereport(WARNING,
							(errmsg("UNDO rollback: invalid record size %u in batch at %X/%X",
									hdr.urec_len, LSN_FORMAT_ARGS(batch_lsn))));
					break;
				}

📄 src/backend/access/undo/undoapply.c (L288-L288)

urec_ptr is hardcoded to InvalidUndoRecPtr, but the rm_undo contract in undormgr.h documents this argument as "Position of this record in UNDO log (for CLR generation)", and hash_undo.c/nbtree_undo.c store it into xl_undo_apply.urec_ptr in the emitted CLR. As a result every CLR carries an invalid urec_ptr. Redo currently ignores this field (idempotency relies on page LSN), so it is not an immediate corruption, but it contradicts the documented contract, produces a misleading value in WAL, and is a latent footgun if any future rm_undo/redo path starts relying on urec_ptr. Either plumb through the real UndoRecPtr for the record, or update the callback contract/comment to state urec_ptr is always invalid in this path.


📄 src/backend/access/undo/undoapply.c (L294-L297)

Leftover commented-out code (pfree(record_starts);) violates PostgreSQL comment discipline (no commented-out code). Remove it; the surrounding comment already explains why the allocation is deliberately not freed. Also note the sibling comment "Large enough to avoid reallocation" asserts a guarantee the code does not provide (see the >1024-record correctness bug above).


📄 src/backend/access/undo/undobuffer.c (L192-L194)

Stale singleton state survives an aborted transaction. undo_t2buf is a process-global, and UndoBufferEnd/UndoBufferReset are only ever called from the AM's finish_bulk_insert hook (recno_handler.c), which runs via table_finish_bulk_insert() on the normal completion path (COPY, ExecEndModifyTable, etc.). If an ERROR is raised mid-DML (constraint violation, out-of-space, serialization failure), execution unwinds without calling finish_bulk_insert, so active, xid, relid, and chain_prev remain set from the aborted transaction. AtAbort_XactUndo() resets XactUndo but does not touch undo_t2buf.

Consequently, on the next UndoBufferBegin() for the same relid, the early-return path below treats the stale state as current and reuses a wrong xid/chain_prev from a dead transaction, corrupting the UNDO chain. Register an abort/EOXact reset hook (e.g. from AtAbort_XactUndo() / an xact callback) that clears undo_t2buf.active and its bookkeeping. (high confidence)


📄 src/backend/access/undo/undobuffer.c (L325-L325)

persistence is hardcoded to UNDOPERSISTENCE_PERMANENT regardless of the relation's actual persistence. recno_begin_bulk_insert() calls UndoBufferBegin() unconditionally, including for TEMP and UNLOGGED recno tables. Per the undo README, UNDOPERSISTENCE_TEMP/UNDOPERSISTENCE_UNLOGGED batches are skipped during crash recovery; emitting them as PERMANENT means they would be incorrectly replayed, causing wrong recovery / standby divergence. The sibling path (UndoRecordSetInsert) uses uset->persistence, and there is an existing helper GetUndoPersistenceLevel(rel->rd_rel->relpersistence) in xactundo.c. Store the relation's persistence in the buffer at UndoBufferBegin() time and use it here (and in the XActUndoUpdateLastBatchLSN call below). (high confidence)


📄 src/backend/access/undo/undobuffer.c (L112-L117)

This header-serialization block is a near-verbatim copy of UndoRecordAddPayload() in undorecord.c (identical field-by-field construction: memset + urec_rmid/urec_flags/urec_info/urec_len/urec_xid/urec_prev/urec_reloid/urec_payload_len/urec_clr_ptr). UndoTier2AddRecordParts duplicates UndoRecordAddPayloadParts the same way. Duplicating the on-disk record format in two places is a maintenance/DRY hazard: any future change to UndoRecordHeader layout or flag semantics must be kept in sync by hand, and drift would silently corrupt the WAL format that the redo/rollback parser (undoapply.c / undo_xlog.c) reads. Consider factoring the header build into a shared helper reused by both paths. (moderate confidence)


📄 src/backend/access/undo/undolog.c (L180-L185)

Inconsistent locking for discard_ptr. The struct comment in undolog.h states lock "Protects metadata (NOT insert_ptr)", and discard_ptr is a plain (non-atomic) UndoRecPtr. Every other reader in this file takes the per-log LWLock before touching discard_ptr (CheckPointUndoLog and UndoLogGetDiscardPtr take LW_SHARED; UndoLogDiscard takes LW_EXCLUSIVE). This function reads log->discard_ptr with no lock held, so it can observe a torn/stale value while UndoLogDiscard concurrently writes it under LW_EXCLUSIVE. Since the returned value feeds WAL-retention decisions, a stale read is a real correctness hazard. Take LWLockAcquire(&log->lock, LW_SHARED) around the read, matching the other readers. (high confidence)


📄 src/backend/access/undo/undolog.c (L43-L45)

These variables are documented as "GUC parameters" (also in the file header and undolog.h), but none of them is ever registered as a GUC (no DefineCustomIntVariable/entry in guc_tables.c anywhere in the tree). They are therefore plain globals that permanently keep their compile-time defaults and cannot be tuned by operators, contradicting the documentation (POLA) and the comment in undobuffer.c that calls them "Tunable via the undo_batch_size_kb ... GUCs". Either register them properly (with correct GUC_UNIT_KB/GUC_UNIT_MB/GUC_UNIT_MS units matching the in-line unit comments) or drop the "GUC" wording. Additionally, undo_retention_time and undo_buffer_size have no consumers anywhere in the change (grep finds only their definition/header), so they are dead knobs and should be removed per YAGNI. (high confidence)


📄 src/backend/access/undo/undoinsert.c (L126-L127)

This comment asserts a hard invariant that is false for several callers. In hashinsert.c the parent calls END_CRIT_SECTION() before invoking HashUndoLogInsert() (which reaches UndoRecordSetInsert()), and nbtinsert.c explicitly emits the nbtree UNDO record "after _bt_split's critical section has ended". Those paths also never issue the matching INJECTION_POINT_LOAD (only PrepareXactUndoData() does), so INJECTION_POINT_CACHED here is a silent no-op for them rather than the documented behavior. The CACHED variant is safe in either case, but the comment is materially inaccurate about a durability-critical function and should describe the actual contract (crit-section entry is the caller's responsibility; the injection points fire only when a caller pre-loaded them).


📄 src/backend/access/undo/undoinsert.c (L108-L109)

uset->nrecords (int) and uset->buffer_size (Size) are silently narrowed to uint32. Although XLogInsert() will reject a record exceeding XLogRecordMaxSize (1GB) before overflow becomes possible, relying on that implicitly is fragile. Add an explicit guard/Assert (e.g. Assert(uset->buffer_size <= UINT32_MAX && uset->nrecords >= 0)) so a future change that lifts the WAL size limit or feeds a larger buffer cannot silently corrupt total_len/nrecords in the on-disk WAL format.


📄 src/backend/access/undo/undoinsert.c (L55-L59)

UndoWalBatchFlush() and UndoWalBatchReset() are pure no-ops whose only callers (in xactundo.c: AtCommit_XactUndo/AtAbort_XactUndo) invoke them with self-admitting "no-op, kept for safety" comments. This is dead scaffolding for an incomplete migration. Per YAGNI/minimalism, either finish the migration by removing these functions along with their call sites, or drop the calls; keeping empty stubs "for callers that haven't been updated yet" is WIP that should not be committed as-is.


📄 src/backend/access/undo/undoinsert.c (L28-L29)

Future/aspirational-tense wording in the file header: "kept as no-ops for callers that haven't been updated yet" describes an incomplete transition rather than what the code does now. Comments should describe current behavior; this reads as WIP.


📄 src/backend/access/undo/undolog.c (L472-L478)

Dead code: UndoLogPath has no callers anywhere in the change, and it builds a path (base/undo/%012u) to segment files that this architecture no longer creates (the comment says so). It is pure scaffolding for a removed I/O path. Per YAGNI it should be dropped (along with its header declaration). The same applies to the neighboring no-op stubs UndoLogSync, UndoFlushGetMaxWritePtr, UndoLogTryPressureDiscard, and ExtendUndoLogSmgrFile: a tree-wide search finds no callers for any of them, so the block comment's claim that these stubs "still have live callers" is only true for a subset (UndoLogCloseFiles / UndoFlushResetMaxWritePtr / UndoLogSealAndRotate / UndoLogDeleteSegmentFile / ExtendUndoLogFile). Remove the callerless stubs and correct the comment. (medium confidence)


📄 src/backend/access/undo/undorecord.c (L308-L316)

Unaligned memory access on a strict-alignment gate. dest points at uset->buffer + uset->buffer_size, where buffer_size is the running total of prior records including their variable-length payloads. When any preceding payload length is not a multiple of the machine word (e.g. an odd-length tuple image), dest is misaligned, yet the code stores multi-byte fields (urec_len/urec_xid/urec_prev/urec_reloid/urec_payload_len/urec_clr_ptr, up to 8-byte XLogRecPtr/TransactionId) through the UndoRecordHeader * pointer. On ARM64/PPC64/RISC-V/SPARC-style strict-alignment targets this is undefined behavior and can SIGBUS. The same buffer is cast back to UndoRecordHeader * in undoinsert.c during the WAL walk. Either pad record_size up to MAXALIGN so every header starts aligned, or use memcpy of individual fields / a byte-packing scheme.


📄 src/backend/access/undo/undorecord.c (L223-L227)

Integer overflow in the capacity-doubling logic can under-allocate and cause a heap buffer overflow. new_capacity = uset->buffer_capacity * 2 and the new_capacity *= 2 loop have no overflow guard: a large additional makes the product wrap around to a small Size, MemoryContextAlloc allocates too little, and the caller's subsequent memcpy of the payload writes past the buffer. Additionally header->urec_len/urec_payload_len are truncated to uint32 with no check that record_size/payload_len fit, and part1_len + part2_len in UndoRecordAddPayloadParts can itself overflow Size. Add explicit overflow checks (e.g. reject sizes that would exceed a documented maximum before doubling, and guard against new_capacity wrapping).


📄 src/backend/access/undo/undorecord.c (L53-L56)

Dead code: UndoRecordSerialize has no callers anywhere in the tree (the actual serialization is done inline by UndoRecordAddPayload/AddPayloadParts writing directly into uset->buffer). Per the minimalism/YAGNI discipline, remove this speculative helper rather than shipping unused scaffolding.


📄 src/backend/access/undo/undorecord.c (L73-L78)

UndoRecordDeserialize has no callers in the tree (dead code) and, as written, is a footgun: it validates only NULL src/header, then blindly copies SizeOfUndoRecordHeader from src and returns a raw pointer into src based on the trusted urec_payload_len, with no bound on how much data src actually holds. A caller feeding it a short or corrupt on-disk/WAL record would read out of bounds. Remove it if unused, or add a source-length parameter and validate SizeOfUndoRecordHeader + urec_payload_len against it.


📄 src/backend/access/undo/undorecord.c (L31-L32)

This comment asserts an invariant the code under review does not enforce: on the COMMIT path AtCommit_XactUndo() never calls UndoRecordSetResetCache() (it returns early when !has_undo and otherwise only frees the record sets), so after a committing transaction that used UNDO, UndoRecordReusableContext (re-parented to TopMemoryContext by UndoRecordSetFree) survives across transaction boundaries rather than being 'cleaned up at transaction end'. Either the comment is wrong or the reset call is missing on commit; align the two.


📄 src/backend/access/undo/undostats.c (L39-L41)

These three functions are declared with PG_FUNCTION_INFO_V1 but have no corresponding entries in pg_proc.dat (verified: no undo entries exist in pg_proc.dat and it is not part of this change set). Without pg_proc entries, none of these functions is reachable from SQL, so the entire purpose of this file is inoperative. The header comment below even claims they are "registered in pg_proc.dat" -- that is false. Add pg_proc.dat entries with OIDs from the developer range (8000-9999; see src/include/catalog/unused_oids and duplicate_oids) and matching prorettype/proargtypes/proretset consistent with the tuple descriptors built here (pg_stat_get_undo_logs is proretset=t returning record; pg_undo_force_discard takes bool, returns int4). (high confidence)


📄 src/backend/access/undo/undostats.c (L352-L365)

This Phase 2 block is a copy of perform_undo_discard()'s Phase 2 in undoworker.c, but it omits the pg_atomic_fetch_sub_u32(&UndoWorkerShmem->sealed_log_count, 1) that the worker performs when freeing a DISCARDABLE log (undoworker.c:347). Freeing a slot here without decrementing leaves sealed_log_count too high, which permanently keeps the discard worker on the fast (UNDO_WORKER_FAST_NAPTIME_MS) sleep path (undoworker.c:569-571). Beyond that specific bug, duplicating the worker's two-phase discard logic inline is a DRY hazard: the two copies will drift. Prefer factoring the shared Phase 1/Phase 2 body into a helper in undoworker.c/undolog.c and call it from both. (high confidence)


📄 src/backend/access/undo/undostats.c (L213-L216)

GetUndoBufferStats() is a no-op that returns all zeros (the header comment confirms UNDO buffer stats are "no longer tracked separately"), yet pg_stat_get_undo_buffers still ships a 6-column tuple descriptor including a hit_ratio that is always 0.0. This is dead/misleading observability surface: a SQL function that always reports zeros. Per YAGNI, either remove pg_stat_get_undo_buffers and the UndoBufferStat plumbing entirely, or reduce it to what is actually populated. Shipping a permanently-zero function invites user confusion. (moderate confidence)


📄 src/backend/access/undo/undostats.c (L94-L96)

size_bytes is computed as an unsigned subtraction of two 40-bit offsets with no guard. If discard_ptr ever exceeds insert_ptr (transient state, or an invariant violation), this underflows to a huge value that is then reported to the user (and, since the column is INT8, may surface as a large or negative-looking number). Clamp to zero when discard_ptr > insert_ptr. (low confidence)

💡 Suggested change

Before:

		stats[count].size_bytes =
			UndoRecPtrGetOffset(stats[count].insert_ptr) -
			UndoRecPtrGetOffset(log->discard_ptr);

After:

		{
			uint64		ins_off = UndoRecPtrGetOffset(stats[count].insert_ptr);
			uint64		dis_off = UndoRecPtrGetOffset(log->discard_ptr);

			stats[count].size_bytes = (ins_off > dis_off) ? (ins_off - dis_off) : 0;
		}

📄 src/backend/access/undo/undostats.c (L14-L14)

This comment claims the SQL functions are "registered in pg_proc.dat", but there are no such entries in pg_proc.dat. Fix the pg_proc.dat gap (see comment above), then this statement becomes accurate; until then the comment is misleading. (low confidence)


📄 src/backend/access/undo/undoworker.c (L346-L347)

sealed_log_count is never incremented. The full-tree search finds only pg_atomic_init_u32(...,0) (init), this fetch_sub, and the pg_atomic_read_u32 in the main loop. The SEALED-state transition in undo_xlog.c does not bump this counter either. Since the counter starts at 0 and is only ever decremented, the very first freed DISCARDABLE log wraps this uint32 to UINT32_MAX. Thereafter sealed_count > 0 is permanently true, forcing the 200ms fast naptime forever and turning the worker into a busy-loop. Either increment the counter at the SEAL transition, or drop this counter and derive the adaptive naptime differently.


📄 src/backend/access/undo/undoworker.c (L128-L130)

SIGTERM handler is not async-signal-safe. shutdown_requested is declared as a plain bool (undoworker.h), not volatile sig_atomic_t, yet it is written here from signal context and read unlocked in the main loop condition (while (!UndoWorkerShmem->shutdown_requested)), while UndoWorkerRequestShutdown() writes the same field under LWLock. Writing a non-sig_atomic_t from a handler and racing an unlocked reader is undefined behavior; the compiler may cache the loop condition, delaying/missing shutdown. Also UndoWorkerShmem is dereferenced here with no NULL guard. Use the standard idiom pqsignal(SIGTERM, SignalHandlerForShutdownRequest) and test ShutdownRequestPending in the loop, matching other background workers.


📄 src/backend/access/undo/undoworker.c (L272-L275)

Phase 1 discards up to log->insert_ptr, the current insert point, whenever log->oldest_xid merely precedes the oldest active xid. insert_ptr also covers UNDO written by transactions newer than log->oldest_xid (potentially still-active ones), so this can discard UNDO that is still needed for rollback -- a data-loss/rollback-correctness hazard. Additionally, log->lock is released at LW_SHARED before the discard, so the insert_ptr used may be stale relative to the state re-checked inside UndoLogDiscard. Discard should be bounded by the oldest still-needed pointer, not the insert pointer.


📄 src/backend/access/undo/undoworker.c (L284-L287)

log->log_number is read here after LWLockRelease(&log->lock) above, so this ereport touches lock-protected metadata without the lock; a concurrent slot free/reuse could change log_number under it. Capture log_number into a local while still holding the lock and log that instead.


📄 src/backend/access/undo/undoworker.c (L396-L397)

Portability: retained_mb is uint64, but it is cast to (unsigned long) and printed with %lu. On Windows (LLP64) and 32-bit platforms unsigned long is 32 bits, so values >= 4 TB of retained WAL are truncated in the warning. Use UINT64_FORMAT (or the %llu + (unsigned long long) convention) for a uint64.


📄 src/backend/access/undo/undoworker.c (L629-L634)

Inconsistent synchronization for shutdown_requested: here it is set under UndoWorkerShmem->lock (LW_EXCLUSIVE), but the SIGTERM handler sets it locklessly and the main loop reads it locklessly. Pick one discipline. Given it is signal-set, the correct fix is to make it (or the standard ShutdownRequestPending) an atomically/sig_atomic_t-accessed flag and drop the LWLock here, so all accessors agree.


📄 src/backend/commands/tablecmds.c (L999-L1000)

Stray unrelated change. This hunk adds break; to the terminal default: case of the switch, which is a functional no-op (control already falls out of the switch after the last case). It is unrelated to the feature set of this patch (recno/undo/fileops) and adds noise to an already enormous diff. Per PostgreSQL patch hygiene, unrelated cosmetic churn is a common rejection reason on -hackers; drop this hunk to keep the diff minimal. (high confidence)


📄 src/backend/access/undo/xactundo.c (L947-L948)

goto inline_undo_done jumps to the label below (after MemoryContextSwitchTo(old_ctx)/MemoryContextDelete(undo_ctx)), so when UndoValidateBatchLSN(perm_lsn) returns false these two lines never run. Consequences: (1) the current memory context is left as undo_ctx instead of the caller's old_ctx, so the subsequent ATMAddAborted(), ApplyPerRelUndo(), UndoRecordSetFree() etc. all allocate in the wrong (child) context; (2) undo_ctx is parented to TopMemoryContext, so it is never deleted and leaks for the life of the backend. The success/error paths (via PG_END_TRY) restore the context correctly, only the goto path is broken. Restore the context before jumping (or move the switch-back/delete before the label).

💡 Suggested change

Before:

						undo_applied = false;
						goto inline_undo_done;

After:

						undo_applied = false;
						MemoryContextSwitchTo(old_ctx);
						MemoryContextDelete(undo_ctx);
						goto inline_undo_done;

Comment thread .github/ocr/litellm.yaml
@@ -0,0 +1,41 @@
# LiteLLM proxy config — bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-ASCII em-dash (U+2014) used in comments. The contribution standard requires ASCII-only in source and diffs (no smart quotes, em-dashes, or ellipses). Two occurrences: line 1 and line 24. Replace the em-dash with an ASCII hyphen/dash (e.g. " - " or "--").

Suggested change
# LiteLLM proxy config bridges Open Code Review (OpenAI protocol) to AWS Bedrock.
# LiteLLM proxy config - bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Comment thread .github/ocr/litellm.yaml
aws_region_name: os.environ/AWS_REGION

# "High effort" review. Claude Opus 4.8 on Bedrock uses *adaptive* thinking
# controlled by output_config.effort. Set it DIRECTLY here — NOT via

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-ASCII em-dash (U+2014) here as well; use an ASCII dash to comply with the ASCII-only rule.

Suggested change
# controlled by output_config.effort. Set it DIRECTLY here NOT via
# controlled by output_config.effort. Set it DIRECTLY here - NOT via

Comment thread .github/ocr/pg-history.py
final_text = "_pg-history: no related history found._"
body = "## 📜 Change history & discussion (Agora / pg.ddx.io)\n\n" + final_text + \
"\n\n<sub>Generated by pg-history via the Agora MCP server (pg.ddx.io).</sub>\n"
open(OUT, "w").write(body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output file is written via open(OUT, "w").write(...) with no with block and no explicit close(). The file object is only flushed/closed on garbage collection, which is prompt on CPython/Actions but not guaranteed on other runtimes — and the next workflow step immediately cats this file into a PR comment, so a delayed flush risks a truncated/empty comment. This same pattern repeats on lines 158, 168, 172, and 212. Confidence: moderate (real defect, low impact given the short-lived CPython CI context).

Suggested change
open(OUT, "w").write(body)
with open(OUT, "w") as f:
f.write(body)

Comment thread .github/ocr/pg-history.py
Comment on lines +51 to +52
resp = urllib.request.urlopen(req, timeout=60)
sid_out = resp.headers.get("Mcp-Session-Id")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

urllib.request.urlopen(...) returns a response that is read but never closed (no with block). Over the multi-round loop (initialize + notifications + list_tools + up to MAX_ROUNDS tool calls) this leaks the connection/socket until GC reclaims it. Wrapping in with urllib.request.urlopen(...) as resp: releases it deterministically. Confidence: moderate; impact low for a short-lived process.

Suggested change
resp = urllib.request.urlopen(req, timeout=60)
sid_out = resp.headers.get("Mcp-Session-Id")
with urllib.request.urlopen(req, timeout=60) as resp:
sid_out = resp.headers.get("Mcp-Session-Id")
raw = resp.read().decode()

Comment on lines +39 to +43
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AWS CLI result is used without checking out.returncode. On any failure (auth error, throttling, transient network issue) out.stdout is empty, so json.loads(out.stdout or "[]") yields [], opus becomes empty, and the script prints newer=false and exits 0. The failure is silently swallowed: the check appears to pass while doing nothing, and the upgrade notification this workflow exists to produce can be suppressed indefinitely with no signal. Fail loudly on a non-zero return code (or empty stdout).

Suggested change
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
if out.returncode != 0:
raise SystemExit(f"aws bedrock list-inference-profiles failed ({out.returncode}): {out.stderr}")
ids = json.loads(out.stdout or "[]")

Comment on lines +137 to +144
- name: Push to origin
if: steps.merge.outputs.merge_status == 'success'
run: |
if [ "${{ inputs.force_push }}" == "true" ]; then
git push origin master --force-with-lease
else
git push origin master
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Push to origin step has no failure handling. If branch protection blocks the push, or --force-with-lease fails because the remote ref advanced, the push fails only after merge_status is already success. The Close existing sync-failure issues step (also gated on success) may then run/have run and close issues even though the sync did not actually land, leaving issue state inconsistent with reality. Consider ordering the close-issue step after a verified push, or verifying the push result before closing issues.

Comment on lines +9 to +14
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job runs on an hourly cron but has no concurrency control, so overlapping runs can race on the same git push origin master --force-with-lease. Since the merge step force-pushes after a rebase, two concurrent runs can push conflicting refs or produce duplicate 'sync-failure' issue comments. Add a concurrency group with cancel-in-progress (the sibling pg-ci.yml / ocr-review.yml workflows already do this) to serialize sync runs.

concurrency:
  group: sync-upstream
  cancel-in-progress: false

Comment on lines +5 to +6
# Run hourly every day
- cron: '0 * * * *'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schedule comment says "Run hourly every day" but the cron '0 * * * *' fires 24 times/day, while the failure-issue body ("The daily sync from ...") and the Summary step ("### Daily Sync Summary") both describe a daily cadence. This mismatch is misleading; align the comment and the issue/summary text with the actual hourly trigger (or change the cron to daily if daily was intended).

Comment on lines +10 to +12
sync:
runs-on: ubuntu-latest
permissions:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job has no timeout-minutes. A hung git fetch/rebase/push (e.g., against an unreachable upstream) could consume runner minutes indefinitely. Add a timeout-minutes to bound the run, consistent with the other workflows in this repo (pg-ci.yml).

Suggested change
sync:
runs-on: ubuntu-latest
permissions:
sync:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:

Comment on lines +141 to +142
run: |
git push origin master --force-with-lease

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step unconditionally runs git push origin master --force-with-lease after a rebase. This is destructive: any local commit that slips past the grep/wc heuristics in the 'Check for local commits' step (a commit message not matching ^dev (setup|v[0-9]), a merge commit, or a commit that mixes .github/ and non-.github/ files) can be silently dropped by the rebase and then permanently discarded by the force push. Note the sibling sync-upstream-manual.yml gates the force push behind a force_push input, but this automatic hourly workflow force-pushes with no guard. Given the large amount of local development in this fork, a false-positive classification here means silent data loss. At minimum, consider making the force push opt-in / adding a safety check that aborts when the diverged set is not fully accounted for by the .github/ and dev-setup classification.

gburd added 5 commits July 15, 2026 10:53
Keeps gburd/postgres rebased hourly on postgres/postgres master with
only .github/ changes on top (sync-upstream automatic + manual).

Drops the bespoke Windows dependency-builder workflow: Windows is
already built and tested in CI by upstream's pg-ci.yml (Visual Studio
+ MinGW meson jobs), so a separate dependency prebuild that nothing
consumed was redundant.
The Open Code Review system: ocr-review and ocr-model-check workflows
plus .github/ocr config (LiteLLM->Bedrock Claude Opus 4.8, rule.json,
context.md, pg-history.py).
A seqlock protects read-mostly, rarely-written shared data with a single
sequence counter (even = stable, odd = write in progress).  Readers take no
lock and pay no atomic read-modify-write and no StoreLoad fence on the
common path: they read the counter, copy the data into local variables,
re-read the counter, and retry if it changed.  A writer -- serialized by
the caller's own mutex -- bumps the counter odd, mutates the single copy in
place, and bumps it even.

This is cheaper than a left-right read (which needs a per-read SeqCst fence)
and far cheaper than an LWLock shared acquire (a CAS on a shared counter),
at the cost that readers may retry under a concurrent writer, so it suits
read-mostly data with short, infrequent writes.

Documented in the lmgr README and covered by a test_seqlock module that
verifies the counter transitions, the read/retry handshake, and torn-read
rejection.
Introduce two TableAmRoutine booleans and the begin_bulk_insert callback
that the UNDO subsystem builds on, plus the RelationAmSupportsUndo()
accessor index AMs use to gate UNDO record generation on the parent
table.

am_supports_undo marks an AM that registers an UNDO resource manager and
emits UNDO records tagged with its own rmid; the UNDO core stays
AM-agnostic and interprets the payload only through that RM's callbacks.
am_inplace_update_keeps_tid marks an AM that updates in place and keeps
the row's TID, so the executor can skip redundant index re-inserts for
unchanged keys.  The heap AM leaves both false.

This commit only adds the routine fields and the accessor; no AM sets the
flags yet.
pgstat_relation.c hardcodes heap semantics for every table AM: every
UPDATE is assumed to leave behind one dead tuple
(delta_dead_tuples += tuples_updated + tuples_deleted), which is true
for heap (a new tuple version plus an old one to reclaim) but wrong
for any in-place-update access method that overwrites the committed
tuple bytes and leaves nothing behind.

Add a new TableAmRoutine capability flag,
am_inplace_update_no_dead_tuple, alongside the existing
am_inplace_update_keeps_tid.  pgstat_count_heap_update() consults it
per relation and tracks the subset of updates that created no dead
tuple in a new PgStat_TableXactStatus counter
(tuples_updated_no_dead); AtEOXact_PgStat_Relations() subtracts that
count back out of delta_dead_tuples on both the commit and abort
paths.  TRUNCATE/DROP counter save/restore is updated to keep the new
counter in sync with tuples_updated.

Without this fix, an in-place-update AM correctly reports zero real
dead tuples but pgstat inflates n_dead_tup by one per UPDATE anyway --
an accounting artifact, not real bloat, but one that also feeds
autovacuums dead-tuple-threshold decision with phantom data.

The heap AM leaves the new flag false (unchanged behavior, verified:
heap n_dead_tup accounting is bit-for-bit identical before and after
this change).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 230 issue(s).

  • 25 inline, 205 in summary (inline capped at 25)
  • ⚠️ 206 warning(s) during review

📄 src/backend/access/hash/hash_undo.c (L202-L209)

Critical: the entry to kill is identified purely by the stored physical (blkno, offset), with no verification of tuple identity. Between the aborted insert and undo application, hash bucket splits (_hash_splitbucket, triggered by the do_expand path in _hash_doinsert itself) and overflow squeezing (_hash_squeezebucket -> PageIndexMultiDelete) move tuples between pages and shift offsets. By the time undo runs, (blkno, offset) can point at a committed tuple, and marking it LP_DEAD silently drops a live index entry -> silent data loss on lookups.

The sibling nbtree_undo.c deliberately avoids exactly this: it re-finds the entry by heap TID (BTreeTupleGetHeapTID + nbtree_undo_find_leaf_entry) rather than trusting the stored offset, with a comment stating "marking it LP_DEAD would silently drop committed rows." Hash undo must do the same: re-locate the entry by its IndexTuple identity (hash value + heap TID), not by the stored offset, before calling ItemIdMarkDead.


📄 src/backend/access/hash/hash_undo.c (L208-L211)

MarkBufferDirty() and the CLR WAL record (XLOG_UNDO_APPLY_RECORD with UNDO_CLR_LP_DEAD) are emitted unconditionally inside the crit section even when !ItemIdIsNormal(lp) (i.e. the LP was already dead/unused/redirect and nothing was changed). This dirties a page and writes a CLR describing an LP_DEAD action that never happened, needlessly bumping the page LSN and generating WAL. Move MarkBufferDirty and the CLR emission inside the ItemIdIsNormal branch so a page is only marked dirty / WAL-logged when an actual modification occurred.


📄 src/backend/access/hash/hash_undo.c (L208-L211)

After marking an entry LP_DEAD the code does not set LH_PAGE_HAS_DEAD_TUPLES on the hash page opaque. The normal hash kill path (_hash_kill_items in hashutil.c) sets opaque->hasho_flag |= LH_PAGE_HAS_DEAD_TUPLES whenever it marks a tuple dead; this hint is what later drives reclamation of dead line pointers (_hash_vacuum_one_page / hashbucketcleanup). Without it the LP_DEAD entries created by undo may never be reclaimed, undermining the stated "zero-VACUUM" goal. Set the flag on the opaque as part of the modification.


📄 src/backend/access/hash/hash_undo.c (L198-L200)

The target page is read and modified via ReadBuffer + raw BUFFER_LOCK_EXCLUSIVE with no validation that hdr.blkno is a hash bucket/overflow page. There is no check of HashPageGetOpaque(page)->hasho_flag & LH_PAGE_TYPE, so a stale/invalid blkno pointing at the metapage, a bitmap page, or an unused/overflow-freed page would have an arbitrary offset marked LP_DEAD, corrupting index metadata. Validate the page type (LH_BUCKET_PAGE / LH_OVERFLOW_PAGE) before touching any line pointer, and skip otherwise.


📄 src/backend/access/hash/hashinsert.c (L248-L249)

Correctness (high confidence): the UNDO record here captures a physical (blkno, offset) of the just-inserted tuple, but it is emitted before _hash_expandtable(rel, metabuf) runs a few lines below. A bucket split (triggered by this insert's do_expand, or by a later insert in the same txn) copies the tuple to a new page/offset via _hash_splitbucket(), invalidating this location. On abort, hash_undo_apply() for HASH_UNDO_INSERT trusts the stored (blkno, offset) verbatim and marks whatever ItemId sits there as LP_DEAD -- with no heap-TID re-descent (unlike the sibling nbtree path, which explicitly re-descends by TID and tolerates stale hints). The result is silent index corruption: an unrelated live entry can be killed, or the moved entry left behind. The recorded location must be resilient to splits (e.g. re-descend by heap TID on apply, as nbtree does), or the undo must be emitted/relocated so the hint stays valid across the split.


📄 src/backend/access/hash/hashinsert.c (L248-L248)

Robustness (low confidence): this dereferences heapRel (via RelationAmSupportsUndo -> rel->rd_tableam, and UndoBufferIsActive -> RelationGetRelid) without a NULL guard. The sibling nbtree code (nbtinsert.c) deliberately writes if (heaprel != NULL && RelationAmSupportsUndo(heaprel)) because "heaprel is NULL during index builds and recovery." Current hash callers happen to pass a non-NULL heapRel, but for consistency with the sibling implementation and to avoid a future crash, add the heapRel != NULL guard.

💡 Suggested change

Before:

	if (RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel))

After:

	if (heapRel != NULL && RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel))

📄 src/backend/access/hash/hashinsert.c (L248-L249)

Maintainability (low confidence): UndoBufferIsActive(heapRel) is checked here and then re-checked immediately inside HashUndoLogInsert(). Because this is the only caller and it only invokes the function when the buffer is active, the non-buffer fallback branch inside HashUndoLogInsert() is unreachable dead code. Consider dropping the redundant guard here (or the internal one) so the contract is expressed in one place.


📄 src/backend/access/hash/hash_undo.c (L276-L277)

The desc callback only prints the index OID and drops blkno/offset, which are the most useful fields for diagnosing an undo record in pg_waldump. Since HashUndoInsert carries both, print them too, e.g. append " blk %u off %u" so the CLR/undo record is fully described. (Note the guard payload_len >= sizeof(Oid) mirrors nbtree_undo.c's convention, so it's fine.)


📄 src/backend/access/heap/heapam_handler.c (L156-L159)

These two callbacks are empty no-ops that add nothing for the heap AM. Both begin_bulk_insert and finish_bulk_insert are declared optional in TableAmRoutine, and the wrappers table_begin_bulk_insert/table_finish_bulk_insert already NULL-check the pointer. Since the heap AM does not use UNDO (am_supports_undo = false), wiring empty functions is dead scaffolding for a path that heap never exercises. This violates YAGNI/minimal-diff: leave the callbacks unset for heap (the wrappers already handle NULL) and let only the RECNO AM implement them. Note also that finish_bulk_insert was deliberately dropped by in-tree AMs (see the struct comment "In-tree access methods ceased to use this"); re-adding a no-op reverses that cleanup. Confidence: high.


📄 src/backend/access/heap/heapam_handler.c (L150-L155)

This comment is aspirational/misleading: it describes activating an UNDO write buffer to batch UNDO records, but the function body is empty and the heap AM sets am_supports_undo = false, so none of this happens. Comments must describe what the code does now. If these callbacks are kept for heap at all, the body must be empty and the comment must not claim UNDO behavior. Confidence: high.


📄 src/backend/access/heap/heapam_handler.c (L162-L165)

Same problem: the comment claims this flushes pending UNDO records and deactivates the write buffer, but the function is a no-op and heap does not support UNDO. Remove the misleading UNDO description (and preferably the callback itself for heap). Confidence: high.


📄 src/backend/access/heap/pruneheap.c (L21-L25)

These three added includes are unnecessary churn and should be removed (minimal-diff discipline). The only code change in this file is these #include lines, yet none are needed:

  • access/parallel.h: no parallel API (ParallelContext, IsInParallelMode, etc.) is referenced anywhere in pruneheap.c.
  • access/xact.h: no symbol exclusive to xact.h is used; the TransactionId type and TransactionId* macros come from access/transam.h, which is already included on the next line.
  • access/visibilitymapdefs.h: redundant. The VISIBILITYMAP_* macros used in this file are already provided by access/visibilitymap.h, which is included on the immediately preceding (unchanged) line and itself #includes access/visibilitymapdefs.h.

Recommend dropping all three lines so the file's includes are unchanged. (high confidence)

💡 Suggested change

Before:

+#include "access/parallel.h"
 #include "access/transam.h"
 #include "access/visibilitymap.h"
+#include "access/visibilitymapdefs.h"
+#include "access/xact.h"

After:

 #include "access/transam.h"
 #include "access/visibilitymap.h"

📄 src/backend/access/index/indexam.c (L306-L309)

Forcing xs_want_itup = true here applies to every index AM used as a secondary index on an in-place table, but the stale-entry recheck it enables only works for nbtree (which fills scandesc->xs_itup). The comment's parenthetical "(RECNO secondary indexes are nbtree)" is not enforced anywhere in the tree -- nothing restricts a RECNO table to btree-only indexes. Consequences for other AMs:

  • hash: amgettuple ignores xs_want_itup and never sets xs_itup, so the executor's scandesc->xs_itup != NULL guard fails and the stale-entry recheck is silently skipped -- an indexed-column UPDATE that leaves a stale (oldkey -> tid) entry returns wrong rows.
  • GiST/SP-GiST: setting xs_want_itup triggers tuple reconstruction via the opclass fetch/reconstruct path, which errors for opclasses without fetch support; and even when it succeeds these AMs fill xs_hitup, not xs_itup, so the recheck again does not fire.

Either reject non-nbtree secondary indexes on am_inplace_update_keeps_tid tables (and state that as the invariant this relies on) or gate the forced xs_want_itup on the index AM actually being able to return the stored key (index_getprocinfo/amcanreturn). As written this is a silent-wrong-results footgun. (high confidence on the mechanism; whether RECNO permits non-btree indexes today is worth confirming.)


📄 src/backend/access/nbtree/nbtinsert.c (L18-L18)

This new include appears unnecessary: nothing from access/heapam.h is used by the added code. NbtreeUndoLogInsert is declared in access/nbtree.h and RelationAmSupportsUndo in access/tableam.h, both already included above. A grep of this file finds no heap_* usage. Please drop this include to keep the diff minimal (POLA/minimal-diff hygiene). Confidence: high.


📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

Gating inconsistency with the hash AM, which guards its equivalent call with RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel) (see hashinsert.c). Here you only check RelationAmSupportsUndo. When no Tier-2 undo buffer is active, NbtreeUndoLogInsert falls into its standalone UndoRecordSetCreate(...) branch instead of piggybacking onto the table's active UNDO context, which is the documented design (recno DESIGN: index inserts "piggyback their UNDO records onto the table's active UNDO context"). Please confirm the standalone path is intended for nbtree index inserts here; otherwise add the && UndoBufferIsActive(heaprel) guard to match hash. This gating appears on both the split and no-split branches added in this diff. Confidence: moderate.


📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

_bt_insertonpg is recursive and also runs for internal-page (!isleaf) inserts, so this fires with isleaf == false and writes an NBTREE_UNDO_INSERT_UPPER record. However nbtree_undo_apply() treats NBTREE_UNDO_INSERT_UPPER as a no-op (return UNDO_APPLY_SKIPPED). That means this newly added split-path call emits undo volume that can never be applied, and it does so while holding the buffer lock. Consider restricting the undo write to isleaf to avoid the wasted work under the lock. Confidence: high.


📄 src/backend/access/nbtree/nbtinsert.c (L1454-L1459)

This block is a near-verbatim copy of the split-path undo call added above (only the brace style differs). Two identical undo-emission sites are a DRY/maintenance hazard - a future fix to one can be missed in the other. Consider extracting a small local helper (or a single call after both branches, gated on which page holds the tuple) so the gating condition and arguments are defined once. Confidence: high.


📄 src/backend/access/recno/Makefile (L35-L35)

Missing newline at end of file. The diff shows \ No newline at end of file after this line. POSIX text files (and every other backend Makefile in the tree) must end with a newline; git diff --check and pgindent conventions flag this. Add a trailing newline. (high confidence)

💡 Suggested change

Before:

+include $(top_srcdir)/src/backend/common.mk

After:

+include $(top_srcdir)/src/backend/common.mk


📄 src/backend/access/nbtree/nbtree_undo.c (L635-L638)

OOB read + page corruption risk. payload_len is only checked >= SizeOfNbtreeUndoDedup, but this memcpy reads hdr.page_len bytes starting past the header. Add a check payload_len >= SizeOfNbtreeUndoDedup + hdr.page_len before this memcpy. Also validate hdr.page_len == BufferGetPageSize(buffer) (i.e. BLCKSZ) before overwriting the live page: a mismatched or truncated image silently corrupts the page. This mirrors the INSERT_LEAF path, which correctly validates payload_len < SizeOfNbtreeUndoInsertLeaf + hdr.itup_sz before dereferencing the tuple.

💡 Suggested change

Before:

				/* Restore the full pre-dedup page image */
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

After:

				/* Restore the full pre-dedup page image */
				if (payload_len < SizeOfNbtreeUndoDedup + hdr.page_len ||
					hdr.page_len != BufferGetPageSize(buffer))
				{
					END_CRIT_SECTION();
					UnlockReleaseBuffer(buffer);
					relation_close(indexrel, RowExclusiveLock);
					return UNDO_APPLY_ERROR;
				}
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

📄 src/backend/access/nbtree/nbtree_undo.c (L254-L256)

Dead/speculative scaffolding. NbtreeUndoLogDedup has no caller anywhere in the series (_bt_dedup_pass is never wired to emit a DEDUP undo record), so no NBTREE_UNDO_DEDUP record is ever produced and the entire DEDUP apply branch in nbtree_undo_apply is unreachable. Per YAGNI/minimal-diff discipline, either wire the logging hook into the dedup pass or drop NbtreeUndoLogDedup, the NbtreeUndoDedup payload struct, and the DEDUP apply case until they are actually needed.


📄 src/backend/access/nbtree/nbtree_undo.c (L63-L64)

Duplicate macro definitions. These same NBTREE_UNDO_* constants are also defined in the modified src/include/access/nbtree.h, which this file includes. Duplicating them here is redundant and a footgun: an edit to one set will silently diverge from the other (or trigger a redefinition warning). Remove these definitions and rely on the ones exported from nbtree.h.


📄 src/backend/access/nbtree/nbtree_undo.c (L88-L88)

Missing parentheses in a SizeOf macro (footgun). PostgreSQL convention wraps such definitions, e.g. #define SizeOfXxx (offsetof(...) + sizeof(...)). Without the outer parens, any use in a larger expression that binds tighter than + (multiplication, or a comparison chain) evaluates incorrectly. This applies to SizeOfNbtreeUndoInsertLeaf, SizeOfNbtreeUndoInsertUpper, SizeOfNbtreeUndoDedup, and SizeOfNbtreeUndoDelete.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertLeaf	offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertLeaf	(offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L103-L103)

Missing parentheses (see SizeOfNbtreeUndoInsertLeaf). Wrap in parens for safe use in larger expressions.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertUpper	offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertUpper	(offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L116-L116)

Missing parentheses (see SizeOfNbtreeUndoInsertLeaf). Wrap in parens.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDedup	offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDedup	(offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L129-L129)

Dead payload struct. NbtreeUndoDelete and its SizeOf macro are never written (no DELETE-logging hook exists) and the NBTREE_UNDO_DELETE apply case is skip-only, so this struct is unused scaffolding. Drop it until a DELETE undo path actually exists.


📄 src/backend/access/nbtree/nbtree_undo.c (L691-L694)

Comment/code drift. The file header states DELETE undo will "re-insert deleted tuples", but the implementation unconditionally returns UNDO_APPLY_SKIPPED. PostgreSQL review flags aspirational "For now, skip..." comments describing behavior that isn't implemented. Either update the header to describe the actual skip-only behavior or remove the misleading DELETE description.


📄 src/backend/access/recno/README (L500-L500)

This line-number citation is wrong and is the kind of reference PostgreSQL reviewers reject because it rots on every unrelated edit. The Assert(entry->data.lockmode == BUFFER_LOCK_UNLOCK) is actually at bufmgr.c:6068 in this tree, not 5892. Drop the line number and cite the function/assertion by name instead (e.g. "in LockBuffer() in bufmgr.c"), which stays correct across future edits.

💡 Suggested change

Before:

in bufmgr.c:5892 enforces this.

After:

in LockBuffer() (bufmgr.c) enforces this.

📄 src/backend/access/recno/README (L487-L487)

This line-range citation is inaccurate and fragile. In the current tree heapam.c:3993-4050 is HOT-update / critical-section code, not the TOAST chain-traversal buffer-locking pattern described here; the analogous logic lives in heap_toast_insert_or_update() (heapam.c around lines 2241 and 3877). Hard-coded line ranges drift with every unrelated change. Reference the function by name only and drop the line numbers.

💡 Suggested change

Before:

See src/backend/access/heap/heapam.c:3993-4050 for the analogous pattern

After:

See heap_toast_insert_or_update() in src/backend/access/heap/heapam.c for the analogous pattern

📄 src/backend/access/recno/recno_compress.c (L855-L857)

On-disk portability hazard (silent data corruption). This delta codec is reached for varlena numeric attributes (NUMERIC) and its output is persisted to disk pages by RecnoFormTuple. Here the raw little-endian bytes of the host integer are copied out (memcpy(out_ptr + 2, &abs_val, bytes_needed) etc.), and RecnoDecompressDelta reads them back the same way. A tuple written on x86_64 (LE) and read on a big-endian machine (s390x/PPC64BE) or an opposite-endian physical replica will silently decode a corrupt value. On-disk formats must be endianness-neutral. Encode/decode the low bytes_needed bytes explicitly (byte-by-byte, defined order) instead of memcpy'ing the native integer. The same applies to the RecnoCompressionHeader uint32 fields (orig_size/comp_size), which are also stored in native byte order.


📄 src/backend/access/recno/recno_compress.c (L1020-L1023)

Buffer-overrun / trust-boundary defect on decompression of on-disk bytes. bytes_stored = input[1] is used unchecked as the memcpy length into a uint64 abs_val (max 8 bytes). A corrupt or opposite-endian datum with input[1] > 8 overruns the stack variable. Callers in recno_slot.c/recno_tuple.c validate comp_size against the varlena length but do NOT bound this per-value tag length. Validate bytes_stored <= sizeof(uint64) and reject via ereport(ERRCODE_DATA_CORRUPTED) before the memcpy.


📄 src/backend/access/recno/recno_compress.c (L1064-L1066)

Output buffer overrun. output is sized from orig_size (VARHDRSZ + orig_size), but here stored_size comes from the compressed payload byte input[1] and is used as the memcpy length into VARDATA(output). If stored_size > orig_size (corrupt datum, or a length that got truncated to 8 bits during encode), this writes past the allocated buffer. Validate stored_size <= orig_size before copying.


📄 src/backend/access/recno/recno_compress.c (L1354-L1357)

Unsafe relation open on the decompression hot path. RecnoDecompressAttribute is called from recno_slot.c/recno_tuple.c while materializing tuples from a page read; for any non-zero dict_id it reaches RecnoGetTrainedDict, which does relation_open(relid, NoLock) followed by recno_dict_read() (ReadBufferExtended + LockBuffer buffer I/O). Opening a relation with NoLock and performing buffer I/O during tuple deforming risks relcache races with concurrent DDL/invalidation and, if invoked while a buffer content lock is held, buffer-lock deadlock. Confirm this never runs under a held buffer lock or in a critical/redo section, and reconsider NoLock here.


📄 src/backend/access/recno/recno_compress.c (L377-L377)

Silent dict_id truncation. dictid here is uint32 (recno_dict_get_active/recno_dict_append use a monotonically increasing meta->next_dictid that is never reused). RecnoCompressionHeader.dict_id is only uint16, so once assigned ids exceed 65535 the stored id wraps and decompression will load the wrong dictionary blob (silent corruption). Either widen the header field to uint32 or assert/clamp the id range when writing the header.


📄 src/backend/access/recno/recno_compress.c (L54-L56)

Dead constants (YAGNI). RECNO_COMPRESS_RATIO_MIN and RECNO_DELTA_MAX_VALUES are never referenced anywhere; the effective ratio threshold is the recno_compression_min_ratio GUC. Having both the macro and the GUC (same 0.8 value) creates two sources of truth. Remove the unused macros.


📄 src/backend/access/recno/recno_compress.c (L578-L578)

Non-ASCII characters in source. This file uses em-dash (U+2014) and similar punctuation in comments throughout (e.g. here "the caller stores it raw." block and many others use em-dashes). PostgreSQL sources are ASCII-only; replace em-dashes with '--' and any smart quotes/ellipses with plain ASCII.


📄 src/backend/access/recno/recno_compress.c (L86-L88)

Aspirational/future-tense comment describing behavior that does not exist. PostgreSQL comments should describe what the code does now and explain why, not speculate about future enhancements. Drop the 'A future enhancement could ...' paragraph.


📄 src/backend/access/recno/recno_dict.c (L130-L141)

Destructive silent reinitialization of an existing metapage. When the fork already has block 0 but its magic doesn't match (a genuinely corrupt metapage, a foreign/future-version page, or a torn page whose init FPI was lost), this path silently calls recno_dict_init_metapage() -- resetting count=0, next_dictid=1, active_dictid=0 -- destroying the entire dictionary directory. All previously-written compressed datums referencing those dictids then become permanently undecompressable (they fail in recno_dict_read with "dictionary id N not found"), i.e. silent data loss.

Worse, this destructive write is reachable from SHARE callers on the read path: recno_dict_read()/recno_dict_get_active()/recno_dict_count() request BUFFER_LOCK_SHARE, and on a magic mismatch this code upgrades to EXCLUSIVE and performs a WAL-logged MarkBufferDirty -- a read-only-looking dictionary lookup mutates and WAL-logs shared on-disk state, which is also unsafe during recovery/on a standby. A mismatched magic on an existing block should ereport(ERROR, ERRCODE_DATA_CORRUPTED) rather than reinitialize.


📄 src/backend/access/recno/recno_dict.c (L51-L51)

The metapage stores a version (RECNO_DICT_METAPAGE_VERSION 2) but it is never checked on read: recno_dict_get_metapage only validates meta->magic. A metapage written by a different on-disk version has the same magic and will be interpreted with the current struct layout, silently misreading entries[]/count. Validate meta->version after the magic check and ereport(ERROR, ERRCODE_DATA_CORRUPTED) on mismatch (the field is otherwise dead).


📄 src/backend/access/recno/recno_dict.c (L71-L74)

Non-idiomatic boolean comparison. PostgreSQL style uses !smgrexists(...) rather than "smgrexists(...) == false". Same pattern recurs at the other two call sites in this file.

💡 Suggested change

Before:

	if (smgrexists(RelationGetSmgr(rel), RECNO_DICT_FORKNUM) == false ||
		smgrnblocks(RelationGetSmgr(rel), RECNO_DICT_FORKNUM) == 0)
	{
		if (mode != BUFFER_LOCK_EXCLUSIVE)

After:

	if (!smgrexists(RelationGetSmgr(rel), RECNO_DICT_FORKNUM) ||
		smgrnblocks(RelationGetSmgr(rel), RECNO_DICT_FORKNUM) == 0)
	{
		if (mode != BUFFER_LOCK_EXCLUSIVE)

📄 src/backend/access/recno/recno_diff.c (L214-L220)

OOB read on corrupt/truncated UNDO data. diff here comes from the UNDO fork during undo apply (recno_undo.c only checks image_len >= sizeof(RecnoDiffRecord), i.e. just the header). This loop reads seg and then advances ptr += SizeOfRecnoDiffSegment; old_bytes = ptr; ptr += seg->ins_len; -- dereferencing seg->ins_len/old_bytes before any bounds check, with no upper bound on the walk. A corrupted ndiffs or ins_len reads past the record buffer during recovery. The record already carries total_size; validate the running ptr (and ndiffs) against diff->total_size before each dereference. Better: pass the caller's buffer length in and bound against min(total_size, buffer_len). [high confidence]


📄 src/backend/access/recno/recno_diff.c (L214-L214)

Unaligned access (portability hard gate). ptr advances by SizeOfRecnoDiffSegment (6 bytes) plus the variable seg->ins_len, so successive segment headers can land on odd byte offsets. Casting ptr to RecnoDiffSegment * and dereferencing its uint16 fields at an unaligned address faults or is slow on strict-alignment architectures (ARM, PPC64, s390x, RISC-V). Copy the header into a local struct via memcpy instead of a pointer cast. [high confidence]


📄 src/backend/access/recno/recno_diff.c (L169-L173)

Same unaligned-access hazard on the write side: after ptr += SizeOfRecnoDiffSegment + ins_len the next seg can sit at an odd offset, so seg->offset = ... etc. store to unaligned uint16 fields. This on-disk record is later read back on potentially different-alignment hardware. Build the header with memcpy from a local struct rather than storing through an unaligned RecnoDiffSegment *. [high confidence]


📄 src/backend/access/recno/recno_diff.c (L227-L230)

Corruption during undo apply is downgraded to DEBUG1 + return false, and the caller (recno_undo.c) turns that into a WARNING and leaves the tuple unreverted. Silently skipping a failed before-image reconstruction during recovery masks data corruption instead of surfacing it. PostgreSQL redo/undo paths normally raise ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), ...)) for unrecoverable inconsistencies. Confirm this soft-fail is intended; if the on-disk diff is malformed the correct behavior is almost certainly a hard error, not a debug log. [moderate confidence]


📄 src/backend/access/recno/recno_fsm.c (L31-L31)

miscadmin.h is included but no symbol from it is used in this file (no CHECK_FOR_INTERRUPTS, MyProc, InterruptPending, etc.). Drop the unused include to keep the diff minimal. Confidence: high.


📄 src/backend/access/recno/recno_dirtymap.c (L272-L277)

Missing memory barrier on the lock-free reader. pg_atomic_read_u64 has NO barrier semantics (see port/atomics.h: the 64-bit ops match their 32-bit counterparts, and pg_atomic_read_u32 is documented "No barrier semantics"). On weakly-ordered architectures (ARM64, PPC64, RISC-V) a scanner can observe the published slot key while the writer's earlier stores that establish the retained before-image are not yet globally visible, or observe a stale empty terminator due to reordering -- causing the scan to wrongly skip the sLog probe and return incorrect MVCC data.

The cited "mark-before-buffer-unlock" contract is insufficient by itself and, worse, is not even honored by the callers (e.g. recno_operations.c DELETE path calls RecnoDirtyMapMark after UnlockReleaseBuffer). This lock-free reader needs an explicit read barrier (or use pg_atomic_read_membarrier_u64), matching the pattern already used in recno_mvcc.c (pg_read_barrier/pg_write_barrier).

💡 Suggested change

Before:

		uint64		cur = pg_atomic_read_u64(&part->slots[slot]);

		if (cur == key)
			return true;
		if (cur == RECNO_DIRTYMAP_EMPTY_KEY)
			return false;		/* provably clean */

After:

		uint64		cur = pg_atomic_read_membarrier_u64(&part->slots[slot]);

		if (cur == key)
			return true;
		if (cur == RECNO_DIRTYMAP_EMPTY_KEY)
			return false;		/* provably clean */

📄 src/backend/access/recno/recno_dirtymap.c (L224-L225)

The publishing store has no matching write barrier. pg_atomic_write_u64 has NO barrier semantics, so on weakly-ordered CPUs the key store here can become visible to a lock-free reader before the stores that recorded the before-image in the sLog. Emit a write barrier before publishing (or use pg_atomic_write_membarrier_u64), consistent with the pg_write_barrier() usage in recno_mvcc.c's single-writer/multi-reader slots.

💡 Suggested change

Before:

			pg_atomic_write_u64(&part->slots[slot], key);
			part->nentries++;

After:

			pg_write_barrier();
			pg_atomic_write_u64(&part->slots[slot], key);
			part->nentries++;

📄 src/backend/access/recno/recno_dirtymap.c (L192-L200)

The spinlock is held across an open-addressed probe loop of up to RECNO_DIRTYMAP_PART_SLOTS (8192) iterations. PostgreSQL spinlocks must protect only very short, straight-line critical sections; holding one across a long probe can hurt scalability badly and risks the stuck-spinlock panic (s_lock timeout). Under collision clustering this is not merely theoretical. Consider an LWLock for the writer side, or bound the probe length far more tightly.


📄 src/backend/access/recno/recno_dirtymap.c (L232-L234)

Premature 'full' latching from collision clustering. The MAX_LOAD (3/4) guard only fires when an EMPTY slot is found. If a probe chain is saturated with non-matching (clustered) keys, the loop can run all RECNO_DIRTYMAP_PART_SLOTS iterations without hitting an empty slot even when the partition is well below 75% occupancy, then permanently latch full and degrade the whole partition to always-probe. Once latched there is no recovery (grow-only). Bounding the probe count and/or checking load before probing would avoid needlessly poisoning a lightly-loaded partition.


📄 src/backend/access/recno/recno_dirtymap.c (L52-L53)

The ~8 MB sizing rationale is inaccurate and the central lock-free claim does not hold on all target platforms. Where 64-bit atomics fall back to spinlock-backed simulation (PG_HAVE_ATOMIC_U64_SIMULATION), sizeof(pg_atomic_uint64) includes a lock, so RecnoDirtyMapShared is larger than the stated 8 MB, and pg_atomic_read_u64/write_u64 are not truly lock-free -- invalidating both the memory estimate here and the module's "Check path is LOCK-FREE" performance rationale. The comment should acknowledge the simulation case (or the design should not depend on 64-bit atomics being lock-free).


📄 src/backend/access/recno/recno_lock.c (L334-L341)

This mapping is inconsistent with the one used to acquire the lock in RecnoLockTuple. Acquisition maps LockTupleKeyShare->AccessShareLock, LockTupleShare->RowShareLock, LockTupleExclusive->AccessExclusiveLock, but here KeyShare/Share are checked as ShareLock and Exclusive as ExclusiveLock. Since LockHeldByMe() (called below with orstronger=false) looks up the LOCALLOCK for the exact lockmode, this returns a false negative for every mode except LockTupleNoKeyExclusive (the only one that happens to map to ExclusiveLock in both places). Any caller relying on this to skip a redundant acquisition or for an assertion will get wrong answers. Use the same mapping as RecnoLockTuple/RecnoUnlockTuple (ideally via a shared helper).

💡 Suggested change

Before:

		case LockTupleKeyShare:
		case LockTupleShare:
			lockmode = ShareLock;
			break;
		case LockTupleNoKeyExclusive:
		case LockTupleExclusive:
			lockmode = ExclusiveLock;
			break;

After:

		case LockTupleKeyShare:
			lockmode = AccessShareLock;
			break;
		case LockTupleShare:
			lockmode = RowShareLock;
			break;
		case LockTupleNoKeyExclusive:
			lockmode = ExclusiveLock;
			break;
		case LockTupleExclusive:
			lockmode = AccessExclusiveLock;
			break;

📄 src/backend/access/recno/recno_lock.c (L70-L86)

The identical LockTupleMode->LOCKMODE switch (including the multi-line comment) is duplicated verbatim in RecnoLockTuple and RecnoUnlockTuple, and a third, divergent copy exists in RecnoHoldsTupleLock. This copy-paste is the root cause of the mapping drift bug in RecnoHoldsTupleLock. Extract a single static helper (e.g. recno_tuplock_lockmode(LockTupleMode)) and call it from all three sites so the mappings cannot diverge.


📄 src/backend/access/recno/recno_lock.c (L180-L182)

RecnoLockPage/RecnoUnlockPage have no callers: they are only defined here, declared in recno.h, and re-declared (but never invoked) in recno_operations.c. This is speculative scaffolding (YAGNI) that adds a heavyweight LOCKTAG_PAGE code path with its own deadlock-ordering implications and zero test coverage. LOCKTAG_PAGE heavyweight locks are essentially unused by modern AMs; page consistency is normally handled by buffer content locks. Drop these until an actual caller and tests exist.


📄 src/backend/access/recno/recno_lock.c (L252-L258)

RecnoLockMultipleTuples has no callers anywhere in the tree, so it is dead speculative code shipping with no tests. Beyond that: (1) the palloc0'd 'locked' array leaks if RecnoLockTuple hits its elog(ERROR) 'invalid tuple lock mode' path, since nothing frees it (rely on a MemoryContext or restructure); (2) the hand-rolled O(n^2) bubble sort with no bound on ntids reinvents qsort() with ItemPointerCompare, the established pattern for consistent lock ordering. Either remove the function or, when a real caller exists, back it with a test and use qsort().


📄 src/backend/access/recno/recno_mvcc.c (L1003-L1003)

Correctness/data-loss hazard (high confidence). Per the new header contract in recno.h, t_commit_ts's low 32 bits now hold t_xmax (an XID), not a timestamp -- and this file's own RecnoTupleDeadToAll() was rewritten to the correct heap-shaped model (RecnoTupleGetXmax + TransactionIdDidCommit + TransactionIdPrecedes). But RecnoCanVacuumTimestamp() still treats its uint64 argument as a wall-clock timestamp and compares it against oldest_active_ts. Its callers in recno_handler.c pass tuple_hdr->t_commit_ts (which now packs XID bits). Comparing an XID-packed word (small integer) against a microsecond timestamp (~1e15) makes vacuum_ts < oldest_active_ts almost always true, so the all_dead / index-removal hint fires on tuples still visible to concurrent transactions -- phantom rows / lost visibility after index entry removal. This timestamp-based vacuum path is now inconsistent with the XID-based visibility model in the same file and should be replaced with the XID horizon path (RecnoGetOldestXminHorizon / RecnoTupleDeadToAll), or removed.


📄 src/backend/access/recno/recno_mvcc.c (L556-L558)

Portability (high confidence): lockless 64-bit slot access is not atomic on 32-bit platforms. xact_start_ts_slots[] is a uint64 array written here and read by RecnoGetOldestActiveTimestamp()/vacuum with only pg_write_barrier/pg_read_barrier. A plain uint64 store/load is not guaranteed atomic on 32-bit architectures (torn read/write), so the VACUUM oldest-active horizon can observe a corrupt timestamp and prematurely reclaim still-visible tuples. Barriers order accesses but do not make the 64-bit access atomic. Use pg_atomic_uint64 slots with pg_atomic_write_u64/pg_atomic_read_u64 (as the rest of this struct already does for global_commit_ts) instead of a bare volatile-free uint64 array.


📄 src/backend/access/recno/recno_mvcc.c (L817-L821)

Dead code (high confidence). serializable_horizon is written here (and initialized in both init paths) but has no reader anywhere in the tree (searched: only these write sites match). This makes the LWLockAcquire/Release on mvcc_lock an unnecessary exclusive-lock acquisition on every serializable commit -- needless contention on a hot path for a value nobody consumes. Remove the field and this locked update (YAGNI).


📄 src/backend/access/recno/recno_mvcc.c (L276-L279)

Dead/duplicated init path (high confidence). Shared-memory init is wired via the callback mechanism (subsystemlist.h registers PG_SHMEM_SUBSYSTEM(RecnoMvccShmemCallbacks), whose init_fn is RecnoMvccShmemInit_cb). This legacy RecnoMvccShmemInit() has no caller in any .c file, yet it duplicates the exact same field initialization AND separately registers RecnoShmemExit via on_shmem_exit. Keeping two copies of the init logic risks divergence (a field added to one but not the other). Remove the unused RecnoMvccShmemInit() (and its header declaration) and keep only the callback path.


📄 src/backend/access/recno/recno_mvcc.c (L843-L843)

Dead code (high confidence). RecnoGetSnapshotTimestamp() has no caller in any .c file (only its header declaration). It also calls RecnoGetCommitTimestamp() on the READ COMMITTED path (a GetCurrentTimestamp() syscall + CAS loop), so if it were ever wired into a per-tuple read path it would be a hot-path regression. As it is unused, remove it (and the header extern) per the minimalism discipline.


📄 src/backend/access/recno/recno_mvcc.c (L147-L147)

ASCII-only violation (high confidence). PostgreSQL source must be ASCII only. This comment uses a non-ASCII em-dash (U+2014). Replace with '--'. There are further non-ASCII characters in this file to fix: em-dashes on the lines containing 'no lock is needed', 'we get them from the sLog', 'analogous to heap's HEAPTUPLE_DEAD case', 'the only other updater', and a U+2248 approx sign in 'oldest_ts ≈ 1'.


📄 src/backend/access/recno/recno_overflow.c (L1593-L1595)

Critical WAL replay bug: this WAL-logs a selective item deletion using RecnoXLogInitPage, whose redo handler (recno_xlog_init_page_redo) reads the block with RBM_ZERO_AND_LOCK and calls RecnoInitPage, i.e. it zeroes and reinitializes the ENTIRE page on replay. But these pages hold mixed content (live tuples and still-referenced overflow records); Pass 2 only removes the orphaned overflow records in memory. On a standby or after crash recovery, replay of this record discards every surviving item on the page, causing data loss and primary/replica divergence. Emit a WAL record that describes the specific offsets deleted (mirroring PageIndexTupleDelete-style redo), not a full page-init record.


📄 src/backend/access/recno/recno_overflow.c (L591-L593)

Critical-section violation reachable for large columns. START_CRIT_SECTION() is entered before PageAddItem and only ended at line 661. Inside this crit section, when the prev record is not tracked in overflow_buffers (i.e. any chain longer than MAX_OVERFLOW_BUFFERS==32 records, or when overflow_buffers is NULL), this fallback executes ReadBuffer() and LockBuffer() -- both of which can block on I/O and can ereport(ERROR) on a read failure -- and then RecnoXLogOverflowWrite() (an XLogInsert). Any ERROR raised inside a critical section is promoted to PANIC. Move the prev-link read/lock out of the critical section, or ensure the prev buffer is always available without I/O.


📄 src/backend/access/recno/recno_overflow.c (L76-L79)

GUC is not actually registered. This declares a plain C global set to the default, and the file/DESIGN describe it as "configurable via GUC", but the accompanying expected regress output shows SHOW recno_overflow_inline_prefix; returns ERROR: unrecognized configuration parameter. So it is dead config that no user can change; either register it in guc_tables.c (with bounds/category) or drop the GUC framing from the comment and treat it as a compile-time constant.


📄 src/backend/access/recno/recno_overflow.c (L692-L694)

data_len is Size but is truncated into uint32 ov_total_length with no bounds check, and passed to hash_bytes() cast to int. hash_bytes takes an int keylen, so a data_len > INT_MAX becomes negative (UB/wrong hash); a data_len > UINT32_MAX silently truncates ov_total_length, which would later make RecnoFetchOverflowColumn fail the bytes_read != total_len check. The RECNO_MAX_OVERFLOW_CHAIN cap (1024 * ~8KB) keeps this bounded in practice today, but add an explicit up-front size check (ereport with ERRCODE_PROGRAM_LIMIT_EXCEEDED) so an oversized value fails cleanly rather than truncating.


📄 src/backend/access/recno/recno_overflow.c (L384-L384)

The 'target_block == 0' heuristic is a footgun. It assumes block 0 is always the caller's main-tuple page and unconditionally refuses to place the first overflow record there, but block 0 is not guaranteed to be the main tuple's page (and overflow-only relations still have a block 0). This both wastes space (block 0 is never reused for the first overflow record) and conflates 'first overflow record' (count == 0) with 'block 0'. The self-labeled WORKAROUND should be replaced by having the caller pass its pinned main-tuple block so this function can skip the correct page precisely.


📄 src/backend/access/recno/recno_overflow.c (L953-L954)

Comment claims cleanup is 'idempotent' and defers to VACUUM, but the deletions here (RecnoPageIndexTupleDelete + MarkBufferDirty + RecnoRecordFreeSpace) modify shared on-disk state without WAL logging and without advancing PageSetLSN. On a physical standby / crash recovery the primary's page will diverge from the replica's, and torn-page/checksum handling relies on LSN progression. The README section documents this as an intentional TOAST-like choice, but unlike TOAST (which drops whole out-of-line chunks referenced only by the parent) these overflow records live on shared data pages that also hold other tuples, so the divergence is observable. Confirm the recovery model actually tolerates this (the presence of 070_fileops_standby_divergence.pl suggests divergence is a known risk).


📄 src/backend/access/recno/recno_stats.c (L156-L164)

Wrong on-disk layout: reads a bogus compression header. RECNO_TUPLE_COMPRESSED is a tuple-level flag, but compression is applied per-attribute. As recno_compress.c documents, the layout is [varlena header][RecnoCompressionHeader][compressed data] embedded inside each compressed varlena attribute value. The bytes at (char *) hdr + RECNO_TUPLE_OVERHEAD are the attribute null-bitmap (t_attrs_bitmap) followed by attribute data (see RecnoTupleGetData and recno_tuple.c: data_ptr = (char *) header + RECNO_TUPLE_OVERHEAD + MAXALIGN(bitmap_len)), never a RecnoCompressionHeader. Casting those bytes to RecnoCompressionHeader yields garbage orig_size/comp_size (and a potentially unaligned read), producing a meaningless compression_ratio. To measure compression you must walk the tuple's attributes and read the header from within each compressed varlena, as recno_tuple.c already does via VARDATA_ANY.


📄 src/backend/access/recno/recno_stats.c (L50-L52)

Dead code (YAGNI). Neither RecnoCollectRelationStats nor RecnoLogRelationStats has any caller in the backend; the only references outside this file are their prototypes in recno.h and a mention in the README. Nothing wires this into the ANALYZE path (recno_handler acquire_sample_rows) and no planner/cost code reads RecnoRelationStats, contradicting the file header's claim that stats are 'made available ... so that the planner can incorporate RECNO-specific cost adjustments.' As it stands this is unwired scaffolding plus an aspirational comment. Either wire it in (and gate the full-relation scan below) or drop the file from the patch.


📄 src/backend/access/recno/recno_stats.c (L85-L86)

If this is wired into ANALYZE (see dead-code comment), the full sequential scan over every page defeats ANALYZE's sampling design and doubles I/O on large relations. The header comment openly states 'It does its own full scan.' This is a hot-path performance regression and needs to be gated or driven off the sampled blocks rather than a mandatory full pass.


📄 src/backend/access/recno/recno_stats.c (L248-L253)

Convention/portability: total_pages, total_live_tuples, total_dead_tuples are int64 and commit_ts_min/max are uint64. Use INT64_FORMAT/UINT64_FORMAT directly instead of casting to (long long)/(unsigned long long) with %lld/%llu, per PostgreSQL convention. Also wrap the user-facing errmsg text in _() for translation if these are intended as anything above debug.


📄 src/backend/access/recno/recno_slot.c (L276-L284)

High-confidence bug: tts_recno_deform calls RecnoFetchOverflowColumn() for overflow columns while the slot may hold a pinned buffer whose page contains the overflow record. RecnoFetchOverflowColumn does LockBuffer(BUFFER_LOCK_SHARE); the normal seqscan path stores an on-page buffer-pinned tuple via RecnoSlotStoreTuple(slot, tuple_header, ..., scan->rs_cbuf) (recno_handler.c:719). When the executor later deforms an overflow column, this re-locks a buffer the slot/scan already pins, hitting the lock re-entry assertion in bufmgr.c. The authors already worked around this on the ANALYZE path (recno_handler.c:4732 explicitly documents materializing first to avoid this), but the general scan/deform path has no such protection. This is a correctness/deadlock hazard, not just performance. Beyond that, relation_open/relation_close per overflow attribute on the hot deform path is expensive relcache churn.


📄 src/backend/access/recno/recno_slot.c (L458-L463)

High-confidence correctness/side-effect footgun: GetCurrentTransactionId() assigns a real XID to the current transaction as a side effect (xact.c:515-516 calls AssignTransactionId). Invoking it from a read path (e.g. SELECT xmin, xmax FROM <recno table>) forces XID assignment for an otherwise read-only transaction, causing needless XID consumption/clog pressure and, on a hot standby or in recovery where no XID can be assigned, an ERROR. It is also semantically wrong: returning the reader's current XID as the tuple's xmin/xmax has nothing to do with who wrote the tuple. Either derive the value from the sLog (as cmin/cmax does below) or, if the writer XID is genuinely unavailable, use GetCurrentTransactionIdIfAny()/report the column as unsupported rather than mutating transaction state on a read.


📄 src/backend/access/recno/recno_slot.c (L348-L355)

Portability (hard gate): comp_hdr is cast directly onto on-page varlena payload (VARDATA_ANY(data_ptr)), then its multi-byte fields are read (comp_hdr->comp_size/orig_size are uint32, dict_id is uint16 per RecnoCompressionHeader in recno.h). For a short (1-byte-header) varlena, VARDATA_ANY is not maxaligned, so these are unaligned multi-byte accesses that fault on strict-alignment targets, and comparing the raw stored integers against host-order thresholds makes an on-disk format endianness-dependent. Read the header via memcpy into a local RecnoCompressionHeader (and byte-swap if you intend a fixed on-disk byte order) before comparing. The same pattern repeats in the overflow branch above.


📄 src/backend/access/recno/recno_slot.c (L292-L294)

Data-correctness footgun: when tts_tableOid == InvalidOid but the attribute is an overflow pointer, the code hands the raw on-page RecnoOverflowPtr back to the caller as if it were the attribute's varlena value. Any downstream consumer (output functions, comparisons, heap_form_tuple in copy_heap_tuple) will interpret the overflow reference as real data, producing wrong results or a crash. This silent fallthrough should elog(ERROR, ...) instead of returning an overflow pointer as the value.


📄 src/backend/access/recno/recno_slot.c (L460-L463)

System-column semantics are inconsistent with real MVCC: for a tuple flagged RECNO_TUPLE_DELETED, xmax returns GetCurrentTransactionId() regardless of which transaction actually deleted it, so a reader sees its own XID as xmax even when another transaction performed the delete. Combined with xmin always being the reader's XID, xmin/xmax on RECNO tuples are misleading for tooling and users. Consider deriving these from the sLog/commit info (as cmin/cmax already does) or reporting the columns as unsupported rather than returning meaningless values.


📄 src/backend/access/recno/recno_slot.c (L850-L855)

Latent leak / invariant asymmetry: the same-buffer fast path frees rslot->tuple when SHOULDFREE is set but does not free rslot->values_block, whereas tts_recno_clear() frees both. This is safe only under the current invariant that a values-only (values_block, SHOULDFREE) slot always has buffer==InvalidBuffer, so rslot->buffer == buffer cannot be true here for a real page buffer. That coupling is fragile and undocumented; if the invariant ever shifts this silently leaks values_block. Free values_block here too (or assert the invariant) to keep the two clear paths symmetric.


📄 src/backend/access/recno/recno_tuple.c (L437-L441)

Memory leak on the compress+overflow path. An attribute can be compressed in Phase 1 (work_values[i] = new palloc'd compressed buffer, is_compressed[i]=true) and then overflowed here in Phase 1b. RecnoStoreOverflowColumn reads work_values[i] but does NOT free it; it returns a fresh varlena that overwrites work_values[i]. The cleanup loop only does pfree(work_values[i]) once, freeing the overflow result, so the intermediate compressed buffer is leaked on every such INSERT/UPDATE. Free the compressed intermediate before overwriting.

💡 Suggested change

Before:

			work_values[i] = RecnoStoreOverflowColumn(rel, work_values[i], i,
													  force_shrink ? 0 : recno_overflow_inline_prefix,
													  overflow_buffers);
			is_overflowed[i] = true;
			has_overflow = true;

After:

			{
				Datum		prev = work_values[i];

				work_values[i] = RecnoStoreOverflowColumn(rel, work_values[i], i,
														  force_shrink ? 0 : recno_overflow_inline_prefix,
														  overflow_buffers);
				/* Free the compressed intermediate now orphaned by overflow */
				if (is_compressed[i])
					pfree(DatumGetPointer(prev));
				is_compressed[i] = false;
			}
			is_overflowed[i] = true;
			has_overflow = true;

📄 src/backend/access/recno/recno_tuple.c (L225-L225)

Unaligned access (portability hard gate). ovbuf is a char array (1-byte aligned); VARDATA(ovbuf) sits at offset VARHDRSZ (4) into it, and this cast to RecnoOverflowPtr * dereferences uint32/BlockNumber/uint16 members. Since the stack buffer has no guaranteed alignment, the struct members will typically be misaligned, causing an unaligned read on strict-alignment architectures (ARM, RISC-V, PPC64, s390x). RecnoGetOverflowPtr()/RecnoFetchOverflowColumn() dereference the same fields via VARDATA_ANY. Build the pointer in a properly aligned varlena (e.g. palloc0, which is MAXALIGN'd) or copy into a local RecnoOverflowPtr for field access.


📄 src/backend/access/recno/recno_tuple.c (L868-L869)

Dead code (YAGNI). RecnoPageUpdateTuple has no callers anywhere in src/ (verified). It also directly mutates page contents (memcpy into old_tuple / PageAddItem) and updates only the page opaque commit_ts with no critical section and no WAL logging, so if it were wired up as-is it would leave shared on-disk state crash/replica-inconsistent. Remove the unused function until an actual caller exists; if kept, it must be WAL-logged inside a critical section.


📄 src/backend/access/recno/recno_tuple.c (L939-L939)

Dead code (YAGNI). RecnoPageGetLiveTuples has no callers anywhere in src/ (verified), and the comment itself admits 'no current callers' while the snapshot_ts parameter is explicitly ignored via (void) snapshot_ts;. This is speculative scaffolding with an unused parameter and a self-admitted WIP marker. Remove it until a real caller needs it.


📄 src/backend/access/recno/recno_vm.c (L553-L557)

Correctness (high): PD_ALL_VISIBLE is cleared on the heap page here without MarkBufferDirty and without any WAL logging/LSN update. This differs from heap, which clears PD_ALL_VISIBLE inside the tuple's WAL-logged critical section (heapam.c sets all_visible_cleared=true so REDO reproduces the clear). These RecnoVMUpdateFor* helpers are also invoked AFTER END_CRIT_SECTION() in the insert/update/delete paths (e.g. recno_operations.c line 811 for insert, after the crit section ends at line 738). A cleared PD_ALL_VISIBLE bit can therefore be lost on buffer eviction or crash, leaving PD_ALL_VISIBLE set on a page that now holds a not-all-visible tuple. An index-only scan that trusts PD_ALL_VISIBLE would then skip the heap fetch and return incorrect results. The heap-page bit clear must be dirtied and WAL-logged in the same critical section as the tuple modification.


📄 src/backend/access/recno/recno_vm.c (L570-L575)

Correctness/consistency (high): the same lost-bit hazard applies here. PageClearAllVisible modifies the heap page but the change is neither dirtied (MarkBufferDirty) nor WAL-logged, so it can be lost on eviction/crash while a new, non-all-visible tuple version exists on the page.


📄 src/backend/access/recno/recno_vm.c (L279-L286)

Performance (medium): heap's visibilitymap_get_status reads map[mapByte] with no content lock, relying on single-byte read atomicity (see visibilitymap.c: "A single byte read is atomic"). Here every VM probe takes BUFFER_LOCK_SHARE, and RecnoVMCheck additionally does a full ReadBufferExtended + UnlockReleaseBuffer per call. Since these are called on the index-only-scan / seqscan visibility hot path (recno_handler.c, recno_operations.c), the SHARE lock and per-call buffer read are an avoidable regression versus heap. Drop the content lock and reuse a pinned buffer like visibilitymap_get_status.


📄 src/backend/access/recno/recno_vm.c (L72-L79)

YAGNI/dead code (medium): RecnoVMInit is an exported function with an empty body (the comment even says the fork is created lazily, so the function does nothing). It has no callers in the tree. Remove it and its declaration in recno.h.


📄 src/backend/access/recno/recno_vm.c (L551-L551)

YAGNI/dead code + aspirational comment (medium): the tuple parameter is only cast to (void) with a future-tense "reserved for future timestamp checking" comment, and the whole insert path unconditionally clears the bits regardless. PostgreSQL flags aspirational/future-tense comments for behavior that has not shipped. Either implement the timestamp check or drop the unused parameter and the comment; pass only the buffer (as RecnoVMUpdateForUpdate/Delete already do).


📄 src/backend/access/recno/recno_vm.c (L457-L458)

Dead code (medium): RecnoVMExtend, RecnoVMTruncate, RecnoVMGetPageSize, RecnoVMMapHeapToVM, and RecnoVMPinBuffer are exported but have no callers anywhere in the tree (verified by search). Unused public API is speculative scaffolding; remove these functions and their declarations. Note also that RecnoVMTruncate performs smgrtruncate without WAL logging or the DropRelationBuffers/critical-section interlock used for VM truncation elsewhere, so it would be broken if ever wired up.


📄 src/backend/access/recno/recno_undo.c (L176-L184)

Out-of-bounds read: offnum comes from the untrusted UNDO payload (hdr.tid) and is only guarded against the block count, never against the page's line-pointer count. PageGetItemId() does no bounds checking (it dereferences pd_linp[offnum - 1] directly), so a corrupt/malicious offnum reads past the line-pointer array here. The sibling appliers in this same patch guard this: hash_undo.c uses if (hdr.offset <= PageGetMaxOffsetNumber(page)) and nbtree_undo.c checks stored_offset <= PageGetMaxOffsetNumber(page). Add the same guard before dereferencing (treat out-of-range as a skip/no-op).

💡 Suggested change

Before:

	lp = PageGetItemId(page, offnum);
	if (!ItemIdIsNormal(lp))
	{
		/*
		 * Already cleaned up (e.g. VACUUM ran between the abort and the
		 * logical-revert worker's pass).  Nothing to do.
		 */
		return;
	}

After:

	if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page))
		return;

	lp = PageGetItemId(page, offnum);
	if (!ItemIdIsNormal(lp))
	{
		/*
		 * Already cleaned up (e.g. VACUUM ran between the abort and the
		 * logical-revert worker's pass).  Nothing to do.
		 */
		return;
	}

📄 src/backend/access/recno/recno_undo.c (L243-L246)

Same unvalidated-offnum out-of-bounds read as in apply_recno_undo_insert(): PageGetItemId(page, offnum) is called without first checking offnum <= PageGetMaxOffsetNumber(page). offnum is decoded from the untrusted payload TID and only the block number is validated in the caller. Guard against the page's max offset before dereferencing the line pointer, matching hash_undo.c / nbtree_undo.c.

💡 Suggested change

Before:

	Assert(old_image != NULL && old_len > 0);

	lp = PageGetItemId(page, offnum);
	if (!ItemIdIsNormal(lp))

After:

	Assert(old_image != NULL && old_len > 0);

	if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page))
	{
		ereport(DEBUG2,
				(errmsg_internal("RECNO UNDO: offset %u out of range at block %u, skipping",
								 offnum, blkno)));
		return false;
	}

	lp = PageGetItemId(page, offnum);
	if (!ItemIdIsNormal(lp))

📄 src/backend/access/recno/recno_undo.c (L392-L394)

Third site with the same unvalidated-offnum out-of-bounds read: the DELTA_UPDATE branch dereferences PageGetItemId(page, offnum) via ItemIdIsNormal(lp) before any offnum <= PageGetMaxOffsetNumber(page) check. Add the bounds guard before computing lp.

💡 Suggested change

Before:

				Page		page = BufferGetPage(buffer);
				ItemId		lp = PageGetItemId(page, offnum);
				const RecnoDiffRecord *diff;

After:

				Page		page = BufferGetPage(buffer);
				ItemId		lp;
				const RecnoDiffRecord *diff;

📄 src/backend/access/rmgrdesc/Makefile (L39-L41)

Inconsistent with meson.build and the project's own USE_RECNO design. meson.build compiles recnodesc.c unconditionally (rmgrdesc/meson.build line 36), with an explicit comment that USE_RECNO cannot be disabled because recno symbols are referenced unconditionally, so a build with USE_RECNO unset fails to link. Both src/Makefile.global.in (USE_RECNO = 1) and meson.build (cdata.set('USE_RECNO', 1)) set it unconditionally. Guarding recnodesc.o with ifdef USE_RECNO therefore contradicts the meson side and the documented rationale; the two build systems must stay in sync. Since all the other new desc objects (atmdesc/fileopsdesc/relundodesc/undodesc) are added unconditionally, add recnodesc.o to the OBJS list unconditionally too and drop this ifdef block. (moderate confidence)


📄 src/backend/access/recno/recno_undo.c (L407-L416)

Insufficient validation of the untrusted diff payload. This only checks that the 6-byte fixed header is present (sizeof(RecnoDiffRecord)), but RecnoApplyDiffReverse() then walks diff->ndiffs segments, advancing its read pointer by SizeOfRecnoDiffSegment + seg->ins_len per segment. That function takes no buffer-length argument and never bounds its segment walk against the end of the payload -- it only validates against new_len and old_total_len. A corrupt/truncated record with a large ndiffs/ins_len therefore reads past image_bytes + image_len. Validate the self-described size against the actual payload before dispatching, e.g. diff->total_size > image_len. (Note: use SizeOfRecnoDiffRecord, not sizeof(RecnoDiffRecord), for consistency with the rest of the code.)

💡 Suggested change

Before:

				if (image_len < sizeof(RecnoDiffRecord))
				{
					ereport(WARNING,
							(errmsg_internal("RECNO UNDO DELTA_UPDATE: diff "
											 "truncated (%zu bytes) at %llu",
											 image_len,
											 (unsigned long long) urec_ptr)));
					break;
				}
				diff = (const RecnoDiffRecord *) image_bytes;

After:

				if (image_len < SizeOfRecnoDiffRecord)
				{
					ereport(WARNING,
							(errmsg_internal("RECNO UNDO DELTA_UPDATE: diff "
											 "truncated (%zu bytes) at %llu",
											 image_len,
											 (unsigned long long) urec_ptr)));
					break;
				}
				diff = (const RecnoDiffRecord *) image_bytes;
				if (diff->total_size > image_len)
				{
					ereport(WARNING,
							(errmsg_internal("RECNO UNDO DELTA_UPDATE: diff total_size "
											 "%u exceeds payload %zu at %llu",
											 diff->total_size, image_len,
											 (unsigned long long) urec_ptr)));
					break;
				}

📄 src/backend/access/rmgrdesc/atmdesc.c (L42-L46)

atm_desc() has no default case. For an unexpected/corrupt info byte it appends nothing to buf, yielding empty pg_waldump output. The sibling fileopsdesc.c added in this same patch uses default: appendStringInfo(buf, "unknown fileops op code %u", info);, and atm_redo() in atm.c handles the same situation with elog(PANIC, "atm_redo: unknown op code %u", info). Add a matching default for consistency. (moderate confidence)

💡 Suggested change

Before:

				appendStringInfo(buf, "xid %u", xlrec->xid);
			}
			break;
	}
}

After:

				appendStringInfo(buf, "xid %u", xlrec->xid);
			}
			break;

		default:
			appendStringInfo(buf, "unknown atm op code %u", info);
			break;
	}
}

📄 src/backend/access/recno/recno_xlog.c (L1734-L1735)

Inconsistent PANIC-vs-WARNING policy for the same failure. In recno_xlog_insert_redo, a failed PageAddItem for a block-0 overflow record is treated as benign (elog(WARNING) + skip), on the documented grounds that a later checkpointed operation (CLR from the UNDO subsystem, defrag, prune) may have advanced the page past this record. Here, the identical situation for UPDATE redo PANICs. If the same benign race can occur during UPDATE overflow replay, this PANIC makes the server permanently unrecoverable after certain crash sequences, whereas INSERT tolerates it. Either both paths must PANIC (if the UPDATE case is genuinely a can't-happen corruption) or both must WARN+skip. Please justify why UPDATE overflow replay cannot hit the same race the INSERT path explicitly guards against.


📄 src/backend/access/recno/recno_xlog.c (L2226-L2226)

Non-ASCII character in source. This comment contains a UTF-8 em-dash (U+2014) rather than an ASCII hyphen. PostgreSQL sources must be ASCII-only (no smart quotes, em-dashes, or ellipses). Replace it with an ASCII '--'.

💡 Suggested change

Before:

				 * unreachable, but if it ever fires we must not PANIC —

After:

				 * unreachable, but if it ever fires we must not PANIC --

📄 src/backend/access/rmgrdesc/relundodesc.c (L97-L98)

Portability bug: RelUndoRecPtr is typedef uint64 (relundo_xlog.h:37), but this prints it with %lu and an (unsigned long) cast. On Windows/MSVC and other LLP64 platforms unsigned long is 32-bit, so this truncates the 64-bit UNDO pointer to 32 bits and prints a wrong value. Use UINT64_FORMAT with the native 64-bit type instead.

💡 Suggested change

Before:

				appendStringInfo(buf, "urec_ptr %lu",
								 (unsigned long) xlrec->urec_ptr);

After:

				appendStringInfo(buf, "urec_ptr " UINT64_FORMAT,
								 (uint64) xlrec->urec_ptr);

📄 src/backend/access/rmgrdesc/relundodesc.c (L44-L64)

These raw integers 1..5 duplicate the RelUndoRecordType enum in relundo.h (RELUNDO_INSERT=1 ... RELUNDO_DELTA_INSERT=5, RELUNDO_DELTA_UPDATE=6). Two problems: (1) hard-coded literals silently break if the enum is reordered; use the named constants in the case labels. (2) The enum has a sixth value RELUNDO_DELTA_UPDATE=6 which redo accepts (relundo_xlog.c:250 validates up to RELUNDO_DELTA_UPDATE) and can appear in xl_relundo_insert, but it is not handled here, so such records are described as "UNKNOWN". Add the missing case.

💡 Suggested change

Before:

				switch (xlrec->urec_type)
				{
					case 1:
						type_name = "INSERT";
						break;
					case 2:
						type_name = "DELETE";
						break;
					case 3:
						type_name = "UPDATE";
						break;
					case 4:
						type_name = "TUPLE_LOCK";
						break;
					case 5:
						type_name = "DELTA_INSERT";
						break;
					default:
						type_name = "UNKNOWN";
						break;
				}

After:

				switch (xlrec->urec_type)
				{
					case RELUNDO_INSERT:
						type_name = "INSERT";
						break;
					case RELUNDO_DELETE:
						type_name = "DELETE";
						break;
					case RELUNDO_UPDATE:
						type_name = "UPDATE";
						break;
					case RELUNDO_TUPLE_LOCK:
						type_name = "TUPLE_LOCK";
						break;
					case RELUNDO_DELTA_INSERT:
						type_name = "DELTA_INSERT";
						break;
					case RELUNDO_DELTA_UPDATE:
						type_name = "DELTA_UPDATE";
						break;
					default:
						type_name = "UNKNOWN";
						break;
				}

📄 src/backend/access/transam/twophase.c (L2997-L3002)

TwoPhaseGetOldestUndoBatchLSN() dereferences TwoPhaseState->numPrepXacts / prepXacts[] after only checking max_prepared_xacts > 0, but never checks TwoPhaseState == NULL. Its only caller, UndoGetOldestBatchLSN() (undolog.c), runs from KeepLogSeg() in xlog.c, which executes during recovery restartpoints -- potentially before TwoPhaseShmemInit() has attached TwoPhaseState. The new RecoveryTransactionIdIsPrepared() in this same patch added exactly this TwoPhaseState == NULL guard for the recovery phase; the omission here is inconsistent and a NULL-deref crash risk. Add the same guard.

Confidence: high.

💡 Suggested change

Before:

	if (max_prepared_xacts <= 0)
		return InvalidXLogRecPtr;

	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);

	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)

After:

	if (max_prepared_xacts <= 0)
		return InvalidXLogRecPtr;

	if (TwoPhaseState == NULL)
		return InvalidXLogRecPtr;

	LWLockAcquire(TwoPhaseStateLock, LW_SHARED);

	for (int i = 0; i < TwoPhaseState->numPrepXacts; i++)

📄 src/backend/access/transam/twophase.c (L1680-L1684)

On ROLLBACK PREPARED, a failed ATMAddAborted() (sLog full) only emits elog(WARNING) and then execution falls through to ProcArrayRemove() and durable removal of the 2PC state file. Unlike the ordinary-abort path in xactundo.c, there is no synchronous-rollback fallback here: the permanent-level UNDO chain (FILEOPS, nbtree/hash before-images) is simply never reverted, silently leaving on-disk state inconsistent after the rollback completes. For a durability-critical rollback path a WARNING is not sufficient -- either apply the UNDO synchronously on ATM-full, or raise this to elog(ERROR) so the ROLLBACK PREPARED fails loudly rather than corrupting state. Please confirm the intended failure semantics.

Confidence: high.


📄 src/backend/access/transam/twophase.c (L1682-L1684)

This user-facing message deviates from PostgreSQL message conventions: the primary message should be a lowercase phrase without an embedded colon-prefixed diagnostic or internal capitalization ('ATM full: ...'), diagnostics belong in errdetail, and it should use ereport() with a proper errcode. Given the durability implication above, WARNING is likely the wrong level entirely. (Note the same poor pattern was copied from xactundo.c; that is not a reason to propagate it.)

Confidence: moderate.


📄 src/backend/access/transam/twophase.c (L998-L998)

The TWOPHASE_MAGIC comment hardcodes '(24 bytes)' for the size of last_batch_lsn[NUndoPersistenceLevels]. This silently rots if NUndoPersistenceLevels (currently 3) changes -- the byte count is derived, not authoritative. The header comment in xact.h already documents the bump-both-MAGICs contract; consider dropping the concrete byte count here to avoid a stale comment.

Confidence: moderate.


📄 src/backend/access/transam/xlog.c (L7959-L7962)

This entire per-phase checkpoint timing instrumentation (the eight instr_time/*_ms locals, the INSTR_TIME_SET_CURRENT/INSTR_TIME_SUBTRACT pairs scattered across ~8 phases, and this new ereport(LOG, ...)) is out-of-scope diagnostic scaffolding for an UNDO/ATM feature patch. It bundles an unrelated change (top -hackers rejection reason), duplicates timing already reported by LogCheckpointEnd() called immediately below, and unconditionally adds an extra LOG line to every checkpoint whenever log_checkpoints is set (log clutter / POLA). Recommend dropping this from the patch or splitting it into a separate, independently-justified change.


📄 src/backend/access/transam/xlog.c (L7448-L7455)

Unit/naming mismatch: these locals are named *_ms and are assigned milliseconds via INSTR_TIME_GET_MILLISEC, but the log message divides each by 1000.0 and prints the unit as s (seconds). The naming contradicts the printed unit and is misleading. If this instrumentation is kept, either name the variables *_s and store seconds, or print milliseconds directly. Additionally, the phase labels SyncPre, DelayStart, etc. are CamelCase, which deviates from PostgreSQL's lower-case log-message style.


📄 src/backend/access/transam/xlog.c (L8650-L8654)

The extra bare { } block is unnecessary and inconsistent with the immediately-preceding retention blocks (replication-slot and GetOldestUnsummarizedLSN), which assign keep and test it inline without an added brace level. For consistency, drop the outer braces and follow the existing pattern (assign keep = UndoGetOldestBatchLSN(); at the same indentation, then if (XLogRecPtrIsValid(keep)) { ... }). Functionally the code is correct: reusing keep as scratch matches the surrounding blocks and it is not read after this point.


📄 src/backend/access/transam/xact.c (L6698-L6701)

This comment makes a false claim: it states XLOG_PAGE_MAGIC was bumped (0xD120 -> 0xD121), but the actual value in src/include/access/xlog_internal.h is still 0xD120 -- no bump was made. This is a real WAL-format-compatibility hazard: the on-disk xl_xact_prepare struct was in fact extended by last_batch_lsn[3] (+24 bytes, written into WAL via XLOG_XACT_PREPARE and into 2PC state files), which is exactly the kind of change that requires the magic bump the comment claims to have made. Either the magic bump is genuinely missing (a crash-recovery/compat defect), or the comment is stale and must be corrected. Hardcoding the hex values in a comment also invites drift; describe the requirement without asserting a value that isn't true.


📄 src/backend/access/transam/xact.c (L226-L226)

Use the domain typedef UndoRecPtr (defined as uint64 in undodefs.h) instead of a bare uint64 for the field and the Set/Get accessors, and initialize/compare against the named sentinel InvalidUndoRecPtr rather than a literal 0. Callers already cast to/from UndoRecPtr (e.g. undobuffer.c, xactundo.c), so this improves type clarity and avoids a silent mismatch if the underlying width ever changes.


📄 src/backend/access/transam/xlogrecovery.c (L37-L37)

This include appears to be unused. The three functions called by this diff resolve elsewhere: UndoRecoveryNeeded()/PerformUndoRecovery() are declared in access/undo_xlog.h and PerformRelUndoRecovery() in access/relundo.h. A codebase search finds no other reference to any undolog.h symbol in this file. Per PostgreSQL's minimal-diff discipline, drop this include unless a symbol from it is actually used. (moderate confidence)


📄 src/backend/access/undo/atm.c (L380-L380)

AtmStateRecord has trailing padding after the bool revert_complete field (on LP64 the struct is 8-byte aligned to 32 bytes, leaving 7 uninitialized bytes; there is also 4 bytes of padding before last_batch_lsn after the three Oid/TransactionId uint32s). Because rec is a stack variable populated field-by-field, that padding is uninitialized stack garbage that is then fed to COMP_CRC32C and write(). This (a) makes the CRC and file contents non-deterministic (a guaranteed Valgrind/MSAN "uninitialized bytes in write" failure) and (b) bakes struct padding into the on-disk format, which the portability gate forbids. Zero the record before populating.

💡 Suggested change

Before:

		COMP_CRC32C(crc, &rec, sizeof(rec));

After:

		memset(&rec, 0, sizeof(rec));
		rec.xid = entries[i].xid;
		rec.reloid = entries[i].reloid;
		rec.dboid = entries[i].dboid;
		rec.last_batch_lsn = entries[i].last_batch_lsn;
		rec.revert_complete = entries[i].revert_complete;

		COMP_CRC32C(crc, &rec, sizeof(rec));

📄 src/backend/access/undo/atm.c (L486-L488)

hdr.count is read from an untrusted, possibly-corrupt file and used to size this palloc BEFORE the CRC is verified (the checksum is only computed and compared further down). A corrupt or torn file with a large count triggers a huge allocation and, past MaxAllocSize, an ereport(ERROR) from palloc -- which defeats this function's own documented contract of treating a bad file as absent "with a warning". Add a sanity bound (e.g. cross-check hdr.count * sizeof(AtmStateRecord) against the actual file size via fstat, or an explicit upper limit) before allocating.


📄 src/backend/access/undo/atm.c (L358-L358)

ATM file I/O borrows the two-phase wait events (WAIT_EVENT_TWOPHASE_FILE_WRITE/SYNC/READ). A DBA sampling pg_stat_activity.wait_event will see "TwophaseFileWrite" while the process is actually writing the ATM state file, which is misleading for diagnosis. Register dedicated wait events in src/backend/utils/activity/wait_event_names.txt (do not hand-edit the generated header) for the ATM/undo state file I/O.


📄 src/backend/access/undo/atm.c (L161-L166)

XLogInsert() is emitted and then shared memory is mutated (SLogTxnInsert) outside any critical section. If the backend errors out or is interrupted between the WAL insert and the shared-state update, WAL records the abort but live shared state does not reflect it (or vice-versa in ATMForget). Crash recovery converges via redo, but a running system diverges -- potentially a lost rollback or a stale ATM entry. Per PG convention, wrap the WAL insert plus the shared-state mutation in START_CRIT_SECTION()/END_CRIT_SECTION() so any failure between them is a PANIC that redo makes consistent, rather than a silent divergence.


📄 src/backend/access/undo/logical_revert_worker.c (L233-L234)

Head-of-line blocking / liveness bug (breaks guaranteed rollback). ATMGetNextUnreverted -> SLogTxnGetNextUnreverted always returns the first unreverted entry in ascending xid order for ANY database (see slog.c:1042-1051, it is stateless and does not advance). When that head entry belongs to a database other than MyDatabaseId, this worker does goto sleep and, on the next iteration, the scan returns the very same foreign-db entry again. As a result, a single unreverted entry belonging to database A permanently starves the revert worker of database B: B's own ATM entries are never reverted even though B has work to do. This defeats the cluster-wide guaranteed-rollback claim.

The scan needs to filter by database (or skip past non-matching entries) inside the ATM/sLog layer so each worker can find the oldest unreverted entry for its own database, rather than being blocked by the global-oldest foreign entry.


📄 src/backend/access/undo/logical_revert_worker.c (L524-L525)

Silent-correctness footgun: when the cluster has more connectable databases than max_logical_revert_workers, every database past the ceiling is silently skipped here with no WARNING, and its ATM entries are never reverted (the comment even admits they are only serviced "once a tracked database is dropped"). On a normal cluster where no database is ever dropped, those entries remain unreverted forever, breaking guaranteed rollback for those databases with no diagnostic. At minimum this should emit a warning that databases are going unserviced; ideally the design should not silently strand undo work.


📄 src/backend/access/undo/logical_revert_worker.c (L507-L511)

The launcher tracks databases by OID in known_dbs and carries an OID forward on every scan as long as the database still exists, but it never verifies the per-db worker is actually alive. If a per-db worker exits permanently (e.g. the postmaster fails to auto-restart it, or it repeatedly crashes), its OID stays in known_dbs so the launcher assumes a live worker and never re-spawns one. Consider tracking the BackgroundWorkerHandle / notify PID (as autovacuum does) and re-spawning when GetBackgroundWorkerPid reports the worker is gone, so a database whose worker died is not left with unreverted ATM entries.


📄 src/backend/access/undo/logical_revert_worker.c (L361-L362)

Use snprintf with the field size instead of sprintf for consistency and to satisfy project convention (sprintf is discouraged; LogicalRevertLauncherRegister below already uses snprintf for the same fields). e.g. snprintf(worker.bgw_library_name, BGW_MAXLEN, "postgres") and snprintf(worker.bgw_function_name, BGW_MAXLEN, "LogicalRevertWorkerMain").


📄 src/backend/access/undo/README (L539-L543)

These GUC names do not exist in the tree. undo_batch_record_limit, undo_batch_size_kb, undo_worker_naptime, and undo_retention_time are not defined anywhere in the codebase (no matches in guc_tables.c or the undo sources). Only undo_instant_abort_threshold exists, and even that is a plain extern int global (xactundo.h:115, default 65536), not a registered GUC in guc_tables.c -- so it cannot be SET by users the way this section implies. Documenting nonexistent tuning knobs (and defaults for them) is actively misleading for a commitfest submission. Either wire these up as real GUCs or remove the tuning text.


📄 src/backend/access/undo/README (L779-L780)

This tuning paragraph references undo_batch_size_kb ("default 256 KB") and undo_batch_record_limit ("default 1000 records"), neither of which exists in the code. undo_instant_abort_threshold exists only as a non-GUC global variable, so "set to 0 to always use ATM" is not something an operator can actually configure at runtime. Remove or correct these references.


📄 src/backend/access/undo/README (L1069-L1070)

The LWTRANCHE_RECNO_DIRTY_MAP tranche does not exist. recno_dirtymap.c is protected by per-partition spinlocks (see its header comment: "each with its own writer spinlock"), not an LWLock tranche, and the structure is a hash set of page keys, not a "dirty-page bitmap". Level 7 of this lock-ordering table names a nonexistent lock and mischaracterizes the data structure, undermining the deadlock-avoidance contract it claims to document.


📄 src/backend/access/undo/README (L1063-L1064)

This entry references undo_flush.c, which does not exist in the tree (the undo directory has no such file). There is also no "flush" LWLock instance in the undo sources. This Level-4 lock in the ordering hierarchy is fictional; a lock-ordering table that lists a nonexistent lock is worse than no table -- remove it or point it at the real lock.


📄 src/backend/access/undo/README (L1059-L1061)

Hard-coded source line numbers rot immediately and are already wrong: LWTRANCHE_UNDO_LOG is initialized at undolog.c:100 and :108 (not :97/:105), and LWTRANCHE_UNDO_WORKER at logical_revert_worker.c:114 (not :109). Line-number citations in docs are a known maintenance footgun -- reference the function/symbol name instead of a line number.


📄 src/backend/access/undo/README (L590-L592)

These monitoring functions do not exist anywhere in the tree (no definitions in the undo sources, pg_proc.dat, or contrib). Section 19 also shows SELECT * FROM pg_stat_get_undo_logs(); as a usage example. Documenting SQL-callable functions that are not implemented will mislead reviewers and users. Remove them or implement and register them.


📄 src/backend/access/undo/README (L881-L884)

This section lists test files that do not exist. There is no src/test/regress/sql/undo.sql or undo_toast.sql (the added regress tests are recno_*.sql and fileops.sql), and no contiguous src/test/recovery/t/053-060 range (the added recovery tests are 054, 061, 063, 065, 067-070, with different subjects than listed). Combined with "Status: FEATURE COMPLETE" / "Documentation: Complete" here while Sections 15 and 18 mark UNDO-based MVCC as "in progress", ProcArray integration as "simplified", and pg_undorecover / TOAST-aware UNDO / delta compression as "not yet implemented", the status claims are internally contradictory. Cite the tests that actually exist and drop the FEATURE COMPLETE framing.


📄 src/backend/access/undo/README (L167-L169)

This byte-offset table documents an on-disk/serialized layout that does not match the actual struct. UndoRecordHeader in undorecord.h is documented there as "Size: 48 bytes" (SizeOfUndoRecordHeader = offsetof(urec_clr_ptr) + 8), whereas this table ends at offset 40. Presenting explicit padding offsets for a WAL-serialized header is also a portability hazard (padding/alignment/endianness vary by platform). Section 11 further repeats the wrong "~40 bytes (SizeOfUndoRecordHeader)" / "40-byte header" figures. Either drop the hand-computed offset column or reconcile it with the real struct size.


📄 src/backend/access/undo/README (L1053-L1053)

Non-ASCII characters violate the ASCII-only rule for source/docs. This line uses a Unicode em-dash and the rules bullet below uses a Unicode arrow ("2→5"). Elsewhere the doc uses ASCII "-->"; be consistent and ASCII-only. Replace the em-dash with " -- " and "2→5" with "2->5".


📄 src/backend/access/undo/README (L586-L586)

Section numbering skips from 11 to 13 -- there is no Section 12. Renumber the sections so the sequence is contiguous.


📄 src/backend/access/undo/relundo.c (L142-L142)

relundo_pending_metabuf is process-global static state that holds an EXCLUSIVE-locked metapage buffer across the Reserve->Finish/Cancel window, but nothing resets it on transaction/subtransaction abort. If an ereport(ERROR) fires between RelUndoReserve() (that allocated a new page, thus set this variable) and the paired Stage/Finish/Cancel, AbortTransaction() releases the buffer pin+lock via the ResourceOwner and BufferLockReleaseAll(), but this static still holds the now-stale Buffer number. The next operation in the same backend then either (a) hits a new-page RelUndoFinish[WithTuple] whose Assert(BufferIsValid(...)) passes on the stale buffer and MarkBufferDirty()s a buffer that may now hold an unrelated page (cross-page corruption / use-after-free), or (b) RelUndoCancel() UnlockReleaseBuffer()s a buffer it never locked. A confirmed error window exists in RECNO's CAS/FOLD UPDATE path (CheckForSerializableConflictIn between Reserve and Stage). This needs an AtEOXact/AtAbort reset hook (or ResourceOwner-based release) that clears relundo_pending_metabuf to InvalidBuffer on abort. (high confidence)


📄 src/backend/access/undo/relundo.c (L866-L867)

Non-portable format specifier: ptr is a RelUndoRecPtr (typedef of uint64). Casting to unsigned long and printing with %lu truncates on LLP64 platforms (Windows, where unsigned long is 32-bit) and on 32-bit platforms. Use UINT64_FORMAT instead. (high confidence)

💡 Suggested change

Before:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=%lu, payload_size=%zu, tuple_len=%u",
		 (unsigned long) ptr, payload_size, tuple_len);

After:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=" UINT64_FORMAT ", payload_size=%zu, tuple_len=%u",
		 (uint64) ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo.c (L497-L498)

Development debug scaffolding on the insert hot path. RelUndoReserve() carries a cluster of per-record elog(DEBUG1) calls (and RelUndoFinishWithTuple has one more). Even though DEBUG1 is off by default, these evaluate their arguments and clutter a path this file's own header calls a "severe contention point". A commit-ready patch should drop them; if any are worth keeping, gate them behind a compile-time trace flag rather than leaving unconditional per-record elog on the write path. (moderate confidence)


📄 src/backend/access/undo/relundo.c (L153-L153)

Magic shift >> (32 - 6) hard-codes log2(RELUNDO_HEAD_CACHE_SIZE)=6, decoupled from the RELUNDO_HEAD_CACHE_SIZE=64 macro. The comment on the size macro invites resizing ("Increasing to 128 or 256 is straightforward"), but doing so would silently keep this shift at 6 and collapse the hash into 64 buckets while the mask uses the larger size, degrading distribution. Derive the shift from the size (e.g. >> (32 - RELUNDO_HEAD_CACHE_BITS) with RELUNDO_HEAD_CACHE_BITS defined alongside the size) so a resize stays consistent. (moderate confidence)


📄 src/backend/access/undo/relundo.c (L1448-L1448)

RelUndoMaybeVacuum's throttle uses a single function-local static TimestampTz relundo_last_vacuum shared across ALL relations in the backend, not per-relation. In a backend touching many hot relations, only one relation's UNDO fork gets discarded per 5-second window; other relations' forks can keep growing, which contradicts the stated goal of preventing unbounded UNDO-fork growth. The comment says "each backend independently throttles" but omits that it also throttles globally across relations. Either key the throttle per-relation or fix the comment to state this limitation. (moderate confidence)


📄 src/backend/access/undo/relundo_discard.c (L119-L119)

Correctness (HIGH): relundo_pending_metabuf is a process-global static set here whenever RelUndoReserve() allocates a new UNDO page, but it is cleared only in RelUndoStage/RelUndoFinishWithTuple/RelUndoCancel. There is no AtEOXact/AtAbort hook that resets it (the RelUndoAbortCleanup_hook only drops sLog markers). If an ereport(ERROR) fires between RelUndoReserve() and the paired Finish/Cancel -- which is reachable: the RECNO UPDATE path (recno_operations.c ~4085) calls CheckForSerializableConflictIn() and OOM-able RecnoXLogPrepareLogicalImage() after Reserve, and its PG_CATCH only releases overflow buffers before re-throwing -- ResourceOwner cleanup releases the buffer's pin/lock at abort, but this static still holds the now-released buffer number. The next RelUndoReserve/Finish in the same backend passes Assert(BufferIsValid(relundo_pending_metabuf)) on the stale number and then MarkBufferDirty()s whatever page currently occupies that buffer slot -- a use-after-free / cross-page corruption hazard. Add a transaction/subtransaction abort reset (e.g. an AtEOXact_RelUndo hook that UnlockReleaseBuffer's any pending metabuf and clears it), or avoid the process-global static entirely.


📄 src/backend/access/undo/relundo_page.c (L80-L83)

WAL-consistency bug (high confidence): this metapage creation path mutates shared on-disk state (PageInit + field writes + MarkBufferDirty) but emits NO WAL record and never calls PageSetLSN. Contrast RelUndoInitRelation() in relundo.c, which logs the identical initialization via XLOG_RELUNDO_INIT and sets the page LSN. The dirty metapage here can be written to disk and shipped to a standby with no matching redo, so a subsequent crash-recovery replay or a replica will diverge (metapage magic 0x0 / inconsistent contents). The file-header comment's claim that the metapage is covered by XLOG_RELUNDO_INSERT applies only to relundo_allocate_page's head-pointer update, not to this bootstrap init. This branch must run in a critical section and emit a WAL record (e.g. reuse XLOG_RELUNDO_INIT) with PageSetLSN, or be removed in favor of the WAL-logged RelUndoInitRelation path.


📄 src/backend/access/undo/relundo_page.c (L85-L90)

Dead code and broken lock contract (high confidence): this whole branch is guarded by if (mode == BUFFER_LOCK_EXCLUSIVE), so the inner if (mode == BUFFER_LOCK_SHARE) downgrade is unreachable dead code. Worse, a legitimate SHARE caller hitting the zero-block fork falls through to the else ereport(ERROR) instead of getting a metapage. relundo_get_metapage IS called with BUFFER_LOCK_SHARE (relundo.c:493 RelUndoReserve, relundo.c:1230 RelUndoGetCurrentCounter), so this is reachable and will error out spuriously on a fork that legitimately has zero blocks. Either handle SHARE by initializing under EXCLUSIVE then downgrading, or drop the dead condition and document that init requires EXCLUSIVE.


📄 src/backend/access/undo/relundo_page.c (L60-L62)

Concurrency hazard (high confidence): two backends can both observe smgrnblocks(...)==0 and race into ExtendBufferedRel. The second extend returns block 1, tripping Assert(BufferGetBlockNumber(buf) == 0) (a user-reachable condition, so Assert is the wrong guard) in an assert build, or silently placing a bogus metapage at block 1 in a production build. There is no relation-extension or higher-level lock serializing this bootstrap. This whole zero-block init path should go through the single WAL-logged initializer (RelUndoInitRelation) rather than being duplicated here.


📄 src/backend/access/undo/relundo_page.c (L161-L166)

WAL-consistency bug (high confidence): the invalid-magic reinitialization path rewrites the metapage (PageInit + field writes + MarkBufferDirty) with no WAL record and no PageSetLSN. Same divergence risk as the block-0 creation branch: the reinitialized page can reach disk / a standby without a matching redo. If reinit-on-corruption is genuinely needed, it must be WAL-logged inside a critical section.


📄 src/backend/access/undo/relundo_page.c (L141-L144)

Silently reinitializing a metapage whose magic is wrong treats genuine on-disk corruption identically to a benign incomplete-init, at LOG level, and proceeds. This masks real corruption and is a data-loss footgun: if the fork was actually written and then corrupted, this discards all UNDO state without surfacing an error. At minimum this should be distinguishable from the never-initialized case (e.g. only reinit an all-zero page) and should not be silently downgraded to LOG.


📄 src/backend/access/undo/relundo_page.c (L96-L99)

Wrong errcode (moderate confidence): ERRCODE_INDEX_CORRUPTED is semantically wrong for an UNDO fork - this is not an index. The UNDO subsystem already uses ERRCODE_DATA_CORRUPTED (relundo_recovery.c:296). Use ERRCODE_DATA_CORRUPTED here (and at the version-check ereport on line 176) for consistency.

💡 Suggested change

Before:

			ereport(ERROR,
					(errcode(ERRCODE_INDEX_CORRUPTED),
					 errmsg("UNDO fork for relation \"%s\" has no blocks",
							RelationGetRelationName(rel))));

After:

			ereport(ERROR,
					(errcode(ERRCODE_DATA_CORRUPTED),
					 errmsg("UNDO fork for relation \"%s\" has no blocks",
							RelationGetRelationName(rel))));

📄 src/backend/access/undo/relundo_page.c (L175-L177)

Same wrong errcode here: use ERRCODE_DATA_CORRUPTED instead of ERRCODE_INDEX_CORRUPTED for the UNDO metapage version mismatch.

💡 Suggested change

Before:

		ereport(ERROR,
				(errcode(ERRCODE_INDEX_CORRUPTED),
				 errmsg("unsupported UNDO metapage version %u in relation \"%s\" (expected %u)",

After:

		ereport(ERROR,
				(errcode(ERRCODE_DATA_CORRUPTED),
				 errmsg("unsupported UNDO metapage version %u in relation \"%s\" (expected %u)",

📄 src/backend/access/undo/relundo_page.c (L233-L233)

The free-list pop trusts meta->free_blkno and freehdr->prev_blkno unconditionally: it locks the recycled block and threads freehdr->prev_blkno back into meta->free_blkno with no sanity check that the block is actually a free page. If the free list is ever corrupted or the invariant broken, this silently threads garbage into free_blkno or double-uses a block, corrupting the chain. Add a defensive check (e.g. the page's counter/state marks it free, or freehdr->prev_blkno is Invalid or a valid in-range block) before reuse, or at least an Assert documenting the invariant.


📄 src/backend/access/undo/relundo_page.c (L299-L300)

The 'Verified:' prefix reads like a review annotation rather than a durable code comment, and the referenced guarantee is misleading in this file: XLOG_RELUNDO_INSERT covering the metapage applies to relundo_allocate_page's head-pointer update in the RelUndoFinish caller, but NOT to the metapage init/reinit paths above, which emit no WAL at all. Reword to state the invariant plainly and scope it to the allocation path.


📄 src/backend/access/undo/relundo_recovery.c (L75-L75)

RelUndoRecoveryApplyPage() returns int, but the value it returns is always a BlockNumber (prev, or InvalidBlockNumber). BlockNumber is uint32 and InvalidBlockNumber is 0xFFFFFFFF; storing that into a signed 32-bit int and letting the caller (BlockNumber blkno = RelUndoRecoveryApplyPage(...)) convert it back relies on implementation-defined signed/unsigned round-tripping. Declare the return type as BlockNumber for both the forward declaration and the definition to avoid the sign-conversion footgun and match the values actually returned.

💡 Suggested change

Before:

static int	RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno);

After:

static BlockNumber RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno);

📄 src/backend/access/undo/relundo_recovery.c (L319-L320)

The definition's return type should likewise be BlockNumber (not int) to match the returned prev/InvalidBlockNumber values and the corrected forward declaration.

💡 Suggested change

Before:

static int
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)

After:

static BlockNumber
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)

📄 src/backend/access/undo/relundo_recovery.c (L362-L366)

The loop guard is offset < pd_lower, but the following memcpy reads SizeOfRelUndoRecordHeader bytes. When offset < pd_lower yet offset + SizeOfRelUndoRecordHeader > pd_lower (a corrupt/short trailing record), the copy reads past pd_lower into page slack and interprets it as a header. It stays inside the BLCKSZ page buffer so it is not a true out-of-bounds read, but it can misclassify garbage as a valid record header. Tighten the guard to offset + SizeOfRelUndoRecordHeader <= pd_lower for defensive correctness during recovery.

💡 Suggested change

Before:

	while (offset < pd_lower && nrecs < maxrecs)
	{
		RelUndoRecordHeader rhdr;

		memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader);

After:

	while (offset + SizeOfRelUndoRecordHeader <= pd_lower && nrecs < maxrecs)
	{
		RelUndoRecordHeader rhdr;

		memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader);

📄 src/backend/access/undo/relundo_xlog.c (L456-L458)

Out-of-bounds write from an untrusted WAL field. xlrec->slot is a uint16 (0-65535), but it directly indexes meta->tail_blkno[]/meta->head_blkno[], arrays of size RELUNDO_NUM_HEADS (16). A corrupt or torn record with slot >= RELUNDO_NUM_HEADS corrupts the metapage past the array bounds. Every other field here (magic, block numbers) is guarded with a PANIC check; slot must be too, especially since PANIC-on-invalid is already the established policy in this function.

💡 Suggested change

Before:

		meta->tail_blkno[xlrec->slot] = xlrec->new_tail_blkno;
		if (whole_chain)
			meta->head_blkno[xlrec->slot] = InvalidBlockNumber;

After:

		if (xlrec->slot >= RELUNDO_NUM_HEADS)
			elog(PANIC, "relundo_redo_discard: slot %u out of range",
				 xlrec->slot);

		meta->tail_blkno[xlrec->slot] = xlrec->new_tail_blkno;
		if (whole_chain)
			meta->head_blkno[xlrec->slot] = InvalidBlockNumber;

📄 src/backend/access/undo/relundo_xlog.c (L264-L270)

Speculative optimization that reinvents existing infrastructure (YAGNI). No other rmgr redo routine in the tree calls PrefetchSharedBuffer/pgaio_enter_batchmode manually; WAL-driven prefetch is done centrally by the XLogPrefetcher (xlogprefetcher.c), whose hint this code already consults via XLogRecGetBlockTagExtended's prefetch_buffer. This hand-rolled path adds per-record smgrexists/smgrnblocks calls on the hot recovery path (an fd open / lseek per record), duplicates the prefetcher, and comes with no reproducible benchmark backing the claimed latency win. Recommend dropping the manual prefetch/batch-mode machinery and relying on the standard XLogPrefetcher; keep relundo_redo_insert to just XLogReadBufferForRedo for both blocks.


📄 src/backend/access/undo/relundo_xlog.c (L9-L11)

Comment accuracy: this header lists only INIT, INSERT and DISCARD as replayed record types, but the dispatch in relundo_redo() also handles XLOG_RELUNDO_TRUNCATE and XLOG_RELUNDO_APPLY. The comment must describe what the code does now.

💡 Suggested change

Before:

 *   XLOG_RELUNDO_INIT    - Replay metapage initialization
 *   XLOG_RELUNDO_INSERT  - Replay UNDO record insertion into a data page
 *   XLOG_RELUNDO_DISCARD - Replay discard of old UNDO pages

After:

 *   XLOG_RELUNDO_INIT     - Replay metapage initialization
 *   XLOG_RELUNDO_INSERT   - Replay UNDO record insertion into a data page
 *   XLOG_RELUNDO_DISCARD  - Replay discard of old UNDO pages
 *   XLOG_RELUNDO_TRUNCATE - Replay physical truncation of an emptied fork
 *   XLOG_RELUNDO_APPLY    - Replay a rollback compensation log record (CLR)

📄 src/backend/access/undo/undo_bufmgr.c (L102-L107)

None of the functions defined in this file (UndoMakeBufferTag, ReadUndoBuffer, ReadUndoBufferExtended, ReleaseUndoBuffer, UnlockReleaseUndoBuffer, MarkUndoBufferDirty, InvalidateUndoBuffers, InvalidateUndoBufferRange) have any caller anywhere in the tree -- a codebase-wide search returns zero references outside this file and its header. The companion header undo_bufmgr.h itself states the architecture moved to UNDO-in-WAL, where "UNDO data is written via pwrite() and read via pread(), bypassing shared_buffers entirely," and that the buffer-pool integration is "retained only for the buffer invalidation API used during segment discard" -- yet even the invalidation functions are unused. This is speculative scaffolding (YAGNI): the whole file should be dropped until an actual caller exists, otherwise it is dead code that must be maintained and reviewed for no benefit. (high confidence)


📄 src/backend/access/undo/undo_bufmgr.c (L235-L247)

This function is documented to drop buffers for a range [first_block, last_block], but DropRelationBuffers drops ALL buffers with blockNum >= firstDelBlock (see bufmgr.c: "removes ... all the pages ... that have block numbers >= firstDelBlock"); last_block is completely ignored, used only in the Assert. During partial undo-log truncation a caller expecting only [first_block, last_block] to be invalidated would silently lose still-valid undo buffers beyond last_block -- a data-loss/corruption footgun for the rollback/recovery path. The comment even admits the divergence, which is not acceptable for a correctness-critical invalidation path. Either enforce the upper bound or remove the last_block parameter (and this function, since it has no callers). Note DropRelationBuffers also drops dirty pages without writing them back ("NOT rollback-able"), and there is no enforcement here that the log is truly fully discarded / its WAL flushed. (high confidence)


📄 src/backend/access/undo/undo_xlog.c (L995-L995)

High (high confidence): deferred->dboid = MyDatabaseId is wrong here. PerformUndoRecovery() runs in the startup process during crash recovery (called from xlogrecovery.c), and the startup process is not connected to any database, so MyDatabaseId is InvalidOid. This invalid OID is then persisted into the ATM via ATMAddAborted(deferred->xid, deferred->dboid, ...) in FlushDeferredUndoXacts() and consumed by the logical revert worker, which will be unable to locate the target relations (or will target the wrong DB). The database OID must be derived from the UNDO batch/record (e.g. from the relation's tablespace/database in the undo record) rather than from MyDatabaseId.


📄 src/backend/access/undo/undo_xlog.c (L365-L367)

High (moderate confidence): the delta reconstruction writes old_tuple_len bytes into the on-page item (memcpy(cur_htup, restored, old_tuple_len)) but never validates old_tuple_len against the item's actual on-page storage. The guard only checks prefix_len + changed_len + suffix_len == old_tuple_len, prefix/suffix <= cur_len, and datalen. If old_tuple_len > cur_len, ItemIdSetNormal claims more storage than the slot has and the memcpy overruns the item onto adjacent page data, corrupting the shared buffer during redo. Additionally, prefix_len + changed_len + suffix_len on line above is evaluated in 32-bit unsigned arithmetic before the (Size) cast, so with an untrusted changed_len the equality check can wrap. Note also this UNDO_CLR_HAS_DELTA branch appears to be dead: no forward-apply path sets UNDO_CLR_HAS_DELTA anywhere in the tree, so per YAGNI this redo branch should either be removed or hardened with a full old_tuple_len <= cur_len bound check.


📄 src/backend/access/undo/undo_xlog.c (L422-L425)

Medium (moderate confidence): the HOT_RESTORE and VISIBILITY redo branches copy fixed-size structs out of block data (memcpy(&hot_data, data, SizeOfUndoApplyHot) / memcpy(&vis_rec, vis_data, SizeOfUndoApplyVisibility)) but only verify the length via Assert(datalen >= ...). Asserts are compiled out in production builds, so a short/corrupt CLR would read past the block-data buffer during redo. Replace the length asserts with runtime ereport(ERROR, ...) checks as is already done in the delta branch.


📄 src/backend/access/undo/undo_xlog.c (L108-L113)

Medium (moderate confidence): all the UndoLogShared mutations in the ALLOCATE/DISCARD/ROTATE redo handlers modify plain (non-atomic) fields (in_use, log_number, discard_ptr, oldest_xid, state) and scan for a free slot without holding log->lock or allocation_lock. The header documents UndoLogControl.lock as protecting exactly this metadata. This is safe only if redo is strictly single-threaded with no other process touching UndoLogShared; under hot standby (checkpointer/bgworkers active) these are data races and the free-slot scan is a TOCTOU. Either take the documented lock or add a comment justifying why redo is exclusive here.


📄 src/backend/access/undo/undo_xlog.c (L1206-L1210)

Medium (moderate confidence): this WAL-segment existence guard has two problems. (1) access(path, F_OK) is a TOCTOU check: the segment can be recycled between this call and the subsequent XLogReadRecord, so it does not actually prevent the SIGBUS the comment describes. (2) GetWALInsertionTimeLine() returns the insertion TLI, which is not necessarily the correct timeline for a historical LSN being read during recovery (PITR/timeline switches); using it to build the segment filename can select the wrong file. Use the replay/recovery timeline for the target LSN, and rely on graceful read-error handling from XLogReadRecord rather than a racy pre-check.


📄 src/backend/access/undo/undo_xlog.c (L111-L113)

Low (high confidence): the newly-created UndoLogControl slot in the ALLOCATE handler initializes log_number, insert_ptr, discard_ptr, oldest_xid, in_use but leaves state, seal_ptr, and sealed_time uninitialized, unlike the ROTATE handler which explicitly sets state/seal_ptr. This relies on shared memory being zero-initialized and on UNDO_LOG_ACTIVE/InvalidUndoRecPtr both being 0. Initialize these fields explicitly for consistency and to avoid a latent bug if the enum/sentinel values change.


📄 src/backend/access/undo/undoapply.c (L214-L215)

The fixed-size cap silently drops UNDO records beyond 1024 during rollback/crash recovery, corrupting on-disk state (partial revert, atomicity violation). This is coupled to undo_batch_record_limit (default 1000 in undolog.c), so a batch is not guaranteed to stay under 1024: if that limit is ever raised past 1024, or the size-based flush (undo_batch_size_kb, default 256KB) lets many small records accumulate, a batch can exceed 1024 and the excess records are silently skipped with only a WARNING. The existing recovery walker in undo_xlog.c (lines 889-947) does NOT use a fixed array -- it applies records in a single forward pass. This new code must dynamically size the array (e.g. seed from batch->header.nrecords which is serialized in xl_undo_batch, or grow via a List) rather than assuming 1024. The comment 'Large enough to avoid reallocation' is an unjustified and dangerous assumption.


📄 src/backend/access/undo/undoapply.c (L302-L304)

The outer chain-following loop lacks the monotonicity guard that the equivalent recovery walker in undo_xlog.c has (lines 960-966, which PANICs when next_lsn >= batch_lsn). Here batch_lsn = batch->header.chain_prev is followed unconditionally, so a corrupt or forward-pointing chain_prev (>= current batch_lsn) will loop indefinitely, re-reading the same batch. There is also no CHECK_FOR_INTERRUPTS() in this loop, making the hang non-interruptible during recovery. Validate that chain_prev is strictly less than the current batch_lsn before following it, and add CHECK_FOR_INTERRUPTS().

💡 Suggested change

Before:

		/* Follow chain to previous batch */
		batch_lsn = batch->header.chain_prev;
		UndoFreeBatchData(batch);

After:

		/*
		 * Follow chain to previous batch.  chain_prev must be strictly older
		 * (smaller LSN) than the current batch, or invalid (end of chain).  A
		 * forward-pointing or equal chain_prev would loop forever.
		 */
		{
			XLogRecPtr	next_lsn = batch->header.chain_prev;

			if (XLogRecPtrIsValid(next_lsn) && next_lsn >= batch_lsn)
				ereport(PANIC,
						(errmsg("UNDO rollback: chain_prev %X/%X >= batch_lsn %X/%X; corrupt UNDO chain",
								LSN_FORMAT_ARGS(next_lsn),
								LSN_FORMAT_ARGS(batch_lsn))));
			UndoFreeBatchData(batch);
			batch_lsn = next_lsn;
		}

📄 src/backend/access/undo/undoapply.c (L294-L297)

Commented-out code (pfree(record_starts);) plus a WHAT-not-WHY comment block; pgsql-hackers rejects both. Beyond style: the top-level caller ApplyUndoChainFromWAL() (xactundo.c:963) runs inline without a dedicated per-call context, so each batch leaks ~8KB (1024 * sizeof(char*)) until the surrounding context resets -- a real per-batch accumulation over a long chain. Prefer allocating the array once, sized from batch->header.nrecords, and freeing it, or reset a short-lived per-batch context. Also 1024 is a magic constant that should be a named macro if retained.


📄 src/backend/access/undo/undoapply.c (L110-L117)

When rm_undo returns UNDO_APPLY_ERROR, this path only emits a WARNING and treats the record as skipped, then continues the chain walk. During crash-recovery rollback, a genuine failure to apply an UNDO record silently leaves the database in an inconsistent (partially reverted) state instead of failing loudly. Note undo_xlog.c's recovery path escalates to PANIC on chain corruption. Confirm that swallowing UNDO_APPLY_ERROR is intended; otherwise recovery should ERROR/PANIC rather than continue.


📄 src/backend/access/undo/undobuffer.c (L192-L194)

Cross-transaction state leak on error. undo_t2buf is a file-static in TopMemoryContext, and its only reset paths are UndoBufferEnd/UndoBufferFlush, both driven by the AM's finish-bulk-insert callback (recno_finish_bulk_insert -> UndoBufferEnd). If an ereport(ERROR) is raised between the begin and finish callbacks (e.g. mid-COPY / multi-insert), UndoBufferEnd is never reached and nothing else resets the struct: there is no AtEOXact/AtAbort hook (confirmed: no reference to any UndoBuffer* reset in xact.c or xactundo.c). The next transaction then sees active=true with a stale xid and stale chain_prev; a subsequent UndoBufferBegin for a different relation calls UndoBufferEnd->UndoBufferFlush, emitting an XLOG_UNDO_BATCH tagged with the aborted transaction's xid and a dangling chain link. Register an abort/end-of-xact cleanup (e.g. via RegisterXactCallback or an AtEOXact_* hook) that force-resets the buffer.


📄 src/backend/access/undo/undobuffer.c (L272-L273)

Dead code / unwired fast path. The module header advertises embedding UNDO directly into the AM's DML WAL record as the primary purpose, but the functions that implement it -- UndoBufferTakePayload, UndoBufferReset, UndoBufferHasPendingData -- have no callers anywhere in the tree, and UndoBufferAddRecord (single-payload variant) is also uncalled (only UndoBufferAddRecordParts is used, from hash_undo.c/nbtree_undo.c). In practice all records go through the overflow UndoBufferFlush path. This is speculative scaffolding; either wire up the embed path or drop the unused API to keep the patch minimal.


📄 src/backend/access/undo/undobuffer.c (L37-L38)

Misleading comment: undo_batch_size_kb and undo_batch_record_limit are plain global ints (undolog.c) with no registration in guc_tables.c, so they are not GUCs and cannot be tuned by users. Reword to describe them accurately (compile-time constants / globals), or actually register them as GUCs if runtime tunability is intended.


📄 src/backend/access/undo/undobuffer.c (L119-L126)

record_size/payload_len are Size (64-bit) but are stored into the uint32 fields urec_len/urec_payload_len with an unchecked cast. If a payload ever pushes record_size past UINT32_MAX, the length is silently truncated and the reader's pos += hdr.urec_len walk in undoapply.c desynchronizes, corrupting rollback. A WAL record cannot actually reach 4GB today, so this is defensive, but an Assert (or explicit check) on record_size <= PG_UINT32_MAX would document the invariant and catch misuse. Same applies to UndoTier2AddRecordParts.


📄 src/backend/access/undo/undoinsert.c (L109-L109)

buffer_size is Size (64-bit) but total_len is uint32. This cast silently truncates for a batch larger than 4GB. The rollback/redo reader (UndoReadBatchFromWAL) trusts total_len to size the payload copy (result->payload_len = (Size) xlrec->total_len), so a truncated value would silently drop UNDO records rather than error. The truncation happens before XLogInsert()'s own size check, so relying on that check to catch oversized batches is not sound. Add a defensive check (e.g. if (uset->buffer_size > PG_UINT32_MAX) elog(ERROR, ...)) before the cast on this durability path.


📄 src/backend/access/undo/undoinsert.c (L55-L59)

These no-op stubs are dead scaffolding introduced in this same patch: their only callers (AtCommit_XactUndo/AtAbort_XactUndo in xactundo.c) are also added in this changeset. The comment "kept for callers that haven't been updated yet" describes callers that are being written now, not pre-existing ones. Per the minimal-diff/YAGNI discipline, drop both stubs (and their declarations in undorecord.h) and the calls, rather than shipping empty functions plus stale-on-arrival "legacy" comments.


📄 src/backend/access/undo/undolog.c (L45-L45)

Dead knob: undo_buffer_size is defined here but has no reader anywhere in the tree (only this definition and the extern in undolog.h exist). It is also not registered in guc_tables.c, so despite the "GUC parameters" heading it cannot be tuned by users. Per the minimalism/YAGNI discipline, unused knobs are a documented rejection reason -- remove it. Note also that the header comment labels these as "GUC parameters" while none of them are actually registered GUCs; undo_retention_time likewise has no reader. Consider dropping the dead ones and fixing the misleading label.


📄 src/backend/access/undo/undolog.c (L423-L426)

This block asserts these stubs "still have live callers ... rather than sprinkling mode checks", but several of them have no callers at all: UndoLogSync, UndoFlushGetMaxWritePtr, UndoLogTryPressureDiscard, ExtendUndoLogSmgrFile, and UndoLogPath are never called anywhere in the tree. Only UndoLogCloseFiles, UndoFlushResetMaxWritePtr, UndoLogDeleteSegmentFile, ExtendUndoLogFile, and UndoLogSealAndRotate have live callers. The uncalled stubs are dead code and should be removed; keeping them contradicts the comment and violates the minimalism discipline.


📄 src/backend/access/undo/undolog.c (L180-L185)

UndoLogGetOldestDiscardPtr has no callers anywhere in the tree -- it is dead code and should be removed. Separately, if it were kept, note the synchronization inconsistency: this reads log->discard_ptr (a plain, non-atomic 64-bit field) WITHOUT holding log->lock, whereas every other accessor (UndoLogDiscard, UndoLogGetDiscardPtr, CheckPointUndoLog) reads/writes it under log->lock. That is an unlocked read of a value concurrently mutated under a lock, which can tear/return stale data on some platforms.


📄 src/backend/access/undo/undolog.c (L494-L496)

UndoLogGetInsertPtr and UndoLogGetDiscardPtr (below) have no callers anywhere in the tree -- dead code that should be removed. These are the only non-stub "utility" getters left, and nothing consumes them.


📄 src/backend/access/undo/undolog.c (L472-L478)

UndoLogPath builds a path ("base/undo/%012u") for segment files that, per this file's own header, no longer exist under UNDO-in-WAL. It has no callers, so it is dead code. Beyond being dead, returning a path to a nonexistent file is a footgun for any future caller. Remove it.


📄 src/backend/access/undo/undorecord.c (L308-L316)

Unaligned memory access: records are packed back-to-back at arbitrary offsets (dest = uset->buffer + uset->buffer_size, where buffer_size accumulates header + arbitrary payload_len). Casting dest to UndoRecordHeader * and writing fields like urec_len (uint32), urec_prev and urec_clr_ptr (uint64) directly performs unaligned writes for the 2nd and subsequent records. This traps/crashes on strict-alignment architectures (ARM64, PPC64, s390x, RISC-V), violating the portability hard gate. Note the read path (undoapply.c) correctly copies via memcpy(&hdr, pos, SizeOfUndoRecordHeader) into an aligned local -- the write path here is the asymmetric offender. Either build the header in an aligned local and memcpy it into dest, or MAXALIGN each record start offset.


📄 src/backend/access/undo/undorecord.c (L308-L317)

Same unaligned-access hazard as UndoRecordAddPayload: dest is an arbitrary (unaligned) offset into uset->buffer, and writing header->urec_len/urec_prev/urec_clr_ptr through the cast pointer is unaligned access on strict-alignment platforms. Build the header in an aligned local and memcpy, or MAXALIGN record offsets.


📄 src/backend/access/undo/undorecord.c (L349-L352)

Size overflow / silent truncation. payload_len = part1_len + part2_len (Size + Size) and record_size = SizeOfUndoRecordHeader + payload_len can wrap with large inputs, and urec_len/urec_payload_len are assigned via (uint32) casts that silently truncate any payload >= 4GB. There is no bounds check before use. Combined with the doubling in UndoRecordEnsureCapacity (which can also overflow), a wrapped record_size yields an undersized buffer and a subsequent heap overflow in the payload memcpy. Add an overflow/size-limit check before allocation and reject oversized records via ereport.


📄 src/backend/access/undo/undorecord.c (L223-L227)

Overflow in capacity growth: new_capacity = uset->buffer_capacity * 2 and new_capacity *= 2 can overflow Size for a very large additional, producing a value smaller than uset->buffer_size + additional. The loop condition would then be satisfied by a wrapped (too-small) value, causing MemoryContextAlloc of an undersized buffer and a heap overflow in the caller's memcpy. Bound additional/new_capacity and guard against overflow before allocating.


📄 src/backend/access/undo/undorecord.c (L34-L34)

Comment/behavior drift: this comment claims the cache is 'cleaned up at transaction end by UndoRecordSetResetCache()', and UndoRecordSetResetCache's own header says 'Called at transaction end (commit or abort)'. In fact UndoRecordSetResetCache() is only invoked from AtAbort_XactUndo; AtCommit_XactUndo never calls it, so the recycled context persists across commits (re-parented under TopMemoryContext by UndoRecordSetFree). Either call ResetCache on the commit path too or fix the comments to state the context is intentionally retained across successful transactions.


📄 src/backend/access/undo/undorecord.c (L157-L158)

Comment drift and unverifiable performance claim. SizeOfUndoRecordHeader is offsetof(urec_clr_ptr) + sizeof(XLogRecPtr) = 40 bytes on 64-bit builds (with a 4-byte padding hole after urec_xid), not 48. Both this '48-byte header' comment and the struct's 'Size: 48 bytes' comment in undorecord.h are wrong. Drop the hardcoded byte count (it drifts from the real size) and reference SizeOfUndoRecordHeader instead.


📄 src/backend/access/undo/undorecord.c (L243-L244)

Unverifiable performance claim with magic cycle counts ('~5 cycles vs ~300 cycles'). pgsql-hackers norms require reproducible benchmarks for perf claims; hardcoded cycle numbers in comments will draw objections and drift over time. State what the function does (resets buffer_size/nrecords without touching the context) rather than citing invented cycle counts.


📄 src/backend/access/undo/undorecord.c (L54-L55)

Dead code (YAGNI): UndoRecordSerialize and UndoRecordDeserialize have no callers anywhere in the tree (only their declarations in undorecord.h) -- the actual write path builds the header in place in UndoRecordAddPayload/Parts, and the read path uses memcpy directly in undoapply.c/undo_xlog.c. Remove these two functions and their prototypes unless a caller is added.


📄 src/backend/access/undo/undostats.c (L39-L41)

These functions are declared with PG_FUNCTION_INFO_V1 and the header comment claims they are "registered in pg_proc.dat", but pg_proc.dat contains NO entries for pg_stat_get_undo_logs, pg_stat_get_undo_buffers, or pg_undo_force_discard (the file is not even in this change set). They are therefore unreachable from SQL. The bundled examples (examples/01-basic-undo-setup.sql, examples/05-undo-monitoring.sql) do SELECT * FROM pg_stat_get_undo_logs() and will fail at runtime with "function ... does not exist". Add catalog entries to pg_proc.dat with OIDs from the 8000-9999 developer range (matching prorettype/proretset/proargtypes to the C tuple descriptors), or the whole module is dead code. (high confidence)


📄 src/backend/access/undo/undostats.c (L82-L87)

log->in_use and log->log_number are read without holding log->lock (the !log->in_use check and the iteration precede LWLockAcquire, and log_number is copied only inside the locked block but in_use is tested outside). Per undolog.h, log->lock "protects metadata", and in_use is a plain bool toggled under LW_EXCLUSIVE by the discard worker (undoworker.c Phase 2) and undo_xlog.c redo. Concurrent slot free/alloc can flip in_use mid-scan, producing a torn/inconsistent snapshot or reading a slot mid-transition. Hold at least LW_SHARED across the in_use test, or acquire the allocation_lock, to get a consistent read. (moderate confidence)


📄 src/backend/access/undo/undostats.c (L94-L96)

UndoRecPtrGetOffset returns uint64 (undolog.h: masks with 0xFFFFFFFFFFULL). This subtraction is unsigned: if discard_ptr's offset ever exceeds insert_ptr's offset momentarily, size_bytes underflows to a huge value reported to the user. Also note insert_ptr is read atomically while discard_ptr is a plain read under only LW_SHARED, so the pair is not guaranteed consistent. Guard the subtraction (e.g., report 0 when discard >= insert) and confirm monotonicity within a log's lifetime. (moderate confidence)


📄 src/backend/access/undo/undostats.c (L206-L208)

This entire function is dead scaffolding. GetUndoBufferStats() unconditionally returns zeros (its own comment says UNDO buffer stats are "no longer tracked separately"), so num_buffers/hits/misses/evictions/writes are always 0 and hit_ratio is always 0.0. Meanwhile this function's header comment describes real "buffer cache statistics", which is aspirational/misleading. Per YAGNI/minimalism, either wire it to real accounting or remove the function (and its would-be catalog entry and the example queries). At minimum, fix the stale comment to state the values are hardcoded zeros. (high confidence)


📄 src/backend/access/undo/undostats.c (L289-L291)

This function reproduces the discard worker's Phase 1/Phase 2 nearly verbatim (undoworker.c:255-340) but operates on a lifecycle that no longer exists: undolog.h states "the ACTIVE->SEALED->DISCARDABLE rotation no longer occurs" with UNDO-in-WAL, and both UndoLogSealAndRotate() and UndoLogDeleteSegmentFile() are documented no-ops (undolog.c:454,460). So force_rotate does nothing, and the SEALED->DISCARDABLE->FREE path is unreachable in practice. This is copy-paste of dead logic plus an unregistered SQL function. Remove it, or factor the shared discard logic into one helper rather than duplicating it. (high confidence)


📄 src/backend/access/undo/undostats.c (L184-L184)

log_number is uint32 (undolog.h:93) but is emitted via Int32GetDatum into an INT4 column. A log_number > INT32_MAX would be surfaced to SQL as a negative int4. If log numbers can exceed INT32_MAX, declare the column INT8OID and use Int64GetDatum; otherwise document the bound. (low confidence)


📄 src/backend/access/undo/undoworker.c (L347-L347)

sealed_log_count is decremented here but is never incremented anywhere in the tree (it is only pg_atomic_init_u32(...0) at init and read in the main loop). The XLOG_UNDO_ROTATE redo path sets state = UNDO_LOG_SEALED in undo_xlog.c without touching this counter, and undostats.c's identical Phase-2 block does not touch it either. The first time any DISCARDABLE log is freed, this unsigned fetch_sub underflows to UINT32_MAX, so sealed_count > 0 is then permanently true and the worker is pinned to the 200ms fast naptime forever (busy-loop). Either increment the counter at seal time (both the online seal path and the XLOG_UNDO_ROTATE redo path) or remove the counter and its adaptive-naptime branch entirely.


📄 src/backend/access/undo/undoworker.c (L128-L130)

shutdown_requested is a plain bool (see UndoWorkerShmemData), yet it is written from this SIGTERM handler and also written under UndoWorkerShmem->lock in UndoWorkerRequestShutdown() and read unlocked in the main loop. A signal handler may only touch volatile sig_atomic_t state; a plain bool store is not guaranteed async-signal-safe and races with the LWLock-protected writer. Additionally, unlike WakeUndoDiscardWorker(), this handler dereferences UndoWorkerShmem with no NULL guard, so a SIGTERM arriving before ShmemInit crashes the process. Use a module-level volatile sig_atomic_t got_SIGTERM set by the handler (guarded) and consume it in the loop, rather than writing shared memory from signal context.


📄 src/backend/access/undo/undoworker.c (L395-L397)

retained_mb is a uint64, but it is formatted with %lu and cast to (unsigned long). On LLP64 platforms (64-bit Windows/MSVC) unsigned long is 32 bits, so large retained-WAL distances are truncated in the message and the comparison operand is narrowed. Per portability rules 64-bit values must use UINT64_FORMAT. Format retained_mb with UINT64_FORMAT and drop the cast.

💡 Suggested change

Before:

					ereport(WARNING,
							(errmsg("UNDO WAL retention (%lu MB) exceeds undo_max_wal_retention_size (%d MB)",
									(unsigned long) retained_mb, undo_max_wal_retention_size),

After:

					ereport(WARNING,
							(errmsg("UNDO WAL retention (" UINT64_FORMAT " MB) exceeds undo_max_wal_retention_size (%d MB)",
									retained_mb, undo_max_wal_retention_size),

📄 src/backend/access/undo/undoworker.c (L284-L286)

log->log_number is read here for the DEBUG2 message after LWLockRelease(&log->lock) was already called above (line 275). Reading the metadata field without the lock races with a concurrent slot reuse (Phase 2 or the rotate redo path sets log_number = 0 / reassigns it under the lock). Capture log_number into a local while the lock is held, then use the local in the message.


📄 src/backend/commands/tablecmds.c (L999-L1000)

This break; is a functional no-op: default: is the last label in the switch, so control already falls out of the switch without it. It is also unrelated to this patch's purpose (the recno/undo storage work), so it is unnecessary diff churn on an untouched line. Minimal-diff discipline is a top rejection reason on pgsql-hackers; drop this hunk. (high confidence)

💡 Suggested change

Before:

			(void) heap_reloptions(relkind, reloptions, true);
+			break;

After:

			(void) heap_reloptions(relkind, reloptions, true);

📄 src/backend/commands/dbcommands.c (L499-L502)

Comment is inaccurate: it claims "FileOpsCreate + FileOpsWrite", but the code below uses a raw write() on the fd returned by FileOpsCreate(), not FileOpsWrite(). The write therefore is NOT WAL-logged by the FILEOPS subsystem; crash-safety here relies solely on (a) FileOpsCreate's register_delete=true removing the whole file on abort and (b) dbase_redo re-running CreateDirAndVersionFile(isRedo=true) to rewrite the content on recovery. Fix the comment to describe what the code actually does (create via FILEOPS for delete-on-abort, then write/fsync the version string directly).

💡 Suggested change

Before:

		/*
		 * Use FileOpsCreate + FileOpsWrite for transactional version file
		 * creation with crash-safe rollback support.
		 */

After:

		/*
		 * Create the file via FileOpsCreate so it is removed on transaction
		 * abort (register_delete = true), then write and fsync the version
		 * string directly.  The content is not FILEOPS-logged; on crash
		 * recovery dbase_redo re-runs this with isRedo = true.
		 */

📄 src/backend/commands/dbcommands.c (L517-L527)

Redundant fsync of the version file. FileOpsCreate() already performs pg_fsync(fd) and fsync of the parent directory internally when enableFsync is set (see fileops.c). The pg_fsync(fd) + fsync_fname(dbpath, true) here re-syncs the same file and directory a second time. The write() after FileOpsCreate justifies re-syncing the file itself, but the parent-directory fsync (fsync_fname(dbpath, true)) is pure duplicate I/O since the file's directory entry was already made durable by FileOpsCreate. Consider dropping the extra fsync_fname on the non-redo path.


📄 src/backend/commands/dbcommands.c (L527-L532)

The isRedo and !isRedo branches now duplicate the entire write/fsync/close tail (WAIT_EVENT_VERSION_FILE_WRITE block, pg_fsync, fsync_fname, CloseTransientFile). The only real difference is how fd is obtained (FileOpsCreate vs OpenTransientFile). This DRY violation doubles maintenance risk and inflates the diff; a future fix to the write/fsync sequence must be made twice. Factor the common tail into a single shared block after fd is obtained, e.g. acquire fd per-branch and then run one write/fsync/close sequence.


📄 src/backend/executor/execIndexing.c (L459-L462)

Critical correctness bug (high confidence): this skip treats indexUnchanged as a hard guarantee that the stored key equals the live key, but it is not. index_unchanged_by_update() derives its answer solely from ExecGetUpdatedCols(), i.e. the parse-time UPDATE target-column bitmap. A BEFORE ROW UPDATE trigger can modify an indexed key column that is NOT in the SET list; such a column never appears in updatedCols, so indexUnchanged is true even though the key value actually changed. index_unchanged_by_update()'s own comment acknowledges this ("row-level BEFORE triggers won't affect our behavior, since they don't affect the updatedCols bitmaps").

For a heap AM this is harmless because indexUnchanged is only a hint tuning bottom-up deletion. For an in-place AM (am_inplace_update_keeps_tid) you are using it to SKIP the insert entirely. When a BEFORE trigger changes a key column, the new (newkey -> tid) entry is never inserted, while the stale (oldkey -> tid) entry survives the in-place update and is later dropped by the scan-side stale-entry recheck (ExecIndexInplaceEntryIsStale / bitmap recheck). Net result: the row becomes unreachable via the index on its new key value -- silent, VACUUM-immune index corruption / wrong query results.

Note nodeModifyTable's ExecInitGenerated already handles exactly this hazard: it discards updatedCols (sets it NULL) when rel->trigdesc->trig_update_before_row is set. This skip needs the equivalent guard, i.e. it must not skip the insert when the relation has a BEFORE ROW UPDATE trigger (or must compare the actual stored vs reformed key rather than trusting the target-column bitmap).


📄 src/backend/commands/tablespace.c (L885-L890)

The ternary saved_errno == ENOENT ? LOG : LOG has two identical arms, so it is dead logic and saved_errno is captured but never meaningfully used. This is a copy-paste artifact from collapsing the original redo ? LOG : (saved_errno == ENOENT ? WARNING : ERROR) into a redo-only branch. Replace with a plain LOG and drop the now-pointless saved_errno local.

💡 Suggested change

Before:

				int			saved_errno = errno;

				ereport(saved_errno == ENOENT ? LOG : LOG,
						(errcode_for_file_access(),
						 errmsg("could not remove directory \"%s\": %m",
								linkloc)));

After:

				ereport(LOG,
						(errcode_for_file_access(),
						 errmsg("could not remove directory \"%s\": %m",
								linkloc)));

📄 src/backend/commands/tablespace.c (L912-L917)

Same tautological ternary here: saved_errno == ENOENT ? LOG : LOG always yields LOG, and saved_errno is dead. Simplify to a plain LOG and remove the unused saved_errno local. (Note: this also silently downgrades the original non-redo ENOENT->WARNING / other->ERROR semantics, but since the else branch now defers to FileOpsDelete, this arm is redo-only where LOG is intended.)

💡 Suggested change

Before:

				int			saved_errno = errno;

				ereport(saved_errno == ENOENT ? LOG : LOG,
						(errcode_for_file_access(),
						 errmsg("could not remove symbolic link \"%s\": %m",
								linkloc)));

After:

				ereport(LOG,
						(errcode_for_file_access(),
						 errmsg("could not remove symbolic link \"%s\": %m",
								linkloc)));

📄 src/backend/commands/tablespace.c (L823-L823)

Behavioral change worth flagging: previously the non-redo path raised ERROR on rmdir failure (ENOENT->WARNING), aborting DROP TABLESPACE synchronously. Now routing through FileOpsRmdir defers the rmdir to commit-execution, where FileOpsDoPendingOps() reports non-ENOENT failures only at WARNING. Consequently DROP TABLESPACE can commit the catalog change even though the on-disk directory removal ultimately fails (only a WARNING is logged, no rollback). This weakens the prior fail-fast contract; confirm this is the intended new semantic and that a residual (non-empty / permission-denied) directory left behind after a committed DROP is acceptable.


📄 src/backend/optimizer/path/indxpath.c (L732-L735)

DRY: the three-clause inplace_lossy predicate is duplicated byte-for-byte here and again in build_index_paths (lines 882-885). These two sites MUST stay in lockstep: if they ever drift, get_index_paths could submit the plain IndexPath to add_path while build_index_paths still built it unordered/non-index-only (or vice versa), silently yielding a plan that returns stale/duplicate tuples after an in-place UPDATE. Extract a small static helper (e.g. static bool index_inplace_lossy(RelOptInfo *rel, IndexOptInfo *index)) and call it from both places so the correctness invariant is expressed once.


📄 src/backend/optimizer/path/indxpath.c (L1030-L1032)

Unreachable guard (YAGNI). No lossy AM sets amcanparallel: gist.c, spgutils.c, ginutil.c and brin.c all declare .amcanparallel = false, and the comment itself concedes "No lossy AM currently sets amcanparallel." So index->amcanparallel && !inplace_lossy can never actually be gated by the added term today; this is speculative scaffolding for a path that cannot be reached. If you want to keep the invariant self-documenting, prefer an Assert(!(inplace_lossy && index->amcanparallel)) over silently adding a runtime term nobody can exercise, or drop it and rely on the get_index_paths gating.


📄 src/backend/executor/nodeIndexscan.c (L49-L49)

Unnecessary include: utils/syscache.h is added here but no syscache symbol (SearchSysCache/ReleaseSysCache/etc.) is used anywhere in this file. The only references to "syscache" in the file are this include and a comment that explicitly states the new code "avoids any per-row opclass/operator/syscache lookups." This is unused and violates the minimal-diff rule; remove it. (access/itup.h and catalog/index.h are correctly needed for index_form_tuple/index_deform_tuple and BuildIndexInfo/FormIndexDatum respectively.)


📄 src/backend/executor/nodeModifyTable.c (L5827-L5836)

This loop does not achieve the stated purpose for partitioned tables. For an INSERT into a partitioned table (and UPDATE with row movement), tuples are routed to leaf-partition ResultRelInfos that are created lazily by ExecFindPartition/ExecInitPartitionInfo and are NOT present in mtstate->resultRelInfo here (which for a partitioned INSERT holds only the storage-less root). So table_begin_bulk_insert() is never called for the relations that actually receive tuples, and the batching optimization silently never engages for the partitioned case. If bulk mode is meant to cover partitioned DML, it must be initiated at partition-routing time (and finished symmetrically), not only over the static result rels. Confidence: high.


📄 src/backend/executor/nodeModifyTable.c (L5830-L5835)

Iterating table_begin_bulk_insert() over all result rels conflicts with the underlying buffer model. The recno AM backs this with a single process-global buffer (undo_t2buf) that can only be active for one relation at a time; UndoBufferBegin() flushes and deactivates the previously-active relation whenever a different relid is begun. For an inheritance UPDATE/DELETE with mt_nrels > 1, this loop therefore leaves only the last relation active, so batching is silently disabled for every other target relation. It is safe only because consumers guard with UndoBufferIsActive(), but the loop's intent is not met. Reconcile the per-relation begin loop with the single-active-relation buffer design. Confidence: high.


📄 src/backend/executor/nodeModifyTable.c (L5816-L5819)

This comment hard-codes AM-implementation details ("the UNDO write buffer", "one palloc of ~512 bytes", "batched UNDO recording") into generic executor code. The executor must not assume any particular table AM's internals; heap's begin/finish_bulk_insert are empty stubs, and other AMs may do something entirely different. State the AM-agnostic contract ("notify the AM that bulk DML is starting; the AM may enable bulk optimizations") and move the UNDO-buffer specifics to the recno AM callback. This is also an unsubstantiated performance claim in a code comment. Confidence: high.


📄 src/backend/executor/nodeModifyTable.c (L5876-L5877)

begin/finish are asymmetric: table_finish_bulk_insert() is now called unconditionally for every ModifyTable operation (including CMD_MERGE and even the RETURNING-only/no-op cases), while table_begin_bulk_insert() is restricted to INSERT/UPDATE/DELETE. It is currently harmless because finish is a no-op when no buffer is active, but MERGE performs inserts/updates/deletes and gets no begin, so it never benefits, and the mismatch is a maintenance footgun. Either include CMD_MERGE in the begin condition or gate finish with the same condition. Confidence: moderate.


📄 src/backend/executor/nodeIndexscan.c (L461-L464)

This comment overstates the equivalence with the write side and is a correctness footgun. The write-side predicate (recno_compute_index_update in recno_operations.c) compares values in the table tuple descriptor, i.e. the opclass input type (e.g. name), while this function compares in the index storage type after an index_form_tuple/index_deform_tuple round-trip (e.g. cstring for name_ops). For opclasses whose storage type differs from the input type, datumIsEqual on the two representations is not guaranteed to agree with the write-side datumIsEqual, so an entry the write side considers "changed" could be judged "current" here (returning a stale row) or vice versa (silently dropping a live row). Either weaken this claim (state it is a storage-representation comparison that is expected but not proven identical to the write-side input-type comparison) or make both sides compare in the same representation.


📄 src/backend/executor/nodeIndexscan.c (L475-L475)

This parenthetical is backwards and confusing. indexInfo is a required, non-NULL parameter here (the callers guard on iss_InplaceRecheckInfo/ioss_InplaceRecheckInfo != NULL before calling). It is not "NULL otherwise"; rather, the cached IndexInfo is NULL for non-in-place AMs so this function is never reached for them. Reword to avoid implying indexInfo can be NULL on entry.

💡 Suggested change

Before:

 *		Only called for in-place AMs (indexInfo is NULL otherwise), and only

After:

 *		Only called for in-place AMs; the cached IndexInfo is NULL for other
 *		AMs so this function is never reached for them, and only

📄 src/backend/postmaster/postmaster.c (L93-L93)

Unused include. access/undolog.h is added here but no symbol from it (UndoLog*, undolog functions, GUCs) is referenced anywhere in postmaster.c — the only new code added is the LogicalRevertLauncherRegister() call, which is declared in access/logical_revert_worker.h. Per minimal-diff discipline, drop this stray include. (high confidence)


📄 src/backend/postmaster/bgworker.c (L15-L18)

Include ordering violates the alphabetical-sort convention within the access/ group. access/logical_revert_worker.h sorts before access/parallel.h (l < p), so the new headers cannot simply be appended after parallel.h. Reorder so the group reads alphabetically: logical_revert_worker.h, parallel.h, relundo_worker.h, undoworker.h. pgindent / pgcheckincludes will flag this. (low confidence on impact, high confidence on the rule)

💡 Suggested change

Before:

#include "access/parallel.h"
+#include "access/logical_revert_worker.h"
+#include "access/relundo_worker.h"
+#include "access/undoworker.h"

After:

#include "access/logical_revert_worker.h"
#include "access/parallel.h"
#include "access/relundo_worker.h"
#include "access/undoworker.h"

📄 src/backend/postmaster/postmaster.c (L931-L933)

Stale/inaccurate comment. It states the launcher "scans pg_database once at startup", but LogicalRevertLauncherMain runs a periodic loop that re-scans pg_database (every 60s) to pick up newly created/dropped databases — the code itself notes it is "A shorter interval than the original one-shot design." This comment describes the old one-shot behavior, not the current code. Fix it to describe the periodic scan, e.g. "...The launcher periodically scans pg_database and spawns a per-database LogicalRevertWorker...". (high confidence)

💡 Suggested change

Before:

	 * The Logical Revert Launcher wires up the async physical-undo-apply
	 * path.  The launcher scans pg_database once at startup and spawns a
	 * per-database LogicalRevertWorker which drains the ATM.

After:

	 * The Logical Revert Launcher wires up the async physical-undo-apply
	 * path.  The launcher periodically scans pg_database and spawns a
	 * per-database LogicalRevertWorker which drains the ATM.

Comment thread .local-gitignore
Comment on lines +1 to +3
# Local development ignores (not tracked in .gitignore)
# To enable: git config core.excludesFile .local-gitignore
.local-gitignore

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire file does not belong in the patch. .local-gitignore is a personal, developer-local convenience file (as its own comment states, it's activated per-clone via git config core.excludesFile). Committing it into the tree violates the minimal-diff / one-logical-change discipline: it is unrelated to the actual feature and will draw an immediate rejection on -hackers.

Specific problems:

  • Local dev-environment ignores (build-valgrind/, build-asan/, .direnv/, .cache/, .history, test-db/, install-*/) reflect one contributor's setup and are not tree policy. Genuinely shared ignore patterns belong in the tracked .gitignore, not a parallel file every developer must opt into.
  • CLAUDE.md (line 20) is an AI-assistant artifact; its presence here signals leaked personal tooling and should not be referenced in a PostgreSQL patch.
  • A file whose first line is a comment telling you how to git config it is inherently not something the tree should carry — this is exactly the kind of "personal workflow" file the community asks authors to keep out of the diff.

Drop this file from the patch entirely. If any of these patterns are truly project-wide, add them to the existing .gitignore instead.

Comment on lines +30 to +31
-- STEP 5: Commit the transaction
COMMIT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script issues COMMIT with the comment "Commit the transaction", but no BEGIN precedes the DML. Run through psql in default (autocommit) mode, the CREATE TABLE/INSERT/UPDATE/DELETE each commit as their own transaction, and this bare COMMIT then executes outside any transaction block, producing WARNING: there is no transaction in progress. The comment implying the DML is grouped into one transaction is misleading. This is inconsistent with example 02, which correctly wraps its DML in BEGIN;/ROLLBACK;. Either add BEGIN; after STEP 1 (before the INSERT, or before STEP 2) to actually group the modifications into one transaction, or drop the COMMIT step entirely if autocommit is intended.

Confidence: high.

Suggested change
-- STEP 5: Commit the transaction
COMMIT;
-- STEP 5: Commit the transaction
COMMIT;

--
-- recno_page_items()
--
CREATE FUNCTION recno_page_items(IN page bytea,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three new SQL-callable functions (recno_page_items, recno_page_stats, recno_tuple_infomask_flags) ship with no documentation and no regression tests. Per PostgreSQL contribution standards, new pageinspect functions must be documented in doc/src/sgml/pageinspect.sgml and covered by a regression test (contrib/pageinspect/sql + expected/, and registered in the module's meson.build 'regress' list). A search of the tree finds these names only in this SQL script, recnofuncs.c, and a README -- no .sgml entry and no test file. Without docs + tests this is WIP, not commit-ready. (high confidence)

Comment on lines +35 to +39
-- UNDO records will be applied automatically:
-- - item 3 re-inserted
-- - item 2 price restored to 49.99
-- - item 1 quantity restored to 5
-- - all 3 original inserts deleted

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This UNDO-unwind description is self-contradictory and misleading. Lines listing "item 3 re-inserted", "item 2 price restored to 49.99", and "item 1 quantity restored to 5" describe intermediate reverse-apply steps, but the final line says "all 3 original inserts deleted" — which alone produces the empty result. Since the table started empty, the net effect of rollback is simply that all 3 inserted rows disappear; the "restored" intermediate states never become visible. A reader trying to understand UNDO semantics from this example will be confused. Suggest either describing the LIFO reverse-apply order explicitly (undo DELETE -> undo UPDATEs -> undo INSERTs, net = 0 rows) or simplifying to just state the net effect.

Suggested change
-- UNDO records will be applied automatically:
-- - item 3 re-inserted
-- - item 2 price restored to 49.99
-- - item 1 quantity restored to 5
-- - all 3 original inserts deleted
-- UNDO records are applied in reverse (LIFO) order:
-- 1. undo the DELETE (item 3 re-inserted)
-- 2. undo the UPDATEs (item 2 price and item 1 quantity restored)
-- 3. undo the INSERTs (all 3 rows removed)
-- Net effect: the table returns to its pre-transaction (empty) state.

values[4] = Int32GetDatum(lp_len); /* t_len (from ItemId) */
values[5] = Int16GetDatum(tuphdr->t_natts); /* t_natts */
values[6] = Int16GetDatum(tuphdr->t_flags); /* t_flags */
values[7] = Int64GetDatum(tuphdr->t_commit_ts); /* t_commit_ts */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The t_commit_ts label is stale and misleading. Per src/include/access/recno.h, RecnoTupleHeader.t_commit_ts is no longer a commit timestamp: its comment states "low 32 bits = t_xmax (deleter/updater XID); high 32 bits reserved ... Kept as a uint64 field so the on-disk/WAL byte layout is unchanged from the HLC era." Exposing the raw uint64 verbatim under the name t_commit_ts (both here and in the SQL column) will surface an XID packed into a timestamp column, confusing anyone inspecting a page. Either decode t_xmax explicitly or rename the exposed field. Comments must describe what the code does now, not the historical HLC scheme.

Comment on lines +58 to 59
#include "utils/memutils.h"
#include "utils/spccache.h"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused include (see above): no memutils.h symbol is referenced in this file. Remove it. (high confidence)

Suggested change
#include "utils/memutils.h"
#include "utils/spccache.h"
#include "utils/spccache.h"

Comment on lines 60 to 62
#include "utils/syscache.h"


static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whitespace-only churn: removing this blank line touches an untouched line unrelated to any functional change. PostgreSQL requires a minimal diff; revert cosmetic reformatting of lines the patch does not otherwise need to modify. (high confidence)

Comment on lines 115 to 117
bool *copy);


/*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whitespace-only churn: this blank line was removed with no accompanying functional change. Keep the diff minimal and leave it as-is. (high confidence)

Comment on lines 2141 to 2145
XLogBeginInsert();

XLogRegisterData(&xlrec, SizeOfHeapInsert);

xlhdr.t_infomask2 = heaptup->t_data->t_infomask2;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gratuitous blank line inserted after XLogBeginInsert() with no functional purpose. This is whitespace churn on a hot WAL-emit path in heap_insert; remove it to keep the diff minimal. (high confidence)

Comment on lines 3071 to 3075
XLogBeginInsert();

XLogRegisterData(&xlrec, SizeOfHeapDelete);

XLogRegisterBuffer(0, buffer, REGBUF_STANDARD);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gratuitous blank line in heap_delete after XLogBeginInsert(); no functional change. Remove this whitespace-only edit. (high confidence)

gburd added 4 commits July 15, 2026 13:32
Add the AM-agnostic UNDO engine: the in-WAL UNDO record format and
insertion path, the per-relation RELUNDO fork with its own resource
manager, the shared sLog tuple-state map, the rollback apply driver and
compensation-log generation, the discard horizon, and the background
revert/undo workers.  Register the UNDO, ATM, and RELUNDO resource
managers and wire the subsystem into transaction start/commit/abort,
two-phase commit, recovery, and process startup.

The engine interprets UNDO payloads only through per-RM callbacks and the
RelUndo*_hook function pointers (defined here, left NULL), so the core has
no compile-time knowledge of any specific access method.  Heap, vacuum,
pruning, reloptions, and executor integration consume only the
AM-agnostic interfaces.

No UNDO-producing AM is registered yet: RegisterUndoRmgrs() initializes
the dispatch table but registers no per-AM handlers.  The index-AM apply
handlers and the AM that sets am_supports_undo arrive in later commits.
Add the UNDO resource-manager handlers for the nbtree and hash index
AMs and register them from RegisterUndoRmgrs().  On rollback of an
aborting transaction, the nbtree handler re-descends to the leaf entry
by key and heap TID before marking it dead, so a committed entry that
shifted onto the recorded slot under concurrent inserts or leaf splits
is never killed; entries inside posting-list tuples are left for VACUUM.
The hash handler reverses its own inserts analogously.  Both are gated by
RelationAmSupportsUndo() on the parent table, so they are inert until an
UNDO-supporting table AM exists.
Add pg_setxattr/pg_getxattr/pg_removexattr/pg_listxattr wrappers over the
platform extended-attribute syscalls (Linux/*BSD/macOS, no-op stubs where
unsupported) and build them into libpgport.  The transactional file-ops
resource manager added next uses these to record and reverse xattr
mutations.
Filesystem mutations the server performs outside the buffer manager --
creating and removing directories, copying trees, writing version files,
managing symlinks, setting extended attributes -- historically had no WAL
coverage and relied on best-effort cleanup callbacks that do not survive a
crash.  Add FILEOPS: a resource manager (RM_FILEOPS_ID) that records each
mutation as a deferred pending operation, executes it at commit, and
WAL-logs it so redo reproduces it during crash recovery and standby replay.

This commit adds the operation engine, the public FileOps* API, the WAL
record formats, the redo handler, and the rmgrdesc descriptors.  A README
under src/backend/storage/file explains why FILEOPS exists and how to use
the API.  The rollback (UNDO) side and the rewiring of existing callers
follow in subsequent commits.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 241 issue(s).

  • 25 inline, 216 in summary (inline capped at 25)
  • ⚠️ 213 warning(s) during review

📄 src/backend/access/rmgrdesc/undodesc.c

WAL-before-data violation: RecnoXLogUpdate never stamps PageSetLSN on the main buffer (buffer) or, for cross-page updates, on new_buffer. Every other emit function in this file (RecnoXLogInsert, RecnoXLogDelete, RecnoXLogDefrag, RecnoXLogOverflowWrite, RecnoXLogCompress, RecnoXLogInitPage, RecnoXLogCasUpdate, RecnoXLogCasUpdateUndo, RecnoXLogWriteDict) calls PageSetLSN(page, recptr) after XLogInsert. The sole caller (recno_operations.c:4328) also discards the returned LSN and does not stamp it. As a result the dirtied page keeps a stale LSN, so the buffer manager can flush the modified page to disk before the WAL record is durable, breaking crash/replica consistency (risk of lost/torn updates and standby divergence). Stamp the LSN on buffer (and new_buffer when cross-page) before returning, mirroring the sibling functions.

💡 Suggested change

Before:

		xlrec.flags |= tmpflag;
	}

	recptr = XLogInsert(RM_RECNO_ID, info);

	return recptr;

After:

		xlrec.flags |= tmpflag;
	}

	recptr = XLogInsert(RM_RECNO_ID, info);

	/* Stamp the LSN on all touched pages (mirrors sibling emit functions). */
	PageSetLSN(BufferGetPage(buffer), recptr);
	if (is_cross_page)
		PageSetLSN(BufferGetPage(new_buffer), recptr);

	return recptr;

📄 src/backend/commands/tablecmds.c

Memory-context leak/corruption on the abort path. When UndoValidateBatchLSN(perm_lsn) fails, goto inline_undo_done; jumps over MemoryContextSwitchTo(old_ctx) and MemoryContextDelete(undo_ctx). The temporary undo_ctx ("Inline UNDO Apply") is never restored nor deleted: the leaked context lingers, and worse, the current memory context is left pointing at undo_ctx while ATMAddAborted(), the elog() calls, and the rest of AtAbort_XactUndo() continue executing. Since this is a post-abort step declared MUST NOT FAIL, running under a stray context is a real corruption hazard. Restore/delete the context before the ATM fallback (e.g. do the switch-back/delete on the validation-failure branch too, or replace the goto with structured flow). (high confidence)

💡 Suggested change

Before:

						undo_applied = false;
						goto inline_undo_done;

After:

						undo_applied = false;
						MemoryContextSwitchTo(old_ctx);
						MemoryContextDelete(undo_ctx);
						goto inline_undo_done;

📄 src/backend/commands/tablecmds.c

Stray consecutive blank lines (three in a row) after the ATMAddAborted/WARNING branch. This will not pass pgindent/whitespace hygiene; git diff --check flags trailing blank-line churn. Collapse to a single blank line. (high confidence)

💡 Suggested change

Before:

							elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId());




					}

After:

							elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId());
					}

📄 examples/02-undo-rollback.sql (L34-L39)

The rollback narrative is self-contradictory and misleads readers about UNDO semantics. It first claims "item 3 re-inserted" and "price restored"/"quantity restored", then says "all 3 original inserts deleted". The net effect of ROLLBACK is simply that the table reverts to its pre-transaction state (empty); describing intermediate "restore" steps that are then undone by "delete" steps is confusing for an instructional example. Consider stating the actual outcome directly, e.g. "ROLLBACK undoes every change in reverse order, restoring the table to its pre-transaction (empty) state."

💡 Suggested change

Before:

-- Rollback the transaction
-- UNDO records will be applied automatically:
-- - item 3 re-inserted
-- - item 2 price restored to 49.99
-- - item 1 quantity restored to 5
-- - all 3 original inserts deleted

After:

-- Rollback the transaction.
-- UNDO records are applied automatically in reverse order, restoring the
-- table to its pre-transaction (empty) state.

📄 examples/02-undo-rollback.sql (L30-L31)

SELECT * FROM order_items has no ORDER BY, so recno (like heap) does not guarantee row order. The annotations only assert row counts, so this is not wrong today, but presenting a bare SELECT * as example output with an expected result is a footgun: any future reader comparing rows may see non-deterministic ordering. Add ORDER BY item_id to make the example output stable and reproducible. Low confidence on impact since only counts are claimed.

💡 Suggested change

Before:

-- Check current state (before rollback)
SELECT * FROM order_items;

After:

-- Check current state (before rollback)
SELECT * FROM order_items ORDER BY item_id;

📄 contrib/pageinspect/recnofuncs.c (L319-L321)

combined_flags is populated with the raw single-bit flag names, not combined multi-bit masks. The SQL signature declares two distinct output columns (raw_flags, combined_flags), mirroring heap_tuple_infomask_flags where raw_flags holds individual bit names and combined_flags holds multi-bit combined masks built from a separate decode pass. RECNO has no combined masks, so combined_flags should be an empty array; instead the same array a is assigned to both columns, so combined_flags wrongly duplicates the raw flag names. This produces semantically incorrect diagnostic output. Either build combined_flags as an empty array, or drop the redundant column from the SQL definition if RECNO genuinely has no combined masks.

💡 Suggested change

Before:

	/* raw_flags: same as combined for RECNO (no separate raw names) */
	values[0] = PointerGetDatum(a);
	values[1] = PointerGetDatum(a);

After:

	/* raw_flags: the decoded single-bit flag names */
	values[0] = PointerGetDatum(a);
	/* combined_flags: RECNO has no multi-bit combined masks */
	values[1] = PointerGetDatum(construct_empty_array(TEXTOID));

📄 contrib/pageinspect/recnofuncs.c (L144-L144)

The second clause hdr_size <= lp_len is dead: the outer guard already requires lp_len >= MAXALIGN(sizeof(RecnoTupleHeader)) (== hdr_size), so hdr_size <= lp_len is always true here, and tuple_data_len > 0 already implies it. Drop the redundant clause for clarity.

💡 Suggested change

Before:

				if (tuple_data_len > 0 && hdr_size <= lp_len)

After:

				if (tuple_data_len > 0)

📄 examples/04-transactional-fileops.sql (L30-L30)

Inaccurate description of when the DELETE WAL record is emitted. In fileops.c (PENDING_FILEOP_DELETE case), the XLOG_FILEOPS_DELETE record is written inside the commit path, guarded by if (isCommit && XLogIsNeeded()), i.e. at commit-execution time, not during DROP TABLE. As written here (placed after DROP TABLE and before COMMIT), the comment tells the reader the record already exists mid-transaction, which is wrong and defeats the teaching purpose of the example. Suggest rewording to reflect that only a deferred deletion is registered at DROP time, and the record is emitted at commit.

Confidence: high.

💡 Suggested change

Before:

-- A XLOG_FILEOPS_DELETE WAL record has been written.

After:

-- The deletion is registered as a pending file operation.
-- The XLOG_FILEOPS_DELETE WAL record is emitted at commit time (below).

📄 src/backend/access/Makefile (L26-L30)

Out of sync with meson and dead conditional. The sibling src/backend/access/meson.build calls subdir('recno') unconditionally, and the top-level meson.build documents that RECNO "cannot be conditionally disabled ... a build with USE_RECNO unset fails to link". On the make side, src/Makefile.global.in hardwires USE_RECNO = 1 unconditionally, so this ifdef guard can never be false. The conditional is therefore dead scaffolding that also diverges from the meson build's always-on treatment (violating the "stay in sync with meson.build" rule and the YAGNI/footgun guidance). It also creates a footgun: someone unsetting USE_RECNO would produce a make build that compiles rmgrlist.h without RM_RECNO_ID yet links against pg_proc.dat/guc references to recno symbols. Add recno to the unconditional SUBDIRS list like undo, matching meson. (high confidence)

💡 Suggested change

Before:

	undo

ifdef USE_RECNO
SUBDIRS += recno
endif

After:

	recno \
	undo

📄 examples/05-undo-monitoring.sql (L13-L13)

This call passes no arguments, but the implementation (pg_undo_force_discard in undostats.c) unconditionally reads a mandatory bool force_rotate argument via PG_GETARG_BOOL(0). Unless the registered SQL signature gives that parameter a default, SELECT pg_undo_force_discard(); will fail at runtime with "function pg_undo_force_discard() does not exist". Pass an explicit boolean, e.g. SELECT pg_undo_force_discard(false); (or true to also rotate the active segment). Confidence: high.

💡 Suggested change

Before:

SELECT pg_undo_force_discard();

After:

SELECT pg_undo_force_discard(false);

📄 src/backend/access/common/reloptions.c (L2183-L2188)

This is a no-op refactoring that adds gratuitous churn. default_reloptions() already returns bytea *, so casting its result to StdRdOptions *, storing it in rdopts, and casting back to bytea * is a pointless round trip. Unlike the RELKIND_TOASTVALUE case (which needs rdopts to adjust fillfactor/thresholds), nothing is done with rdopts here. The added block scope and extra indentation only make the code longer and harder to read than the original one-liner.

This hunk is not required by the patch's purpose and violates the minimal-diff rule; after it, the code reads worse than before. Revert to the original one-liner. (high confidence)

💡 Suggested change

Before:

			{
				rdopts = (StdRdOptions *)
					default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

				return (bytea *) rdopts;
			}

After:

			return default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

📄 src/backend/access/common/index_prune.c (L68-L68)

This entire module is unwired dead scaffolding. Verified across the codebase: nothing includes index_prune.h except this file; IndexPruneRegisterHandler/IndexPruneRegisterTargetedHandler are never called (so num_handlers/num_targeted_handlers stay 0 and every notify is a silent no-op); IndexPruneNotifyDiscard/IndexPruneNotifyTargeted/IndexPruneGetStats/IndexPruneResetStats have no callers; and the AM callbacks declared in the header (_bt_prune_by_undo_counter, gin_prune_by_undo_counter, etc.) are never defined. Per YAGNI/minimalism this speculative infrastructure should not be committed until the discard worker and at least one AM callback actually use it, with tests. (high confidence)


📄 src/backend/access/common/index_prune.c (L162-L165)

uint64 values are printed with %lu after casting to (unsigned long). On LLP64 platforms (Windows/MSVC) unsigned long is 32-bit, so 64-bit counts are truncated. Use UINT64_FORMAT and pass the uint64 directly, per the tree's portability gate. Same issue applies to the DEBUG1 message below and to the DEBUG2 message in IndexPruneNotifyTargeted.

💡 Suggested change

Before:

				elog(DEBUG2, "index %s: marked %lu entries as dead for counter %u",
					 RelationGetRelationName(indexrel),
					 (unsigned long) entries_pruned,
					 discard_counter);

After:

				elog(DEBUG2, "index %s: marked " UINT64_FORMAT " entries as dead for counter %u",
					 RelationGetRelationName(indexrel),
					 entries_pruned,
					 discard_counter);

📄 src/backend/access/common/index_prune.c (L195-L198)

Same %lu/(unsigned long) uint64 truncation on LLP64 here.

💡 Suggested change

Before:

		elog(DEBUG1, "UNDO discard: pruned %lu index entries across %d indexes (counter %u)",
			 (unsigned long) total_entries_pruned,
			 num_indexes_pruned,
			 discard_counter);

After:

		elog(DEBUG1, "UNDO discard: pruned " UINT64_FORMAT " index entries across %d indexes (counter %u)",
			 total_entries_pruned,
			 num_indexes_pruned,
			 discard_counter);

📄 src/backend/access/common/index_prune.c (L333-L335)

Same %lu/(unsigned long) uint64 truncation on LLP64 here.

💡 Suggested change

Before:

				elog(DEBUG2, "targeted prune: index %s, %lu entries pruned",
					 RelationGetRelationName(indexrel),
					 (unsigned long) pruned);

After:

				elog(DEBUG2, "targeted prune: index %s, " UINT64_FORMAT " entries pruned",
					 RelationGetRelationName(indexrel),
					 pruned);

📄 src/backend/access/common/index_prune.c (L146-L147)

IndexPruneNotifyDiscard opens each index with index_open(AccessShareLock) and then calls callback(). If the callback throws (ereport/elog(ERROR)), the matching index_close is skipped and indexoidlist is not list_freed. This only unwinds cleanly if the caller establishes a transaction/ResourceOwner that releases the lock and relcache ref on abort. Since the intended caller is a long-lived background discard worker (per the header comment), confirm the surrounding transaction/resource-owner guarantees cleanup, or wrap the loop in PG_TRY/PG_FINALLY; otherwise a throwing callback leaks the AccessShareLock and relcache reference across discard cycles. (moderate confidence)


📄 src/backend/access/common/index_prune.c (L310-L312)

The grouping loop assumes targets[] is already sorted by index_oid (it advances while consecutive entries share cur_oid). The comment above even claims "sort targets by index_oid" but no sort is performed. If the caller passes unsorted targets, the same index forms multiple groups, causing repeated try_relation_open/close and duplicate processing. Either sort here (e.g., qsort by index_oid) or document/enforce the sorted-input contract of the caller.


📄 src/backend/access/common/index_prune.c (L72-L73)

elog(ERROR, ...) does not return, so this return; is unreachable dead code. Remove it. The same dead return; exists in IndexPruneRegisterTargetedHandler.

💡 Suggested change

Before:

		elog(ERROR, "too many index pruning handlers registered");
		return;

After:

		elog(ERROR, "too many index pruning handlers registered");

📄 src/backend/access/common/index_prune.c (L238-L239)

Same unreachable return; after elog(ERROR, ...).

💡 Suggested change

Before:

		elog(ERROR, "too many targeted index pruning handlers registered");
		return;

After:

		elog(ERROR, "too many targeted index pruning handlers registered");

📄 src/backend/access/common/index_prune.c (L59-L59)

prune_stats and the handler registries are plain process-local statics with no synchronization. As designed, handlers must be re-registered in every process (backend and each undo/discard worker) at startup, and the stats reported by IndexPruneGetStats are only that process's local totals, not cluster-wide. Combined with the absence of any registration caller (see above), a discard worker running these notify functions would find zero handlers. If cross-process visibility of handlers or aggregated stats is intended, this needs shared memory + locking; if strictly per-process, that assumption should be documented and every process that runs the notify path must register at init. (moderate confidence)


📄 src/backend/access/hash/hashinsert.c (L248-L249)

Missing heapRel != NULL guard, inconsistent with the sibling nbtree UNDO path in this same feature. RelationAmSupportsUndo() dereferences rel->rd_tableam unconditionally with no NULL check on rel, so passing a NULL heapRel here crashes. In this very patch, nbtinsert.c gates the identical hook with if (heaprel != NULL && RelationAmSupportsUndo(heaprel)) and documents that heaprel is NULL during index builds and recovery. Add the same guard for consistency and to avoid a NULL-pointer dereference. (moderate confidence)

💡 Suggested change

Before:

	if (RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

After:

	if (heapRel != NULL && RelationAmSupportsUndo(heapRel) &&
		UndoBufferIsActive(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

📄 src/backend/access/heap/heapam.c (L42-L43)

These three headers are added but no code in this file uses them, and this diff introduces no functional change to heapam.c (the only other edits are pure whitespace churn). Speculative includes for a not-yet-wired feature should not be committed. If a later patch in the series needs them, add them in that patch alongside the code that uses them. Keep the diff minimal. [moderate confidence]


📄 src/backend/access/heap/heapam.c (L58-L58)

Unused include: no MemoryContext/memutils API is referenced by any change in this file. Remove it to keep the diff minimal and avoid unrelated changes. [moderate confidence]


📄 src/backend/access/heap/heapam.c (L59-L60)

Whitespace-only reformatting of untouched code: this deletes a pre-existing blank line unrelated to the change. PostgreSQL requires a minimal diff (git diff --check clean, no churn on untouched lines); reverting these two blank-line deletions and the two blank-line insertions below removes all non-functional noise from this file. [high confidence]


📄 src/backend/access/heap/heapam.c (L2140-L2141)

Cosmetic blank line inserted into an untouched function body. This is whitespace churn unrelated to any functional change; remove it to keep the diff minimal.

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

📄 src/backend/access/heap/heapam.c (L3069-L3070)

Same cosmetic blank-line insertion in heap_delete's WAL path. Whitespace-only churn on untouched code; remove it.

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapDelete);

📄 src/backend/access/heap/heapam_handler.c (L156-L159)

These two callbacks are empty no-ops, yet the comments describe UNDO behavior ("activates the UNDO write buffer", "Flushes any pending UNDO records") that the bodies never perform. This is dead scaffolding: heapam does not support UNDO (am_supports_undo = false), and both begin_bulk_insert/finish_bulk_insert are optional callbacks that the table_* wrappers already NULL-check (see tableam.h:1757, 1769). Registering empty functions for heap adds an indirect call per bulk DML op for zero benefit and misleads readers. Upstream deliberately leaves heap's finish_bulk_insert unset ("In-tree access methods ceased to use this"). Drop both functions and their registrations from heapam_methods; the AM that actually uses UNDO (RECNO) is where these belong. If kept, at minimum the comments must describe what the code does now (nothing), not aspirational UNDO behavior.


📄 src/backend/access/heap/heapam_handler.c (L166-L169)

Comment describes UNDO flushing/deactivation that this empty body does not perform; the function is a no-op. Remove the callback (heap does not use UNDO), or fix the comment to match the actual behavior.


📄 src/backend/access/heap/heapam_handler.c (L2720-L2721)

If these no-op callbacks are removed, drop their registration here as well. Since both are optional (NULL-checked in table_begin_bulk_insert/table_finish_bulk_insert), heap gains nothing but an extra indirect call per bulk op by registering empty stubs.


📄 src/backend/access/hash/hash_undo.c (L208-L209)

Critical data-corruption hazard: the UNDO payload records only (index_oid, blkno, offset) with no tuple identity (heap TID). On abort this blindly marks LP_DEAD at the stored offset whenever the item id is normal, without verifying it is the entry this transaction inserted.

Between insertion and undo apply, the hash bucket can be compacted (PageIndexMultiDelete in _hash_squeezebucket/vacuum shifts surviving offsets down) or tuples moved between overflow/primary pages, and concurrent inserts can reuse the offset. Marking an unrelated committed entry LP_DEAD silently drops a valid index pointer, causing missing rows and index/heap inconsistency. The emitted CLR carries the same stale offset, so standbys corrupt identically.

The nbtree UNDO path deliberately avoids exactly this: nbtree_undo_find_leaf_entry() locates the target by heap TID ("concurrent inserts and leaf splits can shift a committed entry onto that offset, and marking it LP_DEAD would silently drop committed rows") rather than by physical offset. Hash UNDO must do the same: record the heap TID (available as itup->t_tid at the call site) and confirm the on-page tuple's TID matches before marking it dead.


📄 src/backend/access/hash/hash_undo.c (L55-L60)

The undo payload omits any tuple identity, so the apply path cannot verify it is killing the correct entry (see the critical issue on the apply side). Store the inserted entry's heap TID (from itup->t_tid, which is available in _hash_doinsert at the call site) here, and pass it through HashUndoLogInsert, so hash_undo_apply can match by TID instead of trusting the stale physical offset.


📄 src/backend/access/heap/vacuumlazy.c (L156-L156)

Include ordering violation. Within the utils/ group PostgreSQL keeps headers in alphabetical order. utils/rel.h is inserted before utils/lsyscache.h and utils/pg_rusage.h, but alphabetically rel.h sorts after pg_rusage.h and before timestamp.h. Move it accordingly so the block stays sorted (this also matches what pgindent/the tree convention expects).

Separately (moderate confidence): this one-line include is the only change to this file, and it is not required by any code change here -- the file already compiled with these Relation* macros via a transitive include. If nothing in this file newly needs rel.h directly, this hunk is unrelated churn and should be dropped to keep the diff minimal; if a direct include is genuinely desired, at least place it in sorted order.


📄 src/backend/access/index/indexam.c (L300-L301)

Comment inaccuracy (low confidence, verified against nbtree). so->dropPin is not computed in _bt_first; it is set in btrescan() (src/backend/access/nbtree/nbtree.c:419) as so->dropPin = (!scan->xs_want_itup && ...). _bt_first never assigns dropPin. Since committer-standard review requires comments to describe what the code actually does, suggest correcting the referenced function name to avoid misleading a future reader chasing this behavior.

💡 Suggested change

Before:

	 * Consequence for nbtree (RECNO secondary indexes are nbtree): forcing
	 * xs_want_itup makes _bt_first set so->dropPin false, which RETAINS the

After:

	 * Consequence for nbtree (RECNO secondary indexes are nbtree): forcing
	 * xs_want_itup makes btrescan set so->dropPin false, which RETAINS the

📄 src/backend/access/nbtree/nbtinsert.c (L18-L18)

Unnecessary include: nothing in nbtinsert.c uses any symbol from heapam.h. The two functions this change relies on are declared elsewhere already included: RelationAmSupportsUndo() in access/tableam.h and NbtreeUndoLogInsert() in access/nbtree.h. Pulling heapam.h into nbtinsert.c is a non-minimal diff and also creates an undesirable dependency of the nbtree AM on the heap AM's private header. Remove it. (high confidence)


📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

The split-path undo is also emitted for internal-page insertions (the recursive _bt_insertonpg call from _bt_insert_parent, where isleaf is false). For those, NbtreeUndoLogInsert writes an NBTREE_UNDO_INSERT_UPPER record whose undo-apply is an unconditional no-op (nbtree_undo_apply returns UNDO_APPLY_SKIPPED for INSERT_UPPER). That wastes undo-log space and WAL on every internal-page split for undo-capable tables with no cleanup benefit. Consider guarding this emission with isleaf (as the aborting-transaction cleanup only acts on leaf entries anyway). (moderate confidence)


📄 src/backend/access/recno/Makefile (L35-L35)

Missing newline at end of file (\ No newline at end of file). PostgreSQL requires a trailing newline; git diff --check and the project's whitespace conventions will flag this. Add a newline after the final include line. (high confidence)


📄 src/backend/access/nbtree/nbtree_undo.c (L161-L165)

The undo-logging half of this module is dead code. NbtreeUndoLogInsert and NbtreeUndoLogDedup are declared in nbtree.h and defined here, but they are never called from any nbtree insert/dedup path (nbtinsert.c / nbtdedup.c contain no calls). Since no nbtree undo records are ever written, the registered apply callback nbtree_undo_apply can never fire for a real nbtree record. The entire NBTREE_UNDO_* machinery is speculative scaffolding for a path that is not wired up. Either wire the logging into _bt_insertonpg() / _bt_dedup_pass() in this same change, or drop the module until the callers exist (YAGNI). A patch that adds an unreachable subsystem will be rejected on -hackers.


📄 src/backend/access/nbtree/nbtree_undo.c (L635-L638)

Buffer-overflow / out-of-bounds read in the DEDUP restore. Only payload_len < SizeOfNbtreeUndoDedup is checked; there is no validation that payload_len >= SizeOfNbtreeUndoDedup + hdr.page_len, nor that hdr.page_len matches the buffer's actual page size. hdr.page_len is a uint16 (max 65535) while page is a BLCKSZ-sized (default 8192) buffer, so a truncated or corrupt undo record makes this memcpy read past payload and, worse, write past the shared buffer page inside a critical section, corrupting memory. Validate the length before copying.


📄 src/backend/access/nbtree/nbtree_undo.c (L602-L605)

Add the missing bounds check for the DEDUP page image before touching the buffer. Guard both against a truncated payload and against a page_len larger than the buffer page size.

💡 Suggested change

Before:

				if (payload_len < SizeOfNbtreeUndoDedup)
					return UNDO_APPLY_ERROR;

				memcpy(&hdr, payload, SizeOfNbtreeUndoDedup);

After:

				if (payload_len < SizeOfNbtreeUndoDedup)
					return UNDO_APPLY_ERROR;

				memcpy(&hdr, payload, SizeOfNbtreeUndoDedup);

				/* Validate the saved page image length before restoring it. */
				if (hdr.page_len > BLCKSZ ||
					payload_len < SizeOfNbtreeUndoDedup + hdr.page_len)
					return UNDO_APPLY_ERROR;

📄 src/backend/access/nbtree/nbtree_undo.c (L103-L103)

Same unparenthesized-macro footgun here; wrap in parentheses to match PG convention.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertUpper	offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertUpper	(offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L116-L116)

Same unparenthesized-macro footgun; wrap in parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDedup	offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDedup	(offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L129-L129)

Same unparenthesized-macro footgun; wrap in parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoDelete	offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16)

After:

#define SizeOfNbtreeUndoDelete	(offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16))

📄 src/backend/access/nbtree/nbtree_undo.c (L674-L677)

Dead-code / write-amplification: NBTREE_UNDO_INSERT_UPPER, INSERT_POST, SPLIT_L/R, NEWROOT, DELETE, and VACUUM are all unconditionally UNDO_APPLY_SKIPPED. Logging these records (INSERT_UPPER via NbtreeUndoLogInsert's non-leaf branch, and the full-page DEDUP image via NbtreeUndoLogDedup) produces undo/WAL volume that is never consumed on apply. If the callers were wired up (see the separate dead-code finding), the non-leaf branch and the DEDUP full-page image would be pure overhead on hot index-insert paths. Do not log records whose apply is a guaranteed no-op.


📄 src/backend/access/nbtree/nbtree_undo.c (L690-L696)

Aspirational/deferred wording violates PG comment discipline. The header docstring lists DELETE undo as 're-insert deleted tuples', but the DELETE case always returns UNDO_APPLY_SKIPPED, so the header overstates what is implemented. Likewise this 'For now, skip' comment describes unimplemented behavior. Comments must describe what the code does now; rewrite to state that DELETE undo is not implemented (entries are left for VACUUM), or remove the overstatement.


📄 src/backend/access/nbtree/nbtree_undo.c (L63-L66)

Inconsistent subtype numbering: INSERT_LEAF=0x0001, INSERT_UPPER=0x0002, INSERT_POST=0x0004 uses bit-flag style (skipping 0x0003), then DELETE=0x0005 onward is sequential. These values are consumed as discrete switch (info) cases, not as a bitmask, so the flag-style gap is misleading and invites accidental collisions when new subtypes are added. Use contiguous sequential values.


📄 src/backend/access/recno/README (L496-L497)

This hard-coded line reference is both wrong and inherently fragile. heap_toast_insert_or_update is not defined in heapam.c at all (it lives in heaptoast.c; heapam.c only calls it at lines 2241 and 3877). Lines 3993-4050 in heapam.c are actually inside heap_update's HOT-update logic, not TOAST handling. Absolute line numbers drift with every edit to heapam.c and should not be cited in a README. Reference the function by name only, or point to heaptoast.c where it is defined.

💡 Suggested change

Before:

See src/backend/access/heap/heapam.c:3993-4050 for the analogous pattern
in heap's TOAST handling (heap_toast_insert_or_update).

After:

See heap_toast_insert_or_update() (src/backend/access/heap/heaptoast.c)
for the analogous pattern in heap's TOAST handling.

📄 src/backend/access/recno/README (L509-L509)

This absolute line reference is incorrect and fragile. The Assert(entry->data.lockmode == BUFFER_LOCK_UNLOCK) with this exact expression is at bufmgr.c line 6068, not 5892 (line 5892 does not hold this assertion). Line numbers in prose drift with any change to bufmgr.c; drop the line number and refer to the assertion in context instead.

💡 Suggested change

Before:

in bufmgr.c:5892 enforces this.

After:

in bufmgr.c enforces this.

📄 src/backend/access/recno/recno_dict.c (L318-L320)

Critical out-of-bounds write: the fixed RecnoDictMeta.entries[256] array does not fit in the metapage. sizeof(RecnoDictMeta) is 8216 bytes (24-byte header padded for the 8-byte-aligned RecnoDictDirEntry, plus 256 * 32-byte entries), but PageGetContents only gives BLCKSZ - MAXALIGN(SizeOfPageHeaderData) = 8192 - 24 = 8168 bytes at the default block size. As meta->count approaches RECNO_DICT_MAX_DIRECTORY, entry = &meta->entries[meta->count] and the writes at lines 319-325 scribble ~48 bytes past the end of the shared buffer, corrupting adjacent buffer-pool memory (and the WAL FPI). RecnoDictMetaSize is defined in recno_dict.h but never used to guard this. Add a StaticAssertDecl that RecnoDictMetaSize <= BLCKSZ - MAXALIGN(SizeOfPageHeaderData), and either shrink RECNO_DICT_MAX_DIRECTORY or the entry layout so the directory fits in one page.


📄 src/backend/access/recno/recno_dict.c (L130-L135)

Data-loss footgun: when block 0 already exists but its magic is wrong, this path unconditionally reinitializes the metapage, destroying the entire dictionary directory (every dictid->blob mapping) and rendering all previously-written compressed datums undecompressable. The legitimate empty-fork case is already handled by the branch above (lines 71-107), which extends and initializes block 0. Reaching here means block 0 exists with an unexpected magic, i.e. real corruption or a version mismatch; that should be reported as corruption (ereport(ERROR, errcode(ERRCODE_DATA_CORRUPTED), ...)), not silently overwritten. Note version is also never validated against RECNO_DICT_METAPAGE_VERSION.


📄 src/backend/access/recno/recno_dict.c (L392-L393)

Integer-overflow bypass of the bounds check on a corrupted fork: copied + ph->bytes_on_page is computed in uint32. A corrupted bytes_on_page near UINT32_MAX can wrap the sum below entry.length, passing this guard, after which the memcpy on the next line copies a huge length into the entry.length-sized result buffer -> heap overflow. Compare without overflow, e.g. ph->bytes_on_page > entry.length - copied (copied <= entry.length is loop-invariant here), or validate ph->bytes_on_page <= RecnoDictPagePayload before use.


📄 src/backend/access/recno/recno_compress.c (L1635-L1635)

RecnoResetCompressionDict() is defined here but has no caller anywhere in the tree (verified by search). Consequently the per-relation dict cache and the trained-dict cache -- both children of CacheMemoryContext holding ZSTD CDict/DDict objects freed only by this function -- are never torn down for the life of the backend, and there is no relcache-invalidation callback keyed on relid. After OID reuse, a stale cached dictionary can be returned for a different relation, causing decompression corruption. Register a cache-invalidation callback (CacheRegisterRelcacheCallback) and/or wire RecnoResetCompressionDict into a reset path; unused teardown code is a footgun.


📄 src/backend/access/recno/recno_compress.c (L1020-L1023)

RecnoDecompressDelta trusts on-disk length bytes without bounds checking. For tag 0x80, bytes_stored = input[1] is used as the memcpy length into a uint64 abs_val (max 8 bytes) -- a corrupt input[1] > 8 overflows the stack variable and reads past the compressed payload. For tag 0x89, stored_size = input[1] bytes are copied into output sized VARHDRSZ + orig_size with no check that stored_size <= orig_size, allowing a buffer overflow. Validate bytes_stored (<= 8) and stored_size (<= orig_size, and within the compressed payload length) before each memcpy, and ereport(ERRCODE_DATA_CORRUPTED) otherwise.


📄 src/backend/access/recno/recno_compress.c (L1031-L1037)

RecnoDecompressDelta leaks uninitialized memory when orig_size is neither 4 nor 8: for tags <= 0x7F, 0x80, and 0x81-0x88 the output VARDATA is only written in the sizeof(int32)/sizeof(int64) branches, so any other orig_size returns a palloc'd buffer whose data bytes are never initialized. The 0x89 path similarly copies stored_size bytes regardless of whether it equals orig_size. Initialize output fully (or reject the mismatch) to avoid disclosing uninitialized memory.


📄 src/backend/access/recno/recno_compress.c (L1928-L1929)

build_zstd_dict_for_attribute() performs a full table scan of an arbitrary relation OID under AccessShareLock with no privilege check. Any user who can call this SQL function can force a full scan of a relation they may not have SELECT on. Add a pg_class_aclcheck(relid, GetUserId(), ACL_SELECT) (and consider ownership) before scanning, matching how other relation-scanning SQL functions gate access.


📄 src/backend/access/recno/recno_compress.c (L54-L56)

RECNO_COMPRESS_RATIO_MIN and RECNO_DELTA_MAX_VALUES are defined but never referenced anywhere (verified). The ratio check uses the recno_compression_min_ratio GUC instead, so RECNO_COMPRESS_RATIO_MIN is a dead duplicate. Remove both unused macros.


📄 src/backend/access/recno/recno_compress.c (L85-L88)

Aspirational/future-tense comments describe behavior that does not exist ('A future enhancement could store dictionaries in a catalog table...', 'Note: Dictionary storage is currently backend-local and non-persistent.'). Per project comment discipline, comments should describe current behavior and explain why, not speculate about future work. Trim these to what the code does today.


📄 src/backend/access/recno/recno_compress.c (L127-L127)

Pointer-star spacing deviates from the tree style (space after '*' in a declarator): 'RecnoCompressionDict * compression_dict'. pgindent will churn this line. Use 'RecnoCompressionDict *compression_dict'. Same pattern appears in the RecnoInitCompressionDict/RecnoGetDictForRelation signatures and forward declarations.


📄 src/backend/access/recno/recno_diff.c (L214-L220)

Out-of-bounds read hazard on the recovery/UNDO-apply path. This loop trusts diff->ndiffs and each seg->ins_len read from the diff record, advancing ptr by SizeOfRecnoDiffSegment + seg->ins_len per iteration, but never validates that the traversal stays within the diff buffer. diff is read straight back from the UNDO fork/WAL: the only guard the callers apply is image_len < sizeof(RecnoDiffRecord) (recno_undo.c:407) / payload_size < ... + sizeof(RecnoDiffRecord) (recno_pvs.c:159), i.e. only the 6-byte fixed header. A truncated or corrupted record with an inflated ndiffs/ins_len makes seg = (RecnoDiffSegment *) ptr and the memcpy(..., old_bytes, seg->ins_len) at line 246 read past the end of the buffer during crash recovery. Bound the walk against diff->total_size (available in the header) before dereferencing each segment, e.g. verify (Size)(ptr - (char *) diff) + SizeOfRecnoDiffSegment <= diff->total_size and ... + seg->ins_len <= diff->total_size, and bail out with false otherwise.


📄 src/backend/access/recno/recno_diff.c (L214-L218)

Portability: unaligned access on the serialized diff format. old_bytes occupy seg->ins_len bytes immediately after each 6-byte segment header, and ins_len can be odd (an equal-length diff emits any run length, and the varlen branch is arbitrary). After ptr += seg->ins_len the next iteration casts an odd ptr to RecnoDiffSegment * and reads seg->offset/del_len/ins_len as uint16, which is an unaligned load. The same is true on the write side (recno_diff.c:169). On strict-alignment targets (some ARM, older SPARC/MIPS) this traps or is silently mis-handled; this violates the portability hard gate for an on-disk format. Read/write the uint16 fields with explicit byte-safe accessors (e.g. memcpy into locals) rather than relying on struct-member access at arbitrary offsets.


📄 src/backend/access/recno/recno_diff.c (L14-L17)

Stale/inaccurate file header comment. This paragraph describes the old model (RecnoDiffSegment entries recording "an offset, length, and the old bytes", reconstructed by "overwrite the segments with their old values"), but the code below implements a splice model with separate del_len/ins_len plus old_total_len that also handles length-changing updates. The '~12 bytes (4 bytes data + 4 bytes offset + 4 bytes header)' example also does not match the actual layout (6-byte record header + 6-byte segment header + old bytes). Comments must describe what the code does now; please update to the splice description used later in the file.


📄 src/backend/access/recno/recno_dirtymap.c (L224-L225)

Memory-ordering bug (high confidence): silent data corruption on weakly-ordered CPUs (ARM64/PPC64/RISC-V).

pg_atomic_write_u64/pg_atomic_read_u64 have No barrier semantics (see atomics.h: they guarantee atomicity only, not ordering). The insert publishes the key with a plain atomic store, and the lock-free reader in RecnoDirtyMapCheck uses plain atomic loads. There is no pg_write_barrier() after the slot store nor acquire/pg_read_barrier() on the reader.

Worse, the header's stated correctness invariant -- "SET before the buffer lock covering the modification is released" -- is violated by an actual caller: in recno_operations.c the DELETE path calls UnlockReleaseBuffer(buffer) (line ~1621) and only then RecnoDirtyMapMark(...) (line ~1661). A concurrent scanner that locks the page after the unlock but before the Mark completes will observe the EMPTY terminator, get a false "clean" result, skip the sLog before-image probe, and return committed-over tuple data.

Either (a) ensure every Mark truly precedes the buffer unlock AND add a write barrier before the unlock plus an acquire read (pg_atomic_read_membarrier_u64 / pg_read_barrier) in Check, or (b) redesign so the fast path cannot skip a needed probe. As written this is a correctness (wrong-results) hazard, not just a perf concern.


📄 src/backend/access/recno/recno_dirtymap.c (L272-L277)

Lock-free reader lacks acquire ordering (high confidence). pg_atomic_read_u64 provides atomicity but No barrier semantics, so on weakly-ordered architectures a reader can observe a published key store out of order with respect to the writer's buffer-lock release, or observe a stale EMPTY terminator that truncates the probe chain before a validly inserted key on the same collision chain. Use an acquire/barrier read (e.g. pg_atomic_read_membarrier_u64) paired with a barrier-semantics store on the writer side, or add explicit pg_read_barrier()/pg_write_barrier(). A missed key here yields silent data corruption in the scan fast path.


📄 src/backend/access/recno/recno_dirtymap.c (L70-L71)

Key is (relid<<32 | blkno) using the relation Oid, and the map is grow-only with no invalidation (high confidence). Across DROP + Oid reuse, or a TRUNCATE/CLUSTER/VACUUM FULL rewrite (which changes the relfilenode but not the Oid), stale keys alias to new physical blocks. The spurious-dirty direction is safe (extra probe), but combined with the finite 8 MB capacity and the sticky per-partition full latch, a long-lived cluster monotonically degrades to always-probe with no reclamation path. Consider keying on RelFileNumber/RelFileLocator rather than Oid, and document/address the no-reclaim consequence. The header defers this to "a future optimization" -- verify it is truly correctness-neutral for rewrites and Oid wraparound.


📄 src/backend/access/recno/recno_dirtymap.c (L267-L268)

part->full is read without the partition spinlock here while it is written under the spinlock in RecnoDirtyMapMark -- a plain non-atomic bool load racing a plain store is technically undefined per the project's concurrency model. The set-once/sticky argument makes it benign in practice, but PostgreSQL convention is to make such racing shared scalars atomic (e.g. pg_atomic_uint32/pg_atomic_flag) or read them under the lock. Recommend converting full to an atomic to be correct and self-documenting.


📄 src/backend/access/recno/recno_dirtymap.c (L77-L83)

False-sharing risk (medium confidence): RecnoDirtyMapPartition places mutex, nentries, and full at the front with no padding, so adjacent partitions' spinlocks and hot metadata can share a cache line. Concurrent Mark calls on different partitions then contend the same line, undermining the stated goal of spreading writer-lock contention. Align/pad the partition struct to a cache line (pg_attribute_aligned(PG_CACHE_LINE_SIZE)), as done for other contended shared partition structs in the tree.


📄 src/backend/access/recno/recno_dirtymap.c (L123-L127)

The full ~8 MB (128 x 8192 x 8 bytes) is requested unconditionally via the shmem subsystem list on every cluster, even ones with no recno tables and even when the feature is unused. This is speculative resource consumption with no GUC to size the partition/slot counts. Consider gating the request on the feature being enabled, or making the sizing configurable rather than hard-coded macros.


📄 src/backend/access/recno/recno_dirtymap.c (L158-L162)

Dead code (confirmed): RecnoDirtyMapShmemInit() is an empty no-op -- initialization is done by RecnoDirtyMapShmemInit_cb via the ShmemCallbacks. A codebase search finds no caller anywhere outside the declarations. Remove this function and its two header declarations (recno.h, recno_dirtymap.h) per YAGNI; leaving an empty "init" entry point is misleading API surface.


📄 src/backend/access/recno/recno_fsm.c (L30-L31)

miscadmin.h is included but no symbol from it (CHECK_FOR_INTERRUPTS, MyDatabaseId, work_mem, etc.) is used in this file. Remove the unused include. Note also the second include block is not alphabetically ordered (miscadmin.h after utils/rel.h) per project convention; dropping it resolves both.

💡 Suggested change

Before:

#include "utils/rel.h"
#include "miscadmin.h"

After:

#include "utils/rel.h"

📄 src/backend/access/recno/recno_lock.c (L331-L344)

RecnoHoldsTupleLock maps LockTupleMode to ShareLock/ExclusiveLock, but RecnoLockTuple/RecnoUnlockTuple (and heap's tupleLockExtraInfo) map to AccessShareLock/RowShareLock/ExclusiveLock/AccessExclusiveLock. Because the mappings disagree, this function checks for a LOCKMODE that RecnoLockTuple never acquires (e.g. KeyShare -> AccessShareLock acquired vs ShareLock checked here), so LockHeldByMe() will silently return false for locks that are actually held. Note also this function currently has no callers anywhere in the tree, so it is dead code; either remove it or fix the mapping to match RecnoLockTuple. (high confidence)


📄 src/backend/access/recno/recno_lock.c (L180-L183)

RecnoLockPage/RecnoUnlockPage have no callers anywhere in the tree (only an unused extern re-declaration in recno_operations.c). LOCKTAG_PAGE is essentially unused by modern AMs, and the comment describes a 'logical page structure' locking design that does not exist in the recno code. This is speculative scaffolding (YAGNI); remove it until an actual caller exists. (high confidence)


📄 src/backend/access/recno/recno_lock.c (L249-L256)

RecnoLockMultipleTuples has no callers in the tree, so this is dead code. If it is kept, note the O(n^2) bubble sort: there is no bound enforced on ntids, so a large TID array turns lock acquisition into a quadratic path. Prefer qsort() with an ItemPointerCompare comparator. Also the separate locked[] palloc0 array is redundant: RecnoLockTuple only sets have_lock true on success and the loop index i already bounds the acquired range, so the rollback loop can release [0, i) directly and the extra allocation (which would leak until context reset if RecnoLockTuple hits its can't-happen elog(ERROR)) can be dropped. (medium confidence)


📄 src/backend/access/recno/recno_lock.c (L304-L309)

RecnoLockRelationForDDL is a one-line wrapper over LockRelationOid with no callers in the tree. This is premature abstraction; remove it and call LockRelationOid directly at the (eventual) call site. (medium confidence)


📄 src/backend/access/recno/recno_lock.c (L70-L86)

The LockTupleMode->LOCKMODE switch is copy-pasted verbatim into RecnoLockTuple, RecnoUnlockTuple, and RecnoHoldsTupleLock. Factor it into a single static helper (e.g. recno_tuple_lockmode(mode)) to keep the three functions in sync and avoid the DRY violation. (low confidence)


📄 src/backend/access/recno/recno_mvcc.c (L280-L282)

Dead code: RecnoMvccShmemInit() has no callers. The live shared-memory init path is the subsystem callback RecnoMvccShmemInit_cb (registered via PG_SHMEM_SUBSYSTEM(RecnoMvccShmemCallbacks) in subsystemlist.h). This legacy function duplicates the entire init/tranche-assignment/on_shmem_exit registration and is never invoked. Per the tree's YAGNI/minimal-diff discipline, remove the dead RecnoMvccShmemInit() (and its extern in recno.h) to avoid maintaining two divergent copies of the init logic. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L597-L599)

Barrier ordering is inverted here versus the writer side. RecnoInitTransactionState()/RecnoPrepareReassignSlot() issue pg_write_barrier() BEFORE storing into the slot, but here (and in RecnoResolvePreparedSlot) the barrier is issued AFTER the store to 0. A write barrier after the store does not order that store against earlier stores as intended, and the reader RecnoGetOldestActiveTimestamp() only does a single pg_read_barrier() before the scan, so it can observe a torn/reordered slot value. This lockless horizon feeds the VM all-visible marking (recno_operations.c: vm_tuple_hdr->t_commit_ts >= oldest_ts); a horizon read too new could set all-visible prematurely and let index-only scans skip needed visibility checks. Place the barrier before the clearing store to match the init side. (moderate confidence)

💡 Suggested change

Before:

			RecnoMvccShmem->xact_start_ts_slots[my_slot] = 0;
			pg_write_barrier();
		}

After:

			pg_write_barrier();
			RecnoMvccShmem->xact_start_ts_slots[my_slot] = 0;
		}

📄 src/backend/access/recno/recno_mvcc.c (L542-L543)

Dead fields: rc_read_point_cid and rc_read_point_xcc are only ever initialized (here in RecnoInitTransactionState) and never read anywhere in the tree. The RC per-command read-point mechanism described in the struct comment is not wired up. Remove both fields and the accompanying comment block, per YAGNI. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L845-L847)

RecnoGetSnapshotTimestamp() has no callers in the tree. For the READ COMMITTED branch it calls RecnoGetCommitTimestamp(), which mutates the global atomic global_commit_ts via a CAS loop -- i.e. it advances a global counter on what is nominally a read path. Since visibility is now CLOG-driven (per this file's header), this function appears to be dead scaffolding. Remove it (and its extern) rather than leaving a footgun that mutates global shared state from a read entry point. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L880-L881)

RecnoTupleVisibleToSnapshot() is a single-use pass-through that unconditionally forwards to RecnoTupleVisibleToSnapshotDual(), and it has no callers -- every read site in recno_handler.c/recno_operations.c calls the Dual variant directly. Redundant indirection; remove this wrapper and its extern. (high confidence)


📄 src/backend/access/recno/recno_mvcc.c (L1239-L1242)

Large stack buffer on the visibility hot path: SLogTupleOp e[SLOG_MAX_TUPLE_OPS] with SLOG_MAX_TUPLE_OPS=128 and sizeof(SLogTupleOp) ~= 56-64 bytes allocates ~7-8 KB on the stack per call, and this branch runs per-tuple for uncommitted self-inserts. RecnoTupleSatisfiesMVCC is reached from deep scan call chains; consider bounding the fetch (the loop only needs to find the first ABORTED or matching-cid INSERT) or using a much smaller cap to avoid the per-tuple stack cost. The identical pattern repeats in the xmax branch below. (moderate confidence)


📄 src/backend/access/recno/recno_mvcc.c (L150-L150)

Non-ASCII characters in source. PostgreSQL requires ASCII-only in source/comments. This em dash (and others on lines ~555, ~745, ~756, 794) plus the U+2248 '=' character in the RecnoCommitTransaction comment ('oldest_ts ~= 1') must be replaced with plain ASCII ('--', 'approximately'). git diff --check/pgindent conventions and the ASCII-only rule apply. (high confidence)

💡 Suggested change

Before:

	 * no lock is needed — just a compiler barrier via volatile access.

After:

	 * no lock is needed -- just a compiler barrier via volatile access.

📄 src/backend/access/recno/recno_mvcc.c (L454-L460)

Misplaced/orphaned comment. This '/* Initialize per-transaction MVCC state ... */' block sits directly above the RecnoXactCallback machinery (recno_xact_callback_registered / RecnoXactCallback), not above RecnoInitTransactionState which it describes. It is immediately followed by a second comment block for RecnoXactCallback, leaving this one stranded. Move it down to sit directly above RecnoInitTransactionState. (high confidence)


📄 src/backend/access/recno/recno_pvs.c (L159-L166)

Buffer over-read: this bound check only validates the fixed 6-byte RecnoDiffRecord header (sizeof(RecnoDiffRecord)), but the diff is variable-length. RecnoApplyDiffReverse walks diff->ndiffs segments, each with variable-length old_bytes, starting at diff + SizeOfRecnoDiffRecord, with no bound against the end of the palloc'd payload. A truncated or corrupt UNDO record (bad ndiffs/ins_len) will read past the payload buffer, causing an out-of-bounds read/crash.

Both RelUndoDeltaUpdatePayload.diff_len and RecnoDiffRecord.total_size record the exact diff extent and are ignored here. Validate the full extent before reverse-applying, e.g. verify SizeOfRelUndoDeltaUpdatePayload + du->diff_len <= payload_size (and that du->diff_len >= diff->total_size >= SizeOfRecnoDiffRecord).

💡 Suggested change

Before:

					if (payload_size <
						SizeOfRelUndoDeltaUpdatePayload + sizeof(RecnoDiffRecord))
					{
						elog(WARNING,
							 "RECNO PVS: DELTA_UPDATE diff truncated at %llu",
							 (unsigned long long) verptr);
						break;
					}

After:

					if (payload_size <
						SizeOfRelUndoDeltaUpdatePayload + SizeOfRecnoDiffRecord ||
						(Size) du->diff_len < SizeOfRecnoDiffRecord ||
						(Size) du->diff_len > payload_size - SizeOfRelUndoDeltaUpdatePayload)
					{
						elog(WARNING,
							 "RECNO PVS: DELTA_UPDATE diff truncated at %llu",
							 (unsigned long long) verptr);
						break;
					}

📄 src/backend/access/recno/recno_pvs.c (L94-L94)

The header comment documents tid as "currently unused" and this line explicitly discards it with (void) tid;, but the depth-cap WARNING below dereferences tid via ItemPointerGetBlockNumber(tid)/ItemPointerGetOffsetNumber(tid). The comment has drifted from the code: tid is used. Update the header comment (drop the "currently unused" wording) and remove the (void) tid; cast, since it is misleading. All current callers pass a valid tid, so there is no NULL deref today, but the contradictory documentation is a footgun.


📄 src/backend/access/recno/recno_overflow.c (L1593-L1595)

Crash/replica-consistency bug: this WAL-logs a partial item delete using RecnoXLogInitPage, whose redo handler (recno_xlog_init_page_redo) reads the block with RBM_ZERO_AND_LOCK and calls RecnoInitPage(), reinitializing the entire page. On a standby or during crash recovery, replay will therefore zero the page and destroy the surviving live tuples and still-referenced overflow records that Pass 2 did NOT delete, causing data loss and replica divergence. Use a WAL record that replays exactly the removed offsets (a prune/delete-style record), not an init-page record.


📄 src/backend/access/recno/recno_overflow.c (L953-L954)

WAL/crash-consistency defect: RecnoPageIndexTupleDelete() + MarkBufferDirty() modify a shared on-disk page (removing items and changing free space) with no WAL record and no PageSetLSN() advance. On a standby the freed space is never replayed, so the standby page diverges from the primary; with page checksums enabled this can also surface as checksum mismatches after the primary re-dirties the page. "Relying on VACUUM to clean orphans" does not make an unlogged page mutation safe — any change to shared on-disk state must be WAL-logged and replayed. Emit a proper delete/prune WAL record and set the page LSN.


📄 src/backend/access/recno/recno_overflow.c (L440-L443)

Double-release hazard on the FSM-stale retry path. At this point buffer may be a buffer that was located in overflow_buffers a few lines above (matched by BufferGetBlockNumber == target_block) and is still tracked there with an added record. Calling UnlockReleaseBuffer() here unlocks and unpins it while leaving it in overflow_buffers, so the caller (recno_operations.c) will UnlockReleaseBuffer() it a second time and register an already-released buffer for WAL. Only release/unlock here when the buffer is NOT one tracked in overflow_buffers.


📄 src/backend/access/recno/recno_overflow.c (L76-L79)

Inaccurate comment. This is not a GUC: it is a plain C global that is never registered in guc_tables.c (the recno regression test confirms SHOW recno_overflow_inline_prefix errors with "unrecognized configuration parameter", and the RECNO README states it "is a global variable, not registered as a GUC"). Reword to reflect that it is a compile-time-only tunable, or actually register it as a GUC. Separately, its extern declaration in recno.h lacks PGDLLIMPORT, which will fail to link if referenced cross-module on Windows/MSVC.


📄 src/backend/access/recno/recno_overflow.c (L34-L34)

Aspirational/scaffolding comments. This large "FUTURE ENHANCEMENTS (deferred)" block (and the referenced RECNO_TUPLE_HAS_ROW_OVERFLOW flag / RecnoRowOverflowPtr, which do not exist in the tree) describe behavior that has not shipped. Per the project's comment discipline, comments must describe current behavior, not future plans; trim this block.


📄 src/backend/access/recno/recno_overflow.c (L376-L377)

The "WORKAROUND" for target_block == 0 encodes a fragile assumption (block 0 is "probably the main tuple's page") as a hack rather than a documented invariant, and it special-cases only overflow_buffers->count == 0. This is a footgun: if the main tuple ever lands on a non-zero block, or an overflow record legitimately targets block 0, the logic is wrong. Document the actual invariant (why the caller's page must be skipped and how it is identified) and drop the guesswork comment.


📄 src/backend/access/recno/recno_relundo.c (L133-L133)

Barrier placement is inverted here versus RecnoCleanupTransactionState(). Publishing a value with a write barrier requires storing the value FIRST, then issuing pg_write_barrier() so a reader that observes the store also observes prior stores. Here the barrier precedes the store, so it orders nothing useful for this publication. Compare RecnoCleanupTransactionState() (barrier AFTER the store-to-zero) which is the correct pattern. Because RecnoGetOldestActiveTimestamp() feeds the VM all-visible check (recno_operations.c: t_commit_ts >= oldest_ts), a stale/torn slot read can advance the horizon past a still-active transaction's start ts and set all-visible prematurely, letting index-only scans skip visibility checks. Make the barrier ordering consistent (store, then barrier).

💡 Suggested change

Before:

TableAMPrepare_hook = AtPrepare_Recno;

After:

		if (my_slot >= 0 && my_slot < RecnoMvccShmem->num_xact_slots)
		{
			RecnoMvccShmem->xact_start_ts_slots[my_slot] =
				MyRecnoXactState->xact_start_ts;
			pg_write_barrier();
		}

📄 src/backend/access/recno/recno_relundo.c (L125-L134)

Dead code: RecnoMvccShmemInit() has no caller. Shared-memory setup is wired exclusively through the PG_SHMEM_SUBSYSTEM path (RecnoMvccShmemCallbacks -> RecnoMvccShmemInit_cb, registered in subsystemlist.h). This function fully duplicates RecnoMvccShmemInit_cb's initialization. Remove it (and its header prototype) to avoid a second, drifting copy of the init logic.


📄 src/backend/access/recno/recno_relundo.c (L128-L128)

Dead field: serializable_horizon is only ever written (init to 1 in both init paths, and Min() in RecnoCommitTransaction) and never read anywhere in the tree. On top of that the update direction is wrong: it takes Min() against commit timestamps (all >= 1), so it can only shrink toward 1 and never usefully advance a horizon. Since SSI is delegated to predicate.c, this field and its mvcc_lock-guarded update are dead scaffolding — remove them.


📄 src/backend/access/recno/recno_relundo.c (L128-L133)

Dead fields: rc_read_point_cid and rc_read_point_xcc are initialized in RecnoInitTransactionState() but never read anywhere. The comment describes an RC per-command read-point mechanism that is not actually consumed. Remove the fields and their initialization (YAGNI).


📄 src/backend/access/recno/recno_relundo.c (L126-L134)

Dead pass-through wrapper: RecnoTupleVisibleToSnapshot() only forwards to RecnoTupleVisibleToSnapshotDual(), and all ~35 read sites call the Dual variant directly. No caller of this wrapper exists. Remove it (and its header prototype) to drop redundant indirection.


📄 src/backend/access/recno/recno_relundo.c (L125-L126)

Dead code: RecnoGetSnapshotTimestamp() has no callers. Note also its READ COMMITTED branch calls RecnoGetCommitTimestamp(), which performs an atomic CAS advancing the global commit-ts counter on what is nominally a read path — a scaling footgun were it ever wired up. Since it is unused and visibility is now CLOG-driven, remove it (and its header prototype).


📄 src/backend/access/recno/recno_stats.c (L51-L52)

high confidence: RecnoCollectRelationStats() and RecnoLogRelationStats() have zero callers anywhere in the tree (verified by searching all *.c files); only the definitions here and the extern declarations in recno.h exist. This is speculative scaffolding (YAGNI). Worse, the header comment above (recno.h) and this file's header both assert as fact that these stats are "collected during ANALYZE", "stored in the relation's pg_class.reloptions", and "consumed by the planner to improve cost estimates" -- none of which is wired up. That is aspirational documentation for unshipped behavior. Either wire this into the analyze path (with tests + docs), or drop the file until the consumer lands. Also note recno_overflow.c already provides RecnoGetOverflowStats(); the overflow-byte/chain accounting here partially duplicates it.


📄 src/backend/access/recno/recno_stats.c (L248-L253)

high confidence: portability/convention violation. These counters are int64 in RecnoRelationStats, and commit_ts_min/max are uint64. PostgreSQL forbids %ld/%lld for 64-bit values; use INT64_FORMAT / UINT64_FORMAT (which map to the correct platform format) and drop the (long long)/(unsigned long long) casts. Also, ANALYZE/LOG-level ereport text is user-facing and should start lowercase and be wrapped in errmsg_internal() for debug-only messages, or in _() if translatable; "RECNO stats for ..." starts uppercase.


📄 src/backend/access/recno/recno_stats.c (L85-L86)

high confidence, performance: this does an unconditional O(nblocks) full sequential scan reading and share-locking every page. The header comment says it runs "during ANALYZE after the standard sampling is complete" -- that directly defeats ANALYZE's bounded-sampling design (default_statistics_target based block sampling), and would turn ANALYZE on a large RECNO table into a full table read plus buffer-cache churn. If a full scan is genuinely required for these relation-wide metrics, it must not be tied to ordinary ANALYZE; gate it behind an explicit, opt-in path.


📄 src/backend/access/recno/recno_stats.c (L213-L215)

medium confidence, metric correctness: overflow record bytes are excluded from total_tuple_bytes but the pages they occupy are still counted in (nblocks * BLCKSZ), so bloat_factor over-reports bloat for relations with significant overflow (TOAST-like) usage. Additionally avg_overflow_chain_len divides total_overflow_chains (every overflow record seen across all pages) by total_overflow_tuples (live tuples flagged HAS_OVERFLOW) -- these are counted over different populations and the quotient is not a coherent "records per overflow tuple". Since the stated purpose is to feed planner cost estimates, these skewed metrics would mislead the planner.


📄 src/backend/access/recno/recno_stats.c (L124-L125)

medium confidence, correctness/portability: for every normal item the code casts PageGetItem() straight to RecnoTupleHeader* and immediately reads hdr->t_flags / hdr->t_commit_ts without first checking item_len >= RECNO_TUPLE_OVERHEAD. A short/corrupt item id length yields an out-of-bounds read inside the shared buffer. The compression branch does guard the item_len before reading comp_hdr, but the base-header read is unguarded. Note also comp_hdr is read via a struct cast at offset RECNO_TUPLE_OVERHEAD (MAXALIGN'd, so aligned) -- fine here, but the base-header validation is the gap.


📄 src/backend/access/recno/recno_slot.c (L458-L463)

Returning GetCurrentTransactionId() for xmin/xmax is wrong on two counts. (1) Semantics: it fabricates a XID that has nothing to do with the tuple's real visibility, so any consumer reading the xmin/xmax system columns (user queries, EXPLAIN, RI-adjacent logic) sees a bogus value; xmax in particular is reported as the current xid for any deleted tuple regardless of which xact deleted it. (2) Side effect: GetCurrentTransactionId() calls AssignTransactionId() when no XID is set, so merely selecting a system column in a read-only transaction forces XID assignment -- promoting a read-only/parallel transaction to a writing one and adding XID-exhaustion pressure. If a value must be produced, derive it from the sLog / commit_ts without allocating an XID, and use GetCurrentTransactionIdIfAny() at most. Confidence: high.


📄 src/backend/access/recno/recno_slot.c (L278-L283)

This opens and closes the relation for every overflow attribute during deform, which sits on scan/executor hot paths -- relation_open is not free (relcache hash lookup + refcount + ResourceOwner bookkeeping). The reference deform in recno_tuple.c avoids this by taking an already-open Relation as a parameter. Worse, this is an error-path leak: RecnoFetchOverflowColumn() can ereport(ERROR) (e.g. corrupt offset, see recno_overflow.c:790), and RecnoDecompressAttribute() can also throw; if either does, relation_close() never runs and the relcache reference is leaked until abort, producing a "relcache reference leak" WARNING. Prefer plumbing the open relation into the slot (or the deform) rather than opening per attribute, and if an open is unavoidable, protect it with PG_TRY/PG_FINALLY. Confidence: high.


📄 src/backend/access/recno/recno_slot.c (L357-L362)

Inconsistent InvalidOid handling versus the overflow branch above. The overflow path explicitly checks slot->tts_tableOid != InvalidOid before attempting to fetch/decompress, but this inline-compressed path calls RecnoDecompressAttribute(slot->tts_tableOid, ...) unconditionally. For a transient slot (tts_tableOid == InvalidOid) that holds dictionary-compressed inline data, RecnoDecompressAttribute takes the dict path and ereport(ERROR, ... "RECNO dict-compressed datum needs relation context") (recno_compress.c:452). A plain deform of a valid tuple should not error just because the slot has no table OID. Guard this branch consistently (e.g. skip decompression / return as-is when tts_tableOid is InvalidOid, mirroring the overflow branch). Confidence: moderate.


📄 src/backend/access/recno/recno_slot.c (L850-L855)

This same-buffer fast path frees only rslot->tuple when clearing SHOULDFREE, but not rslot->values_block. Every other cleanup site (tts_recno_clear, tts_recno_release) frees both under SHOULDFREE. If a slot ever reaches here with a values_block owned, that block leaks. Free values_block here too (and NULL it) to match the clear() contract. Confidence: moderate.

💡 Suggested change

Before:

		if (unlikely(TTS_SHOULDFREE(slot)))
		{
			if (rslot->tuple)
				pfree(rslot->tuple);
			slot->tts_flags &= ~TTS_FLAG_SHOULDFREE;
		}

After:

		if (unlikely(TTS_SHOULDFREE(slot)))
		{
			if (rslot->tuple)
				pfree(rslot->tuple);
			if (rslot->values_block)
			{
				pfree(rslot->values_block);
				rslot->values_block = NULL;
			}
			slot->tts_flags &= ~TTS_FLAG_SHOULDFREE;
		}

📄 src/backend/access/recno/recno_slot.c (L849-L849)

Non-ASCII em-dash characters ('—') appear in these comments. PostgreSQL source must be ASCII only (no smart quotes, em-dashes, or ellipsis). Replace with ' -- ' or a regular hyphen. Confidence: high.

💡 Suggested change

Before:

		/* Same buffer — just free any materialized data */

After:

		/* Same buffer - just free any materialized data */

📄 src/backend/access/recno/recno_slot.c (L859-L859)

Non-ASCII em-dash ('—') again; PostgreSQL source must be ASCII only. Replace with ' -- ' or a hyphen. Confidence: high.

💡 Suggested change

Before:

		/* Different buffer — full clear (releases old pin) and acquire new */

After:

		/* Different buffer - full clear (releases old pin) and acquire new */

📄 src/backend/access/recno/recno_tuple.c (L1190-L1200)

Alignment bug: the overflow branch advances data_ptr += attr_len without aligning the next attribute's start. The write path (recno_form_tuple_internal) aligns every attribute with att_align_nominal(data_ptr, att->attalign) before writing, and RecnoDeformTuple does the same align-before at the top of its loop. But here the varlena branches use align-after (att_align_nominal(data_ptr + attr_len, ...)) while the overflow branch omits alignment entirely. When an overflowed varlena is followed by another varlena attribute, the next attribute's start is left unaligned, so VARSIZE_ANY(data_ptr) reads from the wrong offset -> data corruption / misdecode. The fixed-length branch happens to re-align (line 1243), masking the problem only when the following column is fixed-length. Make the overflow branch align the next start consistently, e.g. data_ptr = (char *) att_align_nominal(data_ptr + attr_len, att->attalign); in both the rel==NULL and rel!=NULL exits.

💡 Suggested change

Before:

							/* Not compressed - use fetched data as-is */
							slot->tts_values[i] = fetched;
						}
						else
						{
							/* No relation - return overflow pointer as-is */
							slot->tts_values[i] = PointerGetDatum(data_ptr);
						}
						slot->tts_isnull[i] = false;
						data_ptr += attr_len;
						continue;

After:

							/* Not compressed - use fetched data as-is */
							slot->tts_values[i] = fetched;
						}
						else
						{
							/* No relation - return overflow pointer as-is */
							slot->tts_values[i] = PointerGetDatum(data_ptr);
						}
						slot->tts_isnull[i] = false;
						data_ptr = (char *) att_align_nominal(data_ptr + attr_len,
															  att->attalign);
						continue;

📄 src/backend/access/recno/recno_tuple.c (L1236-L1238)

Inconsistent alignment strategy vs. RecnoDeformTuple and the write path. RecnoDeformTuple / recno_form_tuple_internal apply att_align_nominal(data_ptr, ...) before each attribute; this function instead aligns the next attribute's start after each varlena and aligns fixed-length before. The two schemes only coincide because varlena att_align_nominal is idempotent, and the divergence in the overflow branch (which aligns neither before nor after) breaks it. Recommend making this function use the same align-before-every-attribute scheme as RecnoDeformTuple so the read paths are provably identical to the write layout.


📄 src/backend/access/recno/recno_tuple.c (L833-L836)

Dead code (YAGNI). RecnoPageUpdateTuple is exported in recno.h but has no callers anywhere in the tree (only its definition/declaration match). It also mutates buffer page contents in-place (memcpy over old_tuple, RecnoPageIndexTupleDelete + PageAddItem) with no MarkBufferDirty and no WAL logging, so as written it would be unsafe for crash/replica consistency if ever wired up. Either remove it, or if it is intended future infrastructure, drop it from this patch until a caller exists.


📄 src/backend/access/recno/recno_tuple.c (L904-L906)

Dead code (YAGNI). RecnoPageGetLiveTuples has no callers (confirmed tree-wide) and takes a snapshot_ts parameter it explicitly discards via (void) snapshot_ts;, with a comment admitting "no current callers". An estimate helper that nobody calls and that ignores its only visibility argument is speculative scaffolding; remove it from the patch until a caller needs it.


📄 src/backend/access/recno/recno_tuple.c (L687-L693)

Compression is detected by sniffing bytes at VARDATA_ANY(data_ptr) as a RecnoCompressionHeader (range-checking comp_type/comp_size/orig_size). RecnoCompressionHeader has no magic field, so an uncompressed varlena whose leading bytes happen to fall in these ranges could be misinterpreted as compressed (and vice-versa), corrupting the decoded value. The tuple-level RECNO_INFOMASK_COMPRESSED flag only gates whether any attribute is compressed, not which one. Consider a per-attribute marker (e.g. a magic in the header, or reusing the null/overflow-style bookkeeping) instead of content-based detection. This same heuristic is duplicated in RecnoDeformTuple and RecnoTupleToSlotWithOverflow (both fetched and inline paths) -- a single shared helper would also address the DRY duplication.


📄 src/backend/access/recno/recno_undo.c (L360-L363)

offnum comes from the untrusted UNDO payload TID (hdr.tid) and is passed straight into the per-op helpers, all of which call PageGetItemId(page, offnum) (in apply_recno_undo_insert, apply_recno_undo_restore_tuple, and the DELTA_UPDATE branch) with no range check. PageGetItemId is unchecked pointer arithmetic; if offnum is 0 or greater than PageGetMaxOffsetNumber(page) (corrupt/truncated payload, or a page that shrank), ItemIdIsNormal(lp) reads memory past the line-pointer array, and the restore path can then memcpy into an out-of-bounds slot. The heap WAL/undo redo path guards every such access with offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page) (see heapam_xlog.c:325,991,1051). Validate offnum here after locking the buffer, before dispatching, and skip (UNDO_APPLY_SKIPPED / warning) if out of range.

💡 Suggested change

Before:

	buffer = ReadBuffer(rel, blkno);
	LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE);

	switch (info)

After:

	buffer = ReadBuffer(rel, blkno);
	LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE);

	/*
	 * offnum is taken from the (untrusted) UNDO payload; validate it against
	 * the current page before any PageGetItemId() call, which is unchecked
	 * pointer arithmetic.
	 */
	if (offnum < FirstOffsetNumber ||
		offnum > PageGetMaxOffsetNumber(BufferGetPage(buffer)))
	{
		ereport(DEBUG2,
				(errmsg_internal("RECNO UNDO: offset %u out of range on block %u of relation %u, skipping",
								 offnum, blkno, reloid)));
		UnlockReleaseBuffer(buffer);
		relation_close(rel, RowExclusiveLock);
		return UNDO_APPLY_SKIPPED;
	}

	switch (info)

📄 src/backend/access/recno/recno_undo.c (L127-L128)

Setting a page LSN on an unlogged/temp relation diverges from the RECNO forward-path convention, which simply omits PageSetLSN when !RelationNeedsWAL (e.g. recno_operations.c only calls PageSetLSN inside if (RelationNeedsWAL(relation))). Unlogged pages should keep their LSN unchanged; stamping them with GetXLogInsertRecPtr() is unnecessary and inconsistent. Drop the PageSetLSN call and just return.

💡 Suggested change

Before:

		PageSetLSN(BufferGetPage(buffer), GetXLogInsertRecPtr());
		return;

After:

		return;

📄 src/backend/access/transam/twophase.c (L1680-L1684)

Data-integrity hazard on ATM-full: when ATMAddAborted() returns false (sLog/ATM full) this path only emits a WARNING and then falls through to ProcArrayRemove(), finalizing the ROLLBACK PREPARED. The permanent UNDO chain for this xact is then silently dropped and its before-images are never restored -- this violates the CTR "UNDO is guaranteed to be applied" invariant asserted by the undo/README and by test 069. atm.c documents the false return as "signaling the caller to fall back to synchronous rollback", but there is no such fallback here, unlike the (already fragile) xactundo.c path. Since this runs in a normal backend where ERROR is safe, prefer failing the ROLLBACK PREPARED (ereport ERROR) so it can be retried, or perform a synchronous revert, rather than losing the UNDO. Confidence: high.

💡 Suggested change

Before:

			if (XLogRecPtrIsValid(perm_lsn) &&
				!ATMAddAborted(xid, hdr->database, perm_lsn))
				elog(WARNING,
					 "ATM full: could not record rolled-back prepared "
					 "transaction %u for UNDO", xid);

After:

			if (XLogRecPtrIsValid(perm_lsn) &&
				!ATMAddAborted(xid, hdr->database, perm_lsn))
				ereport(ERROR,
						(errcode(ERRCODE_OUT_OF_MEMORY),
						 errmsg("could not record rolled-back prepared transaction %u for UNDO", xid),
						 errhint("The aborted-transaction map is full; retry ROLLBACK PREPARED after the revert worker makes progress.")));

📄 src/backend/access/transam/xlog.c (L8144-L8145)

Comment inaccuracy: this comment claims CheckPointUndoLog() persists UNDO log discard pointers, but the function does neither a persist nor a sync -- with UNDO-in-WAL there are no segment files, and the implementation (undolog.c) only reads counters and emits a LOG line under log_checkpoints. The comment should describe what the code does now (log statistics), not persist state. (moderate confidence)

💡 Suggested change

Before:

	/* Persist UNDO log discard pointers and log statistics */
	CheckPointUndoLog();

After:

	/* Log UNDO log statistics (no segment files to sync with UNDO-in-WAL) */
	CheckPointUndoLog();

📄 src/backend/access/transam/xlog.c (L7446-L7455)

Scope creep / does-more-than-one-thing: this per-phase checkpoint timing instrumentation (8 new locals, unconditional INSTR_TIME measurements around each phase, and a new ereport(LOG)) is generic checkpoint observability unrelated to the UNDO/ATM feature this patch delivers. Bundling it makes the patch harder to review and to bisect, and it overlaps with existing checkpoint stats (LogCheckpointEnd/CheckpointStats). Split it into its own patch (or drop it). (high confidence)


📄 src/backend/access/transam/xlog.c (L7966-L7969)

Confusing units: these variables are named *_ms and hold milliseconds (INSTR_TIME_GET_MILLISEC), but the LOG message divides each by 1000.0 and prints with an 's' (seconds) suffix. Either name them *_ms and print ms, or capture seconds and rename. Mixing the two is a maintenance footgun. (moderate confidence)


📄 src/backend/access/transam/xlog.c (L8650-L8660)

Misleading comment vs. code: the comment says 'If UNDO-in-WAL is active' but there is no activation guard in this block; retention is gated only implicitly inside UndoGetOldestBatchLSN() (which returns InvalidXLogRecPtr when UndoLogShared == NULL). Also the anonymous brace scope is stylistically inconsistent with the neighboring wal_keep_size / summarization blocks. Consider dropping the redundant braces and rewording the comment to state the gating is done by UndoGetOldestBatchLSN() returning InvalidXLogRecPtr when UNDO-in-WAL is inactive. (moderate confidence)


📄 src/backend/access/transam/xact.c (L3061-L3063)

This comment contradicts the code directly below it. It claims "Physical UNDO application is NOT needed during standard transaction abort" and that UNDO records are merely "preserved in the UNDO log for use by the undo worker, crash recovery, or future in-place update mechanisms." But AtAbort_XactUndo() a few lines below DOES apply physical UNDO synchronously and inline in this backend (see ApplyPerRelUndo -> RelUndoApplyChain and the ApplyUndoChainFromWAL path in xactundo.c, both of which restore before-images in place via EnterInlineUndoApplyState). The comment describes behavior that is not what the code now does; it is stale/misleading. Rewrite it to describe the actual inline-apply-on-abort behavior, and drop the "future in-place update mechanisms" aspirational framing.


📄 src/backend/access/transam/xact.c (L2775-L2778)

XactUndoHasUnrecoverableUndo() returns false unconditionally (see xactundo.c), so this ereport(ERROR) is unreachable dead code today. Per the minimalism/YAGNI discipline, a guard whose error arm can never fire is speculative scaffolding kept for a "future non-2PC-safe UNDO mechanism" that does not exist in this change. Consider dropping the guard and its message until a mechanism actually needs it; if kept intentionally as a choke point, that decision belongs in the -hackers design discussion rather than shipped as a permanently-false branch.


📄 src/backend/access/transam/xlogrecovery.c (L36-L37)

The #include "access/undolog.h" appears to be unnecessary. The functions used by this diff are declared elsewhere: UndoRecoveryNeeded() and PerformUndoRecovery() come from access/undo_xlog.h, and PerformRelUndoRecovery() comes from access/relundo.h. No symbol declared in undolog.h (e.g. UndoLog*, undo_retention_time, UndoRegisterBatchLSN, ...) is referenced anywhere in xlogrecovery.c. Drop this include to keep the diff minimal. (high confidence)

💡 Suggested change

Before:

#include "access/undo_xlog.h"
#include "access/undolog.h"

After:

#include "access/undo_xlog.h"

📄 src/backend/access/undo/atm.c (L374-L380)

On-disk format hazard: AtmStateRecord ends with a bool and the rec local is written raw via write(fd, &rec, sizeof(rec)) with the CRC also computed over sizeof(rec). The trailing padding after revert_complete (and any inter-field padding) is never initialized, so uninitialized stack bytes are fed into COMP_CRC32C and written to disk. This makes the checksum nondeterministic (will trip valgrind and can differ run-to-run) and bakes struct padding/alignment/endianness into the on-disk format, violating the portability gate for on-disk formats. The two-phase code this claims to mirror (RecreateTwoPhaseFile) serializes into a packed content buffer, not raw padded structs. Serialize each field explicitly with fixed-width types (or at minimum memset(&rec, 0, sizeof(rec)) before filling, though that only fixes the nondeterministic-CRC symptom, not the layout portability).


📄 src/backend/access/undo/atm.c (L336-L336)

Memory leak on error: entries is palloc'd by SLogTxnSnapshotForCheckpoint, but every write/fsync/close failure in this function throws via ereport(ERROR) before reaching pfree(entries) at the end. CheckPointATM runs in the checkpointer, so each failed checkpoint leaks the snapshot array. Wrap the I/O in PG_TRY/PG_FINALLY (or free before each ereport) so entries is released on all paths.


📄 src/backend/access/undo/atm.c (L134-L137)

Stale comment: SLogTxnInsert never fails and always returns true (its own doc says the return is "retained for API compatibility and is always true"). This "Returns false if the sLog is full" claim describes behavior that can no longer occur; the resulting elog(WARNING, "ATM full...") branches in callers are dead. Update the comment (and consider the return type) to reflect current behavior.


📄 src/backend/access/undo/atm.c (L147-L151)

Stale comment: same issue as ATMAddAbortedInternal -- SLogTxnInsert always returns true, so ATMAddAborted never returns false and no caller ever falls back to synchronous rollback on this basis. Reword to match current behavior.


📄 src/backend/access/undo/atm.c (L486-L488)

Unbounded allocation from untrusted on-disk header: hdr.count is read straight from the file and used to size palloc((Size) hdr.count * sizeof(AtmStateRecord)) before any CRC validation. A corrupt-but-magic-matching header with a huge count triggers a very large palloc (OOM/error) before the checksum can reject it. Add a sanity upper bound on hdr.count (e.g., vs. the physically possible file size) before allocating.


📄 src/backend/access/undo/atm.c (L358-L360)

Misleading wait events: pg_undo_atm is not a two-phase state file, but the I/O here reports WAIT_EVENT_TWOPHASE_FILE_WRITE/SYNC (and _READ in ATMReloadFromCheckpoint). This mislabels waits in pg_stat_activity for the new ATM subsystem. Add dedicated wait events via wait_event_names.txt (do not hand-edit the generated header).


📄 src/backend/access/undo/README (L1065-L1076)

The Lock Ordering table is a deadlock-avoidance reference, so its inaccuracies are actively dangerous. Verified against the code:

  • LWTRANCHE_UNDO_LOG is a single tranche, not three. Levels 2/3/4 all name it as distinct "per-log"/"allocation"/"flush" instances, but the code only initializes it for the per-log lock (undolog.c:100) and the allocation lock (undolog.c:108). There is no separate flush lock or flush tranche.
  • undo_flush.c does not exist anywhere in the tree (file_find found nothing), so undo_flush.c:61 is a fabricated reference.
  • Line numbers are wrong: the per-log init is undolog.c:100 (not :97), allocation is undolog.c:108 (not :105), and the revert-worker lock init is logical_revert_worker.c:114 (not :109).
  • Level 6 "sLog partition spinlocks": the sLog does not use spinlocks. The transaction sLog uses LWTRANCHE_SLOG (an LWLock) and the tuple sLog uses per-partition seqlocks + LWTRANCHE_SLOG writer_lock (slog.c). Calling them spinlocks (and the Rules that reference "sLog partition spinlocks") is incorrect.
  • Level 7 LWTRANCHE_RECNO_DIRTY_MAP does not exist. The recno dirty map uses slock_t/SpinLockInit per partition (recno_dirtymap.c), not an LWLock tranche.

Correct the tranche names, line references, and lock primitives (or drop the exact line numbers, which will bit-rot).


📄 src/backend/access/undo/README (L1059-L1059)

Non-ASCII characters violate the ASCII-only rule for source/docs. Line 1059 uses an em-dash (U+2014) and line 1081 uses an arrow (U+2192, "2→5"). Replace with ASCII: em-dash -> "--", arrow -> "->" (e.g., "locks 2->5").

💡 Suggested change

Before:

locks sit outside this hierarchy — they must be acquired before any

After:

locks sit outside this hierarchy -- they must be acquired before any

📄 src/backend/access/undo/README (L1081-L1081)

ASCII-only violation: the arrow character (U+2192). Use "->".

💡 Suggested change

Before:

- The revert worker acquires locks 2→5 in sequence during UNDO apply;

After:

- The revert worker acquires locks 2->5 in sequence during UNDO apply;

📄 src/backend/access/undo/README (L539-L543)

Both documented defaults are wrong. In undolog.c: undo_worker_naptime = 10000 (10s), and undo_retention_time = 60000 (60s). The README says 60000 (1 minute) and 3600000 (1 hour) respectively. Note this also disagrees with postgresql.conf.sample, which shows undo_worker_naptime = 60s and undo_retention_time = 300s -- a three-way inconsistency across code default, sample file, and this README. Reconcile all three to the actual code defaults (10s / 60s) or fix the code/sample to match a single intended default.

💡 Suggested change

Before:

    undo_worker_naptime     Sleep interval between discard cycles (ms)
                            Default: 60000 (1 minute)

    undo_retention_time     Minimum retention time for UNDO records (ms)
                            Default: 3600000 (1 hour)

After:

    undo_worker_naptime     Sleep interval between discard cycles (ms)
                            Default: 10000 (10 seconds)

    undo_retention_time     Minimum retention time for UNDO records (ms)
                            Default: 60000 (60 seconds)

📄 src/backend/access/undo/README (L291-L296)

The initialization-flow description is inaccurate. undo.c does not get called from ipci.c; it registers with the shmem framework via .init_fn = UndoShmemInit_internal and a UndoShmemRequest() hook (see undo.c header comment and .init_fn wiring), and ipci.c is not among the modified files for this change. Also UndoShmemSize() no longer decomposes as UndoLogShmemSize() + XactUndoShmemSize() + UndoWorkerShmemSize() -- undo.c:84 aggregates a different set (including the ATM/sLog subsystems now present). Update this block to describe the actual shmem_startup_hook/init_fn registration and the real size composition.


📄 src/backend/access/undo/README (L889-L891)

Stale/incorrect test references. src/test/regress/sql/undo.sql and undo_toast.sql do not exist in the tree (file_find found neither); the actual regression tests for this feature are the recno_*.sql set (and fileops.sql). The recovery-test range 053-060 is also wrong: 053 is the pre-existing unrelated 053_standby_login_event_trigger.pl, and the UNDO/recovery TAP tests added by this change are 054, 061, 063, 065, 067, 068, 069, 070 -- not a contiguous 053-060 range. Point at the tests that actually exist so this file map is usable.


📄 src/backend/access/undo/README (L545-L545)

Section numbering skips 12: it jumps from "## 11. Performance Characteristics" directly to "## 13. Monitoring and Troubleshooting". Renumber to keep the sequence contiguous.


📄 src/backend/access/undo/logical_revert_worker.c (L316-L318)

Bug: on the error path this commits an already-failed transaction. After EmitErrorReport()/FlushErrorState() the transaction is still in TRANS_INPROGRESS but its resources were unwound by the error longjmp. Falling through to CommitTransactionCommand() tries to commit that broken transaction rather than aborting it, causing assertion failures / undefined behavior. The standard PG error-recovery idiom (see autovacuum.c's sigsetjmp handler calling AbortCurrentTransaction()) is to abort the failed transaction in the catch block. Call AbortCurrentTransaction() inside PG_CATCH and only CommitTransactionCommand() on the success path.


📄 src/backend/access/undo/logical_revert_worker.c (L233-L234)

Starvation/livelock: ATMGetNextUnreverted -> SLogTxnGetNextUnreverted returns the globally-oldest unreverted entry (xid-major iteration), it does NOT filter by database. If that oldest entry belongs to a database that has no live worker (e.g. one beyond the max_logical_revert_workers cap, or a template db), every worker repeatedly fetches the same foreign-db entry, hits goto sleep, and can never reach entries for its own database, which then pile up unreverted. Since these workers are the mechanism that honors guaranteed rollback, this silently leaves aborts un-reverted. Filter the scan by db (pass MyDatabaseId into the ATM lookup) or otherwise advance past non-matching entries.


📄 src/backend/access/undo/logical_revert_worker.c (L138-L143)

SIGTERM handling breaks prompt shutdown. This handler only sets got_SIGTERM and never triggers die()/ProcDiePending, but got_SIGTERM is only checked at the top of the loop. During a long-running process_revert_entry()/ApplyUndoChainFromWAL(), the CHECK_FOR_INTERRUPTS() calls will not abort the operation (no die() handler was installed), so fast/smart shutdown can hang until the current ATM entry finishes. Other always-on bgworkers install die or SignalHandlerForShutdownRequest for SIGTERM so CHECK_FOR_INTERRUPTS actually aborts. Use the standard handler rather than a flag-only one.


📄 src/backend/access/undo/logical_revert_worker.c (L361-L362)

Use bounded string ops. Project convention (and the review rules) forbid sprintf; use snprintf(..., BGW_MAXLEN, ...) / strlcpy as LogicalRevertLauncherRegister() already does for the same fields.

💡 Suggested change

Before:

	sprintf(worker.bgw_library_name, "postgres");
	sprintf(worker.bgw_function_name, "LogicalRevertWorkerMain");

After:

	snprintf(worker.bgw_library_name, MAXPGPATH, "postgres");
	snprintf(worker.bgw_function_name, BGW_MAXLEN, "LogicalRevertWorkerMain");

📄 src/backend/access/undo/README (L885-L885)

Self-contradictory status. The header states "Functional; hardening in progress" and the prose lists open hardening items ("Optimization and hardening"), yet the bullet list two lines down declares "Optimization and hardening: Complete". Reconcile these -- either hardening is complete or it is in progress, not both.


📄 src/backend/access/undo/README (L1059-L1059)

Non-ASCII character: this line contains an em-dash (U+2014). PostgreSQL sources must be ASCII-only. Replace with " -- ".

💡 Suggested change

Before:

locks sit outside this hierarchy — they must be acquired before any

After:

locks sit outside this hierarchy -- they must be acquired before any

📄 src/backend/access/undo/README (L1081-L1081)

Non-ASCII character: this line contains an arrow (U+2192, "2->5" rendered with a Unicode arrow). PostgreSQL sources must be ASCII-only. Replace with "2->5".

💡 Suggested change

Before:

- The revert worker acquires locks 2→5 in sequence during UNDO apply;

After:

- The revert worker acquires locks 2->5 in sequence during UNDO apply;

📄 src/backend/access/undo/relundo.c (L142-L142)

High confidence, correctness. relundo_pending_metabuf is a per-backend static holding a locked+pinned metapage across the Reserve->Stage/Finish boundary. It is only cleared in RelUndoStage/RelUndoFinishWithTuple (new-page path) and RelUndoCancel. Confirmed caller recno_operations.c: the CAS UPDATE path calls RelUndoReserve() (may set this static for a new page) and then CheckForSerializableConflictIn(), which can ereport(ERROR) before RelUndoStage() consumes it. On abort the buffer's content lock+pin are released by ResourceOwner/lock cleanup, but this static is NEVER reset, so it retains a now-invalid buffer number. A later RelUndoReserve() in the same backend that does not allocate a new page leaves the stale value in place; the next new-page RelUndoStage()/RelUndoFinishWithTuple() then passes Assert(BufferIsValid(...)) on garbage and MarkBufferDirty()s a released buffer -- cross-transaction state bleed / potential corruption. There is no AtAbort/subxact-abort hook resetting it (RelUndoAbortCleanup_hook only removes sLog entries by xid). Register a transaction/subtransaction abort callback (or a RelUndoResetPending()) that unlocks/releases relundo_pending_metabuf and resets it to InvalidBuffer.


📄 src/backend/access/undo/relundo.c (L866-L867)

Portability. ptr is a RelUndoRecPtr (typedef uint64). Casting to unsigned long and printing with %lu truncates on LLP64 platforms (Windows/MSVC, where unsigned long is 32-bit) and mis-sizes the varargs read. Use UINT64_FORMAT with a (uint64) cast.

💡 Suggested change

Before:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=%lu, payload_size=%zu, tuple_len=%u",
		 (unsigned long) ptr, payload_size, tuple_len);

After:

	elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=" UINT64_FORMAT ", payload_size=%zu, tuple_len=%u",
		 (uint64) ptr, payload_size, tuple_len);

📄 src/backend/access/undo/relundo.c (L497-L498)

Maintainability / performance (hot path). RelUndoReserve carries eight elog(DEBUG1) calls on the reservation hot path (per DML op), including one printing a raw pointer with %p. Even when the log level suppresses output, the varargs are still evaluated and passed. This is debug scaffolding that the community rejects on write-hot paths; remove these DEBUG1 traces before submission.


📄 src/backend/access/undo/relundo.c (L1448-L1448)

Correctness (design gap vs stated purpose). relundo_last_vacuum is a function-scoped static, so the 5s throttle is global per backend, not per relation. A backend actively writing relation A consumes the timer and suppresses this discard backstop for relation B for up to 5s. Since RelUndoMaybeVacuum is explicitly the only backstop preventing unbounded UNDO-fork growth for in-place AMs that never trigger autovacuum, a second hot relation can grow unbounded while the timer is held by the first. Consider a per-relation throttle (keyed by relid, e.g. a small backend-local hash) instead of a single global timestamp. Minor: GetCurrentTimestamp() runs on every hot-path call before the throttle check.


📄 src/backend/access/undo/relundo.c (L1377-L1378)

Comment discipline. This future-tense/aspirational comment describes behavior that does not exist ('If in the future we need explicit fork removal...'). PostgreSQL comments must describe what the code does now; drop the speculative paragraph.


📄 src/backend/access/undo/relundo_discard.c (L10-L14)

This file-header comment describes an algorithm the code does not implement. It claims discard is "counter-based" and that "Each page's generation counter is compared against the oldest-visible cutoff using modular 16-bit arithmetic. If a page's counter precedes the cutoff...". The actual implementation (relundo_page_is_discardable) compares the 32-bit TransactionId field hdr->max_xid against oldest_xmin via TransactionIdPrecedes(). The header in relundo.h (RelUndoPageHeaderData) explicitly documents that the 16-bit counter field "is no longer used for discard eligibility". This stale/incorrect comment will actively mislead reviewers and maintainers about the discard horizon; rewrite it to describe the max_xid vs oldest_xmin (TransactionId) comparison actually used. (high confidence)

💡 Suggested change

Before:

 * Discard walks the page chain from the tail (oldest) toward the head
 * (newest).  Each page's generation counter is compared against the
 * oldest-visible cutoff using modular 16-bit arithmetic.  If a page's
 * counter precedes the cutoff, all records on that page are safe to
 * discard and the page is moved to the free list.

After:

 * Discard walks the page chain from the tail (oldest) toward the head
 * (newest).  Each page's max_xid (the largest urec_xid of any record on
 * the page) is compared against the oldest-visible cutoff (oldest_xmin)
 * using TransactionIdPrecedes.  If a page's max_xid precedes the cutoff,
 * all records on that page are safe to discard and the page is moved to
 * the free list.

📄 src/backend/access/undo/relundo_discard.c (L6-L6)

This counter-based discard description is also inconsistent with the implementation. The file header says "counter-based discard logic for per-relation UNDO", but discard eligibility is decided by TransactionId comparison (max_xid vs oldest_xmin), not a generation counter. Please align this line with the actual max_xid-based logic. (high confidence)

💡 Suggested change

Before:

 * This file implements the counter-based discard logic for per-relation UNDO.

After:

 * This file implements the max_xid-based discard logic for per-relation UNDO.

📄 src/backend/access/undo/relundo_discard.c (L296-L296)

Accounting mismatch: discarded_records is documented as a "Cumulative count of discarded records" with the invariant (total_records - discarded_records) = live records, but here it is incremented by npages_freed, a page count, not a record count. This breaks the documented invariant (pages and records are different units) and the redo path mirrors the same page-count increment. The /* approximate */ note does not make the semantics correct. Either count actual discarded records, rename the field to reflect pages, or drop this bookkeeping if it is unused. (moderate confidence)


📄 src/backend/access/undo/relundo_page.c (L80-L85)

The metapage reinitialization here mutates shared on-disk state (PageInit + field writes + MarkBufferDirty) but emits no WAL record and runs outside a critical section. This breaks the WAL-before-data rule: the checkpointer can flush this dirty page to disk with no matching WAL, so a standby (or subsequent crash recovery) never reproduces the reinit, causing primary/standby divergence. Compare RelUndoInitFork() in relundo.c, which performs the identical initialization but wraps it in START_CRIT_SECTION()/XLogInsert(RM_RELUNDO_ID, XLOG_RELUNDO_INIT) + PageSetLSN(). The same defect applies to the invalid-magic reinit branch below (lines ~146-164). This path must WAL-log the metapage init (or reuse RelUndoInitFork's logging) rather than silently dirtying the buffer.


📄 src/backend/access/undo/relundo_page.c (L132-L138)

On this early-return path (another backend fixed the magic while we dropped/reacquired the lock) the function returns without validating meta->version, whereas both the normal path and the post-reinit path fall through to the meta->version != RELUNDO_METAPAGE_VERSION check at line 174. A metapage written by an incompatible version would be silently accepted here. Fall through to the version check instead of returning directly.


📄 src/backend/access/undo/relundo_page.c (L96-L99)

ERRCODE_INDEX_CORRUPTED is inaccurate for an UNDO fork, which is not an index. Use ERRCODE_DATA_CORRUPTED for the metapage corruption/version errors here (and at the version-mismatch ereport below).


📄 src/backend/access/undo/relundo_recovery.c (L360-L366)

Buffer over-read on a corrupt or partially-written UNDO page. The loop guard only checks offset < pd_lower, but the memcpy reads SizeOfRelUndoRecordHeader bytes starting at offset. If a header straddles the boundary (offset < pd_lower but offset + SizeOfRelUndoRecordHeader > pd_lower), or if the on-disk pd_lower itself is corrupt and larger than the page contents area, this reads past valid data. pd_lower is read directly from disk (hdr->pd_lower) and is never validated against the page bounds before it drives this loop. During crash recovery, guarding against a corrupt page is exactly the point of the other defensive checks here, so the header read must be bounded too. Validate pd_lower against the usable page area first, and require offset + SizeOfRelUndoRecordHeader <= pd_lower before the memcpy; likewise verify offset + rhdr.urec_len <= pd_lower so a corrupt urec_len cannot make offsets[] point at a record that runs off the page.

💡 Suggested change

Before:

	/* Collect record offsets and xids in insertion order. */
	offset = SizeOfRelUndoPageHeaderData;
	while (offset < pd_lower && nrecs < maxrecs)
	{
		RelUndoRecordHeader rhdr;

		memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader);

After:

	/*
	 * A corrupt pd_lower must not drive the walk off the page.  It can never
	 * exceed the usable page area.
	 */
	if (pd_lower > SizeOfRelUndoPageHeaderData + RelUndoPageMaxFreeSpace)
	{
		UnlockReleaseBuffer(buf);
		ereport(ERROR,
				(errcode(ERRCODE_DATA_CORRUPTED),
				 errmsg("per-relation UNDO page %u has corrupt pd_lower %u",
						blkno, pd_lower)));
	}

	/* Collect record offsets and xids in insertion order. */
	offset = SizeOfRelUndoPageHeaderData;
	while (offset + SizeOfRelUndoRecordHeader <= pd_lower && nrecs < maxrecs)
	{
		RelUndoRecordHeader rhdr;

		memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader);

📄 src/backend/access/undo/relundo_recovery.c (L319-L321)

Return type is int but the function returns prev, a BlockNumber (uint32, with InvalidBlockNumber == 0xFFFFFFFF), and the caller assigns the result back into a BlockNumber. Routing a uint32 through int relies on implementation-defined signed conversion to round-trip 0xFFFFFFFF; it happens to work today but is misleading and a portability smell. Declare the return type (and the forward declaration on line 61) as BlockNumber to match what the value actually is.

💡 Suggested change

Before:

static int
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)
{

After:

static BlockNumber
RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno)
{

📄 src/backend/access/undo/relundo_recovery.c (L156-L161)

The comment says this mirrors ResetUnloggedRelations, but the reinit.c pattern (ResetUnloggedRelationsInTablespaceDir) emits an ereport(LOG, ...) before returning on ENOENT so the skipped tablespace is visible in the log; this silently returns. Consider matching reinit.c and logging the skipped directory, since a dangling pg_tblspc symlink at recovery time is worth a log line.


📄 src/backend/access/undo/relundo_worker.c (L462-L465)

RelUndoLauncherMain and its entire launcher machinery (per-DB slots, spawn/backoff logic, max_relundo_workers) are never registered or started anywhere in the tree. The only wired-up path is the synchronous StartRelUndoWorker/WaitForPendingRelUndo used by AbortTransaction. This is ~200 lines of dead speculative scaffolding (YAGNI). Either wire the launcher up via RegisterBackgroundWorker and add tests, or drop the launcher, LauncherDbSlot, launcher_spawn_worker, and the max_relundo_workers GUC from this patch. Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L56-L58)

These are labelled "GUC parameters" but are never registered via DefineCustomIntVariable/guc_tables.c, so they are plain globals that cannot be changed by users and never validated for bounds. relundo_worker_naptime is only consumed by the unregistered launcher. Either register them as real GUCs (with min/max) or remove the misleading "GUC" label and the unused knobs. Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L168-L169)

RelUndoRecPtr is typedef uint64 (src/include/access/relundo.h). Casting to (unsigned long) and printing with %lu truncates to 32 bits on Windows/LLP64 and all 32-bit platforms, producing wrong pointer values in the log. Use UINT64_FORMAT. Same defect recurs in process_relundo_work_item's LOG message. Confidence: high.

💡 Suggested change

Before:

	elog(DEBUG1, "Queued per-relation UNDO work for database %u, relation %u (ptr=%lu)",
		 dboid, reloid, (unsigned long) start_urec_ptr);

After:

	elog(DEBUG1, "Queued per-relation UNDO work for database %u, relation %u (ptr=" UINT64_FORMAT ")",
		 dboid, reloid, (uint64) start_urec_ptr);

📄 src/backend/access/undo/relundo_worker.c (L282-L283)

Same uint64 truncation: (unsigned long)/%lu on a RelUndoRecPtr. Use UINT64_FORMAT. Confidence: high.

💡 Suggested change

Before:

	elog(LOG, "Per-relation UNDO worker processing: database %u, relation %u, UNDO ptr %lu",
		 item->dboid, item->reloid, (unsigned long) item->start_urec_ptr);

After:

	elog(LOG, "Per-relation UNDO worker processing: database %u, relation %u, UNDO ptr " UINT64_FORMAT,
		 item->dboid, item->reloid, (uint64) item->start_urec_ptr);

📄 src/backend/access/undo/relundo_worker.c (L313-L320)

This PG_CATCH swallows every error (dropped relation AND genuine WAL/buffer/corruption errors) and only logs "skipping". Control then returns to RelUndoWorkerMain, which unconditionally calls RelUndoQueueMarkComplete() + CommitTransactionCommand(), so the item is removed from the queue with no retry. For a transient/real failure this silently abandons UNDO for an aborted transaction, leaving un-reverted aborted changes on disk -- a data-integrity hazard. Distinguish "relation dropped" (safe to skip via try_table_open/missing_ok) from other errors that must be retried or re-raised, and do not mark complete when apply actually failed. Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L338-L346)

The SIGTERM handler only sets a flag + SetLatch; got_SIGTERM is checked only between loop iterations. RelUndoApplyChain (relundo_apply.c) has no CHECK_FOR_INTERRUPTS in its unbounded backward-walk loop, and StartTransactionCommand/table_open/CommitTransactionCommand are not interruptible via this custom flag. A worker applying a long UNDO chain therefore cannot be terminated promptly on SIGTERM/fast-shutdown. Follow the standard bgworker pattern (die handler + CHECK_FOR_INTERRUPTS in the apply loop) so shutdown is responsive. Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L715-L717)

Stale/contradictory comment. process_relundo_work_item now opens with RowExclusiveLock (and explains why AccessExclusiveLock was dropped), but this comment still asserts the worker "can acquire AccessExclusiveLock on the target relation" and that this is why locks are released first. Reconcile: describe the lock level actually taken now. If the synchronous-rollback protocol genuinely relies on an exclusive lock to safely restore before-images without racing concurrent DML, then RowExclusiveLock is unsafe and must be revisited. Confidence: high (comment drift); moderate (whether RowExclusiveLock is actually safe for the restore).


📄 src/backend/access/undo/relundo_worker.c (L633-L634)

Unit mixing and possible int overflow: relundo_worker_naptime is an int in milliseconds. relundo_worker_naptime * 4 * 1000 is evaluated in int before the TimestampTz cast binds only to the leftmost operand, so with a large naptime the ms->us conversion can overflow int. Use the standard USECS_PER_MSEC helper and force 64-bit arithmetic. (Note this whole branch lives in the currently-unwired launcher.) Confidence: moderate.

💡 Suggested change

Before:

				if (now - db_slots[free_slot].last_spawn_attempt <
					(TimestampTz) relundo_worker_naptime * 4 * 1000)

After:

				if (now - db_slots[free_slot].last_spawn_attempt <
					(TimestampTz) relundo_worker_naptime * 4 * USECS_PER_MSEC)

📄 src/backend/access/undo/undo_bufmgr.c (L102-L107)

This entire file is dead code. None of the eight exported functions (ReadUndoBuffer, ReadUndoBufferExtended, ReleaseUndoBuffer, UnlockReleaseUndoBuffer, MarkUndoBufferDirty, UndoMakeBufferTag, InvalidateUndoBuffers, InvalidateUndoBufferRange) has any caller anywhere in the tree. The companion header undo_bufmgr.h itself documents the actually-shipped design as UNDO-in-WAL: "UNDO records are embedded in the WAL stream... There are no separate UNDO segment files" and "UNDO data is written via pwrite() and read via pread(), bypassing shared_buffers entirely." So this buffer-pool integration is speculative scaffolding for a path that is not wired up. Per YAGNI/minimalism, remove this file (and the unused declarations in undo_bufmgr.h) until a real caller exists.


📄 src/backend/access/undo/undo_bufmgr.c (L234-L242)

InvalidateUndoBufferRange claims to invalidate the range [first_block, last_block], but DropRelationBuffers drops every buffer with blockNum >= firstDelBlock for the fork (confirmed in bufmgr.c: it invalidates any buffer where tag.blockNum >= firstDelBlock[j]). last_block is never used, so this over-invalidates all blocks past last_block. The comment openly admits this. If a caller ever relied on the documented range semantics during partial undo-log truncation, it would silently drop live undo pages (crash-recovery corruption hazard). Since the function has no callers today, the cleanest fix is to delete it; if it must stay, do not accept a last_block parameter whose contract you cannot honor.


📄 src/backend/access/undo/undo_bufmgr.c (L8-L9)

The header block cites external design docs by volatile line number ("ZHeap README, lines 30-40"). Line-number citations drift and become misleading; reference the document by name/section, or better, explain the rationale directly. This is also inconsistent with the shipped design that undo_bufmgr.h documents (UNDO-in-WAL, bypassing shared_buffers), so the whole "routes undo log I/O through PostgreSQL's shared buffer pool" premise here is stale/aspirational relative to what actually ships.


📄 src/backend/access/undo/undo_xlog.c (L267-L270)

Assert()-only bounds check on redo path (high, high confidence). This is the live CLR branch (recno emits UNDO_CLR_HAS_TUPLE). Assert(datalen >= xlrec->tuple_len) is compiled out in production builds, so a truncated/corrupt CLR lets the memcpy(htup, data, xlrec->tuple_len) read past the WAL block buffer during recovery -- a crash/corruption hazard. The UNDO_CLR_HAS_DELTA branch below correctly uses ereport(ERROR) for the same class of check; make this consistent. Additionally, xlrec->target_offset is passed to PageGetItemId() with no check against PageGetMaxOffsetNumber(page), so a corrupt offset reads/writes past the line-pointer array. Validate the offset before PageGetItemId in every branch.


📄 src/backend/access/undo/undo_xlog.c (L237-L279)

Dead CLR branches (high, high confidence). Across the whole change set, only two clr_flags values are ever emitted: UNDO_CLR_LP_DEAD (nbtree_undo.c/hash_undo.c) and UNDO_CLR_HAS_TUPLE (recno_undo.c). No producer ever sets UNDO_CLR_LP_UNUSED, UNDO_CLR_HAS_DELTA, UNDO_CLR_HAS_VISIBILITY, or UNDO_CLR_HOT_RESTORE. These four redo branches (and the xl_undo_apply_hot / xl_undo_apply_visibility structs) are dead code / speculative scaffolding for a feature that isn't wired up. Per YAGNI, remove them (and the corresponding flags/structs) until a producer exists; unreachable redo code is a maintenance and correctness liability that cannot be tested.


📄 src/backend/access/undo/undo_xlog.c (L319-L323)

errcode for corrupt-WAL condition (low, high confidence). These ereport(ERROR) calls detect corrupt/truncated WAL but omit errcode(), defaulting to ERRCODE_INTERNAL_ERROR. PostgreSQL convention for detected data corruption is ERRCODE_DATA_CORRUPTED.

💡 Suggested change

Before:

ereport(ERROR,
		(errmsg("invalid delta CLR at %X/%X: "
				"block data too short (%zu bytes)",
				LSN_FORMAT_ARGS(record->ReadRecPtr),
				datalen)));

After:

										ereport(ERROR,
												(errcode(ERRCODE_DATA_CORRUPTED),
												 errmsg("invalid delta CLR at %X/%X: "
														"block data too short (%zu bytes)",
														LSN_FORMAT_ARGS(record->ReadRecPtr),
														datalen)));

📄 src/backend/access/undo/undo_xlog.c (L161-L167)

Unlocked mutation of shared UndoLogControl during redo (high, high confidence). The normal path advances discard_ptr under LWLockAcquire(&log->lock, LW_EXCLUSIVE) (see UndoLogDiscard in undolog.c), and the checkpointer reads discard_ptr under LW_SHARED (CheckPointUndoLog). Restartpoints run during recovery, so the checkpointer can read these fields concurrently with this redo handler, which writes discard_ptr/oldest_xid with no lock and no barrier -- a data race. Acquire &log->lock before mutating, matching the forward path. The same pattern applies to the plain stores in XLOG_UNDO_ALLOCATE (log_number/discard_ptr/oldest_xid/in_use) and XLOG_UNDO_ROTATE, which mix pg_atomic_write with unlocked plain stores on the same struct.


📄 src/backend/access/undo/undo_xlog.c (L475-L478)

Dead WAL opcode for a brand-new resource manager (medium, high confidence). RM_UNDO_ID is introduced in this same patch, so there is no 'WAL from before the transition' -- the claim of backward compatibility is false and this empty case is speculative scaffolding (YAGNI). If XLOG_UNDO_PAGE_WRITE is never emitted (confirmed: no XLogInsert of it anywhere), remove the opcode define and both this case and the parallel one in undodesc.c, rather than carrying a dead-code handler with a misleading comment.


📄 src/backend/access/undo/undo_xlog.c (L1007-L1009)

Wrong dboid for deferred undo (medium, high confidence). This runs in the startup process, which is not bound to any database, so MyDatabaseId is InvalidOid here. That value flows through ATMAddAborted -> SLogTxnInsert into the ATM entry, and the logical revert worker skips entries where entry_dboid != MyDatabaseId (see logical_revert_worker.c). Deferred transactions recorded with dboid=InvalidOid will therefore never be reverted. The correct database OID must come from the undo record/relation, not the current process; UndoRecordHeader carries urec_reloid but no dboid, so a real source of the dbid is needed before this path can be correct.


📄 src/backend/access/undo/undo_xlog.c (L652-L660)

O(n^2) recovery tracking (medium, medium confidence). This linear scan is repeated in UndoRecoveryTrackBatch (per batch), UndoRecoveryRemoveXid (per commit/abort), and PerformUndoRecovery (per xid). With up to UNDO_RECOVERY_MAX_ENTRIES (1,048,576) distinct in-flight XIDs, batch tracking degrades to O(n^2) across crash recovery of a busy system. UndoRecoveryRemoveXid also only zeroes the xid without compacting, so dead slots keep getting walked. Use dynahash/simplehash keyed by xid (DRY: the tree already provides these) instead of a linear array.


📄 src/backend/access/undo/undoapply.c (L254-L261)

Missing bound check on urec_payload_len. The forward scan validates urec_len >= SizeOfUndoRecordHeader and end - pos >= urec_len, but never checks that urec_payload_len <= urec_len - SizeOfUndoRecordHeader. In the second pass, the payload pointer is record_starts[i] + SizeOfUndoRecordHeader and the length passed to rm_undo is header.urec_payload_len. On a corrupt or maliciously-crafted batch (recovery reads WAL from disk), an inflated urec_payload_len makes the RM callback read past the record body -- and past the batch buffer for the final record -- causing a buffer over-read / crash or garbage application during rollback. This runs on the crash-recovery path where the input is not trustworthy, so a defensive check is warranted (the sibling loop in undo_xlog.c has the same gap).

💡 Suggested change

Before:

				if (hdr.urec_len < SizeOfUndoRecordHeader ||
					(Size) (end - pos) < hdr.urec_len)
				{
					ereport(WARNING,
							(errmsg("UNDO rollback: invalid record size %u in batch at %X/%X",
									hdr.urec_len, LSN_FORMAT_ARGS(batch_lsn))));
					break;
				}

After:

				if (hdr.urec_len < SizeOfUndoRecordHeader ||
					(Size) (end - pos) < hdr.urec_len ||
					hdr.urec_payload_len > hdr.urec_len - SizeOfUndoRecordHeader)
				{
					ereport(WARNING,
							(errmsg("UNDO rollback: invalid record size %u in batch at %X/%X",
									hdr.urec_len, LSN_FORMAT_ARGS(batch_lsn))));
					break;
				}

📄 src/backend/access/undo/undoapply.c (L74-L77)

UndoRecPtr is uint64 (src/include/access/undodefs.h). The tree convention is to format uint64 with UINT64_FORMAT rather than casting to unsigned long long and using %llu. Applies to all three occurrences of (unsigned long long) urec_ptr in this file.

💡 Suggested change

Before:

		ereport(WARNING,
				(errmsg("UNDO rollback: unknown RM ID %u for record at %llu, skipping",
						header->urec_rmid,
						(unsigned long long) urec_ptr)));

After:

		ereport(WARNING,
				(errmsg("UNDO rollback: unknown RM ID %u for record at " UINT64_FORMAT ", skipping",
						header->urec_rmid,
						urec_ptr)));

📄 src/backend/access/undo/undo_xlog.c (L1224-L1228)

Wrong timeline + TOCTOU in recycled-segment guard (medium, medium confidence). Building the segment path from GetWALInsertionTimeLine() for a historical batch_lsn can select the wrong file after a timeline switch (PITR / promoted standby); the LSN's timeline should be derived from the WAL history (e.g. tliOfPointInHistory), not the current insertion TLI. This can yield a false 'segment recycled' skip or read of the wrong record. Also, access(F_OK) is a TOCTOU: the segment can be recycled between this check and XLogReadRecord below. Both feed the silent 'skipping rollback (cleaned by VACUUM)' degraded path, which masks real recovery-correctness problems.


📄 src/backend/access/undo/undo_xlog.c (L1188-L1190)

Stale FIXME describing shipped behavior + acknowledged layering breach (high, high confidence). The AM-agnostic UNDO core hard-codes RM_HEAP_ID and heap opcode->offset knowledge (SizeOfHeapInsert/Delete/Update, XLH_*_HAS_UNDO) and explicitly rejects every other rmid, including RM_RECNO_ID. Since recno is added in this same change set and carries embedded UNDO batches (chain_prev), its rollback during recovery falls into the NULL-return/skip path here -- a functional gap for the feature being shipped, not a deferrable cleanup. Either route recno through an rmgr callback (rm_undo_batch_locate as the FIXME suggests) or handle RM_RECNO_ID explicitly; a large forward-looking FIXME embedded in shipped code that silently drops recno undo is not acceptable.


📄 src/backend/access/undo/undo_xlog.c (L872-L873)

Unbounded palloc churn during recovery + no reader reset on error (medium, medium confidence). PerformUndoRecovery() is called directly from xlogrecovery.c with no per-batch temporary MemoryContext. UndoReadBatchFromWAL palloc's UndoBatchData + payload in CurrentMemoryContext and UndoFreeBatchData is intentionally a no-op, so payload buffers for every record of every loser transaction accumulate for the entire undo phase -- unbounded growth for large in-flight transactions. Wrap the per-batch work in a short-lived MemoryContext that is reset each iteration. Separately, if XLogReadRecord throws, the module-static undo_batch_reader is left inconsistent; UndoResetBatchReader exists but there is no PG_TRY/PG_CATCH in this path that calls it, so the next abort/recovery reuses a stale reader.


📄 src/backend/access/undo/undobuffer.c (L111-L119)

Misaligned memory access hazard (portability hard gate). dest = undo_t2buf.data + undo_t2buf.len is then cast to UndoRecordHeader * and written field-by-field (header->urec_len, header->urec_xid, header->urec_prev, etc.). The first record starts at a MAXALIGN'd offset, but undo_t2buf.len advances by SizeOfUndoRecordHeader + payload_len, and callers pass variable-length payloads (e.g. nbtree passes an index tuple itemsz in UndoBufferAddRecordParts). When payload_len is not a multiple of the alignment, the next record's header lands at an unaligned offset and these direct struct-field stores are unaligned writes. On strict-alignment architectures (ARM64 configs, PPC64, s390x, SPARC) this is UB / SIGBUS, and it trips UBSan everywhere. Either MAXALIGN the per-record advance (and account for it on the read side) or serialize each field via memcpy through an on-stack UndoRecordHeader. The same defect exists in UndoTier2AddRecordParts below.


📄 src/backend/access/undo/undobuffer.c (L89-L94)

Size overflow in the capacity-doubling loop. while (new_cap < undo_t2buf.len + additional) new_cap *= 2; has no guard: for a large additional (a big TOAST/index-tuple payload), new_cap *= 2 can wrap to 0 or a value smaller than the needed size, after which repalloc allocates too little and the subsequent memcpy overruns the heap. Also undo_t2buf.len + additional itself can overflow. Add an explicit overflow check (e.g. via add_size/repalloc_huge semantics or an ereport(ERROR) when the required size exceeds a sane cap).


📄 src/backend/access/undo/undobuffer.c (L192-L194)

The per-backend static state (active, xid, chain_prev, nrecords, buffered data) is only cleared by UndoBufferEnd, which is called solely from recno_finish_bulk_insert on the success path. If an ereport(ERROR) fires between recno_begin_bulk_insert (UndoBufferBegin) and finish, the static buffer stays active with a stale xid/chain_prev and any accumulated records. On the next operation, UndoBufferBegin bails out early when relid matches (return; above), so those stale records keep the wrong urec_xid and a wrong chain_prev, and a later flush emits an XLOG_UNDO_BATCH attributed to a dead xid -- a rollback-correctness/data-integrity hazard. There is no AtEOXact/AtAbort hook resetting undo_t2buf (verified: AtAbort_XactUndo/AtCommit_XactUndo do not touch it). Register a transaction-end/abort reset callback so the buffer is always cleared regardless of the error path.


📄 src/backend/access/undo/undobuffer.c (L206-L209)

rel is unused in UndoBufferEnd (the function flushes/ends whatever is active and logs undo_t2buf.relid, the OLD relid). Worse, in UndoBufferBegin above, when a different relation is active the code calls UndoBufferEnd(rel) passing the NEW relation rather than the old one; it only happens to work because the parameter is ignored. This is an API footgun (POLA violation). Either drop the Relation parameter from UndoBufferEnd or validate it against undo_t2buf.relid.


📄 src/backend/access/undo/undobuffer.c (L272-L274)

UndoBufferTakePayload, UndoBufferReset (below), and UndoBufferHasPendingData have no callers anywhere in the tree (verified by search). These implement the module's stated primary purpose per the file header -- embedding the buffer directly inside the AM's WAL record to avoid a separate XLOG_UNDO_BATCH. Since nothing wires up the take-payload/reset path, only the overflow flush is ever exercised, and the file-header description is aspirational rather than what the code does. Per YAGNI/minimalism, either wire up the embed path (and its callers) or remove this dead API and correct the header comment to describe only the overflow-flush behavior that actually ships.


📄 src/backend/access/undo/undobuffer.c (L325-L325)

persistence is hard-coded to UNDOPERSISTENCE_PERMANENT, and the per-record urec_flags never encode persistence either. The recno AM activates this buffer unconditionally (recno_begin_bulk_insert does not gate on RelationNeedsWAL), and recno supports unlogged relations, so an unlogged/temp relation reaching this flush would emit a permanent XLOG_UNDO_BATCH -- WAL-logging data that must not be WAL-logged. Derive persistence from the relation instead of hard-coding it, and skip/adjust the flush for non-permanent relations.


📄 src/backend/access/undo/undobuffer.c (L60-L64)

Designated-initializer indentation is inconsistent (first field at one tab level, subsequent fields double-indented). This will not survive pgindent cleanly.


📄 src/backend/access/undo/undolog.c (L43-L45)

These are declared and commented as "GUC parameters", and postgresql.conf.sample (lines 943-950) advertises them as settable (e.g. #undo_retention_time = 300s, #undo_batch_size_kb = 256). However none of them are registered in guc_tables.c (no &undo_retention_time / DefineCustomIntVariable anywhere in the tree). Consequences: (1) putting any of these in postgresql.conf yields an "unrecognized configuration parameter" error at startup; (2) runtime consumers such as undobuffer.c (undo_batch_size_kb * 1024) can never observe a user override -- only these hardcoded initializers. Also the defaults here disagree with the sample: undo_retention_time = 60000 (60s) here vs 300s in the sample, and undo_worker_naptime = 10000 (10s) here vs 60s. Either register these in guc_tables.c with the correct units (GUC_UNIT_MS / GUC_UNIT_KB / GUC_UNIT_MB) and reconcile the defaults with the sample, or drop the "GUC" framing and the sample entries.


📄 src/backend/access/undo/undolog.c (L180-L185)

Inconsistent locking on discard_ptr. Every other access to this field takes log->lock (UndoLogDiscard, CheckPointUndoLog, UndoLogGetDiscardPtr, and the undo_xlog.c redo path), but here both log->in_use and log->discard_ptr are read without holding log->lock. discard_ptr is a plain uint64 (UndoRecPtr), so on 32-bit platforms a concurrent writer under the lock can cause a torn read here, and even on 64-bit there is no ordering guarantee vs in_use. If a stale/torn value ever feeds a WAL-retention decision this is a data-loss hazard. Take log->lock LW_SHARED around the read (as UndoLogGetDiscardPtr already does). Separately, this exported function currently has no callers in the tree -- if it is not needed yet, drop it (YAGNI) rather than shipping an unlocked, unused reader.


📄 src/backend/access/undo/undolog.c (L475-L477)

UndoLogPath has no callers in the tree and, per this file's own header comment, UNDO data now lives in WAL with no base/undo/ segment files. Constructing a path to files that no longer exist is a footgun: any future caller would get a plausible-looking but bogus path. Remove this dead function (and its header declaration) rather than shipping misleading legacy surface.


📄 src/backend/access/undo/undoinsert.c (L108-L109)

uset->buffer_size is a Size and uset->nrecords is an int, but both are stored into xlrec.total_len/xlrec.nrecords as uint32 with an unchecked cast. UndoRecordEnsureCapacity() grows the buffer without any bound tied to UINT32_MAX, so a sufficiently large transaction can silently truncate total_len here. On replay, UndoReadBatchFromWAL() copies only total_len bytes of payload, so a truncated length silently drops UNDO records -- a data-corruption footgun on the rollback/recovery path rather than a hard error. Add an explicit guard (e.g. if (uset->buffer_size > UINT32_MAX) elog(ERROR, ...)) or a documented invariant that callers flush before exceeding this bound.


📄 src/backend/access/undo/undoinsert.c (L55-L59)

These two functions are no-op stubs whose only purpose is to keep unmigrated callers compiling. Comments describe an in-progress migration ("kept for callers that haven't been updated yet", "for now") rather than what the code does. Per PostgreSQL's minimalism/YAGNI discipline, a commit-ready patch should not carry transitional dead-code scaffolding: either the callers are migrated and these (and their prototypes in undorecord.h) are removed, or the change is WIP. This drift also affects the file header block ("Legacy support: UndoWalBatchFlush/Reset are kept as no-ops..."), which narrates the transition instead of documenting current behavior.


📄 src/backend/access/undo/undorecord.c (L223-L227)

Unbounded capacity doubling can overflow Size. Both uset->buffer_size + additional and the new_capacity *= 2 loop can wrap around SIZE_MAX for a very large additional. If the wrapped new_capacity ends up smaller than the (also-wrapped) required size, the loop exits with an undersized capacity, MemoryContextAlloc allocates a too-small buffer, and the caller's subsequent payload copy overruns it (heap buffer overflow). Add an explicit overflow/MaxAllocSize guard before allocating. Current callers pass page-bounded sizes, so this is not reachable today, but the API is exported and takes Size.

💡 Suggested change

Before:

		Size		new_capacity = uset->buffer_capacity * 2;
		char	   *newbuf;

		while (new_capacity < uset->buffer_size + additional)
			new_capacity *= 2;

After:

		Size		new_capacity = uset->buffer_capacity * 2;
		char	   *newbuf;

		if (additional > MaxAllocSize - uset->buffer_size)
			elog(ERROR, "UNDO record too large");

		while (new_capacity < uset->buffer_size + additional)
			new_capacity *= 2;

📄 src/backend/access/undo/undorecord.c (L316-L323)

record_size and payload_len are silently truncated to uint32 when stored in urec_len/urec_payload_len. If a caller ever passes a payload >= 4GB, the on-disk length is corrupted and deserialization/replay reads the wrong number of bytes. Since these are exported entry points taking Size, add a defensive bounds check (e.g. reject payloads exceeding what fits in the uint32 fields / MaxAllocSize) rather than relying on all callers staying under 4GB.


📄 src/backend/access/undo/undorecord.c (L53-L55)

UndoRecordSerialize and UndoRecordDeserialize have no callers anywhere in the tree (all record assembly goes through UndoRecordAddPayload/UndoRecordAddPayloadParts, which build the header in place). This is dead/speculative API surface (YAGNI); per pgsql-hackers minimalism it should be dropped before submission. If kept, note that UndoRecordDeserialize takes no source-length argument: it memcpy's SizeOfUndoRecordHeader from src and returns a zero-copy payload pointer trusting urec_payload_len, so feeding it a truncated/corrupt on-disk record is an unbounded out-of-bounds read.


📄 src/backend/access/undo/undorecord.c (L263-L264)

Comment drift. UndoRecordSetResetCache() is only invoked from AtAbort_XactUndo(); the commit path (AtCommit_XactUndo) deliberately does NOT reset the cache so the recycled context can be reused across transactions (it is reparented to TopMemoryContext, which is process-lifetime). This comment claiming it runs on "commit or abort" is inaccurate and misdescribes the lifetime model. The same stale claim appears in the file header comment ("cleaned up at transaction end by UndoRecordSetResetCache()"). Fix both to reflect the abort-only reset.


📄 src/backend/access/undo/undostats.c (L39-L41)

These three functions are exposed only via PG_FUNCTION_INFO_V1; there are no matching entries in src/include/catalog/pg_proc.dat (confirmed: a search for these names and for 'undo' in pg_proc.dat returns nothing). Without catalog entries the functions are unreachable from SQL, and if entries are later added they must exactly match the hand-built 6-column TupleDescs (types/order) or the SRF will error at runtime. Add pg_proc.dat entries with developer-range OIDs (8000-9999); do not hand-edit the generated _d.h/fmgr files. The file header comment claiming they are 'registered in pg_proc.dat' is currently false.


📄 src/backend/access/undo/undostats.c (L184-L184)

log_number is declared uint32 (UndoLogControl.log_number in undolog.h), but it is returned here via Int32GetDatum, which truncates/misrepresents values above INT32_MAX (log_number would appear negative). Use Int64GetDatum with an INT8 catalog column, or otherwise handle the full uint32 range.


📄 src/backend/access/undo/undostats.c (L352-L359)

This DISCARDABLE->FREE branch is a near-verbatim copy of the discard worker's Phase 2 (undoworker.c lines 327-351), but it omits the counter decrement the worker performs when freeing a slot: pg_atomic_fetch_sub_u32(&UndoWorkerShmem->sealed_log_count, 1). After a forced discard here, sealed_log_count is left over-counted, so the worker's later reads of sealed_count (undoworker.c:569) are wrong. Either call the same decrement, or better, factor the worker's Phase 1/Phase 2 discard logic into a shared helper and call it from both places (DRY) rather than duplicating it.


📄 src/backend/access/undo/undostats.c (L289-L291)

This whole force-rotate path is effectively dead in the current UNDO-in-WAL mode: UndoLogSealAndRotate() and UndoLogDeleteSegmentFile() are no-ops (undolog.c:454-463), and undolog.h:76-78 states the ACTIVE->SEALED->DISCARDABLE rotation 'no longer occurs'. So force_rotate does nothing, segment deletion does nothing, and the SEALED->DISCARDABLE Phase 2 transitions are unreachable in practice. This is speculative scaffolding (YAGNI); consider removing the rotation/free machinery or the whole function until it is actually wired to real behavior.


📄 src/backend/access/undo/undostats.c (L115-L122)

GetUndoBufferStats() unconditionally writes zeros, and there is no other producer of UndoBufferStat anywhere in the tree. Consequently pg_stat_get_undo_buffers() always returns num_buffers=0 and hit_ratio=0.0 for every column. A SQL function that can only ever return zeros is misleading dead scaffolding. Either remove GetUndoBufferStats/pg_stat_get_undo_buffers, or (if the pg_buffercache direction is intended) drop the fake counters entirely rather than shipping an all-zero observability surface.


📄 src/backend/access/undo/undoworker.c (L125-L131)

Signal-safety violation. shutdown_requested is a plain bool in shared memory, not volatile sig_atomic_t. Writing it from an async-signal handler and reading it in the main loop (while (!UndoWorkerShmem->shutdown_requested)) without a barrier is not async-signal-safe; the read may be hoisted/cached and the shutdown request missed. The tree already provides SignalHandlerForShutdownRequest() (postmaster/interrupt.h) backed by the volatile sig_atomic_t ShutdownRequestPending flag; reuse it instead of a custom handler that writes shared memory from signal context.


📄 src/backend/access/undo/undoworker.c (L129-L130)

NULL-deref hazard: this dereferences UndoWorkerShmem unconditionally, unlike WakeUndoDiscardWorker() a few lines above which guards if (UndoWorkerShmem == NULL) return;. If SIGTERM is delivered before UndoWorkerShmemInit() has run (or in a process where it is NULL), this crashes. This concern disappears if you switch to the standard SignalHandlerForShutdownRequest.


📄 src/backend/access/undo/undoworker.c (L262-L266)

Comment/behavior mismatch and over-aggressive discard. The comment claims the retained pointer is computed from "1. The oldest active transaction 2. The retention time setting", but undo_retention_time (declared, defaulted to 60s) is never consulted here -- the code simply sets oldest_undo_ptr = insert_ptr (the log's current end) and calls UndoLogDiscard(), advancing discard_ptr to the very tip. For any log whose oldest_xid merely precedes the global oldest active xid, this discards the entire log up to the insert point, ignoring retention/PITR. Fix the logic to honor undo_retention_time, and correct the aspirational comment.


📄 src/backend/access/undo/undoworker.c (L395-L397)

Portability: retained_mb is uint64 but is printed with %lu after a cast to unsigned long. On LLP64 platforms (Windows/MSVC) unsigned long is 32-bit, truncating the 64-bit MB value. Use UINT64_FORMAT for the uint64 argument (and drop the cast).


📄 src/backend/access/undo/undoworker.c (L242-L245)

Data race on the dead-backend detection. The slot LSN is read at one instant and the pid check happens at a later instant, with no lock/barrier. ProcNumbers are reused: after the slot's original owner exits, a new backend may take over ProcNumber i and register its own valid batch LSN. If the pid read observes the transient window before the new backend sets its pid, the worker clears a live backend's just-registered LSN, advancing the WAL discard horizon past WAL still needed for that backend's rollback -> unrecoverable rollback/data loss. This needs proper synchronization (e.g. hold ProcArrayLock, or re-validate the slot after the liveness check).


📄 src/backend/access/undo/undoworker.c (L631-L633)

Inconsistent synchronization for shutdown_requested: this writer takes UndoWorkerShmem->lock (LW_EXCLUSIVE), while undo_worker_sigterm() writes the same field with no lock (it cannot take an LWLock from signal context) and the main loop reads it locklessly. Three different disciplines for one field is a race; a plain bool cannot be torn but the lock here provides no guarantee against the lockless writers/readers. Consolidate on the standard ShutdownRequestPending pattern to make access consistent.


📄 src/backend/access/undo/undoworker.c (L615-L616)

Use bounded string ops per PG convention. bgw_name/bgw_type just below use snprintf; these two use sprintf. While the literals fit, mixing sprintf with snprintf is inconsistent and a footgun -- use strlcpy/snprintf here too (e.g. snprintf(worker.bgw_library_name, BGW_MAXLEN, "postgres")).


📄 src/backend/commands/tablecmds.c (L999-L1000)

Unrelated cosmetic change. This break; is added to the last case of the switch (immediately followed by }), so it is functionally redundant -- control flow exits the switch identically without it. More importantly, this hunk is unrelated to the stated purpose of this change set (recno/undo storage infrastructure). Unrelated stylistic churn on otherwise-untouched code inflates the diff and is a common rejection reason on pgsql-hackers. Drop this hunk to keep the patch minimal. (high confidence)

💡 Suggested change

Before:

			(void) heap_reloptions(relkind, reloptions, true);
+			break;

After:

			(void) heap_reloptions(relkind, reloptions, true);

📄 src/backend/access/undo/xactundo.c (L947-L948)

Memory-context leak and corruption on the abort path. When UndoValidateBatchLSN(perm_lsn) fails, goto inline_undo_done; jumps to the label at the bottom of this block, which is placed AFTER MemoryContextSwitchTo(old_ctx); MemoryContextDelete(undo_ctx);. On this path those two calls are skipped: undo_ctx (the "Inline UNDO Apply" AllocSetContext just created above) is never deleted (leak), and CurrentMemoryContext is left pointing at undo_ctx while the rest of AtAbort_XactUndo() (ATMAddAborted, elog, record-set frees) executes. Since this runs during the non-failable abort path, that is a serious correctness hazard. Restore the context and delete undo_ctx before jumping, e.g. do the switch/delete before the goto or move the label after them and skip only the apply.


📄 src/backend/access/undo/xactundo.c (L304-L305)

nestingLevel is written from two incompatible identifier domains. Here (and in PrepareXactUndoDataParts) it is set from GetCurrentTransactionNestLevel() (the transaction nest depth: 1, 2, 3...), while XactUndo_SubXactCallback sets s->nestingLevel = mySubid (a monotonically increasing SubTransactionId). AtSubCommit_XactUndo/AtSubAbort_XactUndo compare (int) cur->nestingLevel != level where level == mySubid. If a slot's nestingLevel was last written by this Prepare path (nest depth), the callback-driven pop finds a mismatch and returns without decrementing subxact_depth, drifting the depth invariant and potentially leaving state that never collapses. Use a single, consistent identifier domain (SubTransactionId) in both paths.


📄 src/backend/access/undo/xactundo.c (L1327-L1330)

OOM ordering hazard: subxact_depth is incremented before EnsureSubxactStackCapacity(). If the stack must grow, repalloc() can ereport(ERROR) on OOM; the longjmp then leaves subxact_depth incremented while the stack was not grown, so CURRENT_SUBXACT() (which indexes subxact_stack[subxact_depth]) reads out of bounds on the next access. Grow the stack first (or increment only after a successful ensure).


📄 src/backend/access/undo/xactundo.c (L151-L155)

Comment/behavior mismatch: this says entries "live in CurTransactionContext", but RegisterPerRelUndo() allocates via MemoryContextAlloc(TopTransactionContext, ...). These two contexts have different lifetimes (per-subtransaction vs. whole top-level transaction). The comment should be corrected to TopTransactionContext, and the divergence matters: entries registered in a subtransaction outlive that subtransaction while the head pointer is only reset at top-level end (ResetXactUndo), which interacts with the subtransaction-abort handling below.


📄 src/backend/access/undo/xactundo.c (L1143-L1150)

AtSubAbort_XactUndo rolls back the subtransaction's cluster-wide (last_batch_lsn) UNDO, but does NOT roll back per-relation UNDO (relundo_list, RECNO) generated by this subtransaction. Only ApplyPerRelUndo() at top-level abort walks relundo_list, and AtSubCommit merges just start_location, not relundo entries. This is the exact hazard the code guards against for cluster-wide UNDO: a subtransaction can ROLLBACK TO SAVEPOINT while the parent commits, in which case the per-relation RECNO in-place changes are never reverted and survive as if committed. The docstring's claim that this "physically restores tuples inserted/modified by the aborting subtransaction" is therefore not fully honored. Please handle relundo_list scoping/rollback at subtransaction abort.


📄 src/backend/access/undo/xactundo.c (L999-L999)

Whitespace hygiene: three consecutive blank lines here will not pass git diff --check/pgindent and are flagged as churn on -hackers. Collapse to a single blank line.


📄 src/backend/commands/dbcommands.c (L476-L479)

Routing the non-redo path through FileOpsMkdir/FileOpsCreate makes CreateDirAndVersionFile emit two extra FILEOPS WAL records (XLOG_FILEOPS_MKDIR + XLOG_FILEOPS_CREATE) in addition to the existing XLOG_DBASE_CREATE_WAL_LOG that CreateDatabaseUsingWalLog already logs (line 576) and replays via dbase_redo -> CreateDirAndVersionFile(isRedo=true). Two problems:

  1. Redundant WAL/cleanup. The directory and PG_VERSION file are now WAL-logged twice, and the FileOpsCreate(register_delete=true) delete-on-abort / FileOpsMkdir rmdir-on-abort duplicate the cleanup already performed by createdb_failure_callback -> remove_dbtablespaces() (line 1720) for the WAL_LOG strategy. This is extra work for no new guarantee on this path.

  2. Latent ordering dependency. The FILEOPS XLOG_FILEOPS_CREATE redo (fileops.c) creates an EMPTY PG_VERSION file; the version content is written here by a plain write() that is NOT FILEOPS-WAL-logged. On a standby/crash the file only gets its 'NN\n' content when the later XLOG_DBASE_CREATE_WAL_LOG record replays (O_TRUNC + write). Correctness thus relies on that record always following the FILEOPS record. Consider not routing the WAL_LOG-strategy dir/version-file creation through FILEOPS at all (the existing dbase WAL record + failure callback already cover crash- and abort-cleanup), keeping FILEOPS for the genuinely new FILE_COPY abort-cleanup done via copydir().


📄 src/backend/commands/dbcommands.c (L507-L507)

Casting write()'s ssize_t result to (int) before comparing against nbytes (a size_t) is an unnecessary change from the upstream idiom (which compared write() directly to nbytes). On a -1 return the int -1 is promoted to a large unsigned value that happens to differ from nbytes, so error detection still works for this tiny write, but the cast is fragile signed/unsigned churn that adds nothing. Drop the cast to keep the minimal-diff idiom.

💡 Suggested change

Before:

		if ((int) write(fd, buf, nbytes) != nbytes)

After:

		if (write(fd, buf, nbytes) != nbytes)

📄 src/backend/commands/dbcommands.c (L505-L526)

The isRedo and non-isRedo branches now duplicate the entire write/fsync/fsync_fname/CloseTransientFile tail almost verbatim (lines 505-525 vs 539-559); the only real difference is the open step (FileOpsCreate vs OpenTransientFile). This copy-paste is a maintenance hazard: a future fix to one branch (e.g., the fsync elevel or wait-event handling) can silently miss the other. Factor the common tail so only the fd-acquisition differs, e.g. obtain fd in the branch, then run one shared write/fsync/close sequence.

Comment thread .github/ocr/litellm.yaml
@@ -0,0 +1,41 @@
# LiteLLM proxy config — bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-ASCII em-dash character (U+2014) here. The project's contribution standards require ASCII-only in source and diffs (no smart quotes, em-dashes, or ellipsis characters). Replace with an ASCII hyphen/dash.

Suggested change
# LiteLLM proxy config bridges Open Code Review (OpenAI protocol) to AWS Bedrock.
# LiteLLM proxy config - bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Comment thread .github/ocr/litellm.yaml
aws_region_name: os.environ/AWS_REGION

# "High effort" review. Claude Opus 4.8 on Bedrock uses *adaptive* thinking
# controlled by output_config.effort. Set it DIRECTLY here — NOT via

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-ASCII em-dash character (U+2014) here. Per the ASCII-only rule for source and diffs, replace it with an ASCII hyphen/dash.

Suggested change
# controlled by output_config.effort. Set it DIRECTLY here NOT via
# controlled by output_config.effort. Set it DIRECTLY here - NOT via

Comment thread .github/ocr/pg-history.py
def main():
commits, stat, files = pr_context()
if not commits and not files:
open(OUT, "w").write("") # nothing to do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File handles are opened and never explicitly closed across all five write sites (also lines 168, 172, 212, 220). open(...).write(...) relies on the returned handle being garbage-collected to flush and close; on CPython this usually works, but it is not guaranteed on other interpreters or if the interpreter exits before the object is collected, risking a truncated/empty PG_HISTORY_OUT that the downstream workflow step reads. Use a context manager (or a small helper) so the buffer is deterministically flushed.

e.g.:
def _write_out(text):
with open(OUT, "w") as f:
f.write(text)
and call _write_out(...) at each site.

Suggested change
open(OUT, "w").write("") # nothing to do
with open(OUT, "w") as f:
f.write("") # nothing to do

Comment on lines +39 to +43
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent-failure footgun. subprocess.run here does not check the AWS CLI return code, and capture_output=True hides stderr. If aws bedrock list-inference-profiles fails (expired/invalid OIDC creds, wrong region, throttling, IAM denial), out.stdout is empty, ids becomes [], and the step happily prints newer=false, succeeds, and surfaces nothing. Since this is a notify-only mechanism, a broken check can pass silently for weeks, defeating its entire purpose. Fail loudly when the CLI errors: check out.returncode (or use check=True) and print the captured stderr / emit ::error:: before exiting non-zero, so a failing lookup shows up as a red run rather than a false "nothing newer".

Suggested change
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
if out.returncode != 0:
print(f"::error::aws bedrock list-inference-profiles failed (rc={out.returncode}): {out.stderr.strip()}", file=sys.stderr)
raise SystemExit(1)
ids = json.loads(out.stdout or "[]")

Comment on lines +65 to +67
const best = '${{ steps.check.outputs.best_id }}';
const bestVer = '${{ steps.check.outputs.best_ver }}';
const curVer = '${{ steps.check.outputs.cur_ver }}';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsafe expression interpolation into an inline script. Embedding ${{ steps.check.outputs.* }} directly inside single-quoted JS string literals is the same anti-pattern as interpolating ${{ }} into a run: block: any value containing a single quote or newline breaks out of the string literal (best of all const best = '...';), turning a fragile string into a script-injection / syntax-break vector. best_id comes from the Bedrock API response rather than the local repo, so it should be treated as untrusted input rather than assumed safe. Pass these values via an env: block on the step and read them with process.env inside the script, matching the safe pattern used elsewhere.

Suggested change
const best = '${{ steps.check.outputs.best_id }}';
const bestVer = '${{ steps.check.outputs.best_ver }}';
const curVer = '${{ steps.check.outputs.cur_ver }}';
const best = process.env.BEST_ID;
const bestVer = process.env.BEST_VER;
const curVer = process.env.CUR_VER;

Comment on lines +13 to +14
rawpage.o \
recnofuncs.o

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new pageinspect 1.14 upgrade adds three SQL functions (recno_page_items, recno_page_stats, recno_tuple_infomask_flags) backed by recnofuncs.o, but no regression test covers them: the REGRESS list here has no recno entry, meson.build's regress list has none either, and there is no sql/recno.sql under contrib/pageinspect. Per PostgreSQL standards a user-visible change (new inspection functions) must ship with regression tests exercising edge/error paths, not just be installable. Add a recno regress test and list it here (and in meson.build to keep them in sync). (high confidence)

COMMIT;

-- STEP 6: Check UNDO log statistics
SELECT * FROM pg_stat_get_undo_logs();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example calls pg_stat_get_undo_logs() (and pg_stat_get_undo_buffers() below), but neither function is registered in the SQL catalog. Their C implementations in src/backend/access/undo/undostats.c carry PG_FUNCTION_INFO_V1, yet there is no pg_proc.dat entry (or CREATE FUNCTION) for them anywhere in the change. As written, these steps fail at runtime with ERROR: function pg_stat_get_undo_logs() does not exist, so the example is broken. Register the functions in pg_proc.dat with OIDs in the 8000-9999 range (the SRF also needs proretset = 't' and its output columns declared). (high confidence)

DELETE FROM customer_data WHERE id = 2;

-- STEP 5: Commit the transaction
COMMIT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no BEGIN earlier in the script, so STEP 2-4 already autocommitted as individual statements. This bare COMMIT runs outside a transaction block and emits WARNING: there is no transaction in progress. Either wrap STEP 2-4 in an explicit BEGIN; ... COMMIT; to make the example demonstrate a single transaction, or drop this COMMIT. (high confidence)

--
-- recno_page_items()
--
CREATE FUNCTION recno_page_items(IN page bytea,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three new SQL-callable functions ship without regression tests or documentation. There is no contrib/pageinspect/sql/ + expected/ test exercising recno_page_items/recno_page_stats/recno_tuple_infomask_flags against a real recno page, and no doc/src/sgml/pageinspect.sgml entry describing them. Per PostgreSQL contribution standards a user-visible feature must include regression/TAP tests (covering edge/error paths, e.g. corrupt/short pages and the NULL-return branches in the C code) and docs; without them, breakage of the on-disk page layout will not be caught by CI. This is WIP, not commit-ready. (high confidence)

--
CREATE FUNCTION recno_page_stats(IN page bytea,
OUT lsn pg_lsn,
OUT tli smallint,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recno_page_stats exposes a tli smallint column, but the C implementation (recnofuncs.c) always sets it to 0 with the comment "tli - no longer stored in page header". Exposing a column that is unconditionally 0 is dead output and a footgun (POLA) for anyone comparing against bt_page_stats/hash_page_stats. Drop the tli OUT parameter rather than emitting a constant. (moderate confidence)

gburd added 2 commits July 15, 2026 17:05
…n/xmax visibility

PostgreSQL's heap appends a new tuple version on every UPDATE and
abandons the old one to VACUUM.  For update-heavy workloads on narrow
hot rows -- counters, queue heads, running aggregates, time-series
tail writes -- the resulting version churn dominates buffer traffic,
bloats relations, and keeps autovacuum permanently behind.

RECNO is a table access method (amname "recno", RM_RECNO_ID) that
updates tuples in place.  An UPDATE overwrites the committed bytes on
the main fork; the prior image is preserved in the per-relation UNDO
fork (RELUNDO_FORKNUM) so a ROLLBACK restores it and a concurrent
snapshot reader can still reconstruct the version it is entitled to
see.  Heap is untouched; a relation opts in with USING recno.

Visibility is driven by a hybrid logical clock rather than per-tuple
xmin/xmax scans.  Each tuple header carries an HLC commit stamp; a
snapshot captures an HLC horizon and a tuple is visible when its
stamp precedes the horizon and its writer has committed.  Transient
state (uncommitted / deleted / updated) lives in header flag bits
that UNDO clears on rollback through the RelUndoClearTransientFlags
hook, and in-place delta reversal runs through RelUndoReverseDelta;
RECNO installs both at init via RecnoRelUndoInstallHooks() so the
UNDO core never sees a RECNO tuple layout.

Same-page updates rewrite the slot directly; updates that no longer
fit spill to an overflow chain with optional per-attribute dictionary
compression (LZ4/ZSTD, ANALYZE-refreshed).  A sparsemap-backed dirty
map and a partitioned secondary log (sLog) serve before-images to old
readers without a separate version-store cache; free space and
visibility are tracked in RECNO-private FSM and VM forks.

WAL coverage is complete: insert, in-place and out-of-place update,
delete, tuple-lock, VACUUM, dict writes, and overflow all log redo
records under RM_RECNO_ID with matching recovery routines, and crash
recovery cooperates with the UNDO driver to roll back in-place loser
transactions.  Index AMs over a RECNO table force a heap recheck on
index-only scans and tolerate stale index entries left by in-place
updates until before-image reclamation retires them.

The commit also wires RECNO into the supporting infrastructure:
pg_am / pg_proc catalog entries, the recno rmgr description routine,
pageinspect coverage (pageinspect 1.13 -> 1.14), the in-tree
documentation chapter, and isolation, regression, and crash-recovery
test suites exercising MVCC correctness, overflow, dictionary
compression, and dual-mode UNDO rollback.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OCR found 258 issue(s).

  • 25 inline, 233 in summary (inline capped at 25)
  • ⚠️ 212 warning(s) during review

📄 src/backend/access/table/tableam.c

Dead/wasted WAL payload: RecnoXLogDefrag registers the mappings array (sizeof(RecnoOffsetMapping) * nmappings) but the record also forces a full-page image via REGBUF_FORCE_IMAGE below. With a forced FPI, redo always takes BLK_RESTORED and recno_xlog_defrag_redo never consumes mappings (it calls PageRepairFragmentation, which itself is on the dead BLK_NEEDS_REDO path). The mapping payload is therefore never read on replay and only bloats every DEFRAG record. Either drop the FPI and replay from mappings, or drop the mappings payload and rely on the FPI; registering both is redundant.


📄 src/backend/access/transam/xact.c

The doc-block title names RecnoXLogMaybeAppendLogicalTuple, but the function immediately below is RecnoXLogPrepareLogicalImage. The comment body also describes returning true and appending to the in-progress WAL record, whereas this function actually prepares an image buffer (registration happens later in RecnoXLogRegisterLogicalImage). Update the header comment to match the current function name and behavior.

💡 Suggested change

Before:

 * RecnoXLogMaybeAppendLogicalTuple
 *		Append a heap-format image of `rtup` to the in-progress WAL record
 *		if `rel` is logically logged.  Returns true and sets
 *		RECNO_WAL_LOGICAL_TUPLE in `*flags` if the image was appended.

After:

 * RecnoXLogPrepareLogicalImage
 *		Prepare a heap-format image of `rtup` in `img` if `rel` is logically
 *		logged.  The image is registered later by
 *		RecnoXLogRegisterLogicalImage(), which sets RECNO_WAL_LOGICAL_TUPLE.

📄 src/backend/access/transam/xact.c

The single-page invariant for this batch is guarded only by Assert(), which is compiled out in production builds. If a future caller ever routes a batch that violates the invariant into this path, the memcpy loop above will overrun the fixed-size stack scratch buffer before any Assert would fire, a classic stack buffer overflow during WAL insertion. Add a hard runtime guard that fails safely instead of relying on Assert for a memory-safety bound.

💡 Suggested change

Before:

	Assert((scratchptr - scratch.data) < BLCKSZ);

After:

	if ((scratchptr - scratch.data) >= BLCKSZ)
		elog(ERROR, "recno multi-insert batch exceeds one page (%ld bytes)",
			 (long) (scratchptr - scratch.data));
	Assert((scratchptr - scratch.data) < BLCKSZ);

📄 src/backend/access/transam/xlogrecovery.c

Stale doc-block name: this comment block titles the function RecnoXLogMaybeAppendLogicalTuple, but the function defined immediately below is RecnoXLogPrepareLogicalImage. The comment also describes a return value ("Returns true and sets ...") that does not match the actual void signature. Comments must describe the current code; update the title and the return-value wording to match RecnoXLogPrepareLogicalImage.

💡 Suggested change

Before:

 * RecnoXLogMaybeAppendLogicalTuple

After:

 * RecnoXLogPrepareLogicalImage

📄 src/backend/access/undo/relundo_page.c

TOCTOU / data-loss race against the lock-skipping reserve path. Pass 1 classifies pages as discardable under a per-page SHARE lock that is released before the splice, and in the whole-chain case (new_tail_blkno invalid) Pass 3 only EXCLUSIVE-locks the old chain tail (runtail_buf) -- the head page is never re-locked or re-validated. Meanwhile RelUndoReserve Tier 1/Tier 2 (relundo.c:445-536) reads head_blkno from its process-local cache / a SHARE-locked metapage, releases the metapage lock, then EXCLUSIVE-locks the data page and appends a fresh live record. A reserver that read the head block before discard ran, but blocks on the head page content lock, can acquire it in the window after Pass 1 releases its SHARE lock and write a live record onto a page discard then splices onto the free list -- corrupting the free-list/data-chain threading and losing live UNDO. The header comment's claim that the metapage EXCLUSIVE lock serializes all mutation is false for these two reserve tiers (they intentionally skip that lock). The reserver's post-lock re-check only tests free space (relundo.c:471,519), not that the page is still the live head, so it cannot detect the splice. Discard runs from VACUUM (ShareUpdateExclusiveLock) and from RelUndoMaybeVacuum in DML hot paths, neither of which excludes the RowExclusiveLock reservers hold, so the race is reachable.


📄 src/backend/access/undo/relundo_page.c

The three chain-walking while-loops (Pass 1, Pass 2, and the free-list count) have no CHECK_FOR_INTERRUPTS(). On a long UNDO chain this is an unbounded, non-interruptible loop that runs while holding the metapage EXCLUSIVE lock, blocking cancellation and prolonging contention on the cluster-wide-shared metapage. Add an interrupt check inside each loop.


📄 examples/04-transactional-fileops.sql (L30-L30)

Inaccurate claim (high confidence). This comment is placed after DROP TABLE but before COMMIT, asserting the XLOG_FILEOPS_DELETE record has already been written. It hasn't. In fileops.c the DELETE record is only emitted inside the commit-time pending-op execution path guarded by if (isCommit && XLogIsNeeded()) (around fileops.c:1672-1681); deletion (and its WAL record) is deferred until commit. This also matches doc/src/sgml/fileops.sgml which states deletion "is deferred until transaction commit." As written, the example teaches users incorrect on-the-wire timing. Move/reword so the DELETE record is described as being written at COMMIT, not at DROP TABLE time.

💡 Suggested change

Before:

-- A XLOG_FILEOPS_DELETE WAL record has been written.

After:

-- The XLOG_FILEOPS_DELETE WAL record is written at COMMIT (deletion is deferred).

📄 contrib/pageinspect/recnofuncs.c (L144-L144)

The hdr_size <= lp_len conjunct is dead code. The outer guard already requires lp_len >= MAXALIGN(sizeof(RecnoTupleHeader)) (i.e. lp_len >= hdr_size), and tuple_data_len > 0 (i.e. lp_len - hdr_size > 0) already implies hdr_size < lp_len. So this second condition is always true here. Drop it to keep the check minimal and match the intent.

💡 Suggested change

Before:

				if (tuple_data_len > 0 && hdr_size <= lp_len)

After:

				if (tuple_data_len > 0)

📄 examples/05-undo-monitoring.sql (L6-L6)

These three SQL calls will fail at runtime with ERROR: function ... does not exist. The C functions pg_stat_get_undo_logs, pg_stat_get_undo_buffers, and pg_undo_force_discard are only defined via PG_FUNCTION_INFO_V1 in src/backend/access/undo/undostats.c; there are no corresponding entries in src/include/catalog/pg_proc.dat (nor any CREATE FUNCTION ... LANGUAGE C in an extension). A PG_FUNCTION_INFO_V1 definition alone does not make a function SQL-callable. The comment in undostats.c claims they are "registered in pg_proc.dat", but they are not. Add pg_proc.dat entries (OIDs in the 8000-9999 range) before shipping an example that invokes them. (high confidence)


📄 examples/05-undo-monitoring.sql (L13-L13)

pg_undo_force_discard is defined to take one boolean argument (force_rotate, read via PG_GETARG_BOOL(0) in undostats.c), but here it is called with zero arguments. Even after the missing pg_proc.dat entry is added, this call will fail unless the catalog declares a default for force_rotate. Pass an explicit argument, e.g. SELECT pg_undo_force_discard(false);. Also note this function requires membership in pg_maintain; a monitoring example should call it out so a non-privileged reader is not surprised by a permission error. (high confidence)

💡 Suggested change

Before:

SELECT pg_undo_force_discard();

After:

SELECT pg_undo_force_discard(false);

📄 src/backend/access/Makefile (L28-L30)

This ifdef USE_RECNO guard is inconsistent with the meson build and misrepresents actual behavior. meson.build (lines 1598-1605) compiles subdir('recno') unconditionally and explicitly documents that RECNO "cannot be conditionally disabled: guc_parameters.dat and pg_proc.dat reference recno symbols unconditionally, so a build with USE_RECNO unset fails to link." src/Makefile.global.in hardcodes USE_RECNO = 1. So this conditional never actually gates anything (USE_RECNO is always defined), yet it implies recno is optional -- a footgun that suggests a disabled build is supported when it in fact fails to link. To stay in sync with meson, build recno unconditionally like the other subdirs. High confidence.

Note: the same pattern exists in src/backend/access/rmgrdesc/Makefile (lines 39-41), which is outside this file's scope but should be reconciled the same way.

💡 Suggested change

Before:

ifdef USE_RECNO
SUBDIRS += recno
endif

After:

	recno

📄 src/backend/access/common/reloptions.c (L2183-L2188)

This change is purely cosmetic and functionally inert. The original return default_reloptions(reloptions, validate, RELOPT_KIND_HEAP); and this rewrite (assign to the pre-existing rdopts, then return (bytea *) rdopts;) produce byte-for-byte identical behavior — same argument, same cast, same result. No new relkind or heap-option handling is introduced. This is unnecessary churn on untouched code and adds a redundant assignment plus a scoped block for no reason. Per patch-hygiene discipline (minimal diff), revert this hunk to the original single-line return. Confidence: high.

💡 Suggested change

Before:

			{
				rdopts = (StdRdOptions *)
					default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

				return (bytea *) rdopts;
			}

After:

			return default_reloptions(reloptions, validate, RELOPT_KIND_HEAP);

📄 src/backend/access/common/index_prune.c (L115-L117)

Dead scaffolding: no caller exists anywhere in the tree for IndexPruneNotifyDiscard (verified via full-repo search). The header also declares the callbacks it dispatches to (_bt_prune_by_undo_counter, hash_prune_by_undo_counter, gin/gist/spg variants) but none of them are defined in the codebase, so nothing can ever register a handler either. Per YAGNI/minimalism, this full-scan registry and notification path is unused speculative infrastructure and should not be in the patch until it is actually wired in. (high confidence)


📄 src/backend/access/common/index_prune.c (L146-L147)

Modifying index pages ('mark dead entries') under only AccessShareLock is incorrect: index_open(AccessShareLock) does not permit page writes, and the callback contract (see header: 'Mark qualifying entries as LP_DEAD') is a write path that needs a proper buffer content lock and, for shared on-disk state, WAL logging for crash/replica consistency. Since this runs from the UNDO discard worker while it may already hold locks on heaprel, RelationGetIndexList + index_open here also risks catalog access and lock-ordering (deadlock) hazards. (high confidence)


📄 src/backend/access/common/index_prune.c (L162-L165)

Portability: uint64 values are printed with %lu after casting to (unsigned long). On LLP64 (Windows) and 32-bit platforms unsigned long is 32-bit, truncating the value. Use UINT64_FORMAT and pass the uint64 directly per project convention. Same issue in the DEBUG1 message below and in the DEBUG2 message in IndexPruneNotifyTargeted. (high confidence)

💡 Suggested change

Before:

				elog(DEBUG2, "index %s: marked %lu entries as dead for counter %u",
					 RelationGetRelationName(indexrel),
					 (unsigned long) entries_pruned,
					 discard_counter);

After:

				elog(DEBUG2, "index %s: marked " UINT64_FORMAT " entries as dead for counter %u",
					 RelationGetRelationName(indexrel),
					 entries_pruned,
					 discard_counter);

📄 src/backend/access/common/index_prune.c (L187-L191)

Process-global mutable state without synchronization. prune_stats is updated with an unsynchronized read-modify-write on every notify call, and the registry arrays are mutated by the register functions. If notification runs in more than one process (or registration is not strictly single-threaded startup) this is a data race. Also note these statics are per-backend, so if the stats/registry are intended to be shared across processes they silently will not be. IndexPruneGetStats returns a per-process pointer accordingly. (moderate confidence)


📄 src/backend/access/common/index_prune.c (L72-L76)

elog(ERROR) does not return, so this 'return;' is unreachable dead code. Remove it. Same in IndexPruneRegisterTargetedHandler. Separately, raising ERROR at AM registration time (a fixed cap of MAX_INDEX_HANDLERS=16) can destabilize process init; consider a compile-time StaticAssert on the number of AMs instead. (moderate confidence)

💡 Suggested change

Before:

		elog(ERROR, "too many index pruning handlers registered");
		return;
	}

	handlers[num_handlers].indexam_oid = indexam_oid;

After:

		elog(ERROR, "too many index pruning handlers registered");
	}

	handlers[num_handlers].indexam_oid = indexam_oid;

📄 src/backend/access/common/index_prune.c (L295-L300)

The comment claims targets are sorted by index_oid ('sort targets by index_oid, then process each group'), but the code never sorts; it only groups consecutive runs of equal index_oid. If a caller passes targets unsorted, entries for the same index scattered in the array cause the same index to be opened/closed and its callback invoked repeatedly per fragmented run. Either sort targets first (e.g. qsort by index_oid) or fix the comment to state the pre-sorted precondition and enforce/assert it. (moderate confidence)


📄 src/backend/access/common/index_prune.c (L139-L141)

Long loops with no CHECK_FOR_INTERRUPTS. Each iteration opens an index and invokes a callback that scans/modifies index pages; without CHECK_FOR_INTERRUPTS the operation cannot respond to cancel/shutdown in a background worker. Add CHECK_FOR_INTERRUPTS() inside the loop body. Same for the while loop in IndexPruneNotifyTargeted. (moderate confidence)


📄 src/backend/access/hash/hashinsert.c (L248-L249)

This guard adds && UndoBufferIsActive(heapRel), which is wrong on two counts.

  1. Missing UNDO / silent corruption (high confidence): HashUndoLogInsert() already branches internally on UndoBufferIsActive() (hash_undo.c lines 110-133): the piggyback path when active, and a standalone UndoRecordSetCreate/Insert/Free path otherwise. By gating the call itself on UndoBufferIsActive(), that else branch becomes dead code, and when the heap AM supports UNDO but no active Tier-2 buffer exists for this relation, the hash insert writes NO undo record at all. On abort the entry is never marked LP_DEAD. Compare the sibling nbtree call site (nbtinsert.c:1255), which gates only on heaprel != NULL && RelationAmSupportsUndo(heaprel) and lets NbtreeUndoLogInsert() choose the path.

  2. Missing NULL guard (moderate confidence): unlike nbtinsert.c:1255, this omits heapRel != NULL. RelationAmSupportsUndo() dereferences rel->rd_tableam without checking rel for NULL. The nbtree comment states heaprel is NULL during index builds/recovery; align the guard to avoid a potential NULL deref.

Suggested: drop the redundant UndoBufferIsActive term and add the NULL check, matching nbtree.

💡 Suggested change

Before:

	if (RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

After:

	if (heapRel != NULL && RelationAmSupportsUndo(heapRel))
		HashUndoLogInsert(rel, heapRel, buf, itup_off);

📄 src/backend/access/heap/heapam_handler.c (L150-L155)

These doc comments describe UNDO write-buffer activation and flushing ("activates the UNDO write buffer", "Flushes any pending UNDO records"), but both function bodies are empty and the heap AM does not use UNDO (am_supports_undo = false). Per PG comment discipline, comments must describe what the code does now, not aspirational/other-AM behavior. Either drop these no-op callbacks entirely for heap AM (see below) or, if kept, the comments must state they are intentional no-ops because heap does not use UNDO.


📄 src/backend/access/heap/heapam_handler.c (L156-L159)

heapam_begin_bulk_insert and heapam_finish_bulk_insert are empty no-ops. Registering them adds a needless indirect call on the DML hot path (table_begin_bulk_insert / table_finish_bulk_insert already NULL-guard the callback, so an unregistered callback is a no-op with zero overhead). Notably upstream heap AM deliberately does not register finish_bulk_insert ("In-tree access methods ceased to use this"); reintroducing an empty heapam_finish_bulk_insert is dead code that reverses that. Recommend removing both empty functions and the two registrations below; only the RECNO AM that actually uses UNDO needs them.


📄 src/backend/access/heap/heapam_handler.c (L2720-L2721)

Registering these empty callbacks is unnecessary. Since the wrappers NULL-check the pointers, leaving them unset behaves identically at zero cost. Drop these two lines (and the empty functions) to keep the heap AM diff minimal and avoid a no-op on the insert-completion hot path.


📄 src/backend/access/heap/heapam_handler.c (L2682-L2682)

Stray comment referencing the RECNO AM sits in the heap AM file and describes another AM's behavior. This is noise in the heapam_handler.c diff; either remove it or fold the explanation into the am_supports_undo field doc in tableam.h. Comments should describe this file's code, not a future/other AM.


📄 src/backend/access/heap/heapam.c (L42-L43)

These three new includes are unused by any change in this patch. access/xact.h symbols (e.g. GetCurrentTransactionId, TransactionIdIsCurrentTransactionId) are only referenced by pre-existing code at unchanged lines, so the file already compiled without directly including it. Nothing in the file uses access/xlog.h symbols beyond XLogInsert/XLogBeginInsert, which come from access/xloginsert.h (already included). utils/memutils.h is entirely unused (no MemoryContext/AllocSet*/MemoryContextSwitchTo in this file). Adding unused includes with no supporting code change is gratuitous churn and will draw a rejection on -hackers. Drop all three unless a real change in this file requires them. (high confidence)


📄 src/backend/access/heap/heapam.c (L58-L58)

utils/memutils.h is unused: it is not referenced anywhere in this file (the only occurrence is this include line). Remove it. (high confidence)


📄 src/backend/access/heap/heapam.c (L63-L64)

Whitespace-only churn on untouched code. Removing this pre-existing blank line (and the identical one near the ExtractReplicaIdentity declaration) is unrelated to any functional change in this patch. Per PostgreSQL minimal-diff discipline, do not reformat lines the change does not require; revert this hunk. (high confidence)


📄 src/backend/access/heap/heapam.c (L2140-L2141)

Gratuitous blank line inserted after XLogBeginInsert(). This (and the identical insertion in the delete path) is pure formatting churn with no functional purpose, splitting a tightly-coupled XLogBeginInsert() / XLogRegisterData() sequence. Revert to keep the diff minimal. (high confidence)

💡 Suggested change

Before:

 		XLogBeginInsert();
+
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

After:

 		XLogBeginInsert();
 		XLogRegisterData(&xlrec, SizeOfHeapInsert);

📄 src/backend/access/hash/hash_undo.c (L202-L209)

Marking hdr.offset LP_DEAD without verifying the item still belongs to this aborted transaction is a data-loss bug. Hash bucket splits (_hash_splitbucket) and page compaction (_hash_squeezebucket) relocate/renumber live tuples, so by abort time hdr.offset on hdr.blkno may point at an unrelated, committed index entry. Both _hash_kill_items (hashutil.c) and the sibling nbtree undo path (nbtree_undo.c lines 513-536) deliberately re-identify the entry by matching the heap TID rather than trusting a stored offset, precisely to avoid "silently dropping committed rows." This code should record the inserted tuple's heap TID and confirm ItemPointerEquals(&ituple->t_tid, want_tid) before calling ItemIdMarkDead. (high confidence)


📄 src/backend/access/hash/hash_undo.c (L208-L211)

When an item is marked LP_DEAD here, the page's LH_PAGE_HAS_DEAD_TUPLES opaque flag is not set. Every other site that marks a hash item dead sets it (hashutil.c:623), and _hash_doinsert only triggers dead-item reclamation via H_HAS_DEAD_TUPLES(pageopaque) (hashinsert.c:140). Without setting the flag, the space is never reclaimed by the insert path, undermining the stated "zero-VACUUM on abort" guarantee. The flag must also be set consistently on the redo side (undo_xlog.c XLOG_UNDO_APPLY_RECORD / UNDO_CLR_LP_DEAD) or primary and standby diverge. (high confidence)


📄 src/backend/access/hash/hash_undo.c (L208-L214)

Returning UNDO_APPLY_SUCCESS when hdr.offset > PageGetMaxOffsetNumber(page) (or when ItemIdIsNormal(lp) is false) reports success while the insertion was not actually undone, leaving a live index entry pointing at a rolled-back heap tuple. Additionally, when the offset is in range but ItemIdIsNormal(lp) is false, MarkBufferDirty and a CLR WAL record are still emitted for a no-op change. Guard the CLR emission on an actual modification and return a status that reflects whether the undo was applied. (medium confidence)


📄 src/backend/access/hash/hash_undo.c (L18-L19)

The header comment asserts "All hooks are gated by RelationAmSupportsUndo(heapRel)", but this file performs no such gating (the gating lives in the caller). This is a WHAT/aspirational comment describing behavior outside this file; it can drift. Trim it to describe what this module actually does. (low confidence)


📄 src/backend/access/heap/pruneheap.c (L21-L25)

These three includes are unnecessary and appear to be leftover churn. This diff adds only include directives with no code change, and none of these headers is required to compile the file:

  • access/parallel.h: no Parallel*/IsParallelWorker/IsInParallelMode symbol is referenced anywhere in this file.
  • access/xact.h: no transaction/xact symbol (e.g. GetCurrentTransactionId, MyXactFlags, RegisterXactCallback) is referenced in this file.
  • access/visibilitymapdefs.h: the VISIBILITYMAP_* constants used here are already pulled in transitively by the pre-existing access/visibilitymap.h (which #includes visibilitymapdefs.h), and those usages predate this change.

Adding unused/redundant includes is exactly the kind of unrelated change that draws rejection on -hackers (minimal-diff discipline). Drop all three lines. (high confidence)

💡 Suggested change

Before:

+#include "access/parallel.h"
 #include "access/transam.h"
 #include "access/visibilitymap.h"
+#include "access/visibilitymapdefs.h"
+#include "access/xact.h"

After:

 #include "access/transam.h"
 #include "access/visibilitymap.h"

📄 src/backend/access/recno/DESIGN (L171-L171)

Minor accuracy nit: there is no page-header field literally named pd_commit_ts. The physical field is pd_commit_ts_and_flags (commit-ts packed with page flags under RECNO_PAGE_TS_MASK), and the monotonicity is applied through the accessors, i.e. RecnoPageSetCommitTs(phdr, Max(RecnoPageGetCommitTs(phdr), xlrec->commit_ts)) in recno_xlog.c. The described semantics are correct, but a reviewer grepping for pd_commit_ts will not find the assignment. Consider writing the rule in terms of the actual accessors so the doc points at real code.

💡 Suggested change

Before:

  pd_commit_ts = Max(pd_commit_ts, xlrec->commit_ts)

After:

  RecnoPageSetCommitTs(phdr, Max(RecnoPageGetCommitTs(phdr), xlrec->commit_ts))

📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

Both the split-path and no-split-path guards fire for internal (non-leaf) inserts as well as leaf inserts. For isleaf == false, NbtreeUndoLogInsert emits an NBTREE_UNDO_INSERT_UPPER record whose apply handler (nbtree_undo.c, NBTREE_UNDO_INSERT_UPPER case) is a documented no-op returning UNDO_APPLY_SKIPPED. So every internal-page insert on an UNDO-supporting table writes an extra UNDO record (and, in the standalone path, an extra WAL record) that can never be usefully applied. Add && isleaf to the guard to avoid the wasted work; the recursion via _bt_insert_parent will still log leaf inserts correctly. This applies to both new call sites.

💡 Suggested change

Before:

		if (heaprel != NULL && RelationAmSupportsUndo(heaprel))
			NbtreeUndoLogInsert(rel, heaprel, buf,
								postingoff == 0 ? itup : origitup,
								itemsz, newitemoff, isleaf);

After:

		if (heaprel != NULL && isleaf && RelationAmSupportsUndo(heaprel))
			NbtreeUndoLogInsert(rel, heaprel, buf,
								postingoff == 0 ? itup : origitup,
								itemsz, newitemoff, isleaf);

📄 src/backend/access/nbtree/nbtinsert.c (L1255-L1258)

Style inconsistency between the two near-identical UNDO blocks: this split-path guard wraps a single statement without braces, while the no-split branch below wraps the identical single call in braces. Pick one style (PostgreSQL commonly omits braces for a single-statement if) so the two blocks read consistently.


📄 src/backend/access/nbtree/nbtree_undo.c (L88-L88)

The SizeOf* payload-size macros are not fully parenthesized. PostgreSQL's SizeOf* macros are conventionally wrapped in parentheses (e.g. SizeOfUndoApply in undo_xlog.h is (offsetof(...) + sizeof(uint32))). As written, SizeOfNbtreeUndoInsertLeaf expands to offsetof(...) + sizeof(Size), which is safe for the current + uses but silently misparses under any higher-precedence operator (e.g. 2 * SizeOfNbtreeUndoInsertLeaf) or when preceded by subtraction. Wrap all four macros in parentheses.

💡 Suggested change

Before:

#define SizeOfNbtreeUndoInsertLeaf	offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size)

After:

#define SizeOfNbtreeUndoInsertLeaf	(offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size))

📄 src/backend/access/nbtree/nbtree_undo.c (L84-L86)

itup_sz is declared as Size (i.e. size_t) but this struct is serialized verbatim into the undo log via memcpy and read back on a possibly different backend/build. Size is 4 bytes on 32-bit and 8 bytes on 64-bit platforms, so an undo log written on one width cannot be replayed on another, and even within one build the differing struct padding is fragile for a persisted format. On-disk/WAL header fields must use fixed-width types; an index tuple size fits in uint16 (bounded by BLCKSZ). Same applies to the identical Size itup_sz in NbtreeUndoInsertUpper.


📄 src/backend/access/nbtree/nbtree_undo.c (L121-L127)

Dead code / speculative scaffolding (YAGNI). NbtreeUndoLogDedup has no caller anywhere in the tree (verified by tree-wide search), and no code produces the NBTREE_UNDO_DEDUP subtype. Consequently the entire DEDUP machinery -- the NbtreeUndoDedup struct, SizeOfNbtreeUndoDedup, the NbtreeUndoLogDedup function, and the NBTREE_UNDO_DEDUP apply branch -- is unreachable. Likewise NBTREE_UNDO_INSERT_POST, NBTREE_UNDO_SPLIT_L/R, NBTREE_UNDO_NEWROOT, NBTREE_UNDO_DELETE, NBTREE_UNDO_VACUUM are only ever handled as UNDO_APPLY_SKIPPED no-ops with no producer, and NbtreeUndoDelete/SizeOfNbtreeUndoDelete are defined but never used. Only INSERT_LEAF and INSERT_UPPER are actually logged. Remove the unused subtypes, structs, size macros, and the DEDUP function/apply case, or wire up the producers; shipping unreachable infrastructure is a rejection reason on -hackers.


📄 src/backend/access/nbtree/nbtree_undo.c (L635-L638)

Missing bounds check before restoring the full page image. The branch validates payload_len < SizeOfNbtreeUndoDedup but never checks that the saved image actually fits: neither SizeOfNbtreeUndoDedup + hdr.page_len <= payload_len (guards against reading past the payload) nor hdr.page_len <= BLCKSZ (guards against overflowing the shared buffer page, which is exactly BLCKSZ bytes). A corrupted or oversized hdr.page_len in the undo log therefore causes an out-of-bounds read of payload and an out-of-bounds write into the shared buffer. Validate both before the memcpy.

💡 Suggested change

Before:

				/* Restore the full pre-dedup page image */
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

After:

				if (hdr.page_len > BLCKSZ ||
					payload_len < SizeOfNbtreeUndoDedup + hdr.page_len)
				{
					UnlockReleaseBuffer(buffer);
					relation_close(indexrel, RowExclusiveLock);
					return UNDO_APPLY_ERROR;
				}

				/* Restore the full pre-dedup page image */
				memcpy(page,
					   payload + SizeOfNbtreeUndoDedup,
					   hdr.page_len);

📄 src/backend/access/nbtree/nbtree_undo.c (L690-L696)

Comment/identity drift: the file header lists INSERT_POST and DELETE as functional UNDO subtypes ('DELETE: re-insert deleted tuples'), but the apply code treats both as UNDO_APPLY_SKIPPED no-ops. The 'For now, skip and let the entries be re-created' wording is aspirational/for-now language that describes intended-but-unshipped behavior. Comments must describe what the code does now; reword the header and this block to state the entries are not re-inserted (left to the reverted heap op / VACUUM), and drop the 'for now'.


📄 src/backend/access/recno/Makefile (L35-L35)

Missing newline at end of file. The diff shows \ No newline at end of file after the final include line. All PostgreSQL backend Makefiles (e.g. the sibling access/nbtree/Makefile) terminate this line with a trailing newline, and POSIX defines a text file line as ending in a newline. Add the missing newline so the file matches tree convention and passes git diff --check. (high confidence)

💡 Suggested change

Before:

+include $(top_srcdir)/src/backend/common.mk

After:

+include $(top_srcdir)/src/backend/common.mk


📄 src/backend/access/recno/README (L496-L497)

This cross-reference is inaccurate on both the file and the line range. heap_toast_insert_or_update() is defined in src/backend/access/heap/heaptoast.c, not heapam.c. Lines 3993-4050 of heapam.c are the HOT-update decision and critical-section prologue inside heap_update(), which is unrelated to the TOAST buffer pin-reuse pattern being described. Hard-coded line numbers in a subsystem that is itself modified by this change drift immediately; cite the function by name (and correct file) rather than a fragile line range.

💡 Suggested change

Before:

See src/backend/access/heap/heapam.c:3993-4050 for the analogous pattern
in heap's TOAST handling (heap_toast_insert_or_update).

After:

See heap_toast_insert_or_update() in src/backend/access/heap/heaptoast.c
for the analogous pattern in heap's TOAST handling.

📄 src/backend/access/recno/README (L509-L509)

The cited line number is wrong: the assertion Assert(entry->data.lockmode == BUFFER_LOCK_UNLOCK) is at bufmgr.c:6068, not 5892 (line 5892 does not contain this assertion). Since bufmgr.c is modified by this very change, a hard-coded line number is guaranteed to drift; reference the function/assertion by name instead of a line number.

💡 Suggested change

Before:

in bufmgr.c:5892 enforces this.

After:

in bufmgr.c (LockBuffer's held-lock bookkeeping) enforces this.

📄 src/backend/access/recno/recno_dict.c (L392-L393)

Integer-overflow in this bounds check enables a heap buffer overflow on corrupt on-disk data. copied and ph->bytes_on_page are both uint32 read from disk; if a corrupted page reports bytes_on_page large enough that copied + ph->bytes_on_page wraps around 2^32, the check passes and the following memcpy(result + copied, payload, ph->bytes_on_page) writes far past the entry.length-sized result allocation (and over-reads the page). Rewrite the comparison so it cannot overflow (the loop guard guarantees copied < entry.length), and additionally reject a per-page count that exceeds the page payload capacity to avoid over-reading the source page:

💡 Suggested change

Before:

		if (copied + ph->bytes_on_page > entry.length)
		{

After:

		if (ph->bytes_on_page > (uint32) RecnoDictPagePayload ||
			ph->bytes_on_page > entry.length - copied)
		{

📄 src/backend/access/recno/recno_dict.c (L379-L382)

This chain traversal can loop forever on corrupt data. It trusts on-disk ph->next_blkno and ph->bytes_on_page with no cap on iterations and no CHECK_FOR_INTERRUPTS. If a corrupted page has bytes_on_page == 0 and a next_blkno that cycles (e.g. points to itself), copied never advances, blkno never becomes InvalidBlockNumber, and the loop spins indefinitely (unkillable, holding a buffer pin/lock each iteration). Add an iteration bound (a blob spans at most entry.length / 1 + 1 pages; practically smgrnblocks(RECNO_DICT_FORKNUM)) and a CHECK_FOR_INTERRUPTS, and reject blkno values >= the fork's block count so a bad pointer errors out cleanly instead of relying on ReadBufferExtended's EOF behavior.


📄 src/backend/access/recno/recno_dict.c (L130-L135)

Silent data-loss footgun: any caller reaching a metapage with a mismatched magic (including read-only callers, since recno_dict_read/count/get_active all funnel here) re-initializes it in place -- resetting count=0, next_dictid=1, active_dictid=0 -- and WAL-logs the wipe. If the fork already holds valid dictionary data but the block-0 magic is wrong for any reason (partial corruption, a torn page, or an on-disk metapage written by a different RECNO_DICT_METAPAGE_VERSION), this permanently discards every directory entry, after which every previously-written compressed datum fails recno_dict_read with "not found". Auto-reinitializing an existing fork on magic mismatch is unsafe; a mismatch on a non-empty fork should ereport(ERROR) (corruption) rather than silently overwrite. Only the genuinely-empty (freshly created) case should initialize.


📄 src/backend/access/recno/recno_dict.c (L114-L115)

meta->version is written on init (RECNO_DICT_METAPAGE_VERSION == 2, implying a prior format existed) but never validated on read. A metapage persisted by a different version with a matching magic will be accepted and its RecnoDictDirEntry array reinterpreted under the current layout, risking misreads. Validate meta->version alongside the magic check and error on an unrecognized version.


📄 src/backend/access/recno/recno_compress.c (L877-L878)

Endianness bug: on-disk data corruption on big-endian platforms (s390x, PPC64BE). Confirmed via recno_tuple.c that compressed attribute datums are persisted on disk and read back (line 694), so this delta encoding crosses architecture boundaries. memcpy(out_ptr + 1, &uval, bytes_needed) copies the low bytes_needed bytes of uval in host byte order. On little-endian this copies the low-order bytes (intended); on big-endian it copies the high-order bytes. RecnoDecompressDelta then reads them back into the low bytes of a zero-initialized uint64, reconstructing a wrong value. All four memcpy sites in RecnoCompressDelta (int32/int64, positive/negative) and their counterparts in RecnoDecompressDelta have this defect. Serialize with an explicit little-endian byte loop (or store the full width) so the format is architecture-independent.


📄 src/backend/access/recno/recno_compress.c (L1020-L1023)

Unbounded read / stack overflow on the decompression path. bytes_stored = input[1] is attacker/corruption-controlled (0-255) and copied via memcpy(&abs_val, input + 2, bytes_stored) into a uint64 that is only 8 bytes; any value > 8 overflows the stack variable abs_val, and there is no length parameter to validate input + 2 + bytes_stored stays within the compressed buffer. Since compressed datums are read from disk (recno_tuple.c line 694), a corrupt page triggers out-of-bounds reads and stack corruption. Validate bytes_stored <= sizeof(uint64) (and that it fits the recorded comp_size) before the memcpy, erroring out otherwise.


📄 src/backend/access/recno/recno_compress.c (L968-L970)

Length truncation corrupts NUMERIC values larger than 255 bytes. The 0x89 fallback stores the original length in a single byte (input_size & 0xFF), but this path is reached for NUMERIC (varlena) values, and delta is only attempted once orig_size >= RECNO_MIN_COMPRESS_SIZE. A NUMERIC of, say, 300 bytes stores length 44 (300 & 0xFF); RecnoDecompressDelta then reads only 44 bytes via input[1], silently truncating the value. Store the length with enough width (e.g. a 4-byte length) or reject values that don't fit.


📄 src/backend/access/recno/recno_compress.c (L377-L377)

Silent dictionary-id truncation. dictid is uint32 (recno_dict_get_active / recno_dict_append return uint32, next_dictid is a monotonic uint32 that is never reset), but the header field dict_id is uint16. (uint16) dictid truncates any id > 65535, so decompression would resolve to the wrong (or a nonexistent) trained dictionary and either error out or corrupt reads. Either widen RecnoCompressionHeader.dict_id to uint32, or explicitly guard/error when dictid exceeds UINT16_MAX before writing the header.


📄 src/backend/access/recno/recno_compress.c (L1354-L1357)

Relation reference leak on the ERROR path. relation_open(relid, NoLock) is followed by recno_dict_read() and pallocs, all of which can ereport(ERROR) (recno_dict_read errors on unknown id or blob overrun). If they throw before relation_close, the relation reference is leaked because there is no PG_TRY/PG_FINALLY. Wrap the open/read/close in PG_TRY/PG_FINALLY, or close before any operation that can throw. Separately, opening with NoLock assumes the caller already holds a lock on relid; on the decompression path this is not guaranteed - confirm the locking contract.


📄 src/backend/access/recno/recno_compress.c (L1429-L1429)

Cached ZSTD CDict bakes in the level from the first call. ZSTD_createCDict(td->blob, td->blob_len, level) compiles the dictionary at the level of whichever compress call built it first, then it is cached on td and reused. In the auto path the level is derived per value from entropy (RecnoHeuristicZstdLevel), so later values at a different level silently reuse the first-seen level, defeating the heuristic. Either key the compiled CDict by level, use ZSTD_CCtx_setParameter/refCDict with the per-call level, or drop the per-value level variability for dict compression.


📄 src/backend/access/recno/recno_compress.c (L1207-L1210)

Non-portable, host-endian dictionary id written to persistent data, plus a documented footgun. *((int *) output) = dict_id writes a raw host-endian int; combined with RecnoDecompressDictionary reading it back the same way, this breaks across architectures if persisted. More importantly, the header comment asserts safety only because RecnoChooseCompressionType never auto-selects RECNO_COMP_DICTIONARY, but RecnoCompressAttribute exposes an explicit comp_type parameter and RECNO_COMP_DICTIONARY is a public enum - any caller passing it persists data that a restarted/other backend cannot decompress (silent data loss). Consider removing the backend-local dictionary codec from the compress path entirely, or hard-erroring if it is requested for anything that reaches disk.


📄 src/backend/access/recno/recno_compress.c (L1630-L1635)

Stale comment: it describes a relid parameter and per-relation reset behavior that does not exist - the function takes void and unconditionally resets everything. Fix the comment to match the actual signature/behavior.


📄 src/backend/access/recno/recno_compress.c (L286-L289)

Latent out-of-bounds read for fixed-length types on the forced-codec path. orig_size is set from get_typlen(typid) for fixed-width types, yet the entropy sampling and RecnoChooseCompressionType unconditionally call VARDATA_ANY(DatumGetPointer(value)), which is invalid for a non-varlena Datum. The current production caller (recno_tuple.c) only passes attlen == -1 varlena attributes, so this is not reachable today, but a fixed-width type forced to ZSTD/LZ4 via recno_compression_algorithm would read past the datum. Guard the VARDATA_ANY() uses to varlena types only.


📄 src/backend/access/recno/recno_dict.c (L318-L319)

RecnoDictMeta does not fit in a single page, so a full directory overruns the metapage buffer. RecnoDictDirEntry is 32 bytes (uint64 trained_ts forces 8-byte alignment: dictid+codec+_pad+start_blkno+length+orig_sample_size = 24, trained_ts @24, size 32). With RECNO_DICT_MAX_DIRECTORY == 256, RecnoDictMeta is offsetof(entries)=24 + 256*32 = 8216 bytes, but PageGetContents only yields BLCKSZ - MAXALIGN(SizeOfPageHeaderData) = 8168 bytes on an 8K page. Writing meta->entries[meta->count] as count approaches 256 (here) writes past the end of the 8K page buffer -- heap/buffer corruption -- and the same overrun is WAL-shipped via the forced FPI. There is no StaticAssertDecl guarding this (RecnoDictMetaSize is defined but unused). Add StaticAssertDecl(RecnoDictMetaSize <= BLCKSZ - MAXALIGN(SizeOfPageHeaderData), ...) and either shrink RECNO_DICT_MAX_DIRECTORY or the entry, and bound-check meta->count against what actually fits.


📄 src/backend/access/recno/recno_diff.c (L211-L220)

Out-of-bounds read on the redo/rollback path from corrupt on-disk data. This loop walks the diff by advancing ptr (ptr += SizeOfRecnoDiffSegment; ptr += seg->ins_len) and dereferences seg->offset/del_len/ins_len plus reads ins_len old bytes, but never validates that these reads stay within the diff record's own extent. The only bounds checks are against new_len and old_total_len (the reconstruction target), not the size of the diff buffer itself.

The two callers only guard truncation of the header: recno_undo.c checks image_len < sizeof(RecnoDiffRecord) and recno_pvs.c checks payload_size < SizeOfRelUndoDeltaUpdatePayload + sizeof(RecnoDiffRecord). Neither validates diff->total_size, the sum of segment sizes, or ndiffs against the actual bytes read from the UNDO fork / WAL. ndiffs and ins_len are uint16 values read straight from disk, so a corrupt UNDO page or WAL record with an oversized ndiffs/ins_len makes ptr run far past the allocated/read buffer — a crash or information-disclosure hazard during crash recovery or rollback. The function signature cannot currently be fixed by the caller because it doesn't accept the buffer length.

Recommend passing the trusted buffer length into RecnoApplyDiffReverse and bounds-checking ptr before every segment-header and old-bytes read (e.g. if (ptr + SizeOfRecnoDiffSegment > diff_end) return false; and if (ptr + seg->ins_len > diff_end) return false;), and use ereport(WARNING, errcode(ERRCODE_DATA_CORRUPTED), ...) rather than DEBUG1 for these corruption conditions. (high confidence)


📄 src/backend/access/recno/recno_dirtymap.c (L158-L162)

RecnoDirtyMapShmemInit() is an exported (declared in both recno.h and recno_dirtymap.h) no-op whose body only carries a comment. It has zero callers anywhere in the tree — shmem setup goes entirely through RecnoDirtyMapShmemCallbacks. This is dead scaffolding (YAGNI). Drop the function and both header declarations; keep only RecnoDirtyMapShmemSize() and RecnoDirtyMapShmemCallbacks.


📄 src/backend/access/recno/recno_dirtymap.c (L70-L71)

The key mixes only relid (an Oid), not the database. Relation OIDs are reusable after DROP, and since the map is grow-only and never reclaimed, a stale key left by a dropped relation can alias a newly-created relation that reuses the same OID and blkno, marking a genuinely clean page as dirty. This is a safe direction (false-dirty only forces an extra sLog probe, never a false-clean), but on a long-lived cluster with heavy DDL churn it silently accumulates false-dirty keys and, combined with the sticky per-partition full latch, drives the fast-path filter toward always-probe with no recovery. Worth documenting this degradation limit here (or keying with dbNode) rather than leaving it implicit.


📄 src/backend/access/recno/recno_fsm.c (L161-L170)

RecnoVacuumFSM is dead code as written, and if it ever executed it would truncate the FSM incorrectly.

(1) Logic bug: the only caller (recno_relation_vacuum) invokes RecnoVacuumFSM(onerel, RelationGetNumberOfBlocks(onerel)), and this function immediately re-reads old_nblocks = RelationGetNumberOfBlocks(rel). Both come from the same relation-size read, so new_nblocks < old_nblocks can never be true and the body never runs.

(2) Even if reached, calling FreeSpaceMapPrepareTruncateRel() alone is incorrect. Its contract (freespace.c) requires the caller to additionally smgrtruncate() the FSM fork using the returned block count and then call FreeSpaceMapVacuumRange(); ignoring the return value and skipping those steps leaves the FSM inconsistent.

(3) It is also redundant: the actual truncation path RecnoTruncateRelation() -> RelationTruncate() already performs the full FSM truncation protocol (FreeSpaceMapPrepareTruncateRel + smgrtruncate of FSM_FORKNUM + FreeSpaceMapVacuumRange) internally.

Recommend removing RecnoVacuumFSM and its call site entirely (and the prototype in recno.h), since FSM truncation is already handled by RelationTruncate. Confidence: high.


📄 src/backend/access/recno/recno_lock.c (L332-L344)

Bug: this mapping diverges from the one used in RecnoLockTuple/RecnoUnlockTuple, which acquire locks as AccessShareLock (KeyShare), RowShareLock (Share), ExclusiveLock (NoKeyExclusive) and AccessExclusiveLock (Exclusive) -- matching heap's tupleLockExtraInfo. LockHeldByMe() matches on the exact LOCKMODE, so querying ShareLock here never matches the AccessShareLock/RowShareLock locks that were actually taken, and querying ExclusiveLock only coincidentally matches the NoKeyExclusive case. As a result RecnoHoldsTupleLock returns false for locks that are genuinely held (all Share/KeyShare/Exclusive modes). Use the same mapping as RecnoLockTuple (ideally via a shared helper).

💡 Suggested change

Before:

	switch (mode)
	{
		case LockTupleKeyShare:
		case LockTupleShare:
			lockmode = ShareLock;
			break;
		case LockTupleNoKeyExclusive:
		case LockTupleExclusive:
			lockmode = ExclusiveLock;
			break;
		default:
			return false;
	}

After:

	switch (mode)
	{
		case LockTupleKeyShare:
			lockmode = AccessShareLock;
			break;
		case LockTupleShare:
			lockmode = RowShareLock;
			break;
		case LockTupleNoKeyExclusive:
			lockmode = ExclusiveLock;
			break;
		case LockTupleExclusive:
			lockmode = AccessExclusiveLock;
			break;
		default:
			return false;
	}

📄 src/backend/access/recno/recno_lock.c (L325-L327)

Dead/speculative code. RecnoHoldsTupleLock, RecnoLockMultipleTuples, RecnoLockPage/RecnoUnlockPage and RecnoLockRelationForDDL have no callers anywhere in the tree (only prototype/extern re-declarations exist). PostgreSQL rejects speculative scaffolding and unused abstractions (YAGNI); remove the functions with no callers, or wire them up in this same patch. Only RecnoLockTuple/RecnoUnlockTuple are actually used (by recno_handler.c / recno_operations.c).


📄 src/backend/access/recno/recno_lock.c (L13-L15)

Inaccurate comment. This file does not implement 'deadlock detection' -- deadlock detection is handled by the lock manager (deadlock.c). Only RecnoLockMultipleTuples imposes a consistent lock ordering to avoid deadlocks. Reword to describe what the code actually does (comments should not claim behavior that isn't here).


📄 src/backend/access/recno/recno_lock.c (L250-L250)

Error-path lock leak. If RecnoLockTuple raises via elog(ERROR) (invalid mode) or the wait=true LockAcquire longjmps out, the tuple locks already acquired in earlier iterations are not released -- the partial-lock cleanup below only runs on the wait=false LOCKACQUIRE_NOT_AVAIL path. LOCKTAG_TUPLE locks are released at transaction end by the ResourceOwner, so they will not linger past commit/abort, but they are held for the remainder of the (sub)transaction rather than being released promptly. Also, ntids <= 0 yields palloc0(0); guard it. If the intent is prompt release on any failure, wrap the acquire loop in PG_TRY/PG_FINALLY.


📄 src/backend/access/recno/recno_lock.c (L252-L265)

O(n^2) bubble sort reinvents existing infrastructure. Use qsort() with an ItemPointerCompare-based comparator (as other AMs do, e.g. heap's index_delete_sort) instead of a hand-rolled bubble sort. The 'adequate since N is typically small' justification is unverified; a multi-row DELETE/UPDATE locking many tuples makes this quadratic. This also removes non-DRY code.


📄 src/backend/access/recno/recno_lock.c (L24-L25)

DRY: the identical LockTupleMode->LOCKMODE switch is copy-pasted in RecnoLockTuple, RecnoUnlockTuple and (divergently) RecnoHoldsTupleLock -- which is exactly how the three mappings drifted apart. Extract a single static helper (e.g. recno_tuple_lockmode(LockTupleMode)) and use it everywhere. Also the storage/lwlock.h and storage/proc.h includes are unused (no LWLock/PGPROC/MyProc references in this file); drop them for a minimal diff.


📄 src/backend/access/recno/recno_mvcc.c (L148-L148)

Non-ASCII em-dash (U+2014) in source comment violates the tree's ASCII-only rule. Replace with an ASCII '--'. Other occurrences exist in this file (near the SLog XID comment and the HEAPTUPLE_DEAD analogy comment); all must be converted. Run git diff --check / grep for bytes >0x7f.

💡 Suggested change

Before:

	 * no lock is needed — just a compiler barrier via volatile access.

After:

	 * no lock is needed -- just a compiler barrier via volatile access.

📄 src/backend/access/recno/recno_mvcc.c (L522-L522)

Non-ASCII em-dash (U+2014) here as well. Convert to ASCII '--'.

💡 Suggested change

Before:

		 * reads it), so no lock is needed — just a write barrier.

After:

		 * reads it), so no lock is needed -- just a write barrier.

📄 src/backend/access/recno/recno_mvcc.c (L710-L710)

Non-ASCII em-dash (U+2014). Convert to ASCII '--'.

💡 Suggested change

Before:

	 * XIDs — we get them from the sLog.

After:

	 * XIDs -- we get them from the sLog.

📄 src/backend/access/recno/recno_mvcc.c (L721-L721)

Non-ASCII em-dash (U+2014). Convert to ASCII '--'.

💡 Suggested change

Before:

		 * conflict to report — analogous to heap's HEAPTUPLE_DEAD case.

After:

		 * conflict to report -- analogous to heap's HEAPTUPLE_DEAD case.

📄 src/backend/access/recno/recno_mvcc.c (L277-L280)

Dead duplicate initialization path. This RecnoMvccShmemInit() (the ShmemInitStruct + on_shmem_exit variant) has no callers anywhere in the tree: shared-memory setup for RECNO MVCC is wired exclusively through RecnoMvccShmemCallbacks -> RecnoMvccShmemInit_cb() (registered via PG_SHMEM_SUBSYSTEM in subsystemlist.h). Two full copies of the init logic (and two on_shmem_exit(RecnoShmemExit, 0) registrations) is unreachable scaffolding that will drift. Remove this function (and its prototype in recno.h) and keep only the callback path.


📄 src/backend/access/recno/recno_mvcc.c (L292-L293)

Misleading LWLock tranche reuse. mvcc_lock is initialized with LWTRANCHE_BUFFER_MAPPING, an unrelated tranche, which misattributes RECNO lock-wait stats to buffer mapping. RECNO already defines its own tranches (e.g. LWTRANCHE_RECNO_DIRTY_MAP). Either allocate a dedicated RECNO tranche for this lock, or (preferably) remove the lock entirely per the serializable_horizon finding below.


📄 src/backend/access/recno/recno_mvcc.c (L758-L762)

Dead field + dead lock. serializable_horizon is write-only across the entire tree: initialized to 1 and Min-updated here under mvcc_lock, but never read for any decision (SSI is fully delegated to predicate.c, as the file header states). The mvcc_lock exists solely to protect this dead field, so this LW_EXCLUSIVE acquire is pure overhead contended on every serializable commit for a value nobody consumes. Remove serializable_horizon, mvcc_lock, and this block.


📄 src/backend/access/recno/recno_mvcc.c (L509-L512)

Dead per-transaction fields. rc_read_point_cid, rc_read_point_xcc, and is_read_only are only ever written (here) and never read anywhere in the tree. Per the YAGNI/minimalism discipline, remove these unused fields (and their initialization) rather than carrying speculative state. xact_commit_ts is likewise only consumed by the write-only serializable_horizon path.


📄 src/backend/access/recno/recno_overflow.c (L346-L349)

Buffer-lock leak on error paths. Once overflow records are collected into overflow_buffers, their buffers are kept LOCKED and pinned for the caller to WAL-log. But several ereport(ERROR) sites here (the FSM "could not allocate space ... after retry", the "page still has insufficient space", the chain-length limit at the end of the loop, and the initial "could not allocate space for overflow record") unwind without releasing the already-collected locked buffers. LWLocks on buffer content are NOT released automatically by ResourceOwner cleanup on error unwind, so this risks stuck buffer locks and self-deadlock. The success path hands buffers to the caller (recno_release_update_overflow_buffers), but the error paths inside this function do not. Each ereport(ERROR) after the first buffer is collected must release all buffers in overflow_buffers first (or the whole function must run under a PG_TRY/PG_FINALLY that cleans them up).


📄 src/backend/access/recno/recno_overflow.c (L144-L149)

Out-of-bounds read on tuples with fewer attributes than tupdesc (post ALTER TABLE ADD COLUMN). The canonical RecnoDeformTuple computes bitmap_len = BITMAPLEN(header->t_natts) (the tuple's own natts) and iterates Min(tupdesc->natts, header->t_natts). This hand-rolled walk instead uses BITMAPLEN(tupdesc->natts) and loops over all tupdesc->natts. When header->t_natts < tupdesc->natts, both the data_ptr start offset (via MAXALIGN(bitmap_len)) and the loop bound are wrong, so VARSIZE_ANY() runs on misaligned/garbage bytes and the loop reads past the stored attributes. This same divergent decoder is copy-pasted five times in this file (here, RecnoCollectOverflowPtrs, RecnoCollectOverflowPtrsByAttr, RecnoDeleteTupleOverflows, and Pass 1 of RecnoVacuumOverflowRecords). It should reuse RecnoDeformTuple (or a shared iterator) to stay in lockstep with the canonical layout.


📄 src/backend/access/recno/recno_overflow.c (L694-L694)

Truncating int cast on a Size. hash_bytes() takes an int length; casting data_len (a Size) to int silently overflows for values > INT_MAX and hashes the wrong range. Similarly ov_total_length and or_data_len are uint32 and are assigned from Size via unchecked (uint32) casts below. While the 1 GiB varlena limit likely bounds this in practice, these truncating casts are unchecked footguns; add an assertion/validation that data_len fits before casting.


📄 src/backend/access/recno/recno_overflow.c (L1593-L1595)

Full data loss / replica divergence on redo. RecnoXLogInitPage logs XLOG_RECNO_INIT_PAGE, whose redo handler (recno_xlog_init_page_redo) reads the block with RBM_ZERO_AND_LOCK and calls RecnoInitPage(), i.e. it re-initializes the page to EMPTY and does not restore a full-page image of the surviving contents. Here the page had only selected orphan overflow records deleted via RecnoPageIndexTupleDelete, but on redo the standby (or crash recovery) will zero the entire page, destroying all live tuples and remaining overflow records that share it. This WAL action does not match the operation performed. Use a WAL record that captures a full-page image / the actual item deletions (as the other RECNO delete paths do), not an init-page record.


📄 src/backend/access/recno/recno_overflow.c (L1317-L1319)

TOCTOU deletion of live overflow records. RecnoVacuumOverflowRecords is invoked from the lazy-vacuum path, which holds only ShareUpdateExclusiveLock on the relation, so concurrent INSERT/UPDATE is allowed. Pass 1 builds the referenced-set under per-page SHARE locks; a transaction that inserts a tuple with a fresh overflow chain after Pass 1 has scanned its head page, but whose overflow records land on a page Pass 2 later processes, will have its live overflow records seen as unreferenced and deleted -> data corruption. This function depends on relation-level exclusivity that is neither held nor asserted here. It must either run under AccessExclusiveLock or be restructured to be safe against concurrent inserts.


📄 src/backend/access/recno/recno_overflow.c (L76-L79)

Comment is inaccurate: recno_overflow_inline_prefix is NOT a GUC. It is a plain C global that is never registered in guc_tables.c (the regression test recno_overflow.out confirms SHOW recno_overflow_inline_prefix returns "unrecognized configuration parameter"). Either register it as a real GUC (with min/max bounds) or fix the comment to describe it as a compile-time-default global; as written the comment and the "configurable via GUC" claim in recno.h are misleading.


📄 src/backend/access/recno/recno_overflow.c (L1608-L1611)

int64 format portability. PostgreSQL convention is INT64_FORMAT for int64; the (long long)/%lld pattern is discouraged. Also this message is not wrapped in _(); either use elog(DEBUG1, ...) for an internal diagnostic or wrap the text. Prefer: ereport(DEBUG1, (errmsg("RECNO overflow vacuum: found " INT64_FORMAT " overflow records, removed " INT64_FORMAT " orphans", overflow_records_found, orphans_removed)));


📄 src/backend/access/recno/recno_overflow.c (L384-L384)

Fragile heuristic and stale-workaround comment. Treating block 0 as "probably the main tuple's page" is not guaranteed to be correct: the caller's tuple can live on any block, and block 0 is a legitimate overflow target. This special-case can both (a) needlessly avoid a valid page and (b) fail to avoid the actual caller-pinned page (self-deadlock when it later gets EXCLUSIVE-locked here). Replace the block-0 guess with an explicit excluded-block parameter passed by the caller (the main tuple's block), and drop the "WORKAROUND" comment.


📄 src/backend/access/recno/recno_pvs.c (L16-L16)

Non-ASCII em-dash (U+2014) in source comment violates the ASCII-only rule for PostgreSQL sources. Replace with a plain ASCII hyphen '-' (or '--').

💡 Suggested change

Before:

* whose urec_xid is NOT in the in-progress set).  The result is the

After:

	 * no lock is needed -- just a compiler barrier via volatile access.

📄 src/backend/access/recno/recno_relundo.c (L9-L11)

Non-ASCII em-dash (U+2014) in source comment violates the ASCII-only rule for PostgreSQL sources. Replace with a plain ASCII '--' (or '-'). Same issue at the other occurrences in this file ("...so no lock is needed — just a write barrier", "XIDs — we get them from the sLog", "conflict to report — analogous to heap's...").

💡 Suggested change

Before:

* behavior -- clearing RECNO transient tuple flags, reversing a RECNO
 * byte-diff, or cleaning up
 * RECNO's sLog bookkeeping after an abort/discard -- it calls through

After:

	 * no lock is needed -- just a compiler barrier via volatile access.

📄 src/backend/access/recno/recno_stats.c (L51-L51)

Dead/unwired code. RecnoCollectRelationStats and RecnoLogRelationStats have no callers anywhere in the tree (only this definition, the header declaration, and a mention in the README). The file header claims these stats are "called during ANALYZE" and "made available through the RecnoCollectRelationStats() interface so that the planner can incorporate RECNO-specific cost adjustments," but the RECNO handler's ANALYZE path (recno_scan_analyze_next_block/tuple, recno_analyze_accumulate_sample) never invokes them, and no planner/cost code consumes RecnoRelationStats. This is speculative scaffolding: it violates the minimalism (YAGNI) discipline and the comment describes aspirational behavior that does not exist. Remove the file (and the header declarations) until the feature is actually wired, or hook it into a real caller in this same patch.


📄 src/backend/access/recno/recno_stats.c (L160-L163)

Out-of-bounds / misinterpreted read: the RecnoCompressionHeader is not located at (char *) hdr + RECNO_TUPLE_OVERHEAD. Per recno_compress.c, a compressed attribute is stored inside a varlena with layout [varlena header][RecnoCompressionHeader][compressed data], and every other reader (recno_slot.c, recno_tuple.c) locates it via VARDATA_ANY(attr_ptr) on the specific attribute. The offset RECNO_TUPLE_OVERHEAD points at the attribute-null bitmap / optional inline-diff / attribute-data region of the tuple, not at any compression header, so comp_hdr->orig_size / comp_hdr->comp_size read garbage bytes and feed a bogus compression_ratio. The item_len > ... length guard does not make the offset correct.


📄 src/backend/access/recno/recno_stats.c (L171-L174)

Misinterpreted field: t_commit_ts is not a commit timestamp. Per its definition in recno.h, the low 32 bits are t_xmax (the deleter/updater XID) and the high 32 bits are reserved, and it is accessed via RecnoTupleGetXmax/SetXmax; it is a uint64 only to preserve the on-disk byte layout. Treating it as a monotonic "commit-ts word" and reporting a [min..max] range is meaningless (it mixes an xmax XID with reserved bits) and the accompanying comments ("per-tuple commit-ts word", "Track commit-ts word range") are incorrect. This block, the commit_ts_* stats fields, and the fourth ereport should be dropped or reworked to reflect that the field is xmax.


📄 src/backend/access/recno/recno_stats.c (L249-L253)

Use the tree's 64-bit format macros instead of %lld/%llu with long-long casts. Convention in the backend is INT64_FORMAT for int64 (total_pages/total_live_tuples/total_dead_tuples are int64) and UINT64_FORMAT for uint64 (commit_ts_min/max), which avoids the long-long casts and matches the codebase.


📄 src/backend/access/recno/recno_slot.c (L458-L462)

xmin/xmax semantics are wrong and this burns XIDs on read-only queries. The heap AM returns the tuple's own stored xmin/xmax (HeapTupleHeaderGetRawXmin/Xmax). Here you return GetCurrentTransactionId(), i.e. the reader's XID, so every tuple appears to have been created by whoever is reading it. Worse, GetCurrentTransactionId() calls AssignTransactionId() when the backend has no XID yet, so a plain SELECT xmin FROM recno_tbl (a read-only query) now allocates a real XID as a side effect of reading a system column -- a footgun that consumes the XID space. At minimum use GetCurrentTransactionIdIfAny() to avoid the side effect; but the returned value is still semantically incorrect. Confirm the intended MVCC mapping and derive xmin/xmax from the tuple's commit_ts/sLog state instead. (high confidence)


📄 src/backend/access/recno/recno_slot.c (L849-L849)

Non-ASCII em-dash characters ('\u2014') appear in these newly-added comments, violating the ASCII-only source rule for PostgreSQL sources. Replace with -- (or -). Both lines 849 and 859 are affected.

💡 Suggested change

Before:

		/* Same buffer — just free any materialized data */

After:

		/* Same buffer -- just free any materialized data */

📄 src/backend/access/recno/recno_slot.c (L859-L859)

Non-ASCII em-dash character ('\u2014') in this newly-added comment violates the ASCII-only source rule; replace with --.

💡 Suggested change

Before:

		/* Different buffer — full clear (releases old pin) and acquire new */

After:

		/* Different buffer -- full clear (releases old pin) and acquire new */

📄 src/backend/access/recno/recno_slot.c (L473-L477)

getsysattr for cmin/cmax indexes the sLog with slot->tts_tid but, unlike tts_recno_is_current_xact_tuple above, does not assert ItemPointerIsValid(&slot->tts_tid). For a materialized/virtual slot (rslot->tuple set from a palloc'd copy, or tuple == NULL) tts_tid may be invalid/zeroed, so SLogTupleLookupFiltered would probe with a garbage TID. It won't crash (the lookup just returns 0 found -> InvalidCommandId), but it silently returns a wrong cmin/cmax. Add an ItemPointerIsValid(&slot->tts_tid) guard (matching is_current_xact_tuple) before the sLog lookup. (moderate confidence)


📄 src/backend/access/recno/recno_slot.c (L186-L187)

Deform walks the tuple with no bound against rslot->tuple_len. data_ptr starts at header + RECNO_TUPLE_OVERHEAD + MAXALIGN(bitmap_len) and is advanced by attr_len/att->attlen per attribute, and attr_len = VARSIZE_ANY(data_ptr) is itself read from the same unvalidated on-page bytes. A truncated/corrupt tuple whose t_natts implies more data than tuple_len provides will read past (char*)header + tuple_len (off the end of the buffer page), and the varlena size read can direct the walk arbitrarily far. Heap deform trusts the tuple too, but RECNO additionally dereferences these bytes as a RecnoCompressionHeader / overflow pointer. Confirm that scan/insert validates tuple_len vs t_natts before this runs, or add a bound check (data_ptr <= (char*)header + tuple_len). (moderate confidence)


📄 src/backend/access/recno/recno_undo.c (L416-L416)

Unaligned access hazard on strict-alignment platforms (ARM, s390x, PPC, RISC-V). image_bytes points into the batch buffer at payload + SizeOfRecnoUndoPayloadHeader, where payload = record_starts[i] + SizeOfUndoRecordHeader (see undoapply.c). Records are serialized back-to-back with arbitrary urec_len, so image_bytes is not guaranteed to be aligned. RecnoDiffRecord (and RecnoDiffSegment) are composed entirely of uint16 fields; casting image_bytes and dereferencing diff->old_total_len/diff->ndiffs (and the segment reads inside RecnoApplyDiffReverse) at an odd address will fault or read incorrectly. The header just above is correctly read via memcpy into a local hdr; the diff must be handled the same way, e.g. copy the fixed header into an aligned local and access the segment stream through a length-checked, memcpy-based walk rather than casting the raw pointer.


📄 src/backend/access/recno/recno_undo.c (L457-L459)

recno_undo_apply() unconditionally returns UNDO_APPLY_SUCCESS regardless of what the switch did. All the failure/skip branches (missing before-image WARNING, apply_recno_undo_restore_tuple returning false for a too-small slot or non-normal item, reverse-diff failure, unknown subtype) fall through and are reported to the caller as a successful apply. The caller (ApplyOneUndoRecord) increments records_applied and logs success even though no page mutation and no CLR were emitted. Skips should return UNDO_APPLY_SKIPPED and hard failures UNDO_APPLY_ERROR so rollback progress is reported accurately. At minimum, propagate the apply_recno_undo_restore_tuple() bool return and the branch outcomes into the result.


📄 src/backend/access/recno/recno_undo.c (L427-L427)

old_total_len is read from the UNDO payload and can legitimately be 0 (or a diff that reconstructs to 0 bytes). palloc(0) succeeds, RecnoApplyDiffReverse can succeed with out_len==0, and the result is then passed to apply_recno_undo_restore_tuple() which begins with Assert(old_image != NULL && old_len > 0). On an assert-enabled build this crashes on a corrupted/degenerate record; on a production build it proceeds to memcpy 0 bytes and call ItemIdSetNormal with length 0. Validate diff->old_total_len > 0 (and sanity-cap against BLCKSZ) before palloc/reconstruction, and reject out_len==0 rather than reaching the assert.


📄 src/backend/access/recno/recno_undo.c (L202-L204)

Missing length guard before touching the tuple header. Unlike the restore path (which checks ItemIdGetLength(lp) < old_len), this branch reads and writes sizeof(RecnoTupleHeader) bytes into/out of the slot without verifying ItemIdGetLength(lp) >= sizeof(RecnoTupleHeader). A corrupted or short item id (item is normal but shorter than the fixed header) causes an out-of-bounds read on the first memcpy and, worse, an out-of-bounds write on the second memcpy that clobbers adjacent page data inside the critical section. Add a length check that skips (like apply_recno_undo_restore_tuple does) when the slot is shorter than the header.


📄 src/backend/access/recno/recno_undo.c (L360-L361)

offnum comes straight from the UNDO payload TID and is passed to PageGetItemId() without bounding it against PageGetMaxOffsetNumber(page). The block number is validated against RelationGetNumberOfBlocks(), but the offset is not. A corrupted/out-of-range offnum makes PageGetItemId() index past the line-pointer array (out-of-bounds read / assert). This affects every branch (INSERT, UPDATE/DELETE restore, and the DELTA_UPDATE PageGetItemId at the top of that case). Validate offnum >= FirstOffsetNumber && offnum <= PageGetMaxOffsetNumber(page) after locking the buffer, consistent with the other AM undo paths.


📄 src/backend/access/recno/recno_vm.c (L72-L74)

Dead exported API. RecnoVMInit has an empty body and no callers anywhere in the tree (confirmed via search). The comment describes behavior it does not perform ("Create the visibility map fork..."), which is aspirational scaffolding. Per YAGNI/minimalism, remove this function (and its declaration in recno.h) or implement it. A no-op function whose comment claims side effects is a footgun.


📄 src/backend/access/recno/recno_vm.c (L277-L279)

RecnoVMCheck reads the VM page with RBM_NORMAL, but every other read path in this file and heap's visibilitymap.c uses RBM_ZERO_ON_ERROR. On a torn/short read RBM_NORMAL will ereport(ERROR) whereas the set path (via recno_vm_readbuf) silently zero-fills. This inconsistency means the check path can error where the set path recovers. Use RBM_ZERO_ON_ERROR for consistency with the rest of the VM code.

💡 Suggested change

Before:

	vmBuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock,
							   RBM_NORMAL, NULL);
	LockBuffer(vmBuf, BUFFER_LOCK_SHARE);

After:

	vmBuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock,
							   RBM_ZERO_ON_ERROR, NULL);
	LockBuffer(vmBuf, BUFFER_LOCK_SHARE);

📄 src/backend/access/recno/recno_vm.c (L339-L340)

Same RBM_NORMAL vs RBM_ZERO_ON_ERROR inconsistency as in RecnoVMCheck (and the write path uses zero-on-error via recno_vm_readbuf). Use RBM_ZERO_ON_ERROR here too so a short/torn VM read does not error out on the read path while succeeding on the write path.

💡 Suggested change

Before:

		*vmbuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock,
									RBM_NORMAL, NULL);

After:

		*vmbuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock,
									RBM_ZERO_ON_ERROR, NULL);

📄 src/backend/access/recno/recno_vm.c (L324-L331)

RecnoVMCheckCached is on the per-page scan hot path (recno_handler.c calls it once per heap block on the not-all-visible fallback), yet it calls smgrexists() and RelationGetNumberOfBlocksInFork() on every invocation. This partly defeats the stated purpose of caching the buffer to "eliminate per-page overhead". Consider hoisting these checks out (e.g. cache fork existence once per scan, or short-circuit when the cached buffer already covers mapBlock) so the fast path is a pure array read.


📄 src/backend/access/recno/recno_vm.c (L362-L364)

Dead exported API: RecnoVMPinBuffer has no callers in the tree (confirmed via search). Per minimalism, drop it and its header declaration until a caller exists. Note also it uses BufferGetBlockNumber(*vmbuf) != mapBlock to decide re-pinning while RecnoVMCheckCached tracks the block number in a separate out-param; if this were ever wired up the two conventions would diverge.


📄 src/backend/access/recno/recno_vm.c (L384-L386)

Dead exported API: RecnoVMExtend has no callers (confirmed via search). Heap VM extension is driven from the heap-extend path; this RECNO variant is never invoked, so the VM fork can only ever be created lazily by RecnoVMSet. Remove until wired, or wire it into the RECNO relation-extend path.


📄 src/backend/access/recno/recno_vm.c (L426-L429)

Dead exported API with a latent WAL-correctness hazard: RecnoVMTruncate has no callers (confirmed via search). If it is later wired in as-is, FlushRelationBuffers(rel) flushes ALL forks (heavier than needed), and more importantly smgrtruncate here is NOT WAL-logged. A physical VM truncate that is not replayed will leave stale VM bits past the truncated heap on crash recovery/standby, causing index-only scans to skip valid fetches (visibility corruption). Either remove until needed, or drive truncation from a WAL-logged path (as heap does via RelationTruncate/smgr truncate records) and restrict the flush to the VM fork.


📄 src/backend/access/recno/recno_vm.c (L436-L440)

Dead exported API: RecnoVMGetPageSize and RecnoVMMapHeapToVM have no callers (confirmed via search). Remove both (and their header declarations) per minimalism; thin one-line wrappers over the internal macros used exactly zero times are pure surface area.


📄 src/backend/access/recno/recno_vm.c (L551-L551)

Speculative/unused parameter and future-tense comments. The tuple argument is discarded via (void) tuple and the comment says it is "reserved for future timestamp checking"; per YAGNI drop the parameter (all call sites currently pass a real value only to be ignored) until the optimization is implemented. Also rewrite the "A future optimization could..." / "For now, conservatively..." comments to describe what the code does now.


📄 src/backend/access/recno/recno_vm.c (L124-L125)

map[mapByte] is written with no assertion that mapByte < MAPSIZE. The invariant holds today because HEAPBLK_TO_MAPBYTE yields (heapBlk % HEAPBLOCKS_PER_PAGE)/4 < MAPSIZE, but this is a direct on-disk-page write with no guard should HEAPBLOCKS_PER_PAGE/MAPSIZE ever drift out of sync. Add Assert(mapByte < MAPSIZE); to document and enforce the bound at the write sites (here and in RecnoVMClear).

💡 Suggested change

Before:

	/* Set the bits for this heap block */
	map[mapByte] |= (flags << (mapOffset * 2));

After:

	/* Set the bits for this heap block */
	Assert(mapByte < MAPSIZE);
	map[mapByte] |= (flags << (mapOffset * 2));

📄 src/backend/access/recno/recno_tuple.c (L1139-L1141)

Alignment logic here diverges from the writer (RecnoFormTuple Phase 3) and from RecnoDeformTuple, causing misreads.

Canonical layout written by RecnoFormTuple: each attribute is aligned at its START (att_align_nominal(data_ptr, att->attalign)) and then data_ptr is advanced by exactly attr_len with NO trailing alignment (recno_tuple.c:528/549). RecnoDeformTuple reads the same way (recno_tuple.c:655/664/699).

This varlena branch does the opposite:

  1. It reads VARSIZE_ANY(data_ptr) with NO leading att_align_nominal, so a varlena that the writer padded at its start (e.g. following a by-reference fixed-length column, or after a preceding varlena) is read from the wrong offset.
  2. The non-overflow varlena paths apply a TRAILING alignment (att_align_nominal(data_ptr + attr_len, att->attalign)) that the writer never emitted, while the overflow path (data_ptr += attr_len) and the fixed-length path do not. Any attribute following a non-overflow varlena is therefore read at a doubly-aligned offset, misreading every subsequent column.

This is a data-corruption / wrong-results bug on the primary scan retrieval path for any tuple mixing multiple varlenas or a varlena followed by an alignment-sensitive column. Align each attribute at its start (like the fixed-length branch already does) and advance by exactly attr_len, matching RecnoFormTuple/RecnoDeformTuple.

💡 Suggested change

Before:

				if (att->attlen == -1)
				{
					Size		attr_len = VARSIZE_ANY(data_ptr);

After:

				if (att->attlen == -1)
				{
					Size		attr_len;

					/* Align at the start, matching RecnoFormTuple/RecnoDeformTuple */
					data_ptr = (char *) att_align_nominal(data_ptr, att->attalign);
					attr_len = VARSIZE_ANY(data_ptr);

📄 src/backend/access/recno/recno_tuple.c (L1236-L1238)

Trailing alignment here (att_align_nominal(data_ptr + attr_len, ...)) is inconsistent with the writer, which advances by exactly attr_len with no trailing padding (recno_tuple.c:549), and with the overflow branch above which uses plain 'data_ptr += attr_len'. This over-advances data_ptr and misreads every subsequent attribute. Advance by attr_len only.

💡 Suggested change

Before:

					/* Not compressed or overflow - return as-is */
					slot->tts_values[i] = PointerGetDatum(data_ptr);
					data_ptr = (char *) att_align_nominal(data_ptr + attr_len, att->attalign);

After:

					/* Not compressed or overflow - return as-is */
					slot->tts_values[i] = PointerGetDatum(data_ptr);
					data_ptr += attr_len;

📄 src/backend/access/recno/recno_tuple.c (L1228-L1231)

Same trailing-alignment inconsistency for the inline-compressed varlena path: the writer never emits trailing padding (advances by exactly attr_len at recno_tuple.c:549), so this over-advances data_ptr and misreads subsequent attributes. Use 'data_ptr += attr_len'.

💡 Suggested change

Before:

								slot->tts_isnull[i] = false;
								data_ptr = (char *) att_align_nominal(
															  data_ptr + attr_len, att->attalign);
								continue;

After:

								slot->tts_isnull[i] = false;
								data_ptr += attr_len;
								continue;

📄 src/backend/access/recno/recno_tuple.c (L225-L225)

Portability (strict-alignment platforms): 'ovbuf' is a plain char[] with only 1-byte guaranteed alignment. Casting VARDATA(ovbuf) (offset VARHDRSZ=4) to RecnoOverflowPtr* and writing its uint32/BlockNumber fields performs unaligned stores on ARM64/PPC64/s390x. RecnoOverflowPtr leads with a uint32 (ov_magic) and thus wants 4-byte alignment; VARHDRSZ happens to be 4, but this only works when ovbuf itself is 4-byte aligned, which a char[] does not guarantee. Declare the backing store with a type that forces alignment (e.g. a union with a RecnoOverflowPtr or double member, or an explicitly MAXALIGN'd buffer) before casting.


📄 src/backend/access/recno/recno_tuple.c (L904-L906)

Dead code (YAGNI): RecnoPageGetLiveTuples has no callers, and the comment itself admits 'no current callers' with snapshot_ts explicitly unused ((void) snapshot_ts). Per the minimalism discipline, remove this function (and its header declaration) until a caller exists.


📄 src/backend/access/recno/recno_tuple.c (L108-L110)

Comment/code drift: this header states compression uses 'the algorithm selected by RecnoChooseCompressionType()', but recno_form_tuple_internal calls RecnoCompressAttribute(rel, values[i], att->atttypid, RECNO_COMP_NONE) and never references RecnoChooseCompressionType(). The RECNO_MIN_COMPRESS_SIZE (32 bytes) gate cited here is also not applied in this function (it is delegated to RecnoCompressAttribute). Align the comment with the actual behavior.


📄 src/backend/access/rmgrdesc/Makefile (L39-L41)

Build-system divergence with meson.build (the required "stay in sync" invariant is broken). This Makefile guards recnodesc.o behind ifdef USE_RECNO, matching how rmgrlist.h registers recno_desc/recno_identify (under #ifdef USE_RECNO). But src/backend/access/rmgrdesc/meson.build compiles recnodesc.c unconditionally (rmgr_desc_sources += files('recnodesc.c')), and its comment justifies this by claiming rmgrlist.h "registers recno_desc/recno_identify unconditionally" -- which is false; they are inside #ifdef USE_RECNO. The two build systems therefore disagree on the condition under which recnodesc is built. Because USE_RECNO is currently always defined in both src/Makefile.global.in and meson.build, this is latent, but the guard is either needed in both places or in neither. Make them consistent: either drop the ifdef USE_RECNO here (and elsewhere) since USE_RECNO is always set, or add the same conditional to meson.build. Confidence: high.


📄 src/backend/access/rmgrdesc/atmdesc.c (L42-L46)

Missing default: case in the switch. If an unexpected info value is decoded (e.g. corrupt WAL, or a future XLOG_ATM_* record type), nothing is appended to buf and pg_waldump prints an empty/misleading description instead of flagging the unknown code. The sibling descriptors added in this same series are exhaustive — fileopsdesc.c uses default: appendStringInfo(buf, "unknown fileops op code %u", info); and undodesc.c emits "UNKNOWN". Add a matching default here for consistency and robust WAL introspection. (moderate confidence)

💡 Suggested change

Before:

				appendStringInfo(buf, "xid %u", xlrec->xid);
			}
			break;
	}
}

After:

				appendStringInfo(buf, "xid %u", xlrec->xid);
			}
			break;

		default:
			appendStringInfo(buf, "unknown atm op code %u", info);
			break;
	}
}

📄 src/backend/access/rmgrdesc/recnodesc.c (L54-L60)

This whole block of duplicated _fe structs is redundant and dangerous. recno_xlog.h already defines frontend-safe versions of these structs under its #else /* FRONTEND */ branch (xl_recno_insert, xl_recno_delete, xl_recno_update, xl_recno_vacuum, xl_recno_compress, xl_recno_multi_insert, ...) and declares recno_desc()/recno_identify() as frontend-safe prototypes. recnodesc.c should just #include "access/recno_xlog.h" and use those canonical definitions instead of hand-copying them. Maintaining two copies is exactly the drift hazard that has already produced the layout bugs flagged below.


📄 src/backend/access/rmgrdesc/recnodesc.c (L56-L60)

Layout mismatch: the authoritative xl_recno_insert (recno_xlog.h) and the bytes actually written by RecnoXLogInsert() are { offnum, flags, tuple_len (uint32), commit_ts (uint64) }. This _fe copy drops tuple_len and invents a nonexistent xact_ts field. As a result the memcpy in the XLOG_RECNO_INSERT case reads commit_ts from the tuple_len slot and xact_ts from past the real commit_ts, printing garbage. Same defect in xl_recno_delete_fe (real layout has tuple_len before commit_ts, no xact_ts).


📄 src/backend/access/rmgrdesc/recnodesc.c (L70-L77)

Layout mismatch: the real xl_recno_update (recno_xlog.h) written by RecnoXLogUpdate() is { offnum, flags, old_commit_ts, new_commit_ts, old_tuple_len (uint16), new_tuple_len (uint16), dst_block_id (uint8), pad[3] }. This _fe copy replaces the tuple-length/dst_block_id trailer with a nonexistent xact_ts field, so the decoded output is wrong and the length check gates on the wrong size.


📄 src/backend/access/rmgrdesc/recnodesc.c (L144-L152)

Layout mismatch: the real xl_recno_lock written by the backend is { offnum (uint16), flags (uint16), infomask (uint8), lock_mode (uint8) } = 6 bytes (see recno_handler.c and recno_xlog.h). This _fe copy invents xmax (uint32), widens infomask to uint16, and adds a nonexistent infomask2 field, giving a padded sizeof of 16. Consequently datalen >= sizeof(xl_recno_lock_fe) is false for every real 6-byte record and pg_waldump will always print "lock (truncated)"; if it were ever satisfied it would misread every field.


📄 src/backend/access/rmgrdesc/recnodesc.c (L28-L31)

Opcode drift risk made concrete: XLOG_RECNO_UPDATE is defined here as 0x10, but recno_xlog.h defines XLOG_RECNO_UPDATE_INPLACE (aliased to XLOG_RECNO_UPDATE) as 0x10 and, more importantly, XLOG_RECNO_VACUUM is only an alias for XLOG_RECNO_DEFRAG (0x30) in the header - there is no separate DEFRAG opcode decoded here, so single-page defrag records will be mislabeled as VACUUM. Because these values are hand-copied under a "keep in sync" comment, they will inevitably diverge from the authoritative header. Include recno_xlog.h and delete these local #defines.


📄 src/backend/access/rmgrdesc/recnodesc.c (L401-L401)

Inconsistent and slightly unsafe length check: every other case uses datalen >= sizeof(...) (Size, unsigned), but this one casts to (int), producing a signed/unsigned comparison the compiler may warn on. Use the same unsigned form for consistency.

💡 Suggested change

Before:

				if (datalen >= (int) sizeof(xl_recno_cas_update_undo_fe))

After:

				if (datalen >= sizeof(xl_recno_cas_update_undo_fe))

📄 src/backend/access/rmgrdesc/recnodesc.c (L504-L506)

recno_identify() returns NULL for unknown info bytes. Every other in-tree rmgr *_identify() returns NULL for unknown opcodes and the pg_waldump/rmgr dispatch tolerates it, so this is acceptable - but note it is the only reason the local id = NULL default is safe. No change strictly required; flagging for awareness given the opcode set here is out of sync with recno_xlog.h (DEFRAG/UPDATE_INPLACE), which will cause some real opcodes to be identified as UNKNOWN/NULL.


📄 src/backend/access/table/tableam.c (L832-L838)

This doc block is titled RecnoXLogMaybeAppendLogicalTuple, but the function immediately below is RecnoXLogPrepareLogicalImage. The comment also describes semantics that don't match this function: it claims the routine "Returns true and sets RECNO_WAL_LOGICAL_TUPLE in *flags", yet RecnoXLogPrepareLogicalImage returns void and takes a RecnoLogicalImage *img (no flags pointer). Those return/flags semantics actually belong to RecnoXLogRegisterLogicalImage. Update the block to describe RecnoXLogPrepareLogicalImage (it prepares/serializes a heap-format image into *img outside the critical section).

💡 Suggested change

Before:

bool
RelationAmSupportsUndo(Relation rel)
{
	if (!rel->rd_tableam)
		return false;
	return rel->rd_tableam->am_supports_undo;
}

After:

 * RecnoXLogPrepareLogicalImage
 *		Serialize a heap-format image of `rtup` into `*img` when `rel` is
 *		logically logged, so it can later be registered onto the WAL record
 *		by RecnoXLogRegisterLogicalImage inside the critical section.  The
 *		image is empty (img->data == NULL) when `rel` is not logically logged.

📄 src/backend/access/table/tableam.c (L832-L838)

The per-tuple memcpy writes into scratch (a single stack-resident PGAlignedBlock, one BLCKSZ) with no runtime bound; the only guard is the trailing Assert, which is compiled out in production and fires only after an overrun. The page-fit invariant makes this safe today, but note the region here also pays SHORTALIGN padding per tuple that the on-page layout does not, so the serialized size can slightly exceed the on-page footprint. For a stack buffer-overflow bound, prefer a hard runtime check before/inside the loop (e.g. verify SHORTALIGN(scratchptr - scratch.data) + SizeOfRecnoMultiInsertTuple + tuples[i]->t_len stays within sizeof(scratch.data) and elog(ERROR) otherwise) rather than relying solely on Assert.


📄 src/backend/access/table/tableam.c (L827-L831)

Stale doc-block title: this comment header is named RecnoXLogMaybeAppendLogicalTuple, but the function defined immediately below is RecnoXLogPrepareLogicalImage. The mismatched name is misleading. Update the header to match the actual function name.

💡 Suggested change

Before:

/*
 * RelationAmSupportsUndo
 *		Returns true if the relation's table AM declared UNDO support.
 *		Used by index AMs to gate UNDO record generation on the parent table.
 */

After:

 * RecnoXLogPrepareLogicalImage

📄 src/backend/access/transam/xlogrecovery.c (L1903-L1903)

Memory-safety bound relies solely on Assert, which is compiled out in production builds. The loop memcpys each tuple body into the fixed-size stack PGAlignedBlock scratch (one BLCKSZ) with SHORTALIGN padding accumulating between entries. The single-page invariant is only checked by Assert((scratchptr - scratch.data) < BLCKSZ) after the writes complete, so if a caller ever violated the invariant the overrun would already have corrupted the stack before the Assert could fire (and never in a production build). Add a hard runtime guard before the copy loop, e.g. accumulate the required size and elog(ERROR) if it would exceed sizeof(scratch.data).


📄 src/backend/access/transam/xlogrecovery.c (L1903-L1903)

The DEFRAG record registers the mappings array as WAL data, but the redo handler (recno_xlog_defrag_redo) never consumes it -- on BLK_NEEDS_REDO it just calls PageRepairFragmentation(page) and only reads xlrec->commit_ts. Moreover the emitter forces REGBUF_FORCE_IMAGE, so redo always takes BLK_RESTORED and the BLK_NEEDS_REDO body is dead. The mappings payload (ntuples + nmappings * sizeof(RecnoOffsetMapping)) is therefore wasted WAL volume on every defrag. Either drop the mapping payload (relying on the FPI, which is authoritative) or drop the FPI and make redo replay the mappings; keep the record minimal.


📄 src/backend/access/undo/Makefile (L15-L38)

The doc-block title RecnoXLogMaybeAppendLogicalTuple does not match the function it documents, RecnoXLogPrepareLogicalImage. It also describes behavior ("Returns true and sets RECNO_WAL_LOGICAL_TUPLE in *flags") that this void function no longer has — the flag is set later by RecnoXLogRegisterLogicalImage. Comments must describe the current code; update the title and the return-value sentence to match.

💡 Suggested change

Before:

OBJS = \
	atm.o \
	logical_revert_worker.o \
	relundo.o \
	relundo_apply.o \
	relundo_discard.o \
	relundo_page.o \
	relundo_recovery.o \
	relundo_worker.o \
	relundo_xlog.o \
	slog.o \
	slog_flathash.o \
	undo.o \
	undo_bufmgr.o \
	undo_xlog.o \
	undoapply.o \
	undobuffer.o \
	undoinsert.o \
	undolog.o \
	undorecord.o \
	undormgr.o \
	undostats.o \
	undoworker.o \
	xactundo.o

After:

 * RecnoXLogPrepareLogicalImage
 *		Prepare a heap-format image of `rtup` for later registration onto the
 *		in-progress WAL record, if `rel` is logically logged.  The prepared
 *		image is stored in `*img`; RecnoXLogRegisterLogicalImage() later appends
 *		it and sets RECNO_WAL_LOGICAL_TUPLE.

📄 src/backend/access/undo/Makefile (L15-L15)

The single-page invariant for scratch (one BLCKSZ stack buffer) is enforced only by an Assert, which is compiled out in production (non-cassert) builds. If a future caller ever routes an oversized batch here (e.g. an overflow-eligible batch that reaches this path), the memcpy loop above overruns the stack buffer before any Assert would fire, a classic stack buffer overflow during a critical section. Add a hard runtime bound check inside the loop (before the memcpy) so the guard survives production builds.

💡 Suggested change

Before:

OBJS = \

After:

	if (unlikely((scratchptr - scratch.data) >= BLCKSZ))
		elog(ERROR, "recno multi-insert batch exceeds one page (%ld bytes)",
			 (long) (scratchptr - scratch.data));

📄 src/backend/access/undo/atm.c (L161-L166)

WAL emission and the corresponding shared-memory mutation are not wrapped in a critical section. PostgreSQL convention requires START_CRIT_SECTION()/END_CRIT_SECTION() around XLogInsert() plus the shared-state change (as in essentially every rmgr): if an error (e.g. an interrupt or ereport) fires between XLogInsert() and ATMAddAbortedInternal(), the WAL record is durable but shared memory was never updated, leaving redo and running state divergent. The mutation after a WAL insert must be unconditional (PANIC on failure), which is exactly what the critical section guarantees. Same issue in ATMForget() below.

💡 Suggested change

Before:

	XLogBeginInsert();
	XLogRegisterData((char *) &xlrec, SizeOfXlAtmAbort);
	XLogInsert(RM_ATM_ID, XLOG_ATM_ABORT);

	/* Now update shared memory */
	return ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn);

After:

	START_CRIT_SECTION();

	XLogBeginInsert();
	XLogRegisterData((char *) &xlrec, SizeOfXlAtmAbort);
	XLogInsert(RM_ATM_ID, XLOG_ATM_ABORT);

	/* Now update shared memory */
	result = ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn);

	END_CRIT_SECTION();

	return result;

📄 src/backend/access/undo/atm.c (L147-L151)

Stale comment and dead fallback. SLogTxnInsert() now always returns true (the ATM is backed by a radix tree that grows on demand from DSA; see slog.c: "this never fails for lack of space; the return type is retained ... and is always true"). So this function can never return false, the "signaling the caller to fall back to synchronous rollback" contract no longer holds, and every caller's if (!ATMAddAborted(...)) elog(WARNING, "ATM full: ...") branch (xactundo.c, twophase.c) is unreachable dead code. Update the comment to match, and consider dropping the now-useless bool return and the dead "ATM full" warnings.


📄 src/backend/access/undo/atm.c (L486-L488)

Unbounded/overflow-prone allocation from an untrusted on-disk value before CRC validation. hdr.count is a uint32 read straight from the file, and the CRC that would detect corruption is only verified after this palloc. A corrupt count (up to ~4e9) drives a multi-gigabyte palloc during startup; worse, on 32-bit builds (Size is 32-bit) (Size) hdr.count * sizeof(AtmStateRecord) can overflow, producing an under-sized allocation followed by an over-read into records. Bound hdr.count against a sane maximum (or against the actual file size via fstat) before allocating, mirroring restoreTwoPhaseData's size sanity checks.


📄 src/backend/access/undo/atm.c (L358-L360)

This ATM-specific state file reuses the TWOPHASE wait events (WAIT_EVENT_TWOPHASE_FILE_WRITE/_SYNC/READ). This mislabels ATM checkpoint/reload I/O as two-phase activity in pg_stat_activity, misleading DBAs. Add dedicated wait events in wait_event_names.txt (which generates the WAIT_EVENT* enums) and use them here instead of borrowing another subsystem's events.


📄 src/backend/access/undo/atm.c (L336-L336)

Memory leak on error paths. SLogTxnSnapshotForCheckpoint() palloc's entries in CurrentMemoryContext, but every ereport(ERROR) in the write/fsync/close path below exits before pfree(entries). CheckPointATM() runs from CheckPointGuts in a long-lived context, so the snapshot buffer leaks until the checkpointer errors out and restarts. Wrap the file I/O in PG_TRY/PG_FINALLY (or free before the ereport) to release entries on the error path. (The transient fd itself is cleaned up automatically.)


📄 src/backend/access/undo/logical_revert_worker.c (L256-L259)

The PG_CATCH block reports and clears the error but never aborts the transaction; control falls through to the unconditional CommitTransactionCommand() at line 318. After an ERROR is thrown inside the PG_TRY, the transaction is left in an aborted-command state, and calling CommitTransactionCommand() (rather than aborting) does not run the abort cleanup path -- buffer pins, LWLocks, and other resources acquired before the error are not released, and the transaction machinery is left inconsistent, which will trip an assertion/FATAL and terminate the worker. The established convention in this same subsystem (see undoworker.c) is to abort in the recovery path: call AbortCurrentTransaction() (guarded by IsTransactionState()) inside the catch block, and only CommitTransactionCommand() on the success path.

💡 Suggested change

Before:

			PG_CATCH();
			{
				EmitErrorReport();
				FlushErrorState();

After:

			PG_CATCH();
			{
				EmitErrorReport();
				FlushErrorState();

				/* Abort the failed transaction to release its resources. */
				if (IsTransactionState())
					AbortCurrentTransaction();

📄 src/backend/access/undo/logical_revert_worker.c (L233-L234)

This per-database worker model can livelock and starve databases. ATMGetNextUnreverted (-> SLogTxnGetNextUnreverted) iterates the ATM in xid-major order and returns the single globally-oldest unreverted entry, with no filtering by database. When that oldest entry belongs to a different database than this worker's MyDatabaseId, the worker takes goto sleep, wakes up, and retrieves the same foreign entry again -- making no progress and never reaching any of its own entries queued behind it. If no worker exists for that foreign database (e.g. it was never spawned because of the max_logical_revert_workers ceiling in the launcher, or its database has no worker), that entry is never reverted. Because the same entry also pins UNDO WAL against recycling via ATMGetOldestUnrevertedLSN -> UndoGetOldestBatchLSN -> KeepLogSeg, it either blocks WAL recycling indefinitely or eventually trips the retention-invariant PANIC below. ATMGetNextUnreverted should accept/filter by database (returning the oldest unreverted entry for the requested dboid) so every database's entries can be drained independently.


📄 src/backend/access/undo/logical_revert_worker.c (L543-L545)

Stale comment referencing a prior design. Comments must describe what the code does now, not its development history. "A shorter interval than the original one-shot design" is meaningless in the committed code and should be removed.

💡 Suggested change

Before:

		 * Sleep until the next scan.  A shorter interval than the original
		 * one-shot design so newly created databases pick up a revert worker
		 * promptly; the no-op fast path (every db already tracked) is cheap.

After:

		 * Sleep until the next scan so newly created databases pick up a
		 * revert worker promptly; the no-op fast path (every db already
		 * tracked) is cheap.

📄 src/backend/access/undo/relundo.c (L589-L589)

The EXCLUSIVE-locked metapage buffer is stashed in a file-scope static and only cleared by RelUndoFinish/RelUndoFinishWithTuple/RelUndoCancel. If the caller raises an error (ereport/elog(ERROR)) between RelUndoReserve() and Finish/Cancel, none of those run. On abort, the ResourceOwner releases the actual pin/lock, but this static is left holding a now-invalid Buffer number. The next transaction that reaches RelUndoCancel (or, after a reserve that does not allocate a new page, any Finish path) will see BufferIsValid(relundo_pending_metabuf) true and UnlockReleaseBuffer() a buffer that was already released at abort -- a double-release/use-after-release. There is no AtEOXact/abort hook resetting this static (confirmed: it is referenced only within relundo.c). Register a resource-owner/xact-abort callback to drop the pending metabuf, or redesign so the metapage buffer is returned to the caller rather than held in a cross-call static.


📄 src/backend/access/undo/relundo.c (L866-L867)

RelUndoRecPtr is a uint64 (typedef uint64 RelUndoRecPtr). Casting it to (unsigned long) and printing with %lu truncates on LLP64 platforms (Windows/MSVC, where long is 32-bit), producing a wrong value and a format/arg width mismatch. Use UINT64_FORMAT with a uint64 argument. (Separately, this DEBUG1 tracing on the insert hot path looks like leftover debugging scaffolding that should be removed before commit.)


📄 src/backend/access/undo/relundo.c (L57-L57)

These hook function pointers are exported globals installed from another module (recno_relundo.c) and read in relundo_apply.c / undoworker.c / xactundo.c, i.e. they are used cross-module. On Windows/MSVC every extern variable referenced from another module must be marked PGDLLIMPORT in its header declaration (see the extern declarations in relundo.h:749-755, which currently lack it). Every existing hook variable in the tree follows this convention (e.g. object_access_hook, planner_hook). Without PGDLLIMPORT this will fail to link on MSVC.


📄 src/backend/access/undo/relundo.c (L1452-L1458)

GetOldestNonRemovableTransactionId() is comparatively expensive (it scans/serializes against the ProcArray) and this function is documented as a backstop for the AM's DML hot path. Although the 5s throttle and the nblocks>=64 gate bound how often the horizon is computed, GetCurrentTimestamp() is still called on every invocation before the cheap nblocks check. Compute the timestamp only after confirming work is plausible, or gate the timestamp behind the nblocks check, to keep the common hot-path call to a single cached smgrnblocks. Also note the documented "caller must not hold a data-page lock" contract for RelUndoDiscard is a footgun with no assertion enforcing it.


📄 src/backend/access/undo/relundo.c (L1291-L1291)

This direct smgrtruncate() shrinks a pre-existing UNDO fork to zero blocks but emits no WAL. The standard pattern (RelationTruncate in storage.c) wraps smgrtruncate in a critical section together with an XLOG_SMGR_TRUNCATE record so crash recovery and standbys perform the same truncation. Here only log_smgrcreate() is emitted (above) and the metapage is re-logged with REGBUF_WILL_INIT, which reinitializes block 0 on redo -- but blocks 1..N-1 of the old fork are not truncated on the replica/after crash, causing fork-size divergence and leaving stale UNDO data pages reachable. Confirm this path is only reached with a fresh relfilenumber; otherwise the truncation must be WAL-logged.


📄 src/backend/access/undo/relundo.c (L327-L330)

This fallback overwrites relundo_head_cache[start] unconditionally, which may clobber an unrelated live entry that legitimately hashed to 'start'. The comment concedes this should be unreachable; if it is truly a can't-happen invariant, prefer Assert(false) (or elog) over a silent wrong-entry overwrite that corrupts the cache's O(1) correctness. As written it is a silent-correctness smell.


📄 src/backend/access/undo/relundo_apply.c (L491-L494)

Memory leak: RelUndoReadRecordWithTuple palloc's a combined header+payload buffer and returns it, but both DELETE (line 491) and UPDATE (line 517) call sites discard the return value. Only tuple_data_buf (a separate palloc) is freed. The returned header buffer is leaked on EVERY DELETE/UPDATE record apply. During a batched bulk rollback (up to RELUNDO_APPLY_MAX_BATCH=128 records per CLR, many CLRs per chain) this accumulates in the transaction memory context and is not reclaimed until context reset. Capture the return value and pfree it, or add an out-param variant that does not allocate the header. (high confidence)

💡 Suggested change

Before:

				RelUndoReadRecordWithTuple(rel, current_ptr,
									   &tuple_data_buf, &tlen);

				for (i = 0; i < del_payload->ntids; i++)

After:

				RelUndoRecordHeader *tuphdr;

				tuphdr = RelUndoReadRecordWithTuple(rel, current_ptr,
													&tuple_data_buf, &tlen);

				for (i = 0; i < del_payload->ntids; i++)

📄 src/backend/access/undo/relundo_apply.c (L175-L177)

Buffer/lock leak on the ERROR path. RelUndoApplyOneRecord pins+exclusive-locks pages into touched[] before the Apply* helpers run, and those helpers can ereport(ERROR) mid-batch (e.g. "invalid offset", "tuple length mismatch", "insufficient space", "no delta-reverse handler registered"). Unlike the "too many pages" and "unknown type" branches, these ERRORs unwind without releasing touched[]. The online caller process_relundo_work_item catches with only EmitErrorReport()/FlushErrorState() and then runs CommitTransactionCommand() -- it does NOT AbortCurrentTransaction(), so the ResourceOwner is never reset and the buffer pins/LWLocks leak (and committing after an unhandled error is itself unsafe). The recovery caller leaks equivalently. Wrap the per-record apply in PG_TRY/PG_FINALLY that releases touched[], or ensure the caller aborts the (sub)transaction on error. (high confidence)


📄 src/backend/access/undo/relundo_apply.c (L729-L729)

Leftover debugging scaffolding on the hot apply path. RelUndoApplyInsert emits five elog(DEBUG1) trace calls per invocation ("calling PageGetItemId", "got ItemId %p", "calling ItemIdSetUnused", etc.), including a raw page pointer (%p) and ItemId pointer that are non-portable and useless in logs. These granular "calling X" traces are debugging leftovers, not appropriate for committed code; strip them (keep at most one meaningful DEBUG2 like the other helpers). (medium confidence)


📄 src/backend/access/undo/relundo_apply.c (L741-L742)

WARNING-then-mutate footgun. When the line pointer is not normal, this only logs a WARNING and then unconditionally calls ItemIdSetUnused(lp), potentially corrupting page state by clobbering a redirected/dead slot. Because rollback runs newest-first, the just-inserted slot must be normal at this point; a non-normal slot indicates a genuine inconsistency and should be a hard elog(ERROR), not WARNING-then-proceed. (medium confidence)

💡 Suggested change

Before:

	if (!ItemIdIsNormal(lp))
		elog(WARNING, "RelUndoApplyInsert: tuple at offset %u is not normal", offset);

After:

	if (!ItemIdIsNormal(lp))
		elog(ERROR, "RelUndoApplyInsert: tuple at offset %u is not normal", offset);

📄 src/backend/access/undo/relundo_apply.c (L973-L975)

RelUndoApplyDeltaInsert is a no-op stub: after input validation it only emits a DEBUG2 message and never restores anything, and its comment ("In a real columnar implementation, we'd need to...") is aspirational future-tense for behavior that has not shipped. RELUNDO_DELTA_INSERT is never emitted anywhere under src/backend/access/recno/, so the entire DELTA_INSERT handling (this helper, the RELUNDO_DELTA_INSERT case in RelUndoApplyOneRecord, and its entry in RelUndoRecordSingleDataPage) is dead scaffolding. Per YAGNI, remove it; otherwise if it ever becomes reachable, rollback silently fails to restore data (silent corruption). (medium confidence)


📄 src/backend/access/undo/relundo_apply.c (L115-L116)

Non-portable format specifier. RelUndoRecPtr is uint64 (relundo.h), but it is logged as (unsigned long) current_ptr with %lu. On LLP64 (Windows) and 32-bit platforms unsigned long is 32-bit, truncating the pointer in the log. Use UINT64_FORMAT with the value cast to uint64 (no unsigned long cast). Same issue at the other %lu sites in this file (lines ~153, ~327). (low confidence)

💡 Suggested change

Before:

	elog(DEBUG1, "RelUndoApplyChain: starting rollback at %lu",
		 (unsigned long) current_ptr);

After:

	elog(DEBUG1, "RelUndoApplyChain: starting rollback at " UINT64_FORMAT,
		 (uint64) current_ptr);

📄 src/backend/access/undo/relundo_apply.c (L517-L520)

Redundant double-read of each DELETE/UPDATE record. The outer loop already reads header+payload via RelUndoReadRecord (into header/payload), then RelUndoReadRecordWithTuple re-reads the very same fork page (ReadBufferExtended + LockBuffer(SHARE) + copy) just to append the trailing tuple bytes. On the hot bulk-rollback path this doubles UNDO-fork buffer traffic per record (and the batch peek path reads a third time). Consider extending RelUndoReadRecord to optionally return the tuple tail, or extract the tuple bytes from the payload already in hand, to avoid the second read. (medium confidence)


📄 src/backend/access/undo/relundo_discard.c (L200-L202)

Whole-chain discard races the lock-free reserve path and can free a page holding a live UNDO record. Pass 1 classifies each page under a SHARE lock that is then released (this line), and Pass 3 only re-locks runtail_buf (old tail) and newtail_buf (new tail) EXCLUSIVE. In the whole-chain-discardable case there is no new tail, so the head page is never re-locked or re-validated. Meanwhile the Tier 1 reserve path in RelUndoReserve() explicitly skips the metapage lock and mutates the cached head page under only its own content lock; VACUUM (ShareUpdateExclusiveLock) and the RelUndoMaybeVacuum DML backstop do not exclude a concurrent reserver's RowExclusiveLock. In the window between Pass 1 releasing the head-page SHARE lock and Pass 3 splicing, another backend can append a fresh live record (xid >= oldest_xmin) to that head page. Discard then splices the whole chain — including the now-non-discardable head page — onto the free list based on the stale classification, and it is recycled with a live record on it. When the truncate AccessExclusiveLock is unavailable, the fork is not shrunk, so the reserver's head_blkno < smgrnblocks guard still passes and it keeps writing to a freed page. This is a data-corruption hazard. The discardability of the head page (and the whole run) must be re-validated under the EXCLUSIVE content lock actually taken in Pass 3, or the reserve path must serialize against discard on the metapage lock.


📄 src/backend/access/undo/relundo_discard.c (L178-L184)

These chain-walk loops (Pass 1 here, plus Pass 2 below) have no CHECK_FOR_INTERRUPTS(). For a large UNDO fork the run can be arbitrarily long (redo explicitly notes npages_freed has no upper bound), and the whole walk runs while holding the metapage EXCLUSIVE lock. A backend cannot be cancelled during a long discard, and the contended metapage lock is held longer than necessary. Add CHECK_FOR_INTERRUPTS() inside these loops, matching the convention in undoworker.c / logical_revert_worker.c.


📄 src/backend/access/undo/relundo_discard.c (L296-L296)

discarded_records is documented in relundo.h as a "Cumulative count of discarded records" with the invariant "(total - discarded) = live records", but here it is incremented by npages_freed (a page count), not the number of records on those pages. The "/* approximate */" note acknowledges the imprecision, but it silently breaks the documented record-count invariant and mixes two units in one field. Either count actual discarded records here, or rename/redocument the field to reflect that it tracks freed pages.


📄 src/backend/access/undo/relundo_discard.c (L10-L14)

Stale/inaccurate algorithm description. The code compares each page's max_xid against oldest_xmin with full TransactionIdPrecedes() 64-bit-epoch comparison (see relundo_page_is_discardable()), not a "generation counter ... using modular 16-bit arithmetic". The 16-bit counter field exists in the metapage but is not what discard uses. This comment describes a different (older) design and misleads the reader about the wraparound-safety argument. Reconcile it with the actual max_xid vs oldest_xmin comparison.

💡 Suggested change

Before:

 * Discard walks the page chain from the tail (oldest) toward the head
 * (newest).  Each page's generation counter is compared against the
 * oldest-visible cutoff using modular 16-bit arithmetic.  If a page's
 * counter precedes the cutoff, all records on that page are safe to
 * discard and the page is moved to the free list.

After:

 * Discard walks each slot's page chain from the head (newest) toward the
 * tail (oldest).  Each page's max_xid (the largest urec_xid of any record
 * on the page) is compared against the oldest-visible cutoff (oldest_xmin)
 * with TransactionIdPrecedes().  A run of pages whose max_xid precedes the
 * cutoff is safe to discard and is spliced onto the free list.

📄 src/backend/access/undo/relundo_discard.c (L10-L11)

This says discard walks "from the tail (oldest) toward the head (newest)", but RelUndoDiscardSlot Pass 1 walks the other way — from head toward tail following prev_blkno (current_blkno = meta->head_blkno[slot]). The function-level comment and the file-level comment contradict each other on walk direction.


📄 src/backend/access/undo/relundo_page.c (L13-L15)

Stale file-header documentation contradicts the implementation. The header claims discard compares a page's "generation counter ... against the oldest-visible cutoff using modular 16-bit arithmetic" and walks "from the tail (oldest) toward the head (newest)." But relundo_page_is_discardable uses full-epoch TransactionIdPrecedes(hdr->max_xid, oldest_xmin) (not the 16-bit counter), and relundo.h:362-364 explicitly states the counter "is no longer used for discard eligibility." Pass 1 also walks head->tail (current_blkno = meta->head_blkno[slot]), the opposite of the header's stated direction. Update the header to describe the xid-based, head->tail scan actually implemented.


📄 src/backend/access/undo/relundo_worker.c (L130-L134)

Data-loss hazard: the queue collapses work by (dboid, reloid). If two different transactions each abort with pending UNDO for the same relation before a worker drains the item, the second RelUndoQueueAdd overwrites start_urec_ptr and xid, silently discarding the first transaction's UNDO chain pointer. Those changes are then never reverted, corrupting data / MVCC visibility. UNDO work must be keyed per (xid, reloid) — or at minimum retain a list of pending chains per relation — not collapsed to one item per relation.

Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L148-L152)

Silently dropping a queued UNDO item on a full queue is a durability/correctness hazard, not merely a capacity limit: the aborted transaction's changes are never reverted. A WARNING to the log is invisible to the aborting backend, which then returns success. Either block/spill, or propagate an error so the abort path knows the rollback could not be applied. A fixed 64-entry shmem array with drop-on-full needs justification versus existing shared-memory infrastructure.

Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L150-L150)

errmsg must start lowercase, per the tree's message conventions: s/Per-relation UNDO work queue is full/per-relation UNDO work queue is full/. (The nearby "could not register ..." messages are already correct.)

Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L168-L169)

Portability: RelUndoRecPtr is uint64 (relundo.h). Casting to unsigned long and using %lu truncates the pointer to 32 bits on LLP64 platforms (Windows/MSVC) and violates the tree's INT64_FORMAT/UINT64_FORMAT rule. Use UINT64_FORMAT with a (uint64) cast. Same issue at the elog in process_relundo_work_item.

Confidence: high.

💡 Suggested change

Before:

	elog(DEBUG1, "Queued per-relation UNDO work for database %u, relation %u (ptr=%lu)",
		 dboid, reloid, (unsigned long) start_urec_ptr);

After:

	elog(DEBUG1, "Queued per-relation UNDO work for database %u, relation %u (ptr=" UINT64_FORMAT ")",
		 dboid, reloid, (uint64) start_urec_ptr);

📄 src/backend/access/undo/relundo_worker.c (L282-L283)

Same uint64 -> unsigned long / %lu truncation as in RelUndoQueueAdd. Use UINT64_FORMAT and cast to (uint64).

Confidence: high.

💡 Suggested change

Before:

	elog(LOG, "Per-relation UNDO worker processing: database %u, relation %u, UNDO ptr %lu",
		 item->dboid, item->reloid, (unsigned long) item->start_urec_ptr);

After:

	elog(LOG, "Per-relation UNDO worker processing: database %u, relation %u, UNDO ptr " UINT64_FORMAT,
		 item->dboid, item->reloid, (uint64) item->start_urec_ptr);

📄 src/backend/access/undo/relundo_worker.c (L367-L369)

The worker installs a bespoke got_SIGTERM flag polled only at the loop top and never calls CHECK_FOR_INTERRUPTS(). A long-running RelUndoApplyChain inside process_relundo_work_item cannot be interrupted, so SIGTERM (shutdown) is not honored until the current item finishes, and a stuck apply cannot be cancelled. Follow the autovacuum/logical-launcher pattern (standard die() SIGTERM handling plus CHECK_FOR_INTERRUPTS in inner loops).

Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L461-L465)

RelUndoLauncherMain (and the entire LauncherDbSlot machinery, Steps 1-3, back-off, and slot management below) is never registered as a background worker anywhere in the tree; only StartRelUndoWorker/WaitForPendingRelUndo are wired in (via xact.c and xactundo.c). Likewise max_relundo_workers and relundo_worker_naptime are declared as GUCs but are never registered in guc_tables.c, so they cannot be configured. This is dead scaffolding for an async path that isn't hooked up. Per YAGNI, remove it (and the two pseudo-GUCs) until the async launcher is actually wired, or wire it and register the GUCs in this patch.

Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L633-L634)

Integer overflow / unit confusion: relundo_worker_naptime is int (milliseconds); GetCurrentTimestamp()/TimestampTz are int64 microseconds. The expression relundo_worker_naptime * 4 * 1000 is evaluated in int arithmetic and only cast to TimestampTz afterwards, so large naptime values overflow int before the cast. Cast to int64/TimestampTz before multiplying, and document the *4 multiplier. (Note: this is in the currently-unreachable launcher; if that code stays, fix the arithmetic.)

Confidence: high.

💡 Suggested change

Before:

				if (now - db_slots[free_slot].last_spawn_attempt <
					(TimestampTz) relundo_worker_naptime * 4 * 1000)

After:

				if (now - db_slots[free_slot].last_spawn_attempt <
					(TimestampTz) relundo_worker_naptime * 4 * INT64CONST(1000))

📄 src/backend/access/undo/relundo_worker.c (L432-L435)

Use bounded string ops per tree convention: replace sprintf with strlcpy for bgw_library_name/bgw_function_name (e.g. strlcpy(worker.bgw_library_name, "postgres", BGW_MAXLEN)). This block is also an exact duplicate of StartRelUndoWorker's BackgroundWorker setup, differing only in bgw_notify_pid -- factor into a shared helper (DRY).

Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L692-L694)

Same as launcher_spawn_worker: use strlcpy instead of sprintf for bgw_library_name/bgw_function_name, and de-duplicate the BackgroundWorker setup shared with launcher_spawn_worker.

Confidence: high.


📄 src/backend/access/undo/relundo_worker.c (L715-L717)

Stale/contradictory comment: this header claims the worker acquires AccessExclusiveLock and that the path is "truly synchronous", but process_relundo_work_item now opens the relation with RowExclusiveLock (and its own comment explains AccessExclusiveLock was removed). Reconcile this comment with the actual RowExclusiveLock level. Separately, verify the concurrency-safety argument: RowExclusiveLock does not exclude concurrent DML on the same tuples being physically restored during rollback -- confirm RelUndoApplyChain's per-buffer locking makes this safe, otherwise the lock-level relaxation is a correctness hazard.

Confidence: high (comment mismatch); moderate (concurrency-safety, depends on RelUndoApplyChain).


📄 src/backend/access/undo/relundo_recovery.c (L344-L347)

pd_lower is read straight from the (possibly torn/corrupt) on-disk page header and used unvalidated as both the maxrecs sizing input and the scan-loop bound. pd_lower is a uint16 and is contents-relative, but the usable contents region is only BLCKSZ - MAXALIGN(SizeOfPageHeaderData) bytes (~8168 for BLCKSZ=8192). If a corrupt or torn UNDO data page yields pd_lower larger than that (up to 65535), the loop below does memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader) with offset running well past the end of the BLCKSZ buffer page -- an out-of-bounds read during crash recovery (potential crash / read of adjacent shared-buffer memory).

Note the redo path already guards this exact invariant: relundo_xlog.c PANICs when undohdr->pd_lower > BLCKSZ. Unlike prev_blkno (which this module carefully caps against a corrupt cycle) and the metapage magic (validated in RelUndoRecoveryScanOneFork), data-page pd_lower gets no sanity check here. Validate it against the usable page size before use, e.g.:

if (pd_lower > BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) { UnlockReleaseBuffer(buf); ... }

(high confidence)

💡 Suggested change

Before:

	pd_lower = hdr->pd_lower;

	/* Upper bound on records: each record is at least a header in size. */
	maxrecs = (pd_lower > SizeOfRelUndoPageHeaderData)

After:

	pd_lower = hdr->pd_lower;

	/*
	 * Defend against a corrupt/torn page: pd_lower is contents-relative and
	 * must not exceed the usable contents area, or the record scan below
	 * reads past the end of the BLCKSZ buffer page.  The redo path enforces
	 * the same invariant (relundo_xlog.c).
	 */
	if (pd_lower > (uint16) (BLCKSZ - MAXALIGN(SizeOfPageHeaderData)))
	{
		UnlockReleaseBuffer(buf);
		return prev;
	}

	/* Upper bound on records: each record is at least a header in size. */
	maxrecs = (pd_lower > SizeOfRelUndoPageHeaderData)

📄 src/backend/access/undo/relundo_recovery.c (L368-L370)

offset and rhdr.urec_len are both uint16, so offset += rhdr.urec_len can wrap around 65535 on a corrupt page (the only lower bound checked is urec_len < SizeOfRelUndoRecordHeader, which does not bound how large urec_len is). After a wrap, offset becomes small again, offset < pd_lower holds again, and the scan re-reads bogus offsets. The nrecs < maxrecs cap prevents an unbounded loop, but the collected offsets[]/xids[] can then point at garbage that is fed into MakeRelUndoRecPtr/RelUndoApplyRecordForRecovery. Add an upper-bound check that the record stays within pd_lower, e.g. reject when rhdr.urec_len > pd_lower - offset (and require offset + SizeOfRelUndoRecordHeader <= pd_lower before the memcpy). (moderate confidence)

💡 Suggested change

Before:

		/* A zero-length or malformed record terminates the scan defensively. */
		if (rhdr.urec_len < SizeOfRelUndoRecordHeader)
			break;

After:

		/* A malformed record (too short, or overruns pd_lower) terminates the scan. */
		if (rhdr.urec_len < SizeOfRelUndoRecordHeader ||
			rhdr.urec_len > pd_lower - offset)
			break;

📄 src/backend/access/undo/relundo_recovery.c (L294-L297)

A corrupt prev_blkno cycle here is promoted to ereport(ERROR), which during PerformWalRecovery escalates to a fatal startup failure -- a single corrupt UNDO fork then prevents the whole cluster from starting. This is a legitimate design choice (fail-loud on corruption), but it is worth reconsidering whether WARNING + skip-this-fork (the ResetUnloggedRelations tolerance philosophy for dangling/damaged on-disk state) is the better failure mode for a per-relation artifact. Additionally, this ERROR unwinds without reaching FreeFakeRelcacheEntry(rel); that leak is harmless only because the ERROR is fatal to startup and the process exits -- if this routine is ever reused in a non-fatal context, the fake relcache entry and its smgr reference leak. (low confidence -- design/robustness, not a hard bug)


📄 src/backend/access/undo/relundo_xlog.c (L130-L132)

Redo diverges from the do-time INIT path: RelUndoInitRelation (relundo.c:1318) sets meta->system_alloc_watermark = InvalidBlockNumber, but this redo function never sets it. Since XLogInitBufferForRedo returns an RBM_ZERO_AND_LOCK (zeroed) page, system_alloc_watermark is left as 0 after replay, while the primary has InvalidBlockNumber (0xFFFFFFFF). This is a genuine primary/replica (and post-crash) divergence: relundo_mask explicitly does not mask metapage block pointers, so wal_consistency_checking will flag it, and 0 is a valid block number (the metapage itself) that later allocation/discard logic treats differently from InvalidBlockNumber. Set it here to match do-time.

💡 Suggested change

Before:

	meta->free_blkno = InvalidBlockNumber;
	meta->total_records = 0;
	meta->discarded_records = 0;

After:

	meta->free_blkno = InvalidBlockNumber;
	meta->total_records = 0;
	meta->discarded_records = 0;
	meta->system_alloc_watermark = InvalidBlockNumber;

📄 src/backend/access/undo/relundo_xlog.c (L757-L758)

Robustness gap for consistency checking: unlike the standard mask_unused_space() (bufmask.c), which sanity-checks pd_lower/pd_upper before memset, this reads pd_lower/pd_upper directly from the (potentially divergent) page and does an unguarded memset(contents + lower, MASK_MARKER, upper - lower). relundo_mask runs under wal_consistency_checking precisely on pages that may be corrupt/divergent; a bad pd_upper (> BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) or pd_lower drives an out-of-bounds write. Add bounds validation on lower/upper (0 <= lower <= upper <= usable contents size) before the memset, matching the convention.


📄 src/backend/access/undo/slog_flathash.c (L523-L527)

DSA before-image leak on hot rows. This reclaim path selects the victim slot gated only on the xid horizon (lines above); it never checks DsaPointerIsValid(entry->ops[i].before_image_dp). Here it overwrites before_image_dp with InvalidDsaPointer without calling dsa_free. Unlike the cleanup and abort paths in slog.c (SLogTupleCleanupRetained / abort processing), which collect the dp before nulling it and then dsa_free() it after the seqlock cycle, the INSERT caller (SLogTupleInsert) never collects this dp. A below-horizon committed UPDATE marker can still carry a valid before-image (the surrounding comment itself only assumes unstamped markers have an invalid dp; it does not verify it), so reclaiming such a slot leaks the DSA-resident SLogBeforeImage. Either skip markers with a valid before_image_dp (leave them to the collect-then-free cleanup path) or free the dp here.


📄 src/backend/access/undo/slog_flathash.c (L106-L109)

The mask h & (uint32)(ht->capacity - 1) in the probe/insert paths is only a valid, complete index mask when capacity is a power of two; otherwise it degenerates and probing skips buckets or reads a wrong slot. The struct comment already documents "capacity (power of 2)", and current callers happen to pass a power of two, but this invariant is unenforced at the boundary. Add Assert((capacity & (capacity - 1)) == 0) (and capacity > 0) here so a future caller that passes a non-power-of-two fails loudly instead of silently corrupting lookups.

💡 Suggested change

Before:

	ht->capacity = capacity;
	ht->num_entries = 0;
	ht->num_tombstones = 0;
	ht->padding = 0;

After:

	Assert(capacity > 0 && (capacity & (capacity - 1)) == 0);

	ht->capacity = capacity;
	ht->num_entries = 0;
	ht->num_tombstones = 0;
	ht->padding = 0;

📄 src/backend/access/undo/slog_flathash.c (L57-L61)

Dead parameter (YAGNI). max_backends is explicitly discarded and only "retained for call-site compatibility". This is unused flexibility that PostgreSQL minimalism discipline flags. Since this is a brand-new file/API (no back-branch ABI to preserve), drop max_backends from both SLogFlatHashShmemSize and SLogFlatHashPartitionedShmemSize (and their header declarations and the two call sites in slog.c) to keep the API minimal.


📄 src/backend/access/undo/slog_flathash.c (L248-L248)

Non-ASCII em-dash character (U+2014). The PostgreSQL tree requires ASCII-only source; git diff --check and pgindent conventions expect plain ASCII. Replace the em-dashes throughout this file's comments (e.g. this line, plus the ones at "No free slot -- first collapse", "reclaim the oldest", "Safe to reclaim", "No free slot -- operation lost") with --.

💡 Suggested change

Before:

			/* Definitive miss — use first_free if we found one, else this */

After:

			/* Definitive miss -- use first_free if we found one, else this */

📄 src/backend/access/undo/undoapply.c (L302-L304)

Missing cycle/forward-chain guard: the loop follows batch->header.chain_prev unconditionally. The recovery path in undo_xlog.c (UndoRedoAborted, ~line 973) explicitly guards this same chain walk with if (XLogRecPtrIsValid(next_lsn) && next_lsn >= batch_lsn) ereport(PANIC, ...) to prevent an infinite loop on a corrupt or forward-pointing chain_prev. Here there is no such guard, so a corrupt chain_prev (>= current batch_lsn) sends this loop into an infinite loop during rollback. Add the same monotonicity check before assigning batch_lsn.

💡 Suggested change

Before:

		/* Follow chain to previous batch */
		batch_lsn = batch->header.chain_prev;
		UndoFreeBatchData(batch);

After:

		/*
		 * Follow chain to previous batch.  chain_prev must be strictly older
		 * than the current batch (or invalid); a forward-pointing or equal LSN
		 * indicates a corrupt chain and would loop forever.
		 */
		{
			XLogRecPtr	next_lsn = batch->header.chain_prev;

			if (XLogRecPtrIsValid(next_lsn) && next_lsn >= batch_lsn)
				ereport(PANIC,
						(errmsg("UNDO rollback: chain_prev %X/%X >= batch_lsn %X/%X; corrupt UNDO chain",
								LSN_FORMAT_ARGS(next_lsn),
								LSN_FORMAT_ARGS(batch_lsn))));
			UndoFreeBatchData(batch);
			batch_lsn = next_lsn;
		}

📄 src/backend/access/undo/undoapply.c (L290-L291)

ApplyOneUndoRecord is always called with InvalidUndoRecPtr, so the urec_ptr forwarded to rm_undo is always 0. That value is not just used for logging: it is stamped into the CLR WAL record (xl_undo_apply.urec_ptr) by every RM callback -- e.g. recno_undo.c emit_recno_undo_clr() xlrec.urec_ptr = urec_ptr, nbtree_undo.c, hash_undo.c. As a result every CLR emitted on the WAL-based rollback path records urec_ptr=0, and all diagnostic messages (record at %llu) print 0. The real UndoRecPtr is recoverable during the forward scan (it can be reconstructed from the batch's log/offset while collecting record_starts) and should be passed instead of InvalidUndoRecPtr.


📄 src/backend/access/undo/undoapply.c (L257-L260)

Silent partial rollback on a corrupt record contradicts this file's own stated design goal. When the forward scan hits a truncated header or invalid urec_len, it emits only a WARNING and breaks, so every record that appears after the corrupt point in serialization order is never collected and thus never applied -- yet the function still returns true (batches_processed > 0). The comment above ("silently truncating rollback is a data-integrity bug ... a partially-applied in-place UPDATE/DELETE is left on the page") describes exactly this hazard, but the code creates it here. During crash recovery this leaves the transaction partially reverted while callers treat the run as successful. Consider ereport(ERROR)/PANIC on a malformed record during recovery rather than continuing with a partial batch.


📄 src/backend/access/undo/undo_xlog.c (L272-L275)

Buffer overflow during redo: tuple_len comes straight from the WAL record and is only checked for > 0 (and datalen >= tuple_len via an Assert, which is compiled out in production). It is never validated against the item's existing on-page storage. ItemIdSetNormal() then grows the line pointer to tuple_len and memcpy(htup, data, tuple_len) writes tuple_len bytes into the page item. If a corrupt/mismatched CLR carries a tuple_len larger than the space actually reserved for this line pointer, the memcpy overruns into adjacent page data, corrupting the page during crash recovery. The recno forward-apply path (apply_recno_undo_restore_tuple) guards this with if (ItemIdGetLength(lp) < old_len) { skip }; the redo path must re-derive the same invariant at runtime (not via Assert). Confidence: high.


📄 src/backend/access/undo/undo_xlog.c (L349-L351)

Buffer overflow during redo (delta path): the length validation is insufficient. old_tuple_len equals prefix_len + changed_len + suffix_len, but changed_len is only bounded by the WAL data length (datalen < hdr_size + changed_len), never by cur_len. The checks confirm prefix_len + suffix_len <= cur_len, but nothing bounds old_tuple_len against cur_len. So a CLR with a large changed_len yields old_tuple_len > cur_len; ItemIdSetNormal(lp, ..., old_tuple_len) grows the item and memcpy(cur_htup, restored, old_tuple_len) writes past the item's storage into adjacent page memory, corrupting the page during recovery. Add a runtime check that old_tuple_len <= cur_len (or <= MaxHeapTupleSize and within the item's actual storage) before writing back. Confidence: high.


📄 src/backend/access/undo/undo_xlog.c (L1007-L1008)

Wrong database OID for deferred rollback. During crash recovery PerformUndoRecovery() runs in the startup process, which has no user database selected, so MyDatabaseId is InvalidOid. The deferred entry is later handed to ATMAddAborted(deferred->xid, deferred->dboid, ...) in FlushDeferredUndoXacts(). The logical revert worker filters ATM entries with if (entry_dboid != MyDatabaseId) continue; (logical_revert_worker.c), so an entry stored with dboid == InvalidOid will never match a worker connected to a real database, and the deferred transaction is silently never rolled back. Derive the originating database OID from the UNDO record's relation (urec_reloid) instead of MyDatabaseId. Confidence: high.


📄 src/backend/access/undo/undo_xlog.c (L164-L165)

Unlocked mutation of shared UNDO state during redo. undo_redo writes discard_ptr, oldest_xid, state, seal_ptr, in_use, log_number (and only some fields via pg_atomic) with no lock, whereas the normal path (undolog.c: UndoLogDiscard, CheckPointUndoLog, UndoLogGetOldestDiscardPtr) reads/writes discard_ptr under log->lock. The checkpointer runs restartpoints during recovery (do_restartpoint = RecoveryInProgress()) and CheckPointUndoLog() reads discard_ptr under log->lock; concurrently the startup process mutates it here unlocked -> torn read / observing a half-initialized slot (plain-C in_use/log_number writes race with the reader). Take log->lock around these mutations to match the normal-path locking protocol. Confidence: high.


📄 src/backend/access/undo/undo_xlog.c (L630-L630)

Non-ASCII characters violate the ASCII-only source rule (hard style gate). This line uses the multiplication sign and the approximately-equal sign. Replace with ASCII, e.g. "1 million entries x ~40 bytes each is about 40 MB". Confidence: high.

💡 Suggested change

Before:

 * 1 million entries × ~40 bytes each ≈ 40 MB, which is reasonable for

After:

 * 1 million entries x ~40 bytes each is about 40 MB, which is reasonable for

📄 src/backend/access/undo/undo_xlog.c (L1213-L1213)

Non-ASCII em-dash in comment violates the ASCII-only source rule. Use "--" or a comma instead of the em-dash character. Confidence: high.

💡 Suggested change

Before:

	 * Compare against GetRedoRecPtr() — if our target is well behind the

After:

	 * Compare against GetRedoRecPtr() -- if our target is well behind the

📄 src/backend/access/undo/undo_xlog.c (L652-L660)

O(n^2) recovery: UndoRecoveryTrackBatch() linearly scans undo_recovery_entries for every batch record and UndoRecoveryRemoveXid() scans linearly for every commit/abort record. With up to UNDO_RECOVERY_MAX_ENTRIES (1,048,576) tracked XIDs, this makes the redo phase quadratic in the number of in-flight transactions/batches and can make crash recovery pathologically slow. Use a dynahash (HTAB) keyed by TransactionId, as recovery code elsewhere does, instead of a hand-rolled array with linear scan. Confidence: high.


📄 src/backend/access/undo/undo_xlog.c (L789-L790)

Stale/aspirational comment contradicting the code. This claims "CLRs are generated during this phase to ensure idempotency in case of a crash during the undo phase itself." But PerformUndoRecovery() runs before WAL insertion is enabled (InRedo is still true; see the call site in xlogrecovery.c which sets InRedo=false only afterwards) and rm_undo is invoked with InvalidUndoRecPtr - no CLR is written here. Idempotency actually relies solely on the page-LSN checks documented lower down. Update this comment to describe the real mechanism; a crash during this phase re-runs the same page-LSN-guarded apply, it does not emit compensation records. Confidence: high.


📄 src/backend/access/undo/undo_xlog.c (L475-L475)

Dead scaffolding (YAGNI). This is a brand-new resource manager in a fork; there is no released/legacy WAL format to be backward-compatible with, so XLOG_UNDO_PAGE_WRITE can never appear in any existing WAL stream. Along with the unused xl_undo_page_write struct in undo_xlog.h, this is dead code that should be removed rather than committed. Confidence: moderate.


📄 src/backend/access/undo/undo_xlog.c (L37-L49)

Meta-review commentary should not be committed to source. These two large FIXME(reviewer-item-2) blocks are process/thread commentary (why a refactor is deferred, what an ABI change would entail) rather than an explanation of what the code does. Track the deferred rmgr-callback refactor in the PR/-hackers thread and keep only a short, factual note here (or none). Confidence: moderate.


📄 src/backend/access/undo/undobuffer.c (L333-L335)

Non-ASCII em-dash in a comment violates the ASCII-only source rule (hard style gate). Replace with an ASCII "--".

💡 Suggested change

Before:

* XLogInsert() returns the end+1 position; the rollback path re-reads the
 * batch by its START LSN.  ProcLastRecPtr is the start of the record we
 * just inserted.  See the matching comment in UndoRecordSetInsert().

After:

	 * Compare against GetRedoRecPtr() -- if our target is well behind the

📄 src/backend/access/undo/undoinsert.c (L126-L128)

This rationale is factually wrong for two of the three call paths, and it defeats the injection points on those paths.

UndoRecordSetInsert() does NOT always run inside a critical section: NbtreeUndoLogInsert() calls it at nbtinsert.c:1456 after END_CRIT_SECTION() (the comment there even reads "This is done after the critical section"), and again at nbtinsert.c:1256 after _bt_split's crit section has ended; NbtreeUndoLogDedup() and hash_undo_insert() likewise call it with no surrounding START_CRIT_SECTION().

More importantly, only the xactundo.c path issues INJECTION_POINT_LOAD("undo-batch-before-wal-insert"/"-after-") (in PrepareXactUndoData()). The nbtree/hash direct-insert paths never call PrepareXactUndoData, so no LOAD is ever issued for them; INJECTION_POINT_CACHED will therefore silently miss and the injection points will never fire on those paths even when attached -- a test-fidelity gap.

Either use INJECTION_POINT()/INJECTION_POINT_RUN here (safe: these callers are outside a crit section), guarding the CACHED variant only for the true in-crit-section caller, or ensure every path issues the matching LOAD pre-crit. At minimum, fix the comment: the "always runs inside the caller's critical section" claim is untrue.


📄 src/backend/access/undo/undoinsert.c (L109-L109)

uset->buffer_size is Size (64-bit) and total_len is uint32; this cast silently truncates for batches >= 4GB, corrupting the WAL record with no diagnostic. The 256KB/1000-record flush threshold applies to the UndoBuffer piggyback path, not to a standalone UndoRecordSet, so buffer_size is not bounded on this path. Add a defensive check before the cast, e.g. if (uset->buffer_size > PG_UINT32_MAX) elog(ERROR, ...) (or an Assert if the invariant is guaranteed elsewhere).


📄 src/backend/access/undo/undorecord.c (L32-L34)

Comment is inaccurate for the commit path. UndoRecordSetFree() re-parents the context to TopMemoryContext and stashes it in UndoRecordReusableContext, but AtCommit_XactUndo (xactundo.c) never calls UndoRecordSetResetCache() -- only AtAbort_XactUndo does. So after every committing transaction that wrote UNDO, one recycled context (retaining its grown buffer, buffer_capacity may be large after bulk inserts) lives on under TopMemoryContext until the next transaction happens to reuse or reset it. That contradicts this comment and is an unbounded cross-transaction retention hazard. Either call UndoRecordSetResetCache() on the commit path too, or clear the cache on both end paths.


📄 src/backend/access/undo/undorecord.c (L53-L55)

UndoRecordSerialize() has no callers anywhere in the tree; the actual record building is done inline in UndoRecordAddPayload()/UndoRecordAddPayloadParts(). Dead API surface (YAGNI) -- remove it, or wire it in so the assembly logic isn't duplicated.


📄 src/backend/access/undo/undorecord.c (L73-L75)

UndoRecordDeserialize() has no callers; consumers on the redo/apply path (undo_xlog.c, undoapply.c) read the header with their own memcpy into a local struct instead. Dead API surface (YAGNI) -- remove it or route the readers through it.


📄 src/backend/access/undo/undorecord.c (L316-L324)

payload_len (Size, 64-bit on LP64) is truncated to a uint32 on-disk length field with no bounds check. record_size is likewise cast. A payload >= 4GB would silently truncate urec_len/urec_payload_len, producing a corrupt record whose reader (which trusts urec_len) reads too few bytes. Add an explicit guard, e.g. ereport(ERROR)/elog on payload_len > PG_UINT32_MAX - SizeOfUndoRecordHeader, so oversized payloads fail loudly rather than corrupt UNDO.


📄 src/backend/access/undo/undorecord.c (L223-L227)

The doubling loop can overflow: if buffer_size + additional is near SIZE_MAX, new_capacity *= 2 wraps to a small value and the while condition can spin or MemoryContextAlloc gets a tiny/wrapped size, leading to a heap overflow on the subsequent memcpy. buffer_size + additional itself can also overflow. Add an overflow check before doubling (bounded by MaxAllocSize), consistent with how core buffer-growth code guards capacity.


📄 src/backend/access/undo/undorecord.c (L243-L244)

Unverifiable micro-benchmark claims ('~5 cycles vs ~300 cycles') in a comment describe rationale, not behavior, and violate pgsql-hackers norms (performance claims need a reproducible benchmark, not inline cycle counts). Drop the cycle numbers and keep the functional explanation.


📄 src/backend/access/undo/undolog.c (L180-L185)

Data race: discard_ptr is a plain (non-atomic) 64-bit UndoRecPtr, yet it is read here without holding log->lock. Every other reader of this field takes the lock (UndoLogGetDiscardPtr and CheckPointUndoLog acquire LW_SHARED; UndoLogDiscard writes under LW_EXCLUSIVE). On platforms where 64-bit reads are not atomic this can observe a torn/stale value. Acquire log->lock (LW_SHARED) around the read to match the rest of this file. Note also that this function has no callers anywhere in the tree, so it appears to be dead code that could simply be removed instead.


📄 src/backend/access/undo/undolog.c (L182-L184)

The < comparison here compares the full UndoRecPtr, whose high 24 bits are the log number (see UndoRecPtrGetLogNo, bits 40-63) and low 40 bits the offset. Across different logs this compares log numbers, not logical age, so the computed "oldest" is not meaningful when more than one log is in use. Contrast with UndoLogDiscard, which correctly compares via UndoRecPtrGetOffset() after matching log_number.


📄 src/backend/access/undo/undolog.c (L423-L425)

This comment is inaccurate on two counts. (1) There is no second (non-WAL) UNDO mode in the tree -- there is no compile-time or runtime toggle -- so "stay uniform across both UNDO modes" is misleading; the stubs exist only for legacy callers of the single WAL mode. (2) The rationale claims these all "still have live callers," but several of the functions defined below have zero callers in the tree: UndoLogSync, UndoFlushGetMaxWritePtr, ExtendUndoLogSmgrFile, UndoLogTryPressureDiscard, UndoLogGetInsertPtr, UndoLogGetDiscardPtr, UndoLogGetOldestDiscardPtr and UndoLogPath. Per YAGNI, these dead stubs should be removed (along with their header declarations) rather than kept, and the comment corrected to describe only the stubs that genuinely have callers (UndoLogCloseFiles, UndoFlushResetMaxWritePtr, UndoLogDeleteSegmentFile, ExtendUndoLogFile, UndoLogSealAndRotate).


📄 src/backend/access/undo/undolog.c (L473-L478)

Dead, misleading helper: UndoLogPath has no callers anywhere in the tree, and by this file's own account the base/undo/ segment files no longer exist. A function that hands back a path to a nonexistent file is a footgun (a future caller may attempt real I/O on it). Remove it (and its header declaration) rather than keeping it as a stub.


📄 src/backend/access/undo/undormgr.c (L36-L36)

Minor: the check uses UNDO_RMID_INVALID but the message hardcodes "0". If UNDO_RMID_INVALID is ever redefined, this message drifts out of sync. Consider referencing the constant's value directly, e.g. "cannot register UNDO RM with invalid ID %u", UNDO_RMID_INVALID.

💡 Suggested change

Before:

		elog(ERROR, "cannot register UNDO RM with invalid ID 0");

After:

		elog(ERROR, "cannot register UNDO RM with invalid ID %u", UNDO_RMID_INVALID);

📄 src/backend/access/undo/undostats.c (L39-L41)

These three functions are declared with PG_FUNCTION_INFO_V1 but are NOT registered in pg_proc.dat (no undo, force_discard, or get_undo entries exist there). Without pg_proc entries they cannot be invoked from SQL, so the examples/*.sql and README that call pg_stat_get_undo_logs(), pg_stat_get_undo_buffers(), and pg_undo_force_discard() will fail with "function does not exist". Add catalog entries in src/include/catalog/pg_proc.dat with OIDs from the developer range (8000-9999). The tuple descriptors built below (6 columns each, with specific types) must exactly match the prorettype/proallargtypes/proargnames declared there or the SRFs will error/crash at runtime. (high confidence)


📄 src/backend/access/undo/undostats.c (L13-L14)

The header comment claims these functions are "registered in pg_proc.dat", but no such entries exist. This is an aspirational/inaccurate comment that will mislead reviewers and maintainers. Update the comment to match reality (or, preferably, add the pg_proc.dat entries and then the comment becomes true). (high confidence)


📄 src/backend/access/undo/undostats.c (L363-L365)

This Phase 2 block frees a DISCARDABLE log slot but, unlike the canonical worker code in undoworker.c (which does pg_atomic_fetch_sub_u32(&UndoWorkerShmem->sealed_log_count, 1) at the equivalent point), it omits the sealed_log_count decrement. Since a log reaching DISCARDABLE was previously SEALED and counted in sealed_log_count, freeing it here leaves the counter over-counted. The worker reads sealed_log_count to drive its adaptive sleep (naptime), so it will believe sealed logs are permanently pending and spin at the fast naptime indefinitely; combined with the worker's own later decrements this can even underflow the unsigned counter. Decrement sealed_log_count here to match the worker. (high confidence)

💡 Suggested change

Before:

			UndoLogDeleteSegmentFile(log_number);
+			freed_count++;
+			continue;

After:

			UndoLogDeleteSegmentFile(log_number);
			pg_atomic_fetch_sub_u32(&UndoWorkerShmem->sealed_log_count, 1);
			freed_count++;
			continue;

📄 src/backend/access/undo/undostats.c (L298-L306)

This entire Phase 1 + Phase 2 block is a hand-copied duplicate of UndoDiscardWorker's discard logic in undoworker.c, and it has already diverged: (a) the worker advances the discard pointer via UndoLogDiscard() (which validates log-number matching) whereas here you assign log->discard_ptr = insert_ptr directly and additionally overwrite log->oldest_xid, which the worker's Phase 1 does not do; (b) the sealed_log_count bookkeeping is missing (see separate comment). Two copies of lifecycle-transition code that mutate shared on-disk-related state and delete segment files will drift and race. Factor the discard/lifecycle logic into a shared helper in undoworker.c/undolog.c and call it from both the worker and this function, rather than duplicating it. (high confidence)


📄 src/backend/access/undo/undostats.c (L232-L233)

GetUndoBufferStats always returns zeros (buffer stats are no longer tracked), so this function unconditionally reports num_buffers=0, all counters=0, and a hit_ratio of 0.0. Exposing a permanently-zero observability view is misleading to operators (POLA/minimalism): either drop pg_stat_get_undo_buffers and its columns, or reconcile it with a real data source. Note this compounds the fact that it is not even registered in pg_proc.dat. (moderate confidence)


📄 src/backend/access/undo/undoworker.c (L273-L278)

TOCTOU / unlocked-metadata access. log->oldest_xid and log->insert_ptr are read under LW_SHARED, then the lock is released before UndoLogDiscard() runs. log->lock is documented as protecting metadata, yet lines 284-287 read log->log_number after the lock was released. Between release and the discard, another backend may seal, free, or reallocate this slot, so the discard/counter can act on a stale or reused log. Hold the lock (or re-validate in_use/log_number) across the discard, or snapshot log->log_number into a local before releasing.


📄 src/backend/access/undo/undoworker.c (L129-L130)

shutdown_requested is a plain bool in shared memory (confirmed in undoworker.h), but it is written from the SIGTERM handler and read in the main loop while (!UndoWorkerShmem->shutdown_requested) with no barrier. Writing/reading a non-sig_atomic_t field from async-signal context is not async-signal-safe and the plain read may be cached/reordered, risking a missed or delayed shutdown. Use the standard pattern: a volatile sig_atomic_t ShutdownRequestPending (or reuse ShutdownRequestPending from postmaster/interrupt.h) instead of a bool in shmem.


📄 src/backend/access/undo/undoworker.c (L341-L344)

Slot reuse race. The slot is freed (in_use=false, state=FREE, log_number=0) under LW_EXCLUSIVE, the lock is released, then UndoLogDeleteSegmentFile(log_number) runs outside the lock. A concurrent allocator can reuse the freed slot and re-create a segment with the same log_number before the delete executes, deleting a live segment file. Perform the file deletion while still holding protection against reallocation, or ensure log_number values are never reused.


📄 src/backend/access/undo/undoworker.c (L281-L282)

total_discarded accounting is wrong and double-counts. This adds the absolute offset of the current insert_ptr on every pass, not the delta discarded since the last pass. Because log->oldest_xid is not advanced after discard, the same log keeps matching the Phase-1 condition and this counter grows without bound each iteration. Note also that the reporting path (CheckPointUndoLog in undolog.c) computes bytes-discarded fresh by summing discard_ptr offsets and never reads this atomic counter, so this increment feeds a statistic nothing consumes. Remove it or track the actual delta.


📄 src/backend/access/undo/undoworker.c (L627-L635)

UndoWorkerRequestShutdown() has no callers anywhere in the tree (only its declaration). It is dead code. Worse, it sets shutdown_requested under UndoWorkerShmem->lock while the SIGTERM handler sets the same field with no lock and the main loop reads it with no lock -- an incoherent synchronization model where this LWLock provides no real protection. Remove this function (YAGNI), or unify on a single consistent access discipline for the shutdown flag.


📄 src/backend/access/undo/undoworker.c (L396-L397)

Non-portable format specifiers for 64-bit values. %lu for retained_mb (uint64) breaks on LLP64/Windows and 32-bit platforms where unsigned long is 32 bits; the project requires UINT64_FORMAT for 64-bit values. Use UINT64_FORMAT (dropping the cast) for retained_mb. (%d for undo_max_wal_retention_size, an int, is fine.)


📄 src/backend/access/undo/undoworker.c (L396-L396)

Message-style conventions: errmsg text should start lowercase, not be prefixed with a subsystem tag, and user-facing text should be wrapped in _(). This WARNING starts with uppercase "UNDO WAL retention". Prefer lowercase, e.g. "undo WAL retention (%s MB) exceeds ...". The many DEBUG/LOG lines prefixed with "UNDO worker:" also violate the no-subsystem-prefix convention.


📄 src/backend/access/undo/undoworker.c (L615-L616)

Use bounded copies for consistency and per project rules. The two calls below use snprintf for bgw_name/bgw_type but these use sprintf. Prefer snprintf(worker.bgw_library_name, BGW_MAXLEN, "postgres") and likewise for bgw_function_name.


📄 src/backend/commands/tablecmds.c (L999-L1000)

This one-line break; addition is unrelated churn and should be dropped from the patch. It has no functional effect: default is the last case in the switch, so control already falls out of the switch after heap_reloptions() whether or not break; is present. This change is also unrelated to the stated purpose of the PR (the recno TAM / undo feature). Per PostgreSQL minimal-diff discipline, avoid touching code not required by the change. (high confidence)

💡 Suggested change

Before:

			(void) heap_reloptions(relkind, reloptions, true);
+			break;

After:

			(void) heap_reloptions(relkind, reloptions, true);

📄 src/backend/commands/dbcommands.c (L507-L507)

Gratuitous (int) cast on write()'s result deviates from upstream and PostgreSQL convention (write() returns ssize_t). Since nbytes is size_t, casting the result to int produces a signed/unsigned comparison: it happens to work here (a short/error return still mismatches nbytes), but the cast is unnecessary for the change and hurts minimal-diff cleanliness. The original code used write(fd, buf, nbytes) != nbytes without a cast. Drop the cast in both this and the redo branch below.

💡 Suggested change

Before:

		if ((int) write(fd, buf, nbytes) != nbytes)

After:

		if (write(fd, buf, nbytes) != nbytes)

📄 src/backend/commands/dbcommands.c (L505-L526)

The non-redo and redo branches now contain near-identical PG_VERSION write/fsync/close sequences (write with ENOSPC fallback, WAIT_EVENT_VERSION_FILE_WRITE, pg_fsync, fsync_fname(dbpath, true), CloseTransientFile). Only the fd-acquisition differs (FileOpsCreate vs OpenTransientFile). This duplicated block harms maintainability (DRY) and risks the two paths drifting. Consider acquiring fd in the branch and sharing the single write/fsync/close tail, which also shrinks the diff.


📄 src/backend/access/undo/xactundo.c (L305-L310)

nestingLevel stores two different value domains, which is a latent rollback-correctness hazard. Here (and in PrepareXactUndoDataParts) it is set to GetCurrentTransactionNestLevel() -- a nesting depth (1,2,3...). But XactUndo_SubXactCallback sets s->nestingLevel = mySubid -- a SubTransactionId (a global counter unrelated to depth), and AtSubAbort_XactUndo/AtSubCommit_XactUndo are invoked with mySubid and compare (int) cur->nestingLevel != level. If an entry pushed via this depth-based path is later matched against a mySubid in AtSubAbort_XactUndo, the comparison fails, the function returns early WITHOUT applying the subtransaction's UNDO and WITHOUT decrementing subxact_depth -- silently skipping rollback of that subtransaction's writes. Use a single consistent identifier domain for nestingLevel.


📄 src/backend/access/undo/xactundo.c (L660-L667)

This function-header comment is stale and directly contradicts the function body. It states the work is "queued for the background per-relation UNDO worker" and that "Per-relation UNDO cannot be applied inline here," but the body below applies each chain INLINE via table_open()/RelUndoApplyChain() and only falls back to the worker when an inline apply fails. Update the comment to describe the current inline-first behavior (the inline rationale is already correctly explained in the body comment at the loop).


📄 src/backend/access/undo/xactundo.c (L753-L757)

AtAbort_XactUndo is documented as a post-abort step that MUST NOT FAIL, yet the fallback path here calls RelUndoQueueAdd() and StartRelUndoWorker() outside any PG_TRY. RelUndoQueueAdd may palloc / exhaust shared memory, and StartRelUndoWorker (RegisterDynamicBackgroundWorker) can fail/ereport when worker slots are exhausted. An ERROR escaping here during abort cleanup escalates toward FATAL/PANIC. Confirm these cannot ereport(ERROR), or guard them.


📄 src/backend/access/undo/xactundo.c (L999-L999)

Stray run of blank lines after the WARNING (git diff --check / pgindent defect). Remove the extra blank lines.

💡 Suggested change

Before:

							elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId());




After:

							elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId());


📄 src/backend/access/undo/xactundo.c (L580-L581)

The relundo_list allocation context contradicts the comments. Here entries are allocated in TopTransactionContext, but both the XactUndoData struct comment and ResetXactUndo() state they "live in CurTransactionContext." These differ inside a subtransaction. Since ResetXactUndo() only NULLs the head pointer and relies on context teardown, the comments should be corrected to say TopTransactionContext to avoid future confusion about lifetime.


📄 src/backend/commands/tablespace.c (L887-L890)

Trailing whitespace / stray blank lines: this WARNING elog is followed by three blank lines inside the block. This fails git diff --check and pgindent. Remove the extra blank lines.

💡 Suggested change

Before:

ereport(saved_errno == ENOENT ? LOG : LOG,
		(errcode_for_file_access(),
		 errmsg("could not remove directory \"%s\": %m",
				linkloc)));

After:

							elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId());
					}

📄 src/backend/commands/tablespace.c (L887-L890)

This WARNING message exceeds the surrounding wrapping style and is not wrapped in _() for translation, unlike the identical message a few lines below in the large-transaction branch (which is wrapped and split). Make the two consistent: wrap the user-facing text with errmsg/_() and match line-length conventions.


📄 src/backend/executor/nodeModifyTable.c (L5827-L5836)

The UNDO write buffer this enables (undo_t2buf in undobuffer.c) is a single process-global buffer that can only be active for one relation at a time. UndoBufferBegin() flushes and ends any previously active relation when called for a different relid. Looping table_begin_bulk_insert() over every result relation is therefore broken for multi-relation ModifyTable (partitioned/inherited UPDATE/DELETE): each iteration tears down the previous relation's buffer, so only the last relation stays active. Moreover, at ExecInitModifyTable time no tuples have been produced yet, so this loop merely leaves the last relation's buffer active for the whole executor run. Reconsider whether begin should be issued here at all, or only for the single-relation case.

Confidence: high.


📄 src/backend/executor/nodeModifyTable.c (L5827-L5828)

begin/finish are asymmetric: begin is gated on CMD_INSERT/UPDATE/DELETE, but finish below is called unconditionally for all operations. CMD_MERGE (which performs INSERT/UPDATE/DELETE work) never gets a begin, so it misses the optimization while still incurring a no-op finish. Either handle CMD_MERGE consistently in both places or document why it is intentionally excluded.

Confidence: high.


📄 src/backend/executor/nodeModifyTable.c (L5830-L5835)

For INSERT into a partitioned table, leaf partitions are set up via tuple routing during execution and are not present in mtstate->resultRelInfo at init time, so table_begin_bulk_insert() is never invoked for the relations that actually receive the rows. The comment claims the optimization is enabled for INSERT of any size, but partitioned INSERT (a common bulk path) is not covered here. Consider hooking begin/finish where the routing ResultRelInfo is created/cleaned up (ExecInitPartitionInfo / ExecCleanupTupleRouting).

Confidence: moderate.


📄 src/backend/executor/nodeModifyTable.c (L5876-L5877)

table_finish_bulk_insert() is called for every result relation including relations whose AM has no begin_bulk_insert/finish_bulk_insert support and, for MERGE, cases where begin was never issued. This works today only because recno_finish_bulk_insert -> UndoBufferEnd early-returns when the global buffer is inactive, i.e. correctness relies on a single shared global buffer. If this per-relation call is intended to end that relation's buffer, note UndoBufferEnd ignores its rel argument entirely and operates on whichever relation happens to be globally active - so finishing relation i can flush relation j's buffer. This coupling is fragile; the finish call and its assumptions should be made explicit.

Confidence: moderate.

Comment thread .github/ocr/litellm.yaml
@@ -0,0 +1,41 @@
# LiteLLM proxy config — bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ASCII-only violation. The review standard for this repository requires ASCII only in source and diffs (no em-dashes). This header comment uses an em-dash (U+2014 ). Replace with an ASCII hyphen/dash. The same non-ASCII em-dash also appears on line 24 ("Set it DIRECTLY here —"). Run git diff --check and grep for non-ASCII before submitting.

Suggested change
# LiteLLM proxy config bridges Open Code Review (OpenAI protocol) to AWS Bedrock.
# LiteLLM proxy config - bridges Open Code Review (OpenAI protocol) to AWS Bedrock.

Comment thread .github/ocr/litellm.yaml
Comment on lines +23 to +25
# "High effort" review. Claude Opus 4.8 on Bedrock uses *adaptive* thinking
# controlled by output_config.effort. Set it DIRECTLY here — NOT via
# reasoning_effort, which LiteLLM still maps to the legacy

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contradiction between this config and its own workflow. This comment asserts you must set output_config.effort DIRECTLY and that reasoning_effort is wrong because LiteLLM maps it to the legacy thinking.type.enabled Opus 4.8 rejects. But .github/workflows/ocr-review.yml pins LiteLLM to a commit specifically because it "maps reasoning_effort -> output_config.effort, incl. xhigh". Both statements cannot be true. Reconcile the comment with the actual behavior of the pinned build so the next maintainer knows the real mechanism (and whether output_config here is even honored vs. silently dropped by drop_params: true).

Comment thread .github/ocr/pg-history.py
Comment on lines +211 to +214
except Exception as e:
open(OUT, "w").write(f"_pg-history: Bedrock call failed: {e}_\n")
print(f"Bedrock error: {e}")
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MCP transport errors are mislabeled as Bedrock failures. mcp.call() (via _mcp_post -> urllib.request.urlopen) has no error handling, so a transient URLError/timeout/HTTPError during any tool call at line 204 propagates up and is caught only by this except, which then writes _pg-history: Bedrock call failed_ even though Bedrock never failed. This mislabels the failure mode and lets a single flaky tool call abort the whole run. Consider wrapping mcp.call() per-tool so a transient MCP error yields a tool-result error string instead of aborting, or at least broaden the message to cover both Bedrock and MCP.

Comment thread .github/ocr/pg-history.py
Comment on lines +220 to +221
open(OUT, "w").write(body)
print(body)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File handles are never explicitly closed here (and in every other open(OUT, "w").write(...) site in main()). This relies on CPython refcount finalization to flush and close; on PyPy or other implementations the buffered content may not be flushed promptly, and if an exception is raised mid-write the file can be left truncated. Since OUT is a persisted CI artifact consumed by a downstream workflow step, prefer with open(OUT, "w") as f: f.write(body) for a deterministic flush/close.

Suggested change
open(OUT, "w").write(body)
print(body)
with open(OUT, "w") as f:
f.write(body)
print(body)

Comment on lines +39 to +43
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AWS CLI result's exit status is never checked. If aws bedrock list-inference-profiles fails (missing bedrock permissions, region error, throttling, or aws CLI absent), out.stdout is empty, so json.loads(out.stdout or "[]") yields an empty list, opus is empty, and the script prints newer=false and exits 0 (line 50). The check then silently reports "healthy / no newer model" while never actually querying anything, defeating the entire purpose of this notify workflow. Fail loudly on a non-zero return code so a broken check is visible instead of masquerading as success. Also note the aws CLI is assumed present on ubuntu-latest with no explicit setup/version step.

Suggested change
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
ids = json.loads(out.stdout or "[]")
out = subprocess.run(
["aws", "bedrock", "list-inference-profiles", "--region", region,
"--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"],
capture_output=True, text=True)
if out.returncode != 0:
raise SystemExit(f"aws bedrock list-inference-profiles failed (rc={out.returncode}): {out.stderr}")
ids = json.loads(out.stdout or "[]")

Comment on lines +30 to +31
-- STEP 5: Commit the transaction
COMMIT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments describe STEPS 2-5 as a single transaction that is finished by an explicit COMMIT, but there is no BEGIN/START TRANSACTION anywhere in the script. Under psql's default autocommit, each INSERT/UPDATE/DELETE commits on its own, so this COMMIT runs with no open transaction and produces WARNING: there is no transaction in progress. The example therefore misleads users about how it groups the modifications. Either wrap STEPS 2-4 in an explicit transaction block or drop the COMMIT.

Suggested change
-- STEP 5: Commit the transaction
COMMIT;
-- STEP 5: Commit the transaction
COMMIT;

Comment on lines +6 to +7
-- Create a table using the recno AM (supports UNDO)
CREATE TABLE order_items (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a top-level examples/ directory that is not part of PostgreSQL's source layout and is not wired into the build, install, or test infrastructure -- it is referenced only by a hand-written examples/README.md. PostgreSQL has no root examples/ convention: runnable example SQL lives under src/test/, and feature demonstrations belong in the SGML documentation or as regression tests that actually assert their output. As unverified scaffolding these files will bit-rot silently (the "Should show:" comments are never checked by any harness). Recommend dropping this from the patch, or converting it into a proper regression test under src/test/regress/sql/ with a matching expected/ output so the claims are enforced. (high confidence)

Comment on lines +35 to +39
-- UNDO records will be applied automatically:
-- - item 3 re-inserted
-- - item 2 price restored to 49.99
-- - item 1 quantity restored to 5
-- - all 3 original inserts deleted

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rollback narration is internally contradictory as written: "item 3 re-inserted" (undo of the DELETE) immediately followed by "all 3 original inserts deleted" (undo of the INSERTs) reads as re-inserting a row only to delete it. While that is technically the LIFO undo order, the phrasing will confuse readers of an example whose whole purpose is didactic. Since nothing verifies these comments, an inaccurate/misleading description is a real defect here. Recommend rephrasing to state the net effect (the table returns to empty because the three inserts are undone, after the delete and updates are first reversed). (moderate confidence)

--
-- recno_page_items()
--
CREATE FUNCTION recno_page_items(IN page bytea,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three new SQL-callable functions are user-visible but have no corresponding entry in doc/src/sgml/pageinspect.sgml (confirmed: no references there). Per PostgreSQL contribution standards, new SQL functions require SGML documentation and regression-test coverage; without them this is WIP rather than commit-ready. (high confidence)

OUT t_len integer,
OUT t_natts smallint,
OUT t_flags smallint,
OUT t_commit_ts bigint,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OUT column is named t_commit_ts and typed bigint, but per src/include/access/recno.h the underlying RecnoTupleHeader.t_commit_ts (uint64) no longer holds a commit timestamp: its low 32 bits are the t_xmax (deleter/updater XID) and the high 32 bits are reserved. Exposing it as t_commit_ts is stale/misleading for users inspecting pages; consider renaming to reflect the actual contents (e.g. t_xmax / the raw word) so the diagnostic output is not astonishing. (moderate confidence)

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.

1 participant