Skip to content

cf 5556 - #16

Draft
gburd wants to merge 22 commits into
masterfrom
cf-5556
Draft

gburd wants to merge 22 commits into
masterfrom
cf-5556

Conversation

@gburd

@gburd gburd commented Oct 24, 2025

Copy link
Copy Markdown
Owner

No description provided.

@gburd
gburd force-pushed the cf-5556 branch 7 times, most recently from e777a6e to 650f621 Compare November 1, 2025 17:22
@gburd
gburd force-pushed the cf-5556 branch 6 times, most recently from 16e0007 to 331cd76 Compare November 7, 2025 20:56
@gburd
gburd force-pushed the cf-5556 branch 4 times, most recently from 9558f42 to 05c4e60 Compare November 16, 2025 18:53
@gburd
gburd force-pushed the cf-5556 branch 4 times, most recently from ae8af13 to 9f584af Compare November 19, 2025 18:18
@gburd
gburd force-pushed the cf-5556 branch 2 times, most recently from b142c27 to 94e88c7 Compare December 1, 2025 18:10
@gburd
gburd force-pushed the cf-5556 branch 2 times, most recently from 0d87b2f to d4f607c Compare March 10, 2026 18:18
@gburd
gburd force-pushed the master branch 3 times, most recently from 571be3b to 03facc1 Compare March 10, 2026 18:37

@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 18 issue(s).

  • 18 inline, 0 in summary

Comment thread .clangd
Comment on lines +1 to +2
Diagnostics:
MissingIncludes: None

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 is personal developer tooling (clangd config) unrelated to the HOT-indexed-updates feature. The repo's own .gitignore states that "Auxiliary files from local workflows, your preferred editor, etc. should be ignored locally using $GIT_DIR/info/exclude or ~/.gitexclude." Committing it violates the minimal-diff rule and will draw an immediate rejection on pgsql-hackers. Keep it out of the patch (use git excludes / .local-gitignore locally).

Comment thread .envrc
Comment on lines +4 to +6
source_up
watch_file flake.nix
use flake

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Personal direnv config that sources machine-specific infrastructure (~/ws/postgres/.envrc, project_steering/project_mcp helpers). This does not belong in a PostgreSQL patch and references paths/tooling that exist only on the author's machine, so it is neither reproducible nor relevant to the feature. Drop it from the patch and keep it as a local, git-excluded file.

Comment thread .gdbinit
Comment on lines +19 to +20
# Main entry: heap_update
break heapam.c:3210

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 hardcoded source line numbers are already stale and point at the wrong code in the current tree, making this file actively misleading. For example, line 3210 is annotated "Main entry: heap_update" but heapam.c:3210 is actually inside heap_delete's cleanup (heap_freetuple(old_key_tuple)); line 4019 is annotated as the "pure HOT" decision but heapam.c:4019 falls inside a comment block. Line-number breakpoints silently drift after any edit to these heavily-modified files (heapam.c alone is +280/-155 in this series). Beyond that, this is personal debugging scaffolding that should not be committed at all; drop it from the patch.

Comment thread .gdbinit
Comment on lines +88 to +89
# Redirect-with-data: initialize bitmap accumulator from collapsed redirect
break heapam_indexscan.c:182

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 line-number breakpoints are stale too: heapam_indexscan.c:182 is annotated "Redirect-with-data: initialize bitmap accumulator" but that line is actually lp = PageGetItemId(page, offnum);. Prefer symbol-based breakpoints over fragile line numbers if this file were to be kept at all -- but it should not be committed in the first place.

Comment thread .local-gitignore
Comment on lines +1 to +2
# 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 local git-exclude file should itself not be committed; it is a personal workflow artifact. The in-tree .gitignore already documents that local-workflow files belong in $GIT_DIR/info/exclude or ~/.gitexclude, not in the tracked tree. Drop from the patch.

Comment on lines +28 to +31
updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 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.

These COALESCE wrappers are dead code. pg_stat_get_tuples_updated, pg_stat_get_xact_tuples_updated, pg_stat_get_tuples_hot_updated, and pg_stat_get_xact_tuples_hot_updated are implemented via the PG_STAT_GET_RELENTRY_INT64 / xact macros, which return 0 (never NULL) for a missing stats entry, and rel_oid is non-NULL here. The COALESCE(..., 0) add noise without changing behavior; per minimalism, drop them. (moderate confidence)

Suggested change
updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
updates := pg_stat_get_tuples_updated(rel_oid) +
pg_stat_get_xact_tuples_updated(rel_oid);
hot := pg_stat_get_tuples_hot_updated(rel_oid) +
pg_stat_get_xact_tuples_hot_updated(rel_oid);

Comment thread contrib/pageinspect/sql/hot_updates.sql Outdated
Comment on lines +93 to +94
WHILE position < max_iterations LOOP
line_ptr := (current_ctid::text::point)[1]::int;

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 WHILE termination guard is incomplete. The inner FOR returns 0 rows when line_ptr points at a non-existent line pointer (a dangling t_ctid); in that case the loop body is skipped and current_ctid/position are unchanged. The only break-out is IF position = 0 THEN RETURN, which fires solely on the first iteration. For a dangling pointer reached after at least one hop (position > 0), the loop spins with no progress until max_iterations (100), emitting nothing further. It terminates, but the guard should break whenever the FOR found no matching line pointer rather than relying on position = 0. (moderate confidence)

Comment on lines +6365 to +6368
else
deconstruct_array_builtin(DatumGetArrayTypeP(c->constvalue),
TEXTOID, &elems, &nulls,
&nelems);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Type-check asymmetry with the read-path helpers. jsonb_subattr_text_path() guards on c->consttype == TEXTARRAYOID before calling deconstruct_array_builtin(), but this MODIFY branch (and the F_JSONB_DELETE_JSONB_TEXT single-key branch above, and the F_JSONB_CONCAT branch below) treats c->constvalue as text/text[]/jsonb based only on !c->constisnull. In practice the catalog pins the argument types and a RelabelType-wrapped Const is already rejected by IsA(c, Const), so this is hard to reach today; but a Const whose consttype is not the base type (e.g. a folded domain value) would be misinterpreted by deconstruct_array_builtin()/DatumGetJsonbP, and the header documents that a wrong answer here can leave an index row unindexed. Add the explicit consttype check (TEXTARRAYOID here, TEXTOID for the single-key branch, JSONBOID for concat) to match the read-path helpers. [low confidence on reachability, but the inconsistency is clear]


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

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 execl -> execlp switch is unrelated to the HOT-update-with-index feature this PR is about, and should be split into its own patch (or dropped). Beyond scope creep, it is also a behavioral change: this becomes the only execlp user in the tree (pg_ctl.c and elsewhere use execl with an explicit path). shellprog is SHELLPROG (= $(SHELL) from make, or hardcoded /bin/sh under meson). While that is normally an absolute path (where execl/execlp behave identically), if $(SHELL) is ever overridden to a bare name, execlp would resolve it via PATH, picking up an unintended interpreter -- a footgun in the test harness. If an explicit motivation exists, it needs its own commit and a justification; otherwise revert to execl.

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

Comment on lines +774 to +777
$node_subscriber->wait_for_log(
qr/conflicting key value violates exclusion constraint "test_hot_excl_k_excl"/,
$log_offset);
pass('a conflicting applied UPDATE is rejected');

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 conflicting UPDATE leaves the apply worker in an error-retry loop until DROP SUBSCRIPTION lands a few lines later. This works (DROP stops the worker safely), but every other error-path case in the subscription suite that uses wait_for_log on an apply error clears the error before moving on (e.g. 035_conflicts.pl TRUNCATEs the table right after each wait_for_log). Here the worker keeps retrying and spamming the subscriber log with the exclusion-constraint ERROR until teardown. Consider disabling the subscription (ALTER SUBSCRIPTION ... DISABLE) immediately after the wait_for_log to stop the retry loop deterministically before the DROPs, matching the established pattern and avoiding log spam that can mask unrelated failures.

@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 13 issue(s).

  • 13 inline, 0 in summary

Comment thread .clangd
Comment on lines +1 to +2
Diagnostics:
MissingIncludes: None

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 change set (.clangd, .envrc, .gdbinit, .local-gitignore, .mcp.json, pg-aliases.sh, flake.nix, shell.nix) is personal developer-environment scaffolding, unrelated to the HOT-indexed-updates feature. A patch destined for pgsql-hackers/commitfest must be a minimal diff of the actual source change; committing local tooling files will get it rejected outright and violates the minimal-diff rule. These belong in a personal (untracked) setup, e.g. via a local git exclude, not in the tree.

Comment thread .envrc
Comment on lines +4 to +10
source_up
watch_file flake.nix
use flake
use project_steering c aws
use project_steering postgresql
use project_mcp postgresq
use project_mcp context7

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

.envrc references private host paths (~/ws/postgres/.envrc) and personal MCP/steering configs. This is machine-specific and leaks private environment details into the tree; it cannot be reproduced by other developers and must not be committed to a PostgreSQL contribution.

Comment thread .gdbinit
Comment on lines +42 to +46
# Predict augmented tuple size (returns 0 if t_hoff would overflow)
break heap_hot_indexed_tuple_size

# Create augmented tuple with embedded modified-column bitmap
break heap_hot_indexed_create_tuple

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 symbol breakpoints reference functions that do not exist in the source. The feature code uses HeapUpdateModifiedIdxAttrs, HeapUpdateHotAllowable, and HeapUpdateDetermineLockmode; there is no heap_hot_indexed_create_tuple, heap_hot_indexed_tuple_size, heap_hot_indexed_serialize_bitmap, etc. in heapam.c. GDB will silently fail to set (or defer) these breakpoints, so this file is misleading and already stale against the committed code.

Comment thread .gdbinit
Comment on lines +19 to +20
# Main entry: heap_update
break heapam.c:3210

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 hardcoded line-number breakpoints do not match the source. heap_update is at line ~3278, not 3210; lines 4019/4024/4033 fall inside the XLOG_HEAP_LOCK / crit-section path, not the HOT-decision block; and HEAP_INDEXED_UPDATED (cited at 4101/4147) does not exist anywhere in heapam.c. Line-number breakpoints are inherently fragile against an evolving patch and here are already wrong. This file should not be tracked.

Comment thread pg-aliases.sh

# Clean commands
alias pg-clean='ninja -C "$PG_BUILD_DIR" clean'
alias pg-full-clean='trash "$PG_BUILD_DIR" "$PG_INSTALL_DIR" 2>/dev/null || rm -rf "$PG_BUILD_DIR" "$PG_INSTALL_DIR"; echo "Build and install directories cleaned"'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Destructive footgun: pg-init and pg-full-clean run rm -rf on paths derived from env vars ($PG_DATA_DIR = /tmp/test-db-$(basename $PWD), $PG_BUILD_DIR, $PG_INSTALL_DIR). If any of these vars is unset when sourced outside the nix shell, rm -rf ""/unexpected expansion can delete unintended paths. Shipping aliases that silently wipe directories in a tracked repo is unsafe for other developers; this does not belong in a contribution.

Comment thread .gdbinit
Comment on lines +88 to +89
# Redirect-with-data: initialize bitmap accumulator from collapsed redirect
break heapam_indexscan.c:182

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 line-number breakpoints in other source files are also wrong (the heapam.c mismatch is already noted separately). None of the referenced lines match their descriptions: heapam_indexscan.c:182 is lp = PageGetItemId(page, offnum); not a redirect-with-data bitmap init; heapam_indexscan.c:250 is skip = false; not bitmap accumulation; heapam_indexscan.c:297 is the body of heapam_index_plain_tuple_getnext_slot() not stale-entry detection; and indexam.c:299 is the opening brace of index_beginscan_bitmap() not an indexed_attrs computation. The described bitmap/HOT-indexed-chain logic does not exist at these locations. Hardcoded line numbers in a committed .gdbinit rot immediately; this whole dev-env file should not be part of the patch.


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

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 execl -> execlp change is unrelated to the HOT-update/subattr test feature this patch adds, and it is a behavioral regression for the test harness. shellprog is SHELLPROG, which the build systems define as an absolute path ($(SHELL) in GNUmakefile/ecpg, hardcoded /bin/sh in meson.build). With an absolute path (containing a slash) execlp behaves identically to execl, so this line is a no-op for the in-tree build and buys nothing. The only way it matters is if shellprog becomes a bare name like sh, in which case execlp performs a $PATH lookup -- introducing non-determinism and a security footgun (a sh earlier in $PATH would be executed instead of the system shell). This looks like it accommodates a local Nix dev environment (cf. the added flake.nix/shell.nix/.envrc), not an upstream need. Revert to execl and keep the shell path absolute; if a non-standard shell is truly required, point $(SHELL)/SHELLPROG at its absolute path instead.

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

Comment on lines +697 to +699
result = lappend_oid(result, RelationGetRelid(indexRelation));
if (indexRelation->rd_index->indimmediate && specConflict)
*specConflict = true;

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 indexRelation->rd_index->indimmediate guard is dead: line 677 above already Assert(indexRelation->rd_index->indimmediate), so within this loop body it is unconditionally true. Unlike the parallel code in ExecInsertIndexTuples() (which runs for both immediate and deferred indexes and so needs the test), ExecCheckHotExclusion() only ever processes immediate exclusion-constraint indexes. The redundant condition is confusing here; consider if (specConflict) to match the fact that the index is always immediate. (low confidence, style/minimalism)

Suggested change
result = lappend_oid(result, RelationGetRelid(indexRelation));
if (indexRelation->rd_index->indimmediate && specConflict)
*specConflict = true;
result = lappend_oid(result, RelationGetRelid(indexRelation));
if (specConflict)
*specConflict = true;

Comment on lines +777 to +779
pass('a conflicting applied UPDATE is rejected');

$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION tap_sub_hot_excl");

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 test case leaves the apply worker in a permanent error-retry loop at teardown: the conflicting UPDATE on id=2 keeps failing the exclusion constraint and is never resolved before DROP SUBSCRIPTION (unlike the tap_sub_dropri case above, which restores a replica identity and lets replication resume before cleanup). The worker will keep re-attempting and re-logging the exclusion-constraint error until DROP SUBSCRIPTION signals it, producing repeated error noise in the subscriber log and relying on DROP SUBSCRIPTION to cleanly tear down a worker stuck mid-apply. Consider resolving the conflict on the subscriber (e.g. DELETE/UPDATE the conflicting row so apply can catch up) before dropping the subscription, matching the established pattern in this file.

@@ -1,4 +1,4 @@
#!/usr/bin/perl
#!/usr/bin/env perl

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 shebang change is unrelated to the rest of the patch and breaks a tree-wide convention. Every other Perl script in the PostgreSQL tree (genbki.pl, gen_node_support.pl, copyright.pl, mark_pgdllimport.pl, and ~35 others) uses #!/usr/bin/perl; this edit makes pgindent the sole outlier. The community deliberately uses the fixed /usr/bin/perl path, and /usr/bin/env perl is a less predictable interpreter resolution (it picks up whatever perl is first in PATH). Please drop this hunk — it does not belong in this change and will draw a rejection on -hackers as unrelated churn.

Suggested change
#!/usr/bin/env perl
#!/usr/bin/perl

@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 17 issue(s).

  • 17 inline, 0 in summary

Comment thread .gdbinit
Comment on lines +1 to +3
# HOT Indexed Updates — GDB breakpoints for code review
#
# Usage: gdb -x .gdbinit <postgres-binary>

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 set of files (.clangd, .envrc, .gdbinit, .local-gitignore, pg-aliases.sh, flake.nix, shell.nix, .mcp.json) is personal developer-environment tooling. The actual change here is the "HOT indexed updates" feature in the heap/executor/index code (heap_update signature rework, HeapUpdateModifiedIdxAttrs, etc.). These tooling artifacts are unrelated to that feature and violate the minimal-diff discipline: a patch destined for pgsql-hackers/a commitfest must not carry them. Remove them from the submitted patch (keep them in a local, uncommitted worktree, e.g. via the .local-gitignore mechanism this very file set adds). This is the most serious issue in the change.

Comment thread .gdbinit
Comment on lines +19 to +20
# Main entry: heap_update
break heapam.c:3210

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 hardcoded source line numbers are already stale against the current tree and are inherently fragile: any edit to the referenced files shifts them. For example, this file claims 'break heapam.c:3210' is the 'Main entry: heap_update', but line 3210 is actually inside simple_heap_delete's tail (heap_freetuple(old_key_tuple)); heap_update begins near line 3278. Likewise 'break indexam.c:299' is annotated 'Compute indexed_attrs ...' but line 299 is just the opening brace of index_beginscan_bitmap. Function-name breakpoints (as already used elsewhere in this file, e.g. 'break heap_update') are robust; line-number breakpoints are misleading the moment the code moves.

Comment thread .mcp.json
Comment on lines +3 to +6
"postgresq": {
"type": "http",
"url": "https://pg.ddx.io/mcp/"
},

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 server key "postgresq" looks like a truncation/typo of "postgres"/"postgresql". It is used consistently in .envrc (use project_mcp postgresq), so it does function as an identifier rather than breaking anything; still, if this is an unintended truncation it is worth correcting the key (and the matching .envrc reference) for clarity. Low confidence that it is a defect rather than an intentional short name.

Comment on lines +35 to +38
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))

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 issue_comment trigger gates only on comment-body prefixes, not on who sent the comment. Any user who can comment on a PR (including first-time external contributors, once the comment posts) can start a Bedrock-backed review run and spend money. The concurrency group dedups runs but does not stop unauthorized initiation. Add an author-association guard so only maintainers can trigger via comment, e.g. require github.event.comment.author_association to be OWNER/MEMBER/COLLABORATOR. (If the called reusable workflow already enforces this, reference that so the gate is not silently assumed.) [high confidence]

Suggested change
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))

(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))
uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@v1

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 reusable workflow is pinned to the mutable tag @v1. A tag can be force-moved, so the fork will execute whatever v1 points to at run time — and this caller hands that code AWS OIDC role credentials and Bedrock access. The checklist allows first-party actions/* on a version tag, but gburd/ci-workflows is a third-party reusable workflow and should be pinned to a full commit SHA for supply-chain safety (same applies to the other two fork-* workflows). [high confidence]

Suggested change
uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@v1
uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@<full-commit-sha> # v1

Comment thread flake.nix
Comment on lines +60 to +62
environment.localBinInPath = true;
}
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

environment.localBinInPath is a NixOS-module option, not a recognized flake output. Because it sits inside the eachDefaultSystem lambda, flake-utils transposes it into environment.<system>.localBinInPath, which is meaningless as a flake output and is never consumed anywhere. This is dead scaffolding that neither enables the intended behavior nor belongs here. Remove it (and, if the intent was to put the script bins on PATH, do that via the devShell's buildInputs/shellHook, which the shell already does).

Suggested change
environment.localBinInPath = true;
}
);
}
);

Comment on lines +244 to +249
INSERT INTO hot_bt_image
SELECT g, repeat('x', 3000) || g, repeat('y', 2500) || g
FROM generate_series(1, 20) g;
SELECT pg_column_compression(t) IS NOT NULL AS t_compressed,
pg_column_size(e) > 2500 AS e_out_of_line
FROM hot_bt_image WHERE id = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Portability (moderate confidence): this block pins TOAST-dependent results that are not stable across BLCKSZ. pg_column_compression(t) IS NOT NULL and pg_column_size(e) > 2500 are asserted as t in check_btree.out, and the subsequent HOT counts (20/20, 40/40, 45/40) depend on t being compressed and e being stored out of line.

Whether a row is TOASTed is governed by TOAST_TUPLE_THRESHOLD (~MAXALIGN_DOWN(BLCKSZ/4)). With the default 8kB BLCKSZ a ~3000-byte value crosses the threshold and is compressed/externalized, but with a 32kB BLCKSZ build (a supported buildfarm configuration) the threshold is ~8kB, so a single ~3000-byte column stays inline and uncompressed. That flips t_compressed/e_out_of_line to f and changes the HOT bookkeeping, producing a spurious regression diff.

Size the values relative to BLCKSZ (or otherwise force TOASTing independently of page size) so the expected output holds on non-default BLCKSZ.

Comment on lines +109 to +111
extern bool gincomparedatums(Relation index, struct IndexInfo *indexInfo,
int attnum, Oid typid, int16 typlen,
bool typbyval, Datum oldValue, Datum newValue);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Style nit (low): the surrounding function typedefs in amapi.h (which this header includes) all use the plain IndexInfo typedef, which is already visible here via access/amapi.h (typedef struct IndexInfo IndexInfo;). Using struct IndexInfo here is correct C but inconsistent with the definition in ginutil.c (plain IndexInfo) and with neighbouring declarations. Prefer plain IndexInfo for consistency.

Suggested change
extern bool gincomparedatums(Relation index, struct IndexInfo *indexInfo,
int attnum, Oid typid, int16 typlen,
bool typbyval, Datum oldValue, Datum newValue);
extern bool gincomparedatums(Relation index, IndexInfo *indexInfo,
int attnum, Oid typid, int16 typlen,
bool typbyval, Datum oldValue, Datum newValue);

@@ -1,4 +1,4 @@
#!/usr/bin/perl
#!/usr/bin/env perl

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 shebang change is unrelated to the stated purpose of the patch and breaks consistency with the rest of the tree. Every other Perl script in PostgreSQL uses #!/usr/bin/perl (genbki.pl, gen_node_support.pl, copyright.pl, mark_pgdllimport.pl, and ~30 others). Switching only this one file to #!/usr/bin/env perl makes it the sole outlier and is a non-minimal diff on an otherwise untouched file. If a tree-wide shebang convention change is actually desired, it should be a separate, dedicated patch discussed on -hackers covering all scripts consistently; it does not belong bundled here. Revert to keep the diff minimal.

Suggested change
#!/usr/bin/env perl
#!/usr/bin/perl


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unrelated change / scope creep. SHELLPROG is $(SHELL) (GNUmakefile) or the hardcoded /bin/sh (meson.build) -- always an absolute path. For a path containing a slash, execlp behaves identically to execl (it only adds $PATH lookup for bare filenames), so this has zero effect on an upstream build and is unrelated to the HOT-update/subattr feature in this PR. It only matters in this fork's Nix dev shell where /bin/sh may be absent. A patch posted to pgsql-hackers should not carry fork-specific environment workarounds; please drop this hunk from the feature patch. (high confidence)

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

gburd added 11 commits October 19, 2026 21:53
This isn't a commit that will be submitting for review, it is purely for
local developer tooling while developing this patch set.  Ignore it.
Add regression coverage for the existing Heap-Only Tuple (HOT) update
rules, so that later changes to them show up as changes to the expected
output.  The new hot_updates regression test covers:

- HOT and non-HOT updates
- summarizing (BRIN) indexes, which do not block HOT
- TOAST columns
- partial, multi-column and unique indexes
- partitioned tables
- expression indexes whose input changes while the expression's value
  does or does not, including expressions stored as another type
- partial indexes, with the row inside, outside, entering or leaving the
  predicate
- BEFORE ROW triggers that change an indexed column

A hot_updates test in contrib/pageinspect checks the HOT chain an update
leaves on the page, and that VACUUM collapses it.

Discussion: https://postgr.es/m/78574B24-BE0A-42C5-8075-3FA9FA63B8FC@amazon.com
heap_update() decides which indexed attributes an UPDATE changed by
comparing the old and new tuples inside HeapDetermineColumnsInfo(), with
the buffer locked.  That knowledge is not heap-specific, and the
executor needs it before it calls the table AM to do anything smarter:
an index whose key did not change does not need a new entry, whatever
the table AM does.  Determine it in the executor instead.

ExecUpdateModifiedIdxAttrs() compares the old and new slots over the
relation's indexed attributes and passes the result to
table_tuple_update() as a new modified_idx_attrs argument.  It compares
every indexed attribute, not just those the UPDATE targets:
ExecGetAllUpdatedCols() names the targeted columns, and misses a column
a BEFORE ROW trigger changes with heap_modify_tuple() (as
tsvector_update_trigger() does), while logical replication apply and
REPACK (CONCURRENTLY) apply hand over whole tuples.  The comparison is
the binary comparison heap_update() has always made, so an unchanged
toasted value still compares equal without being fetched.

heapam_tuple_update() derives the HOT decision and the lock mode from
the set, through the new HeapUpdateHotAllowable() and
HeapUpdateDetermineLockmode(), and passes both to heap_update().
simple_heap_update() has no slots: it fetches the old tuple and computes
the same set with HeapUpdateModifiedIdxAttrs(), the remainder of
HeapDetermineColumnsInfo(), before taking the buffer lock.  heap_update()
keeps the replica identity decision, which needs the old tuple on the
page.  The decisions about
HOT, lock mode and replica identity do not change; hot_updates' expected
output is unchanged.

The set to compare is the new INDEX_ATTR_BITMAP_INDEXED, the attributes
referenced by any index: the union of INDEX_ATTR_BITMAP_HOT_BLOCKING and
INDEX_ATTR_BITMAP_SUMMARIZED, whose meanings do not change.

simple_table_tuple_update() keeps its signature: it fetches the old
version and compares it with table_slot_modified_idx_attrs(), the
executor's comparison, now in tableam.c.  Logical replication apply,
which has both slots, calls the new simple_table_tuple_update_ext() and
passes the set.

In builds with assertions, heap_update() compares the old tuple it
locked with the new one and checks that the caller's set is the same,
so that a caller passing a wrong set is caught.

The syscache-update-pruned isolation test waits at an injection point
before simple_heap_update() pins the old tuple's buffer, which now
happens before heap_update(); the point moves with it.

Discussion: https://postgr.es/m/78574B24-BE0A-42C5-8075-3FA9FA63B8FC@amazon.com
An UPDATE that changes a column used by an expression index cannot be
HOT today, even when the expression's value does not change: an index
on (data->>'name') blocks HOT for every change to data.  Partial
indexes are treated the same way, so an index WHERE status = 'active'
blocks HOT for a row that is outside the predicate before and after
the update.  For JSONB documents with a few indexed fields that is
most UPDATEs.

Since the previous commit the executor computes which indexed
attributes an UPDATE changed before calling the table AM, outside any
buffer lock, so it can now look at what the indexes would store.
INDEX_ATTR_BITMAP_EXPRESSION, a new relcache bitmap, holds the
attributes that non-summarizing indexes reference only through
expressions or as partial indexes.  When the changed attributes are all
in that set, or are referenced otherwise only by summarizing indexes,
ExecUpdateModifiedIdxAttrs() evaluates each affected index's predicate
and index datums for the old and new rows, and drops from the changed
set every attribute whose change no index sees.  The table AM decides
the rest as before, so for heap the update can be HOT.

An index sees the change if:

- the row enters or leaves its predicate.  A HOT chain would leave the
  index pointing at a row outside the predicate, which the planner
  relies on not happening;
- the row is inside the predicate and any index datum differs.  Datums
  are compared byte for byte as stored, using the type of the value
  FormIndexDatum() produced (the expression's type, not the index
  column's, which can differ through opckeytype), so equal values with
  different representations still count as changed;
- the index is not yet ready for inserts or not valid.  README.HOT
  requires such an index to constrain HOT, and a failed build's
  expression need not be computable for every row.

The comparison is made per row, after BEFORE ROW triggers and after
EvalPlanQual has replaced the old and new rows with those built from the
latest version, so neither a concurrent update nor an earlier row of the
same statement can supply the answer.  update-epq-indexes, added with
the tests, covers the concurrent case with a BEFORE ROW trigger.

Nothing is evaluated when an update changes a column that a plain
index uses, so the cost of an update that cannot be HOT is the
attribute comparison it already paid.  Key and replica identity indexes
have neither expressions nor predicates, so the lock mode and the
replica identity decision see the same attributes as before; an
assertion checks this.

This is the second attempt.  Commit c203d6c compared index datums
using the index's storage type and crashed for an index whose stored
type differs from the expression's; it was disabled in 5d28c9b and
reverted in 1c53c4d.  The hot_updates test now covers such indexes
(name_ops, gist point_ops).  Its expected output changes for the
expression and partial index cases this makes HOT, and for no others.

Discussion: https://postgr.es/m/78574B24-BE0A-42C5-8075-3FA9FA63B8FC@amazon.com
Discussion: https://postgr.es/m/20181106185255.776mstcyehnc63ty@alvherre.pgsql

# Conflicts:
#	contrib/pageinspect/expected/hot_updates.out
#	src/backend/executor/nodeModifyTable.c
#	src/backend/utils/cache/relcache.c
#	src/include/utils/rel.h
#	src/include/utils/relcache.h
The previous commit lets an UPDATE be HOT when it changes a column that
indexes use only through expressions or partial-index predicates, if
evaluating those expressions and predicates on the old and new rows gives
the same results.  For a large structured column, such as a jsonb
document, that means reading the document twice per index on every row,
even when the SET clause plainly changes a part no index reads:

    CREATE INDEX ON t ((doc->>'name'));
    UPDATE t SET doc = jsonb_set(doc, '{visits}', ...);

Work this out once per statement, when the update projection is set up,
from the plan tree alone.  For each SET target column that expression or
partial indexes use, find which parts of the column the SET expression may
change, and which parts each index reads; an index whose parts cannot
overlap the changed ones needs no evaluation for that column.  Nothing at
run time is involved, so the result is the same with or without JIT
compilation, and there is no per-row state to reset.

Parts are named by paths of object keys and array positions.  A function
describes its calls through its support function, with the new
SupportRequestSubattr request: which parts of which argument a call reads,
or which parts of its source argument it may change, given its constant
arguments.  Array and jsonb subscripting, composite field selection and
assignment, COALESCE and domain coercion are understood directly.  The
jsonb accessors (->, ->>, #>, #>>, jsonb_extract_path[_text]) and
modifiers (jsonb_set, jsonb_set_lax, jsonb_insert, -, #-, and || with a
constant object) get a support function, jsonb_subattr_support().
Extensions can describe their own document types and functions the same
way.  External Params of a generic plan are read as constants, so
prepared statements benefit as well.

The analysis gives up for a column unless the subplan is a plain scan of
the target relation (directly, or under an Append for inheritance and
partitioning), the statement is an UPDATE rather than a MERGE, there are
no BEFORE ROW UPDATE triggers, and every reference to the column in the
SET expression and the index is one it understands.  At run time, the
conclusion is not used for a row whose old or new value of the column is
null.  Two paths are taken to overlap unless they differ in a key that
cannot be read as an array position, so changes that move array elements,
negative positions and keys such as "01" or " 1" are all covered.
Assert-enabled builds evaluate the indexes anyway and raise an error if
the conclusion was wrong.

This only saves work: an update is HOT exactly when it was before.

Each conclusion is logged at DEBUG1.  amcheck's new TAP test
007_subattr_proof reads them from the server log; check_subattr checks
the HOT counts and the indexes for many SET clauses, and misc_functions
checks HOT counts and an index scan in builds without assertions.
An UPDATE that changes a column an index stores directly, as a key or
included column, always gets new index entries, even when the index would
store exactly what it already does.  Only the index's access method can
tell: a hash index stores a hash code, a GIN index the keys its operator
class extracts, a btree index the value, detoasted.  Index access methods
for documents such as RUM store keys for a few paths of a document chosen
by operator class options, so to core every change to the document looks
like a change to the index.

Add an optional callback,

    bool amcomparedatums(Relation index, int attnum, Oid typid,
                         Datum oldValue, Datum newValue);

which says whether the new, non-null value of an index column gives
exactly the same index entries as the old one.  ExecUpdateModifiedIdxAttrs
already narrows the set of changed indexed attributes for those used only
through expressions or partial-index predicates, by forming each such
index's datums for the old and new row and comparing them byte for byte.
Treat a column that indexes also store directly the same way, if all of
those indexes have the callback, and ask the callback whenever an index
datum differs byte for byte.  The new relcache bitmap
INDEX_ATTR_BITMAP_COMPARABLE names those columns.

The columns of candidate keys and of the replica identity are left out:
the table AM takes its tuple lock strength and what it logs for logical
decoding from the changed set, and those stay decided by value.  So are the
indexes of exclusion constraints: the constraint is checked on the values
when an entry is inserted, and two values that give the same entries need
not have the same conflicts.

No core access method implements the callback yet, so nothing changes
until one does.  dummy_index_am gets one, configured by its options, to
test the executor side.

# Conflicts:
#	src/backend/utils/cache/relcache.c
A btree index stores a column's value, detoasted (and compressed again if
large), and returns it in index-only scans.  An updated row gives the same
entry exactly when the new value is the same, byte for byte once detoasted:
datum_image_eq().

The executor compares values as stored, so it sees a change when an UPDATE
writes back the value a row already has but the old value is stored
compressed or out of line and the new one is not compressed yet.  Every
UPDATE that sends all columns, as ORMs and logical replication do, is like
that for large indexed values, and so was never HOT.  Now it is.

Values the operator class only considers equal, such as numeric 1.0 and
1.00 or strings equal under a nondeterministic collation, still need new
entries: an index-only scan would return the old one.

Columns of unique indexes that foreign keys can reference, and of the
replica identity, are still compared as stored (see the previous commit).
A GIN index stores, for each row, the keys its operator class extracts from
the indexed value, and nothing else about the value.  Scans see a row only
as its set of keys: that is all the consistent function is given, and what
it cannot decide from them it leaves to a recheck against the heap tuple.
So an updated row gives the same entries exactly when extractValue gives
the same keys for the new value, compared as GIN compares keys.  Keys equal
under the compare function share one entry whichever came first, so the
compare function is the right test.

Run ginExtractEntries() on both values, which sorts and deduplicates them
and represents empty items and null keys as GIN does, and compare.  The
GinState is cached in ii_AmCache, as gininsert() caches it, so it is built
once per statement.

Updates that become HOT include reordering or repeating the elements of an
array column under array_ops, and changing a tsvector, or a text column
under a to_tsvector() expression index, in ways that keep its lexemes, such
as moving words around.
A hash index stores only the hash code of the value, and every scan, plain
or bitmap, rechecks the heap tuple.  So an updated row gives the same entry
exactly when the new value has the same hash code as the old one.

Updates that become HOT include changing the display scale of a numeric,
which hash_numeric() ignores, and changing a string to one equal under a
nondeterministic collation.

A test checks that an exclusion constraint using a hash index still sees a
change to a value with the same hash code (hashint8(1) = hashint8(2^32)):
the executor does not consult amcomparedatums for exclusion constraints.
An exclusion constraint is checked when its index gets an entry, against
the values, and values that give the same index entries need not have the
same conflicts (hashint8(1) = hashint8(2^32)).  So the previous commits
never let an exclusion constraint's index keep its entries when one of its
values changed: such an update was never HOT.  DocumentDB builds every
unique index as an exclusion constraint on the whole document, so a
collection with one got no HOT updates at all.

Let it keep them, and check the constraint anyway.  When amcomparedatums
says an exclusion constraint's index would get the same entries although a
value changed, ExecUpdateModifiedIdxAttrs() notes the index in the
ResultRelInfo, and if the table AM then makes the update HOT,
ExecCheckHotExclusion() runs check_exclusion_or_unique_constraint() for
the new tuple with the new values, choosing the wait mode as
ExecInsertIndexTuples() does (both now share that choice).  The old
entries lead to the new tuple through the HOT chain, so the constraint's
index scan finds it as "self", and finds every conflicting row it would
have found had the new entry been inserted.  A concurrent inserter of a
conflicting value finds the old entries, and its scan, with a non-MVCC
snapshot, returns both the old version and the in-progress new one, so it
waits for us; our check finds the inserter's entry, and waits for it.
ExecCheckHotExclusion()'s header comment gives the argument in full.

UPDATE, MERGE, logical replication apply and REPACK all check after a
HOT update.

Only constraints checked immediately take part.  A deferrable one's index
is not asked whether entries are the same, so its updates are decided as
before.  Its recheck at commit, unique_key_recheck(), finds the row by
the TID of the version that got the index entry and checks that
version's values, relying on every version the entry leads to having the
same values; that stays true.

Tests: hash_index covers an immediate constraint (HOT without a conflict,
an error with one, and the index finding both rows afterwards) and shows
that a deferrable one's update is not HOT and still checked at commit.
hot-exclusion (isolation) covers a concurrent insertion of the new value
before and after the HOT update, and of the old value after it, each
committed and rolled back.  repack_hot_exclusion (injection points)
covers REPACK (CONCURRENTLY) replaying such an update, and
032_subscribe_use_index covers logical replication apply.  Disabling the
check after a HOT update, or not noting the index, makes a test fail.
To decide whether an UPDATE can leave an index's entries alone,
ExecUpdateModifiedIdxAttrs() evaluates the index's predicate and forms
its datums for the old row and the new.  When some index needs a new
entry anyway, ExecInsertIndexTuples() then evaluated the predicate and
formed the datums for the new row again.  For an expensive expression
whose value changes (to_tsvector() or md5() of a large text) that third
evaluation made the UPDATE about half again as slow as before these
commits; Matthias van de Meent suggested passing the datums on.

Save, per index, what ExecUpdateModifiedIdxAttrs() learned of the new
row in the ResultRelInfo: that it is outside the index's predicate, or
its datums, which stay in the per-tuple memory context until the next
row.  ExecInsertIndexTuples() uses them when its caller passes
EIIT_NEW_DATUMS, which the UPDATE, logical replication apply and REPACK
paths do, and ExecCheckHotExclusion() always does.  Indexes that were
not evaluated are evaluated as before.

The saved values describe the row ExecUpdateModifiedIdxAttrs() decided
last.  It resets them first, and an EvalPlanQual retry decides the row
again; a cross-partition update inserts through another ResultRelInfo
without the flag.  Builds with assertions recompute each reused value
and check that it matches.

An expression whose value changes is now evaluated twice per row, for
the old and new rows, as against once before these commits; there is
no way to know the value is unchanged without the old row's.

Tests in check_btree cover reuse with a partial index the row stays in
and one it leaves.  Saving the old row's datums instead of the new
row's, or keeping one row's datums for the next, makes them fail.

@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 20 issue(s).

  • 20 inline, 0 in summary

Comment on lines +35 to +38
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))

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 issue_comment trigger lets any commenter invoke a credentialed Bedrock run (OIDC role assumption via AWS_ROLE_ARN). The if: condition only gates on the comment body prefix, not on who posted it. On a public fork, external contributors who can comment on a PR can trigger AWS-authenticated runs, enabling unauthorized Bedrock spend. Add an actor/association gate, e.g. require github.event.comment.author_association to be OWNER/MEMBER/COLLABORATOR. (If the reusable workflow in gburd/ci-workflows already enforces this, document that here; it is not verifiable from this repo.) [high confidence]

Comment on lines +28 to +29
jobs:
review:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No permissions: block is declared, so this workflow inherits the repository's default GITHUB_TOKEN scope (often broad/write), violating least-privilege. An OIDC-authenticated review workflow needs id-token: write to assume the AWS role, plus pull-requests: write/contents: read for posting the review, and nothing more. Declare an explicit, minimal permissions: block (passed down to the reusable workflow). Without id-token: write granted at the caller, OIDC role assumption may also fail outright.

(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))
uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@v1

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 third-party reusable workflow is pinned to the mutable tag @v1 rather than a full commit SHA. Because this workflow runs with AWS OIDC credentials, a repointed tag is a supply-chain hazard: whoever controls gburd/ci-workflows can silently change what runs with your AWS role. Pin to a full commit SHA (e.g. gburd/ci-workflows/.github/workflows/ocr-review.yml@<sha>). Same applies to the model-check workflow.


on:
pull_request:
# Note: no draft filter — drafts are reviewed too.

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) in this comment. Per the project's ASCII-only source rule, use an ASCII hyphen with surrounding spaces instead.

Suggested change
# Note: no draft filter — drafts are reviewed too.
# Note: no draft filter - drafts are reviewed too.

Comment on lines +12 to +13
jobs:
check:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No permissions: block is declared, so this job inherits the repository default token scope. The comment states it opens/updates an issue, which requires only issues: write (plus id-token: write if it assumes the AWS role for the Bedrock model lookup). Declare an explicit, minimal permissions: block rather than relying on the default.

Comment thread pg-aliases.sh
# ============================================================
# Debugger attachments
# ============================================================
alias pg-debug-gdb='gdb -x "$GDBINIT" -x .gdbinit "$PG_INSTALL_DIR/bin/postgres"'

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 aliases source .gdbinit from the current working directory via -x .gdbinit, and .gdbinit contains hardcoded numeric line breakpoints (heapam.c:3210, pruneheap.c:1802, etc.). If the source has drifted, GDB aborts on the first missing/moved breakpoint, making pg-debug/pg-attach fail outright rather than degrade gracefully. Combined with the already-flagged brittleness of the numeric breakpoints, this is a footgun. (Moot if this personal-tooling file is removed from the patch, per the confirmed findings.)

Comment on lines +4769 to +4770
slot = MakeTupleTableSlot(RelationGetDescr(relation), &TTSOpsBufferHeapTuple, 0);
bslot = (BufferHeapTupleTableSlot *) slot;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Performance: simple_heap_update() is the engine of CatalogTupleUpdate() and runs on every catalog UPDATE (a hot DDL path). It was previously a thin wrapper over heap_update(). It now allocates a TupleTableSlot per call and performs an extra heap_fetch() (a ReadBuffer + share lock on the page) solely to recompute modified_idx_attrs -- a read that heap_update() then repeats under the buffer lock. This adds a per-call palloc plus a redundant buffer pin/lock to every catalog tuple update. Worth a benchmark on DDL-heavy / many-small-transactions workloads, and consider whether the slot allocation can be avoided (e.g. a bare HeapTupleData + heap_getattr against the pinned buffer) on this path. [moderate confidence]

Comment on lines +6299 to +6302
Assert(req->kind == SUBATTR_MODIFY);
req->source = 0;
switch (req->funcid)
{

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 modifier branches are safety-critical: an under-reported changed path silently leaves an index entry stale (the executor in execSubattr.c treats any path not overlapping mods.paths as byte-for-byte unchanged, via paths_may_overlap()). The new regress tests (src/test/regress/sql/hot_updates.sql) only exercise jsonb_set and jsonb_insert; there is no coverage for jsonb_concat (||), jsonb_delete (- text / - text[]), jsonb_delete_path (#-), jsonb_set_lax delete, jsonb_extract_path, nor for the error-prone array element-move cases (deleting/inserting an element that shifts later positions, negative indices, '01' vs '1' key-as-position overlap). These are exactly the branches where a wrong answer corrupts an index. Please add regression tests that assert the HOT/index-skip decision for each branch, including the array-move and integer-key overlap edges.


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

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 execl -> execlp change is unrelated to the HOT-update test feature this patch adds; it's a minimal-diff violation (the stated purpose is test coverage, not reworking shell spawning) and should be dropped or split into its own patch.

Beyond that, the change is a behavior regression, not an improvement. shellprog is SHELLPROG, which is $(SHELL) (an absolute path like /bin/sh) from the make/meson build, so it is always an absolute path. execl uses that path directly; execlp performs a $PATH search. For an absolute path there is no benefit, and if shellprog ever became a bare name an attacker-controlled $PATH could select a different shell -- a PATH-search footgun. The rest of the tree (e.g. src/bin/pg_ctl/pg_ctl.c:504) uses execl("/bin/sh", ...); this is the only execlp in the codebase, so it also deviates from the established convention. Suggest reverting to execl.

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

@@ -1,4 +1,4 @@
#!/usr/bin/perl
#!/usr/bin/env perl

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 shebang change is unrelated to the stated purpose of this change (which concerns HOT updates / sub-attribute indexing across the heap/executor). Changing the interpreter line from #!/usr/bin/perl to #!/usr/bin/env perl is unrelated churn that will draw an immediate rejection on -hackers for not being a minimal diff.

More importantly, it breaks the tree-wide convention: every other Perl script in the PostgreSQL source (e.g. genbki.pl, gen_node_support.pl, copyright.pl, mark_pgdllimport.pl, and ~30 others) uses #!/usr/bin/perl. This hunk would make pgindent the sole outlier. Revert it.

Suggested change
#!/usr/bin/env perl
#!/usr/bin/perl

gburd added 4 commits October 21, 2026 11:51
Squashes the four CI commits this fork carried on top of upstream into
one, and cuts the carry from 3776 lines to ~1000.

Removed:

- .github/{README,QUICKSTART}.md and .github/docs/* (2273 lines). Beyond
  being bulk we rebase hourly, they had drifted into being wrong: they
  described a daily (not hourly) sync, Claude 3.5 Sonnet via the direct
  Anthropic API (we run Opus on Bedrock), a static IAM user (we use
  OIDC), and a Windows dependency-builder workflow that the same commit
  which documented it had already deleted. 16 referenced paths did not
  exist. An AI reviewer reading this repo ingests that as truth.

- sync-upstream-manual.yml (252 lines). sync-upstream.yml already has
  workflow_dispatch; the sole behavioral difference was a force_push
  toggle whose "false" setting cannot work after a rebase anyway.

- .github/.gitignore, which only ignored scripts/ai-review/, a directory
  that does not exist here.

Rewritten:

- sync-upstream.yml -> fork-sync-upstream.yml, 260 lines to 47. git
  rebase already handles the fast-forward, no-op and diverged cases that
  the ahead/behind arithmetic hand-rolled, so all that goes away, as do
  the issue open/comment/close machinery (a scheduled-run failure is
  already emailed to the one consumer, and the run log carries what the
  issue body would) and the "dev setup|dev v[0-9]" commit allowlist,
  which matched zero commits on master and had since it was written.
  Sync drops to every three hours: hourly meant 24 full-history clones
  of an 840MB repo per day, each force-push also re-triggering pg-ci.yml
  on master, to collect a handful of upstream commits.

Renamed:

- ocr-review.yml, ocr-model-check.yml -> fork-*.yml. Upstream owns
  .github/ too (it has touched pg-ci.yml three times this year), so
  fork-owned workflows now live under a name prefix upstream will not
  collide with, making the rebase structurally conflict-free and letting
  the sync guard match fork-owned paths exactly rather than allowing all
  of .github/.

Unchanged: the OCR review workflows' logic and .github/ocr/* (rule.json,
context.md, litellm.yaml, pg-history.py) - the part that carries value.
The OCR review workflows and their config were the last bulk this fork
carried on top of upstream: ~970 lines rebased onto postgres/postgres
every few hours, editable only by amending a commit that gets force-
pushed. They are now reusable (workflow_call) workflows in the sidecar
repo gburd/ci-workflows, and master carries two caller stubs instead.

Carry after this: 3 files, ~110 lines, one commit.

The sidecar is also where the value is: ocr/rule.json and ocr/context.md
(the PostgreSQL committer-grade review corpus) become normal reviewable
files with normal PRs and history, rather than a blob inside a commit
that is rewritten on every sync.

Mechanics worth knowing:

- The review jobs check ci-workflows out at github.job_workflow_sha, so
  the rules always come from the same commit as the logic using them, and
  pinning @v1 (or a sha) in the stub pins both. This replaces the old
  "git show origin/<default_branch>:.github/ocr/..." dance, which existed
  only because the config had to be read off a branch other than the PR's.

- AWS settings are passed as explicit inputs rather than read from vars
  inside the reusable workflow. vars does resolve across the boundary,
  but the contract stays visible at the call site.

- A reusable workflow does not change the OIDC subject: it stays
  repo:gburd/postgres:*, so the ocr-bedrock-ci trust policy is unchanged.

- ci-workflows must stay public; callers read it with GITHUB_TOKEN.

The sync guard narrows to .github/workflows/fork-* accordingly, and
sync-upstream keeps needing SYNC_TOKEN: the stubs are still workflow
files, which GITHUB_TOKEN may not push.

@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 13 issue(s).

  • 13 inline, 0 in summary

Comment thread .gdbinit
@@ -0,0 +1,156 @@
# HOT Indexed Updates — GDB breakpoints for code review

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 is personal debugging scaffolding and should not be committed. The repo's own .gitignore header states that "Auxiliary files from local workflows, your preferred editor, etc. should be ignored locally using $GIT_DIR/info/exclude or ~/.gitexclude" -- a patch destined for pgsql-hackers/commitfest must be a minimal diff, and committing local tooling pollutes the tree and will be rejected.

Beyond that, the content is already stale/wrong against this very change set: the function-name breakpoints heap_hot_indexed_create_tuple, heap_hot_indexed_serialize_bitmap, heap_hot_indexed_read_bitmap, heap_hot_search_buffer, heap_xlog_indexed_update, etc. do not exist as symbols in the diffed implementation, which instead uses HeapUpdateModifiedIdxAttrs, HeapUpdateHotAllowable, HeapUpdateDetermineLockmode, and ExecUpdateModifiedIdxAttrs. The hardcoded line-number breakpoints (heapam.c:3210, heapam.c:4019, pruneheap.c:1802, indexam.c:299, heapam_indexscan.c:182, ...) drift the instant any line is added or removed, so they are silently incorrect. Remove this file from the patch.

Comment thread .clangd
Comment on lines +1 to +2
Diagnostics:
MissingIncludes: None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Editor/LSP configuration is personal developer-environment scaffolding unrelated to the HOT indexed updates change. The repo's .gitignore header directs that such local-workflow files be ignored via $GIT_DIR/info/exclude or ~/.gitexclude, not tracked. This also hardcodes a developer-specific -I../../../../src/include relative path that is wrong depending on where clangd is invoked. Remove from the patch.

Comment thread .envrc
Comment on lines +4 to +10
source_up
watch_file flake.nix
use flake
use project_steering c aws
use project_steering postgresql
use project_mcp postgresq
use project_mcp context7

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

direnv config is personal environment scaffolding and must not be in the patch (see the repo .gitignore guidance to use a local exclude). It is also non-portable: source_up pulls in ~/ws/postgres/.envrc, and use project_steering / use project_mcp depend on one developer's machine-local direnv extensions, so this cannot evaluate on another machine or in CI. Note use project_mcp postgresq reuses the truncated server name from .mcp.json. Remove from the patch.

Comment thread .local-gitignore
Comment on lines +1 to +2
# 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 is a personal local-exclude file; the PostgreSQL convention (see the repo root .gitignore header) is to keep such workflow-local ignores in $GIT_DIR/info/exclude or ~/.gitexclude, not as a tracked file. Remove from the patch.

Comment thread .mcp.json
@@ -0,0 +1,15 @@
{
"mcpServers": {
"postgresq": {

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 server config is personal tooling that does not belong in a PostgreSQL patch. Additionally, the server-identifier key "postgresq" (also referenced by .envrc's use project_mcp postgresq) looks like a truncated "postgresql" typo. Remove this file from the patch.

id int PRIMARY KEY,
indexed_col int,
non_indexed_col text
) WITH (fillfactor = 50, autovacuum_enabled = off);

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 expected output for these queries hardcodes concrete ctid block/offset values (expected/hot_updates.out shows (0,1) -> (0,4) before VACUUM and (0,4) after). Which block a tuple and its HOT updates land on, and at which offset, depends on tuple placement under fillfactor = 50, which is sensitive to non-default BLCKSZ. On a buildfarm member built with a smaller block size (e.g. 1kB), the three inserts plus HOT updates can spill to a second page, shifting both the block number and offsets and breaking the hardcoded expected output. The companion src/test/regress/sql/hot_updates.sql deliberately prints only BLCKSZ-independent counts via get_hot_count and never prints ctids, precisely to stay portable. This test should either guard for default block size (e.g. skip / branch on SHOW block_size) or avoid asserting on absolute block/offset numbers. (high confidence)

Comment on lines +93 to +97
-- Chain-of-1 on id=1 still has a predecessor line pointer.
WITH current_tuple AS (SELECT ctid FROM hot_test WHERE id = 1)
SELECT has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
chain_position, print_hot_chain.ctid, lp_flags, t_ctid
FROM current_tuple, LATERAL print_hot_chain('hot_test', current_tuple.ctid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test stability hazard, distinct from fillfactor placement: the pre-VACUUM expected output requires the HOT chain to still be intact when this statement runs, but the current_tuple CTE scans hot_test, and any heap page access can trigger opportunistic pruning (heap_page_prune_opt), which would collapse (0,1)->(0,4) into a redirect before print_hot_chain reads the raw page. autovacuum_enabled = off does not prevent on-access pruning, so the chain-of-1 assertion can flake. Consider pinning the chain deterministically (e.g. hold an older snapshot / explicit guard) rather than relying on pruning not firing.


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unrelated change bundled into a HOT-updates feature patch. execl -> execlp adds a PATH-based lookup of shellprog. SHELLPROG is $(SHELL) (make) or /bin/sh (meson), both normally absolute paths, so this is a no-op in standard builds and only affects environments where the shell is a bare name (e.g. a Nix dev shell). It also widens the attack surface by resolving the shell via PATH. This has nothing to do with the stated feature and violates the minimal-diff rule; drop it from this patch. (high confidence)

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

@@ -1,4 +1,4 @@
#!/usr/bin/perl
#!/usr/bin/env perl

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unrelated change. Switching the shebang from a fixed /usr/bin/perl to /usr/bin/env perl is a developer-environment accommodation (matches the Nix dev-shell files added elsewhere in this change) and is not part of the HOT-updates feature. It also changes behavior: env perl resolves whatever perl is first on PATH instead of the system perl. Keep this out of the feature patch (minimal-diff). (high confidence)

Suggested change
#!/usr/bin/env perl
#!/usr/bin/perl

Comment on lines +1227 to +1231
if (IsA(child, SeqScan) || IsA(child, IndexScan) ||
IsA(child, BitmapHeapScan) || IsA(child, TidScan) ||
IsA(child, TidRangeScan))
{
if (((Scan *) child)->scanrelid == rti)

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 scan-node type list is hardcoded and duplicated here and in the top-level check below (lines ~1245). Any plan shape not in the list (e.g. IndexOnlyScan, which the planner can legitimately choose to locate the target row of an UPDATE, plus future/custom scan nodes) silently disables the whole-statement proof with no indication. This is a correctness-safe fallback (per-row evaluation still runs), but it is a maintainability footgun: a reader must notice this list is a hidden allow-list that must be kept in sync with planner scan choices, and the two copies can drift. Consider factoring the "is this a scan of rti" test into a single helper, and either documenting why IndexOnlyScan is intentionally excluded or including it.

…olumns the index cannot return

- 0005: amcomparedatums is not asked about a column the index can return
  (amcanreturn); dummy_index_am tests it.
- 0006 replaces "nbtree: implement amcomparedatums": an indexed value
  that is the same but stored differently (compressed or out of line in
  one row) is unchanged for every index (datum_image_eq), except in
  candidate keys and the replica identity.  hot_updates gets SP-GiST and
  partial-index write-back cases and a unique-key case that stays
  non-HOT.
- Rebased on upstream 4b97fd1.

@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 28 issue(s).

  • 25 inline, 3 in summary (inline capped at 25)

📄 src/test/regress/sql/hot_updates.sql (L439-L440)

The comment claims the value is "stored compressed in the old row," but the hardcoded length of 3000 only guarantees out-of-line/compressed storage at the default 8kB block_size (TOAST threshold ~2032 bytes). PostgreSQL supports block sizes up to 32kB, where the TOAST tuple threshold is ~8160 bytes, so a 3000-byte value stays inline and uncompressed. On such builds this case silently stops exercising the compressed-value code path it is meant to cover. The HOT-count assertion (10/10) likely still holds, so the degradation is silent. Consider sizing from current_setting('block_size') as the TOAST section above does, or add a comment noting the 8kB assumption. (low confidence)

The same applies to the repeat('x', 2000) value in the unique-index case below.


📄 src/test/regress/sql/hot_updates.sql (L450-L451)

This case asserts hot=10 (see expected output), which requires the 3000-byte t values to be stored out-of-line so that 10 rows plus their HOT update versions fit on one page with fillfactor 50. At the default 8kB block_size the ~3000-byte value exceeds the TOAST threshold (~2032 bytes) and is pushed out of line, leaving only a toast pointer in the main tuple -- so HOT works. But PostgreSQL supports block sizes up to 32kB, where the TOAST tuple threshold is ~8160 bytes; there the 3000-byte value stays inline, 10 rows no longer fit on a fillfactor-50 page, and some updates become non-HOT, breaking the expected output. Like the TOAST test above (line 67), derive the length from current_setting('block_size') or force out-of-line storage with SET STORAGE EXTERNAL/MAIN so the assertion holds at every supported block size. The same applies to the SP-GiST case above.


📄 src/test/regress/sql/hot_updates.sql (L457-L458)

This unique-index case hardcodes repeat('x', 2000) and asserts hot=0. 2000 bytes sits right at the default 8kB TOAST tuple threshold (~2032 bytes), so the storage decision (and therefore whether the written-back value compares equal 'as stored' for the unique index) is sensitive to block_size: at 16kB/32kB the threshold is far higher, the value stays inline/uncompressed, and the hot count may differ from 0. Make the length block-size-independent (derive from block_size or force storage via SET STORAGE) so the assertion is portable.

Comment thread .gdbinit
Comment on lines +19 to +20
# Main entry: heap_update
break heapam.c:3210

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 breakpoints hardcode absolute line numbers that are already stale against this fork's source. The diff for src/backend/access/heap/heapam.c shows heap_update() beginning near line 3278 (not 3210), and the HOT-update decision collapsed to a simple if (hot_allowed) use_hot_update = true; near line 4174 -- it is not at lines 4019/4024/4033. Line-number breakpoints drift the instant surrounding code changes, so sourcing this file will silently break on the wrong statements. For a change headed to pgsql-hackers this is personal, unmergeable scaffolding and should not be tracked at all; if kept, prefer function-name breakpoints over line numbers.

Comment thread .gdbinit
Comment on lines +42 to +43
# Predict augmented tuple size (returns 0 if t_hoff would overflow)
break heap_hot_indexed_tuple_size

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 break on function names that do not exist anywhere in the current change set (heap_hot_indexed_tuple_size, heap_hot_indexed_create_tuple, heap_hot_indexed_serialize_bitmap, etc.). heapam.c in this diff contains no such symbols -- they belong to a different/unmerged revision of the HOT-indexed-updates series. GDB will report these as pending/unresolved breakpoints, confirming the file is out of sync with the tree and is private debugging state, not a contribution.

Comment thread .envrc
Comment on lines +4 to +10
source_up
watch_file flake.nix
use flake
use project_steering c aws
use project_steering postgresql
use project_mcp postgresq
use project_mcp context7

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 .envrc is non-portable, machine-specific developer state. source_up pulls from a personal ~/ws/postgres/.envrc (per the comment), and use project_steering / use project_mcp are custom direnv extensions that are not defined anywhere in this tree (confirmed by search). On any other contributor's machine direnv allow will error on these undefined use functions. Committing this to the repo root is exactly the kind of unrelated, personal tooling that gets a pgsql-hackers patch rejected for not being a minimal, self-contained change.

Comment thread .mcp.json
Comment on lines +3 to +6
"postgresq": {
"type": "http",
"url": "https://pg.ddx.io/mcp/"
},

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 JSON key "postgresq" is a truncated misspelling of "postgresql" (the same truncated name appears in .envrc as use project_mcp postgresq). Beyond the typo, this embeds a private/personal service endpoint (https://pg.ddx.io/mcp/) in a tracked repo file -- that is both a leak of personal infrastructure and non-reproducible for anyone else. This file should not be part of a patch destined for the mailing list.

Comment thread pg-aliases.sh
--dlpath="$bdir/src/test/regress" \
--outputdir="$outdir" \
--temp-instance="$outdir/tmp" \
--port=40099 \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pg-test-one hardcodes a fixed pg_regress port (40099). Two concurrent invocations -- or any other process already bound to 40099 -- will fail with a port-in-use error. Consider deriving the port from the PWD/basename hash or letting the caller override it, consistent with how the data dir already uses $(basename $PWD). Low severity since this is a local helper, not product code.

Comment on lines +4796 to +4797
slot = MakeTupleTableSlot(RelationGetDescr(relation), &TTSOpsBufferHeapTuple, 0);
bslot = (BufferHeapTupleTableSlot *) slot;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

simple_heap_update() open-codes part of the static heapam_fetch_row_version() (MakeTupleTableSlot + heap_fetch(SnapshotAny) + ExecStorePinnedBufferHeapTuple). This duplicates fetch logic across modules and will silently drift if heapam_fetch_row_version() changes (e.g. snapshot or keep_buf handling). Consider exposing a shared helper rather than replicating the sequence here.


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

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 execl -> execlp change is out of scope for a HOT-updates patch and changes the test harness's process-spawning semantics on every Unix platform. shellprog is SHELLPROG, which is $(SHELL) in the Make build and hardcoded /bin/sh in meson -- i.e. normally an absolute path. For a path containing a slash, execlp is identical to execl, so this is a no-op in the standard build. Its only observable effect is when SHELLPROG is a bare program name (no slash), in which case execlp performs a PATH search in the forked child -- a footgun (attacker-controlled/ambient PATH picks the shell) and a reproducibility hazard, with no test or rationale. This looks like it leaked in from the added Nix dev-shell setup. Revert it to keep the diff minimal and the harness's shell resolution deterministic.

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

# psql depends on create_am
# amutils depends on geometry, create_index_spgist, hash_index, brin
test: rules psql psql_crosstab psql_pipeline amutils stats_ext collate.linux.utf8 collate.windows.win1252
test: rules psql psql_crosstab psql_pipeline amutils stats_ext collate.linux.utf8 collate.windows.win1252 hot_updates

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hot_updates is appended to a parallel group whose header comment (lines above) documents constraints specific to the collate/psql/amutils tests; hot_updates is unrelated to any of them. The test itself appears parallel-safe (per-relation stats on its own tables), so this is not a correctness bug, but tacking it onto this particular group is arbitrary. Consider placing it in a more topically appropriate group (or its own test: line) so the grouping stays self-explanatory.

Comment on lines +17 to +18
-- count from zero: flush what earlier transactions counted
SELECT pg_stat_force_next_flush();

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 pg_stat_force_next_flush() calls are unnecessary here. The test reads transaction-local counters via pg_stat_get_xact_tuples_updated/pg_stat_get_xact_tuples_hot_updated (note the _xact_ in the names), and every SELECT * FROM dummy_cmp_hot() runs inside the same BEGIN/COMMIT block as the UPDATEs it measures. Transaction-local stats start at zero at transaction begin regardless of what prior transactions flushed, so forcing a flush does not affect correctness or determinism. The accompanying comment "count from zero: flush what earlier transactions counted" is also misleading, since the flush does not reset the xact-local counters the test reads. Consider dropping the flush calls (and comments) to keep the test minimal. Confidence: moderate.

@@ -1,4 +1,4 @@
#!/usr/bin/perl
#!/usr/bin/env perl

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 shebang change is an unrelated, one-off deviation from a tree-wide convention and should be reverted. Every other Perl script in the tree (~40 of them: genbki.pl, gen_node_support.pl, copyright.pl, mark_pgdllimport.pl, the catalog scripts, etc.) uses #!/usr/bin/perl; after this change pgindent would be the sole file using #!/usr/bin/env perl.

Three problems:

  1. Patch hygiene / minimal diff: this hunk is cosmetic and unrelated to the rest of the change set. Switching to env interpreter resolution for one script only is inconsistent churn.
  2. Behavior change: /usr/bin/env perl resolves perl via $PATH instead of the fixed path the rest of the tree relies on, which is less predictable for a maintained in-tree tool.
  3. If a move to env-based shebangs is actually wanted, it must be a deliberate, tree-wide decision raised on pgsql-hackers, not a single-file edit.

Revert to #!/usr/bin/perl.

Suggested change
#!/usr/bin/env perl
#!/usr/bin/perl

hot_updates: the unique-key case writes back a 3000-byte value, which is
compressed on 32-bit builds too; at 2000 bytes it stayed inline there,
so its write-back was rightly HOT.

@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 20 issue(s).

  • 20 inline, 0 in summary

Comment thread .clangd
Comment on lines +1 to +2
Diagnostics:
MissingIncludes: None

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 — along with the rest of this group (.envrc, .gdbinit, .local-gitignore, .mcp.json, flake.nix, shell.nix, pg-aliases.sh) — is personal developer tooling/environment configuration committed into the repository root. For a change destined for pgsql-hackers/commitfest, these are unrelated additions that violate the minimal-diff discipline. None of them are wired into any in-tree build/test infrastructure (PG_SOURCE_DIR and friends are referenced only by this tooling). They should be removed from the submission and kept in a developer-local, gitignored location instead.

Comment thread .gdbinit
# =========================================================================

# Main entry: heap_update
break heapam.c:3210

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 breakpoints hardcode absolute source line numbers (heapam.c:3210/4019/4024/4033/4101/4147 and later pruneheap.c/indexam.c/heapam_indexscan.c line refs). Any line added or removed in those files silently retargets the breakpoints, making this file actively misleading rather than merely stale. Prefer function-name breakpoints (which this file already uses elsewhere) over raw line numbers.

Comment thread .gdbinit
Comment on lines +43 to +46
break heap_hot_indexed_tuple_size

# Create augmented tuple with embedded modified-column bitmap
break heap_hot_indexed_create_tuple

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Several of these symbol breakpoints reference functions that do not exist anywhere in the tree: heap_hot_indexed_tuple_size, heap_hot_indexed_create_tuple, heap_hot_indexed_serialize_bitmap, heap_hot_indexed_read_bitmap (and further down heap_xlog_indexed_update). GDB will reject them (or defer as pending) on load, so this debugging aid is broken on arrival. Reconcile the breakpoints with the symbols that actually exist in the current code.

Comment thread .gdbinit
@@ -0,0 +1,156 @@
# HOT Indexed Updates — GDB breakpoints for code review

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Source and diffs in this tree must be ASCII only. The header comment (and several section headers below) use a non-ASCII em-dash ('\u2014'); replace with a plain ASCII hyphen.

Comment thread pg-aliases.sh

alias pg-start='ulimit -c unlimited && "$PG_INSTALL_DIR/bin/postgres" -D "$PG_DATA_DIR" -k "$PG_DATA_DIR"'

alias pg-stop='pkill -f "postgres.*-D.*$PG_DATA_DIR" || true'

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 pkill/pgrep patterns interpolate $PG_DATA_DIR into a regex with no quoting of the value and no guard against it being empty. PG_DATA_DIR is only ever set by shell.nix's shellHook; if this script is sourced outside that flake shell (or the var is unset), 'postgres.-D.' matches any running postgres backend, so pg-stop/pg-restart can kill unrelated servers. Guard against an empty PG_DATA_DIR before using it in a process-matching pattern.

Comment on lines +244 to +249
INSERT INTO hot_bt_image
SELECT g, repeat('x', 3000) || g, repeat('y', 2500) || g
FROM generate_series(1, 20) g;
SELECT pg_column_compression(t) IS NOT NULL AS t_compressed,
pg_column_size(e) > 2500 AS e_out_of_line
FROM hot_bt_image WHERE id = 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Portability: this block hardcodes repeat('x', 3000) and asserts pg_column_compression(t) IS NOT NULL, which only holds at the default 8KB BLCKSZ. On a build with a larger block size (16KB/32KB), TOAST_TUPLE_THRESHOLD rises above ~3000 bytes, so column t (EXTENDED storage) stays inline and uncompressed: pg_column_compression(t) returns NULL, making t_compressed false and diverging from the expected output; the HOT counts that depend on the old value being toasted also change. The sibling src/test/regress/sql/hot_updates.sql deliberately avoids this by sizing the payload with current_setting('block_size')::int / 2 and checking pg_column_toast_chunk_id(...) IS NOT NULL ("stored out of line at any block size"). Size the value relative to block_size here for the same reason.

Comment on lines +4796 to +4797
slot = MakeTupleTableSlot(RelationGetDescr(relation), &TTSOpsBufferHeapTuple, 0);
bslot = (BufferHeapTupleTableSlot *) slot;

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 adds an unconditional second buffer access on a very hot path: every catalog tuple update (CatalogTupleUpdate / CatalogTupleUpdateWithInfo -> simple_heap_update) now allocates a BufferHeapTupleTableSlot and does a full heap_fetch() with SnapshotAny (ReadBuffer + share lock + PredicateLockTID + HeapCheckForSerializableConflictOut) just to compute modified_idx_attrs, after which heap_update() reads and locks the same tuple again under the exclusive buffer lock. Previously the modified-column determination happened once, inside heap_update() under the lock it already held. This is a measurable regression on DDL / catalog-update-heavy workloads. Since this feature is destined for pgsql-hackers, the extra fetch needs justification (and ideally a benchmark); consider a lighter path that reuses heap_update()'s under-lock read rather than a separate SnapshotAny fetch + slot allocation per call. [performance, moderate confidence]

Comment on lines +4681 to +4682
HeapUpdateModifiedIdxAttrs(Relation relation, const Bitmapset *attrs,
HeapTuple oldtup, HeapTuple newtup)

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 patch renames HeapDetermineColumnsInfo() to HeapUpdateModifiedIdxAttrs(), but the header comment on heap_attr_equals() (just above, "Subroutine for HeapDetermineColumnsInfo.") still references the old name, which no longer exists anywhere in the tree. Update the comment to name HeapUpdateModifiedIdxAttrs() so it describes the code as it is now. [documentation, high confidence]


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

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 execl -> execlp change is unrelated to the HOT-update/subattr test work in this PR and should be split out (minimal-diff discipline).

Functionally it is also a subtle semantic change for no stated benefit: shellprog is defined from SHELLPROG, which is $(SHELL) in the Makefile build and "/bin/sh" in the meson build -- always an absolute path. execlp() only consults PATH when its file argument contains no slash, so for an absolute path it behaves identically to execl(). The only observable difference is that if SHELL/SHELLPROG were ever a bare name, the shell would now be resolved via PATH, which is a POLA-violating, PATH-dependent behavior change with no justification in the commit. Recommend reverting this line unless a new test actually requires PATH-based shell lookup (none in this change does).

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

Comment on lines +184 to +186
SELECT * FROM get_hot_count('hot_test_part1');
SELECT pg_stat_force_next_flush();
SELECT * FROM get_hot_count('hot_test_part2');

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 second pg_stat_force_next_flush() here is redundant: no DML runs between the two get_hot_count() reads, so the counters for hot_test_part2 are already flushed by the first call. It only adds another output line to keep stable in the expected file. Consider dropping it.

(low confidence, style/maintainability)

Suggested change
SELECT * FROM get_hot_count('hot_test_part1');
SELECT pg_stat_force_next_flush();
SELECT * FROM get_hot_count('hot_test_part2');
SELECT * FROM get_hot_count('hot_test_part1');
+SELECT * FROM get_hot_count('hot_test_part2');

OCR review of 2b19876:
- heapam.c: heap_attr_equals()'s header named HeapDetermineColumnsInfo,
  which 0002 renamed to HeapUpdateModifiedIdxAttrs.
- hot_updates: drop a redundant pg_stat_force_next_flush() between two
  reads with no DML in between.

@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 27 issue(s).

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

📄 src/tools/pgindent/pgindent (L1-L1)

This shebang change is unrelated to the feature and breaks the tree-wide convention: every other Perl script under src/ uses #!/usr/bin/perl (e.g. genbki.pl, gen_node_support.pl, mark_pgdllimport.pl, copyright.pl); there are zero #!/usr/bin/env perl shebangs in the tree. This is a local developer-environment convenience (consistent with the Nix files added in this PR) that will be rejected on -hackers as unrelated churn. Revert to keep the diff minimal.

💡 Suggested change

Before:

#!/usr/bin/env perl

After:

#!/usr/bin/perl

📄 src/backend/commands/copyto.c (L881-L883)

Minor: this path does two separate syscache lookups per child -- get_rel_relkind(child) at the top of the loop and now get_rel_persistence(child) here, plus get_rel_name(child) again in each error branch. Since the relcache entry is being looked up repeatedly for the same OID, consider fetching the tuple once (e.g. via SearchSysCache1(RELOID)) and reading relkind/relpersistence/relname from it, consistent with how hot loops over inheritors avoid redundant lsyscache calls. Low severity / maintainability only.

Comment on lines +28 to +29
jobs:
review:

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 caller invokes a reusable workflow that authenticates to AWS Bedrock via GitHub OIDC (per the header comment: "Auth is GitHub OIDC"). OIDC token minting requires id-token: write, which is never granted by default and cannot be requested inside the reusable workflow -- it must be declared by the caller. Without a permissions: block granting id-token: write, the aws-actions/configure-aws-credentials call in the reusable workflow will fail with "Credentials could not be loaded" / missing OIDC token. The review workflow also needs pull-requests: write (and likely issues: write) to post review comments. Add an explicit least-privilege permissions: block at the workflow level.

Suggested change
jobs:
review:
permissions:
contents: read
id-token: write
pull-requests: write
issues: write
jobs:
review:

(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))
uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@v1

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 external reusable workflow is pinned to the mutable tag @v1. A tag can be force-moved in the gburd/ci-workflows repo, so a compromise of that repo (or a maintainer) would let attacker-controlled logic run with this fork's AWS OIDC role and GITHUB_TOKEN on every PR. Pin third-party/external reusable workflows to a full commit SHA (optionally with a # v1.x trailing comment) rather than a tag.

Suggested change
uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@v1
uses: gburd/ci-workflows/.github/workflows/ocr-review.yml@<full-commit-sha> # v1


on:
pull_request:
# Note: no draft filter — drafts are reviewed too.

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) in this comment. PostgreSQL requires ASCII-only in source and diffs (no em-dashes, smart quotes, or ellipses). Replace with a plain hyphen or --.

Suggested change
# Note: no draft filter — drafts are reviewed too.
# Note: no draft filter -- drafts are reviewed too.

Comment on lines +12 to +14
jobs:
check:
uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@v1

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 caller lacks a permissions: block. The reusable workflow authenticates to Bedrock via OIDC (needs id-token: write, never granted by default and only requestable from the caller) and, per the header comment, "opens/updates one issue here" (needs issues: write). Without these, OIDC auth and/or issue creation will fail. Add an explicit least-privilege permissions: block.

Suggested change
jobs:
check:
uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@v1
permissions:
contents: read
id-token: write
issues: write
jobs:
check:
uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@v1


jobs:
check:
uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@v1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

External reusable workflow pinned to the mutable tag @v1. Pin to a full commit SHA so a force-moved tag in gburd/ci-workflows cannot silently change the code that runs with this repo's OIDC/token access.

Suggested change
uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@v1
uses: gburd/ci-workflows/.github/workflows/ocr-model-check.yml@<full-commit-sha> # v1

Comment on lines +274 to +276
ereport(ERROR,
errcode(ERRCODE_INSUFFICIENT_RESOURCES),
errmsg("could not find an unpinned shared buffer"));

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 error-message rewording (and the matching one in localbuf.c) is unrelated to the modified_idx_attrs / HOT table-AM refactor that this change set is about. Mixing an independent buffer-exhaustion message/errcode change into this patch violates the "minimal diff, one logical change per commit" discipline and will draw a split-it request on -hackers. Recommend moving these two hunks to a separate patch.

Confidence: high.

ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_RESOURCES),
errmsg("no empty local buffer available")));
errmsg("could not find an unpinned local buffer")));

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 message rewording is unrelated to the table-AM/HOT refactor that is the purpose of this change set. Like the freelist.c hunk, it should be split into a separate, self-contained patch rather than bundled here (patch hygiene: one logical change per commit).

Confidence: high.

Comment on lines +881 to +883
/* Unlogged partitions are inaccessible during recovery. */
if (RecoveryInProgress() &&
get_rel_persistence(child) == RELPERSISTENCE_UNLOGGED)

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 COPY-TO-during-recovery guard for unlogged (partitioned) tables is unrelated to the HOT-update / modified_idx_attrs work in the other file of this change (repack.c). Bundling two independent concerns in one patch is a common pgsql-hackers rejection reason; split it into its own commit/patch with its own rationale and thread reference. (moderate confidence)

Comment on lines +887 to +891
ereport(ERROR,
errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot access temporary or unlogged relations during recovery"),
errdetail("Partition \"%s\" is an unlogged table in partitioned table \"%s\".",
relation_name, RelationGetRelationName(rel)));

ghost Oct 21, 2026

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 check runs inside the foreach_oid loop before the "Exclude tables with no data" step, so it also fires on intermediate partitioned tables (RELKIND_HAS_PARTITIONS), which are themselves persistence UNLOGGED-capable but hold no data. For a permanent root with an unlogged intermediate partitioned level, this errors with errdetail "...is an unlogged table..." even though that relation stores no rows. Consider only checking leaf relations (e.g. test relkind / move the check after the no-data exclusion) so the detail message isn't misleading. (low confidence)


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

ghost Oct 21, 2026

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 execl -> execlp change is unrelated to the HOT-update / SupportRequestSubattr feature this PR implements, and it changes behavior in a risky way. shellprog comes from SHELLPROG, defined as $(SHELL) (Make) or /bin/sh (meson) -- normally an absolute path. execl() runs exactly that file; execlp() performs a $PATH search when the argument has no slash. The replacement only alters behavior if shellprog has no slash, in which case the shell is resolved from the environment's PATH -- a PATH-injection footgun that did not exist before. This looks like a local Nix/direnv convenience (matching the flake.nix/shell.nix/.envrc files added elsewhere in this PR). Revert it to keep the diff minimal and avoid the behavior change.

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

- Rebased on upstream 5ffb8c7.
- 0006: the narrowing no longer compares a column an index stores
  again: it is changed exactly when it is still in the changed set
  (IndexDatumsDiffer() takes the set), so its values are not fetched a
  second time.  Only expression datums are compared again.
- hot_updates: a write-back through an expression that returns the
  value as is, evaluated for a predicate column's change (HOT after
  0006).

ghost 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 21 issue(s).

  • 21 inline, 0 in summary

Comment thread .clangd
Comment on lines +1 to +2
Diagnostics:
MissingIncludes: None

ghost Oct 22, 2026

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 (along with .envrc, .gdbinit, .local-gitignore, .mcp.json, flake.nix, shell.nix, and pg-aliases.sh) is personal developer tooling and does not belong in a PostgreSQL contribution destined for pgsql-hackers/commitfest. It violates the "minimal diff" discipline: a patch must not bundle unrelated scaffolding at the repo root. The repo already uses per-directory .gitignore files, and this change even ships its own .local-gitignore mechanism (git config core.excludesFile .local-gitignore) -- use that to keep these files local and untracked rather than committing them. Including them will draw immediate rejection on the list.

Comment thread .gdbinit
Comment on lines +19 to +20
# Main entry: heap_update
break heapam.c:3210

ghost Oct 22, 2026

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 file+line breakpoints are already stale against the code in this same change. For example break heapam.c:3210 is annotated "Main entry: heap_update", but heap_update()'s definition is at ~line 3278 in the modified file; the "HOT decision block" at 4019/4024/4033 no longer matches -- the HOT decision is now if (hot_allowed) at ~line 4174. Likewise break execIndexing.c:370 is annotated "Index skip decision" but line 370 is return true; inside the predicate-qual helper, not any skip decision. Line-number breakpoints are inherently fragile: they silently point at the wrong code as the patch evolves and will mis-direct any debugging session. If this file were to be kept at all (it should not be committed -- see .clangd), breakpoints should be keyed to function names, not line numbers.

Comment thread .mcp.json
@@ -0,0 +1,15 @@
{
"mcpServers": {
"postgresq": {

ghost Oct 22, 2026

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 server key "postgresq" is missing a trailing 'l' and looks like a typo for "postgresql". The same truncated name is referenced from .envrc via use project_mcp postgresq, so the two are at least internally consistent, but the identifier itself is almost certainly wrong. If intentional, it is highly confusing; if a typo, it names the MCP server incorrectly.

Comment thread .envrc
Comment on lines +4 to +10
source_up
watch_file flake.nix
use flake
use project_steering c aws
use project_steering postgresql
use project_mcp postgresq
use project_mcp context7

ghost Oct 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

source_up relies on a parent-directory .envrc (the comment points at ~/ws/postgres/.envrc), and the custom use project_steering / use project_mcp directives depend on direnv functions and MCP servers that exist only on one developer's machine. Combined with the hard-coded host paths in shell.nix/pg-aliases.sh (e.g. /tmp/test-db-$(basename $PWD), sudo tee /proc/sys/kernel/core_pattern), this ties the committed tree to a single developer's layout and is not portable. This file should not be committed -- keep it local via the .local-gitignore mechanism.

Comment on lines +35 to +38
(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
(startsWith(github.event.comment.body, '/open-code-review') ||
startsWith(github.event.comment.body, '@open-code-review') ||
startsWith(github.event.comment.body, '/pg-history')))

ghost Oct 22, 2026

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 issue_comment trigger fires for any comment starting with /open-code-review, @open-code-review, or /pg-history, but there is no gate on the comment author. Any user who can comment on a PR (not only maintainers) can start an AI review and drive Bedrock spend. Add an author_association gate on the if: condition, e.g. require the commenter to be OWNER/MEMBER/COLLABORATOR:

(github.event_name == 'issue_comment' && github.event.issue.pull_request &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) &&
(startsWith(...)))

If gating is intended to live downstream in gburd/ci-workflows, note that the reusable workflow is not vendored in this repo, so it cannot be verified here. Confidence: high.

Comment on lines +349 to +353
/* Temporary and unlogged relations are inaccessible during recovery. */
if (!RelationIsPermanent(rel) && RecoveryInProgress())
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot access temporary or unlogged relations during recovery")));

ghost Oct 22, 2026

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 user-visible error path (currtid/currtid2 on a standby) has no test coverage. The accompanying TAP additions in src/test/recovery/t/001_stream_rep.pl only exercise the COPY TO guard in copyto.c, not this one. Per project policy a user-visible behavior change should include a test covering the error path, e.g. a currtid2(...) call against an unlogged/temp relation on $node_standby_1 asserting the same qr/cannot access temporary or unlogged relations during recovery/ message. Confidence: moderate.


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

ghost Oct 22, 2026

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 execl -> execlp change is unrelated to the SUBATTR support-function feature that the rest of this patch implements; it is unrelated churn and should be split out (or dropped).

Functionally it is also a footgun. shellprog comes from SHELLPROG, which is defined as $(SHELL) in src/test/regress/GNUmakefile and hardcoded /bin/sh in meson.build -- i.e. normally an absolute path. For an absolute path execlp is identical to execl, so this buys nothing. The only case it changes behavior is when $(SHELL) is a bare command name with no slash (e.g. make SHELL=sh), and then execlp resolves the shell through the ambient $PATH instead of failing. That is a non-deterministic, PATH-injection hazard in the test harness for no benefit. Revert to execl.

Confidence: high.

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

@@ -1,4 +1,4 @@
#!/usr/bin/perl
#!/usr/bin/env perl

ghost Oct 22, 2026

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 shebang change is unrelated to the rest of the patch and breaks the tree-wide convention. Every other Perl script in the PostgreSQL source (e.g. genbki.pl, gen_node_support.pl, copyright.pl, mark_pgdllimport.pl, and ~30 others) uses the fixed #!/usr/bin/perl shebang. Switching only pgindent to #!/usr/bin/env perl makes it the sole outlier and is gratuitous churn that will draw a rejection on -hackers. If a case is to be made for /usr/bin/env perl, it should be a separate, deliberate, tree-wide patch discussed on its own; it does not belong bundled into this change. Revert this line.

Suggested change
#!/usr/bin/env perl
#!/usr/bin/perl

Comment on lines +350 to +353
if (!RelationIsPermanent(rel) && RecoveryInProgress())
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot access temporary or unlogged relations during recovery")));

ghost Oct 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Style nit (low confidence): this new ereport uses the legacy extra-parentheses wrapping ereport(ERROR, (errcode(...), errmsg(...))), but every other ereport in this same function (the RELKIND_HAS_STORAGE check just above, and the two in currtid_for_view) uses the modern parenthesis-free form. For a minimal patch that reads as if always written that way, match the surrounding style.

Suggested change
if (!RelationIsPermanent(rel) && RecoveryInProgress())
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot access temporary or unlogged relations during recovery")));
if (!RelationIsPermanent(rel) && RecoveryInProgress())
ereport(ERROR,
errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("cannot access temporary or unlogged relations during recovery"));

Comment on lines +260 to +262
UPDATE subattr_two SET d = jsonb_set(d, '{c}', '0');
-- both rows HOT: the index's value didn't change
SELECT pg_stat_get_xact_tuples_hot_updated('subattr_two'::regclass) AS hot;

ghost Oct 22, 2026

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 test's HOT-count assertions do not actually exercise the SupportRequestSubattr path in a way that a regression could be caught in normal (non-assert) builds. IndexUnaffected() in nodeModifyTable.c, when the subattr proof does not apply, falls back to IndexEntryChanges(), which evaluates the expression index and compares its datums byte-for-byte. For both UPDATEs here the fallback reaches the same HOT classification as the proof path:

  • jsonb_set(d,'{c}','0'): two_keys(d,'a','b') is unchanged, so the fallback also treats the index as unaffected -> HOT.
  • jsonb_set(d,'{b}','0'): the value changes, so both paths -> not HOT.

So pg_stat_get_xact_tuples_hot_updated() returns 2 / 2 with the support function reverted too; the EXPLAIN/index-scan check for '2/0' likewise passes via the fallback. Per the test-discipline rule (a test that still passes with the feature reverted is worthless), the happy-path assertions here do not catch a regression in the support-function path on a non-assert buildfarm animal. The comment itself concedes the real verification only happens under USE_ASSERT_CHECKING / amcheck. Consider either dropping the redundant HOT-count assertions or adding coverage that is observably different only when the support function is consulted. (moderate confidence)

…ap_update compares slots

- 0008: hashcomparedatums() says "different" without hashing when the
  type's default btree opclass has equalimage true for the index's
  collation; the executor only asks about values that differ once
  detoasted.  Exclusion-constraint collision tests use interval spans of
  1 and 2^32 microseconds (int8 is no longer asked).
- 0002: simple_heap_update() puts the new tuple in a slot and uses
  table_slot_modified_idx_attrs(); HeapUpdateModifiedIdxAttrs() is now
  assert-only (heap_update()'s cross-check).
- Rebased on upstream.

ghost 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 26 issue(s).

  • 25 inline, 1 in summary (inline capped at 25)

📄 src/tools/pgindent/pgindent (L1-L1)

This change breaks the tree-wide convention: every other Perl script in the PostgreSQL source tree (genbki.pl, gen_node_support.pl, copyright.pl, mark_pgdllimport.pl, and ~30 others) uses #!/usr/bin/perl. After this edit, pgindent would be the sole outlier using #!/usr/bin/env perl.

The change has no stated functional purpose and is unrelated to any other work in this patch set, so it is unnecessary churn that will draw a rejection on -hackers. If a env-based shebang were genuinely desired, it would need to be a deliberate, tree-wide decision discussed on the list, not a one-off edit to a single file. Revert this to #!/usr/bin/perl to keep the diff minimal and consistent.

💡 Suggested change

Before:

+#!/usr/bin/env perl

After:

+#!/usr/bin/perl

Comment thread .gdbinit
Comment on lines +19 to +20
# Main entry: heap_update
break heapam.c:3210

ghost Oct 23, 2026

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 (and the whole .clangd/.envrc/.gdbinit/.local-gitignore/.mcp.json/flake.nix/shell.nix/pg-aliases.sh group) is developer-local environment scaffolding with no relationship to the stated code change. It is unrelated churn that violates PostgreSQL patch hygiene (minimal diff) and would be rejected on pgsql-hackers; it should not be committed to the repository. Beyond that, the hardcoded source line numbers here are already wrong: heapam.c:3210 lands inside heap_delete's cleanup (not heap_update), and heapam.c:4019/:4024/:4033 land inside heap_lock_tuple's visibility-map/WAL code, not the described HOT decision block. Line-number breakpoints will keep silently drifting, making this actively misleading.

Comment thread .gdbinit
break heap_hot_indexed_tuple_size

# Create augmented tuple with embedded modified-column bitmap
break heap_hot_indexed_create_tuple

ghost Oct 23, 2026

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 break targets reference functions that do not exist in the source tree (e.g. heap_hot_indexed_create_tuple, heap_hot_indexed_serialize_bitmap, heap_hot_indexed_read_bitmap). The only occurrence of these symbols in the repo is this .gdbinit file itself. The breakpoints will never resolve, confirming this script is stale, personal tooling that does not belong in the patch.

Comment thread .mcp.json
@@ -0,0 +1,15 @@
{
"mcpServers": {
"postgresq": {

ghost Oct 23, 2026

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 JSON key "postgresq" is misspelled (missing trailing l); it should be "postgresql". The same truncated name appears in .envrc (use project_mcp postgresq), so the two are self-consistent but the name is almost certainly unintended.

Suggested change
"postgresq": {
"postgresql": {

Comment thread .envrc
# Inherit shared CFLAGS from ~/ws/postgres/.envrc, load this worktree's
# flake, then layer the postgres agent context (helpers need flake's PATH +
# write into this worktree's dir).
source_up

ghost Oct 23, 2026

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 relies on local-machine assumptions that are not reproducible for anyone else: source_up reads a parent ~/ws/postgres/.envrc that is not part of the repo, and use project_steering / use project_mcp are custom direnv functions defined nowhere in this change. Running direnv allow on a fresh checkout will error. This is personal tooling, not shareable repo config, and should not be committed.

Comment thread .envrc
use flake
use project_steering c aws
use project_steering postgresql
use project_mcp postgresq

ghost Oct 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

project_mcp postgresq carries the same truncated name as the .mcp.json key; if it is meant to reference the MCP server defined there, both should read postgresql.

Comment on lines +458 to +461
while (isspace((unsigned char) *p))
p++;
if (*p == '-' || *p == '+')
p++;

ghost Oct 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

key_may_be_position() uses isspace() from <ctype.h> with the current locale. The leading comment claims answering "yes too often" is the safe direction (an over-reported overlap only costs an unnecessary index entry), which is true for soundness. But locale-dependent isspace() is a portability smell in the backend: on some locales/platforms it may classify bytes >0x7F as space, and the behavior differs from strtol()'s skip set in subtle ways across platforms. Prefer scanner_isspace()/the backend's locale-independent helpers to keep the heuristic deterministic across the supported platforms. (low confidence that this causes a bug, but it is the kind of locale dependence the tree avoids in parse/scan paths.)

Comment on lines +94 to +97
WITH current_tuple AS (SELECT ctid FROM hot_test WHERE id = 1)
SELECT has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
chain_position, print_hot_chain.ctid, lp_flags, t_ctid
FROM current_tuple, LATERAL print_hot_chain('hot_test', current_tuple.ctid);

ghost Oct 23, 2026

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 expected output hardcodes concrete ctids ((0,1), (0,4)) that depend on exact tuple placement within the page. With fillfactor=50 and these fixed small tuples this should be deterministic, but it couples the test to heap layout details (tuple alignment/padding). If future changes to tuple header size or alignment shift placement, the test breaks in a way unrelated to HOT behavior. Consider asserting the structural property (chain length / has_hot_chain result) rather than literal ctids where possible. (low confidence; acceptable as-is but worth noting.)

Comment on lines +4162 to +4163
/* We fill in schema names below, so work on a copy */
schemaElts = copyObject(schemaElts);

ghost Oct 23, 2026

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 copyObject deep-copies the entire schemaElts list, but the function's header comment (lines 4150-4152) still reads "we copy any CREATE TABLE subcommands that we might modify" and transformCreateSchemaCreateTable still does a redundant flat-copy of each CreateStmt to "not damage the input data structure." The header comment is now stale/misleading: the whole list is copied up front, not just CREATE TABLE subcommands. Please update the header comment to reflect that the entire list is copied so a reader isn't misled. (moderate confidence)

* analyzes the elements of a CREATE SCHEMA statement
*
* This presently has two responsibilities. We verify that no subcommands
* This presently has two responsibilities. We ensure that no subcommands

ghost Oct 23, 2026

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 reword ("verify" -> "ensure") is unneeded churn and is slightly less precise: this first responsibility is still purely a verification (it raises an error on a schema-name mismatch); the new fill-in behavior falls under the schema-name handling described below. Per the minimal-diff discipline, prefer leaving the untouched wording as-is unless the change is required. (low confidence)

Suggested change
* This presently has two responsibilities. We ensure that no subcommands
* This presently has two responsibilities. We verify that no subcommands


cmdline2 = psprintf("exec %s", cmdline);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

ghost Oct 23, 2026

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 execl -> execlp change is unrelated to the SupportRequestSubattr feature this patch adds and looks like scope creep. SHELLPROG is $(SHELL) from the Makefile and "/bin/sh" under meson; both are absolute paths, for which execlp behaves identically to execl (PATH is only searched when the name has no slash). So this is a behavioral no-op in the normal case and a silent PATH-search footgun in the unusual case where SHELLPROG is a bare name. Either drop this hunk from the feature patch (minimal-diff discipline), or split it into its own commit with a justification for why the test shell should be resolved via PATH. Confidence: high.

Suggested change
execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL);
execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL);

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