Skip to content

fix: twelve metadata lookups read their whole catalog (#1207) - #1285

Merged
jdatcmd merged 1 commit into
commandprompt:mainfrom
OffgridwithJD:fix/1207-catalog-scans-by-index
Oct 12, 2026
Merged

jdatcmd merged 1 commit into
commandprompt:mainfrom
OffgridwithJD:fix/1207-catalog-scans-by-index

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Closes #1207. @jdatcmd for review. jd's rule from today applies: this lands only on your approval.

What

Twelve systable_beginscan calls in columnar_metadata.c passed InvalidOid on a key an existing index starts with. Each now goes through pgcolumnar_scan_index_oid(), the size-aware resolver from #1213/#1217. Below index_min_blocks the behaviour is unchanged.

catalog sites reached by
free_space AllocateFreeSpace, TrailingFreeSpaceSafe, DeleteFreeSpaceAtOrAbove compact_rewrite, truncate()
row_group row_group_exists every DELETE
storage 3 in InsertNativeStorageRow, SetSortedExtent every write path, parallel_copy, vacuum_sorted
projection_declaration list, rename column, drop one, drop all TRUNCATE/rewrite, RENAME COLUMN, drop_projection, DROP TABLE

pg_constraint at columnar_tableam.c:2582 (the 13th) is a core catalog and out of scope.

My #1207 comment from this morning called free_space "reachable, low frequency". The row_group_exists site is the important one: it runs on every DELETE.

Evidence

A/B on populated catalogs, PG 17, catalog buffers per operation (from the suite's own output, fix against revert):

one-row DELETE   row_group                45 -> 19
INSERT           storage                  19 -> 13
vacuum_sorted    storage                 128 -> 93
compact_rewrite  free_space               43 -> 37
truncate()       free_space               26 -> 17
RENAME COLUMN    projection_declaration    9 -> 4

The new suite, catalog_scan_index.sh, plus a pytest twin. It covers eleven operations that reach all twelve sites:

fix,    PG 15/16/17/18/19   49 passed + 0 failed     (each major)
revert, PG 15/16/17/18/19   38 passed + 11 failed    (each major, the same 11)

Single-site sweep, PG 17. Each site was reverted alone, and each one reddens the arm for the operation that reaches it:

AllocateFreeSpace          an online rewrite (seq=2)
TrailingFreeSpaceSafe      an end truncation (seq=1)
DeleteFreeSpaceAtOrAbove   an end truncation (seq=2)
row_group_exists           a one-row delete (seq=1)
InsertNativeStorageRow:1   a parallel bulk load (seq=6)
InsertNativeStorageRow:2   an insert into a table created in the same transaction (seq=1)
InsertNativeStorageRow:3   insert into existing (2); parallel bulk load (1); sorted rewrite (10)
SetSortedExtent            a sorted rewrite (seq=1)
ListProjectionDeclarations truncating a projected table (seq=1)
Rename...Column            renaming a projected column (seq=1)
DeleteProjectionDeclaration          dropping a projection (seq=1)
DeleteProjectionDeclarationsForRel   dropping a projected table (seq=1)

Regression, PG 17. These are the 34 suites that drive these paths: all 34 pass at the default threshold. With PGOPTIONS='-c pgcolumnar.index_min_blocks=0' forcing every converted site onto the index, 32 of 34 pass. The two that fail are catalog_delete_index and catalog_plan_index. Their failing arms assert that the default declines probes on small catalogs, which forcing contradicts by construction.

The full pytest guard half passes (404), and so do harness_selftest.sh (1213 checks) and docs_style.sh. Parity with compare_to_bash.py is clean, with 0 missing.

The design choice I most want attacked: it asserts the path

catalog_delete_index.sh says not to assert seq_scan. I do here, and the argument is in the suite header. That objection holds where declining a probe can be correct, which is below the threshold. Every arm runs after 400 other projected tables have pushed all four catalogs above it, and a premise checks that against the live setting.

Two cost designs failed first, both measured:

Fixture traps the premises caught (both silent, both rc=0)

  • compact() in the same psql -c as its DELETE retires nothing (one implicit transaction). Every free_space arm was measuring an empty free list.
  • pgcolumnar.truncate() refuses a transaction block; a multi-statement -c is one. It raised, q() swallowed the error, and the arm read zero.
  • With a projection, truncate() has no tail to reclaim, because the projection's storage sits behind the base's last group. Measured: 0 blocks with the projection, 3 without. The truncation arm uses a projection-free target.

Side effects, neither relied on

  • AllocateFreeSpace best-fit: a tie now goes to the lowest offset rather than heap order.
  • Projection declarations list in name order rather than heap order.
  • The server_dir fixture moved from test_sorted_pathkeys.py to conftest.py, because a second file needs it. That follows the pgc_own_db precedent.

Ledger

1810 → 1859 rows; never 1633 → 1671; dated 177 → 188. The 11 path arms are dated by the revert, merged one major at a time from a five-major run. The budget comment shows the arithmetic.

🤖 Generated with Claude Code

…1207)

Twelve systable_beginscan calls in columnar_metadata.c passed InvalidOid on
a key an existing index starts with. The pgcolumnar catalogs are shared by
every columnar table in the database, so each paid for every other table's
rows:

    free_space              compaction reuse, both end-truncation reads
    row_group               row_group_exists, reached by EVERY DELETE
    storage                 three existence checks on every write, and
                            the sorted-extent update
    projection_declaration  list, rename a column, drop one, drop all

Each now goes through pgcolumnar_scan_index_oid(), so it probes at or above
pgcolumnar.index_min_blocks pages and reads the heap below it, as commandprompt#1213 and
commandprompt#1217 do. PG 17, 400 other projected tables, catalog buffers, before/after:

    one-row DELETE   row_group    45 -> 19
    INSERT           storage      19 -> 13
    vacuum_sorted    storage     128 -> 93
    truncate()       free_space   26 -> 17

test/catalog_scan_index.sh and its pytest twin drive eleven operations that
reach all twelve sites. Every arm asserts no sequential scan of a catalog a
premise has shown to be above the threshold, where declining the probe
cannot be correct. Two cost-based designs were tried first and a revert
passed both, which the suite header records.

    fix, PG 15..19       49 passed + 0 failed, each major
    revert, PG 15..19    38 passed + 11 failed, each major
    one site reverted    each of the 12 reddens the arm that reaches it (PG 17)

Regression: 34 suites that drive these paths pass on PG 17 at the default
threshold, and 32 of 34 with index_min_blocks forced to 0. The two that fail
under forcing are catalog_delete_index and catalog_plan_index, whose failing
arms assert the default declines on small catalogs, which forcing contradicts
by construction.

Ledger 1810 -> 1859 rows, never 1633 -> 1671, dated 177 -> 188.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving 8734f1e. I will merge once the full CI matrix is green on this head.

What I checked, and how

The twelve conversions are correct. Each converted scan keys on the leading column or columns of the index it names, which is what systable_beginscan needs when it maps keys onto index columns:

index columns scan keys
free_space_pkey (storage_id, file_offset) storage_id
row_group_pkey (storage_id, group_number) both
storage_pkey (storage_id) storage_id
projection_declaration_pkey (rel, name) rel

Your fix-against-revert result reproduces here, PG17, in my own worktrees from this head. I reversed only the src/columnar_metadata.c hunk:

fix      catalog_scan_index  49 passed + 0 failed
revert   catalog_scan_index  38 passed + 11 failed

These are the same 11 path arms your table names. The values range from got [1] to got [11] want [0], so none is a borderline count.

The ledger arithmetic reproduces: 1859 rows, 1671 never-red, budget 1671. All 49 new rows carry 15;16;17;18;19, 11 dated and 38 never.

The pytest twin is independent. It does not import or call the shell suite.

On asserting the path

I accept the argument. The objection in catalog_delete_index.sh is about the region where declining a probe is correct. Your premise pins every arm above the threshold against the live setting, so it does not apply there. The two failed cost designs you describe are the better evidence: each was measured and rejected, which is more than the rule's own text did.

Two things for later, neither blocking

  1. #1284 conflicts with this PR in check_ledger.tsv and the budget's census line. This one blocks alpha5 and mine does not, so this merges first and I rebase #1284 and recount.
  2. catalog_scan_index joins the #1236 reading list. It has 11 dated arms beside 38 never-red checks. That is the issue's inference working as designed, not a defect in this PR. I will record it there.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Merging now: approved on 8734f1e, all 15 checks green on that head, same check set as #1280. I will rebase #1284 onto the result.

@jdatcmd
jdatcmd merged commit c1e253f into commandprompt:main Oct 12, 2026
15 checks passed
jdatcmd added a commit that referenced this pull request Oct 12, 2026
)

Rebuilt onto c1e253f after #1285 landed. Same content as eec3199. The
ledger rows come from the same mutation logs, merged again by a script;
that script run on be31874 reproduces eec3199's ledger byte for byte.

Ten parts no longer hold a never-red check: harness_selftest 570, 190,
290, 560 and 580, rewrite_storage_oid, catalog_delete_index,
catalog_plan_index, projection_scan_cost and projection_parallel.
Twenty checks are dated from mutation runs on all five majors, one
worktree per mutation per major, merged one log at a time with
--mutation and --target. Three checks that only describe their own
fixture are renamed premise:, the owner's decision of 2026-10-12.

The never-red census moves 1671 -> 1651.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

21 more catalog scans pass InvalidOid where their key is a prefix of an existing index

2 participants