From 2edbc86967d0560c5a75cb4b037960fda9c952e7 Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Mon, 3 Aug 2026 15:36:07 +0500 Subject: [PATCH v3] Add intrapage indexing to GiST Scanning a GiST internal page currently applies the consistent function to every downlink. This becomes expensive for operator classes with costly keys or pages with high fanout, even when one union key could reject several neighboring downlinks at once. Add skip tuples that form a second, page-local level of GiST keys. Each skip tuple contains the union key of the real downlinks immediately following it. When the skip key is inconsistent with a scan key, the scan skips the whole group; otherwise, it examines every downlink normally. Subtree selection similarly skips a group when its first-column penalty is already worse than the best downlink found so far. Represent a skip tuple with InvalidBlockNumber in t_tid and store the group size in ip_posid. This does not consume INDEX_AM_RESERVED_BIT and remains distinct from the legacy GiST invalid-tuple representation. Treat skip tuples as derived metadata. Removing them leaves a valid ordinary GiST page with the same downlinks. Rebuild the affected group on a page update, include metadata space when splitting pages and in sorted and buffered builds, and move a downlink out of its group before VACUUM deletes it. Existing GiST WAL record types can then log complete page states without a separate record for skip-tuple maintenance. Create skip tuples only on internal pages. Supporting them on leaf pages would additionally require maintaining group boundaries during LP_DEAD cleanup and tuple-level vacuuming. Add a TAP test covering sorted and buffered builds, insertions through skip groups, exact scan results, VACUUM page deletion, WAL consistency, standby replay, and crash recovery. Discussion: https://postgr.es/m/7780A07B-4D04-41E2-B228-166B41D07EEE%40yandex-team.ru --- src/backend/access/gist/README | 30 ++ src/backend/access/gist/gist.c | 373 +++++++++++++++++- src/backend/access/gist/gistbuild.c | 62 ++- src/backend/access/gist/gistget.c | 29 +- src/backend/access/gist/gistutil.c | 41 ++ src/backend/access/gist/gistvacuum.c | 141 ++++++- src/include/access/gist_private.h | 34 +- src/test/modules/Makefile | 1 + src/test/modules/gist/.gitignore | 2 + src/test/modules/gist/Makefile | 15 + src/test/modules/gist/meson.build | 12 + .../modules/gist/t/001_intrapage_index.pl | 109 +++++ src/test/modules/meson.build | 1 + 13 files changed, 813 insertions(+), 37 deletions(-) create mode 100644 src/test/modules/gist/.gitignore create mode 100644 src/test/modules/gist/Makefile create mode 100644 src/test/modules/gist/meson.build create mode 100644 src/test/modules/gist/t/001_intrapage_index.pl diff --git a/src/backend/access/gist/README b/src/backend/access/gist/README index 75445b07455..7b846930ac0 100644 --- a/src/backend/access/gist/README +++ b/src/backend/access/gist/README @@ -115,6 +115,36 @@ cases where a parent page's downlink key is "enlarged" after we look at it. Any such enlargement would be to add child items that we aren't interested in returning anyway. +Intrapage Indexing +------------------ + +Internal pages can contain a second, page-local level of GiST keys. A skip +tuple stores the Union key of a group and is followed immediately by the real +downlinks covered by that key. Its TID has InvalidBlockNumber, distinguishing +it from real downlinks, and its offset field stores the number of following +tuples in the group. Groups do not overlap or contain other skip tuples. + +A scan first applies Consistent to the skip tuple. If it does not match, the +whole group can be skipped. If it matches, the scan examines each real tuple +normally. Skip tuples are never followed as downlinks or returned as heap +TIDs. Ordered searches use the same rule: the group key only rejects a group +and does not contribute a queue item or distance. + +Insertion uses the same page-local level while choosing a subtree. Once a +candidate downlink has established the best penalty for the first key column, +a group whose union key has a worse penalty is skipped. Otherwise Penalty is +evaluated for each member as usual. Later key columns continue to break ties +between individual downlinks in the normal way. + +Skip tuples are derived metadata. Removing them must leave an ordinary valid +GiST page with exactly the same downlinks. Page updates rebuild the affected +group from its real tuples, while page splits and both build methods account +for metadata space. Before VACUUM deletes a downlink, it moves that downlink +out of its group and decrements the group count. Existing pages without skip +tuples therefore need no conversion. The current implementation creates skip +tuples only on internal pages; adding them to leaf pages would also require +maintaining group boundaries during tuple-level vacuuming and LP_DEAD cleanup. + Insert Algorithm ---------------- diff --git a/src/backend/access/gist/gist.c b/src/backend/access/gist/gist.c index 8565e225be7..b7e64f30ead 100644 --- a/src/backend/access/gist/gist.c +++ b/src/backend/access/gist/gist.c @@ -243,6 +243,30 @@ gistplacetopage(Relation rel, Size freespace, GISTSTATE *giststate, bool is_leaf = (GistPageIsLeaf(page)) ? true : false; XLogRecPtr recptr; bool is_split; + bool has_skip = false; + OffsetNumber groupoff = InvalidOffsetNumber; + OffsetNumber *groupdeloffs = NULL; + int ngroupdel = 0; + IndexTuple *groupwrite = NULL; + int ngroupwrite = 0; + + if (!is_leaf) + { + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + + for (OffsetNumber off = FirstOffsetNumber; off <= maxoff; + off = OffsetNumberNext(off)) + { + IndexTuple pageitup = (IndexTuple) PageGetItem(page, + PageGetItemId(page, off)); + + if (GistTupleIsSkip(pageitup)) + { + has_skip = true; + break; + } + } + } /* * Refuse to modify a page that's incompletely split. This should not @@ -283,6 +307,102 @@ gistplacetopage(Relation rel, Size freespace, GISTSTATE *giststate, is_split = gistnospace(page, itup, ntup, oldoffnum, freespace); } + /* Rebuild only the skip group containing the replaced downlink. */ + if (has_skip && OffsetNumberIsValid(oldoffnum)) + { + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + int groupcount = 0; + + for (OffsetNumber off = FirstOffsetNumber; off <= maxoff; + off = OffsetNumberNext(off)) + { + IndexTuple pageitup = (IndexTuple) PageGetItem(page, + PageGetItemId(page, off)); + + if (GistTupleIsSkip(pageitup)) + { + int count = GistTupleGetSkipCount(pageitup); + + if (count > maxoff - off) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index \"%s\" contains an invalid GiST skip tuple", + RelationGetRelationName(rel)))); + if (oldoffnum > off && oldoffnum <= off + count) + { + groupoff = off; + groupcount = count; + break; + } + off += count; + } + } + + if (OffsetNumberIsValid(groupoff)) + { + IndexTuple *members; + int nmembers = 0; + IndexTupleData *newlist; + int newlenlist; + char *data; + Size required = freespace; + + members = palloc_array(IndexTuple, groupcount - 1 + ntup); + for (OffsetNumber off = OffsetNumberNext(groupoff); + off <= groupoff + groupcount; off = OffsetNumberNext(off)) + { + if (off == oldoffnum) + { + for (int i = 0; i < ntup; i++) + members[nmembers++] = itup[i]; + } + else + members[nmembers++] = (IndexTuple) PageGetItem(page, + PageGetItemId(page, off)); + } + Assert(nmembers == groupcount - 1 + ntup); + + if (nmembers > 0 && + gistFormSkipGroups(rel, page, members, nmembers, giststate, + &newlist, &newlenlist, &ngroupwrite)) + { + data = (char *) newlist; + groupwrite = palloc_array(IndexTuple, ngroupwrite); + for (int i = 0; i < ngroupwrite; i++) + { + groupwrite[i] = (IndexTuple) data; + data += IndexTupleSize(groupwrite[i]); + } + is_split = false; + } + else + is_split = true; + + groupdeloffs = palloc_array(OffsetNumber, groupcount + 1); + for (OffsetNumber off = groupoff; + off <= groupoff + groupcount; off = OffsetNumberNext(off)) + groupdeloffs[ngroupdel++] = off; + + if (!is_split) + { + for (OffsetNumber off = FirstOffsetNumber; off <= maxoff; + off = OffsetNumberNext(off)) + { + IndexTuple pageitup; + + if (off >= groupoff && off <= groupoff + groupcount) + continue; + pageitup = (IndexTuple) PageGetItem(page, + PageGetItemId(page, off)); + required += IndexTupleSize(pageitup) + sizeof(ItemIdData); + } + for (int i = 0; i < ngroupwrite; i++) + required += IndexTupleSize(groupwrite[i]) + sizeof(ItemIdData); + is_split = (required > GiSTPageSize); + } + } + } + if (is_split) { /* no space for insertion */ @@ -303,17 +423,27 @@ gistplacetopage(Relation rel, Size freespace, GISTSTATE *giststate, * remove the old version from the vector. */ itvec = gistextractpage(page, &tlen); - if (OffsetNumberIsValid(oldoffnum)) + if (has_skip || OffsetNumberIsValid(oldoffnum)) { - /* on inner page we should remove old tuple */ - int pos = oldoffnum - FirstOffsetNumber; + int dst = 0; - tlen--; - if (pos != tlen) - memmove(itvec + pos, itvec + pos + 1, sizeof(IndexTuple) * (tlen - pos)); + /* Remove derived metadata and the replaced real tuple. */ + for (int src = 0; src < tlen; src++) + { + OffsetNumber off = src + FirstOffsetNumber; + + if (GistTupleIsSkip(itvec[src]) || off == oldoffnum) + continue; + itvec[dst++] = itvec[src]; + } + tlen = dst; } itvec = gistjoinvector(itvec, &tlen, itup, ntup); - dist = gistSplit(rel, page, itvec, tlen, giststate); + if (is_leaf) + dist = gistSplit(rel, page, itvec, tlen, giststate, 0); + else + dist = gistSplitPageWithSkipGroups(rel, page, itvec, tlen, + giststate, true); /* * Check that split didn't produce too many pages. @@ -419,6 +549,41 @@ gistplacetopage(Relation rel, Size freespace, GISTSTATE *giststate, } } + /* + * Add derived intrapage indexes to internal pages. The metadata is + * optional for correctness, so keep the ordinary representation when + * its extra tuples would no longer fit. + */ + for (ptr = dist; ptr; ptr = ptr->next) + { + if (ptr->block.blkno == GIST_ROOT_BLKNO && ptr->block.num > 0) + { + IndexTuple *pagevec; + char *data = (char *) ptr->list; + IndexTupleData *newlist; + int newlenlist; + int newlen; + + pagevec = palloc_array(IndexTuple, ptr->block.num); + for (int i = 0; i < ptr->block.num; i++) + { + IndexTuple pageitup = (IndexTuple) data; + + pagevec[i] = pageitup; + data += IndexTupleSize(pageitup); + } + + if (gistFormSkipGroups(rel, ptr->page, pagevec, + ptr->block.num, giststate, + &newlist, &newlenlist, &newlen)) + { + ptr->list = newlist; + ptr->lenlist = newlenlist; + ptr->block.num = newlen; + } + } + } + /* * Fill all pages. All the pages are new, ie. freshly allocated empty * pages, or a temporary copy of the old page. @@ -538,16 +703,39 @@ gistplacetopage(Relation rel, Size freespace, GISTSTATE *giststate, } else { + IndexTuple *writetup = itup; + int nwritetup = ntup; + OffsetNumber *deloffs = NULL; + int ndeloffs = 0; + /* * Enough space. We always get here if ntup==0. */ + if (OffsetNumberIsValid(groupoff)) + { + writetup = groupwrite; + nwritetup = ngroupwrite; + deloffs = groupdeloffs; + ndeloffs = ngroupdel; + } + + /* A group rewrite registers one WAL data segment per new tuple. */ + if (OffsetNumberIsValid(groupoff) && !is_build && RelationNeedsWAL(rel)) + XLogEnsureRecordSpace(BufferIsValid(leftchildbuf) ? 2 : 1, + 2 + nwritetup); + START_CRIT_SECTION(); /* * Delete old tuple if any, then insert new tuple(s) if any. If * possible, use the fast path of PageIndexTupleOverwrite. */ - if (OffsetNumberIsValid(oldoffnum)) + if (OffsetNumberIsValid(groupoff)) + { + PageIndexMultiDelete(page, deloffs, ndeloffs); + gistfillbuffer(page, writetup, nwritetup, InvalidOffsetNumber); + } + else if (OffsetNumberIsValid(oldoffnum)) { if (ntup == 1) { @@ -580,17 +768,19 @@ gistplacetopage(Relation rel, Size freespace, GISTSTATE *giststate, { if (RelationNeedsWAL(rel)) { - OffsetNumber ndeloffs = 0, - deloffs[1]; + OffsetNumber local_deloffs[1]; - if (OffsetNumberIsValid(oldoffnum)) + if (!OffsetNumberIsValid(groupoff) && + OffsetNumberIsValid(oldoffnum)) { - deloffs[0] = oldoffnum; + local_deloffs[0] = oldoffnum; + deloffs = local_deloffs; ndeloffs = 1; } recptr = gistXLogUpdate(buffer, - deloffs, ndeloffs, itup, ntup, + deloffs, ndeloffs, + writetup, nwritetup, leftchildbuf); } else @@ -990,6 +1180,8 @@ gistFindPath(Relation r, BlockNumber child, OffsetNumber *downlinkoffnum) { iid = PageGetItemId(page, i); idxtuple = (IndexTuple) PageGetItem(page, iid); + if (GistTupleIsSkip(idxtuple)) + continue; blkno = ItemPointerGetBlockNumber(&(idxtuple->t_tid)); if (blkno == child) { @@ -1151,6 +1343,8 @@ gistformdownlink(Relation rel, Buffer buf, GISTSTATE *giststate, IndexTuple ituple = (IndexTuple) PageGetItem(page, PageGetItemId(page, offset)); + if (GistTupleIsSkip(ituple)) + continue; if (downlink == NULL) downlink = CopyIndexTuple(ituple); else @@ -1451,7 +1645,8 @@ gistSplit(Relation r, Page page, IndexTuple *itup, /* contains compressed entry */ int len, - GISTSTATE *giststate) + GISTSTATE *giststate, + int max_page_tuples) { IndexTuple *lvectup, *rvectup; @@ -1493,9 +1688,12 @@ gistSplit(Relation r, rvectup[i] = itup[v.splitVector.spl_right[i] - 1]; /* finalize splitting (may need another split) */ - if (!gistfitpage(rvectup, v.splitVector.spl_nright)) + if (!gistfitpage(rvectup, v.splitVector.spl_nright) || + (max_page_tuples > 0 && + v.splitVector.spl_nright > max_page_tuples)) { - res = gistSplit(r, page, rvectup, v.splitVector.spl_nright, giststate); + res = gistSplit(r, page, rvectup, v.splitVector.spl_nright, + giststate, max_page_tuples); } else { @@ -1505,12 +1703,16 @@ gistSplit(Relation r, res->itup = gistFormTuple(giststate, r, v.spl_rattr, v.spl_risnull, false); } - if (!gistfitpage(lvectup, v.splitVector.spl_nleft)) + if (!gistfitpage(lvectup, v.splitVector.spl_nleft) || + (max_page_tuples > 0 && + v.splitVector.spl_nleft > max_page_tuples)) { SplitPageLayout *resptr, *subres; - resptr = subres = gistSplit(r, page, lvectup, v.splitVector.spl_nleft, giststate); + resptr = subres = gistSplit(r, page, lvectup, + v.splitVector.spl_nleft, giststate, + max_page_tuples); /* install on list's tail */ while (resptr->next) @@ -1530,6 +1732,141 @@ gistSplit(Relation r, return res; } +/* + * Form an intrapage index over an ordinary tuple vector. + * + * Skip tuples are derived metadata: each one is a union key followed by the + * real tuples that it summarizes. If the metadata would make the vector no + * longer fit on a page, leave the caller's ordinary representation alone. + */ +bool +gistFormSkipGroups(Relation rel, Page page, IndexTuple *itvec, int len, + GISTSTATE *giststate, IndexTupleData **list, int *lenlist, + int *newlen) +{ + SplitPageLayout *groups; + SplitPageLayout *group; + IndexTuple *grouped; + int ngroups = 0; + int pos = 0; + + Assert(len > 0); + + if (len <= GIST_SKIP_GROUP_SIZE) + { + groups = palloc0_object(SplitPageLayout); + groups->block.num = len; + groups->list = gistfillitupvec(itvec, len, &groups->lenlist); + groups->itup = gistunion(rel, itvec, len, giststate); + } + else + groups = gistSplit(rel, page, itvec, len, giststate, + GIST_SKIP_GROUP_SIZE); + + for (group = groups; group != NULL; group = group->next) + ngroups++; + + grouped = palloc_array(IndexTuple, len + ngroups); + for (group = groups; group != NULL; group = group->next) + { + char *data = (char *) group->list; + + GistTupleSetSkip(group->itup, group->block.num); + grouped[pos++] = group->itup; + for (int i = 0; i < group->block.num; i++) + { + IndexTuple itup = (IndexTuple) data; + + grouped[pos++] = itup; + data += IndexTupleSize(itup); + } + } + Assert(pos == len + ngroups); + + if (!gistfitpage(grouped, pos)) + return false; + + *list = gistfillitupvec(grouped, pos, lenlist); + *newlen = pos; + return true; +} + +/* + * Partition an ordinary tuple vector into pages that include skip groups. + * Metadata space participates in the fit decision, so this can produce more + * pages than gistSplit() would for the same real tuples. + */ +SplitPageLayout * +gistSplitPageWithSkipGroups(Relation rel, Page page, IndexTuple *itvec, + int len, GISTSTATE *giststate, bool force_split) +{ + IndexTupleData *list; + int lenlist; + int newlen; + SplitPageLayout *result; + + Assert(len > 0); + + if (!force_split && + gistFormSkipGroups(rel, page, itvec, len, giststate, + &list, &lenlist, &newlen)) + { + result = palloc0_object(SplitPageLayout); + result->block.blkno = InvalidBlockNumber; + result->buffer = InvalidBuffer; + result->block.num = newlen; + result->list = list; + result->lenlist = lenlist; + result->itup = gistunion(rel, itvec, len, giststate); + return result; + } + + /* A singleton page cannot be split further; omit useless metadata. */ + if (len == 1) + { + Assert(!force_split); + result = palloc0_object(SplitPageLayout); + result->block.blkno = InvalidBlockNumber; + result->buffer = InvalidBuffer; + result->block.num = 1; + result->list = gistfillitupvec(itvec, 1, &result->lenlist); + result->itup = gistunion(rel, itvec, 1, giststate); + return result; + } + else + { + SplitPageLayout *halves; + SplitPageLayout *newresult = NULL; + SplitPageLayout *tail = NULL; + + halves = gistSplit(rel, page, itvec, len, giststate, 0); + for (SplitPageLayout *half = halves; half != NULL; half = half->next) + { + IndexTuple *halfvec; + char *data = (char *) half->list; + SplitPageLayout *partition; + + halfvec = palloc_array(IndexTuple, half->block.num); + for (int i = 0; i < half->block.num; i++) + { + halfvec[i] = (IndexTuple) data; + data += IndexTupleSize(halfvec[i]); + } + + partition = gistSplitPageWithSkipGroups(rel, page, halfvec, + half->block.num, giststate, + false); + if (newresult == NULL) + newresult = partition; + else + tail->next = partition; + for (tail = partition; tail->next != NULL; tail = tail->next) + ; + } + return newresult; + } +} + /* * Create a GISTSTATE and fill it with information about the index */ diff --git a/src/backend/access/gist/gistbuild.c b/src/backend/access/gist/gistbuild.c index 7f57c787f4c..49472c72169 100644 --- a/src/backend/access/gist/gistbuild.c +++ b/src/backend/access/gist/gistbuild.c @@ -520,18 +520,31 @@ gist_indexsortbuild_levelstate_flush(GISTBuildState *state, pfree(itvec_local); } - /* Apply picksplit to list of all collected tuples */ - dist = gistSplit(state->indexrel, levelstate->pages[0], itvec, vect_len, state->giststate); + /* Apply picksplit to list of all collected tuples. */ + if (isleaf) + dist = gistSplit(state->indexrel, levelstate->pages[0], itvec, + vect_len, state->giststate, 0); + else + dist = gistSplitPageWithSkipGroups(state->indexrel, + levelstate->pages[0], itvec, + vect_len, state->giststate, false); } else { - /* Create split layout from single page */ - dist = palloc0_object(SplitPageLayout); - union_tuple = gistunion(state->indexrel, itvec, vect_len, - state->giststate); - dist->itup = union_tuple; - dist->list = gistfillitupvec(itvec, vect_len, &(dist->lenlist)); - dist->block.num = vect_len; + /* Create split layout from single page. */ + if (isleaf) + { + dist = palloc0_object(SplitPageLayout); + union_tuple = gistunion(state->indexrel, itvec, vect_len, + state->giststate); + dist->itup = union_tuple; + dist->list = gistfillitupvec(itvec, vect_len, &(dist->lenlist)); + dist->block.num = vect_len; + } + else + dist = gistSplitPageWithSkipGroups(state->indexrel, + levelstate->pages[0], itvec, + vect_len, state->giststate, false); } MemoryContextSwitchTo(oldCtx); @@ -1101,8 +1114,13 @@ gistbufferinginserttuples(GISTBuildState *buildstate, Buffer buffer, int level, { ItemId iid = PageGetItemId(page, off); IndexTuple idxtuple = (IndexTuple) PageGetItem(page, iid); - BlockNumber childblkno = ItemPointerGetBlockNumber(&(idxtuple->t_tid)); - Buffer childbuf = ReadBuffer(buildstate->indexrel, childblkno); + BlockNumber childblkno; + Buffer childbuf; + + if (GistTupleIsSkip(idxtuple)) + continue; + childblkno = ItemPointerGetBlockNumber(&(idxtuple->t_tid)); + childbuf = ReadBuffer(buildstate->indexrel, childblkno); LockBuffer(childbuf, GIST_SHARE); gistMemorizeAllDownlinks(buildstate, childbuf); @@ -1439,7 +1457,7 @@ gistGetMaxLevel(Relation index) { Buffer buffer; Page page; - IndexTuple itup; + IndexTuple itup = NULL; buffer = ReadBuffer(index, blkno); @@ -1462,8 +1480,18 @@ gistGetMaxLevel(Relation index) * matter which downlink we choose, the tree has the same depth * everywhere, so we just pick the first one. */ - itup = (IndexTuple) PageGetItem(page, - PageGetItemId(page, FirstOffsetNumber)); + for (OffsetNumber off = FirstOffsetNumber; + off <= PageGetMaxOffsetNumber(page); off = OffsetNumberNext(off)) + { + itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + if (!GistTupleIsSkip(itup)) + break; + } + if (itup == NULL || GistTupleIsSkip(itup)) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index \"%s\" contains an empty GiST internal page", + RelationGetRelationName(index)))); blkno = ItemPointerGetBlockNumber(&(itup->t_tid)); UnlockReleaseBuffer(buffer); @@ -1557,7 +1585,11 @@ gistMemorizeAllDownlinks(GISTBuildState *buildstate, Buffer parentbuf) { ItemId iid = PageGetItemId(page, off); IndexTuple idxtuple = (IndexTuple) PageGetItem(page, iid); - BlockNumber childblkno = ItemPointerGetBlockNumber(&(idxtuple->t_tid)); + BlockNumber childblkno; + + if (GistTupleIsSkip(idxtuple)) + continue; + childblkno = ItemPointerGetBlockNumber(&(idxtuple->t_tid)); gistMemorizeParent(buildstate, childblkno, parentblkno); } diff --git a/src/backend/access/gist/gistget.c b/src/backend/access/gist/gistget.c index e8a1e456287..112ff3768f0 100644 --- a/src/backend/access/gist/gistget.c +++ b/src/backend/access/gist/gistget.c @@ -450,7 +450,34 @@ gistScanPage(IndexScanDesc scan, GISTSearchItem *pageItem, MemoryContextSwitchTo(oldcxt); MemoryContextReset(so->giststate->tempCxt); - /* Ignore tuple if it doesn't match */ + if (GistTupleIsSkip(it)) + { + OffsetNumber count = GistTupleGetSkipCount(it); + + if (count > maxoff - i) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index \"%s\" contains an invalid GiST skip tuple", + RelationGetRelationName(r)))); + for (OffsetNumber member = OffsetNumberNext(i); + member <= i + count; member = OffsetNumberNext(member)) + { + IndexTuple member_itup = (IndexTuple) PageGetItem(page, + PageGetItemId(page, member)); + + if (GistTupleIsSkip(member_itup)) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index \"%s\" contains an invalid GiST skip tuple", + RelationGetRelationName(r)))); + } + + if (!match) + i += count; + continue; + } + + /* Ignore ordinary tuple if it doesn't match. */ if (!match) continue; diff --git a/src/backend/access/gist/gistutil.c b/src/backend/access/gist/gistutil.c index 0f58f61879f..85400fe6e72 100644 --- a/src/backend/access/gist/gistutil.c +++ b/src/backend/access/gist/gistutil.c @@ -441,6 +441,47 @@ gistchoose(Relation r, Page p, IndexTuple it, /* it has compressed entry */ bool zero_penalty; int j; + /* + * A skip key summarizes the following downlinks. Its penalty is a + * lower-bound estimate for the penalties of the group members. Once + * a better first-column penalty has been found, none of those members + * can improve the choice, so avoid evaluating them individually. + */ + if (GistTupleIsSkip(itup)) + { + OffsetNumber count = GistTupleGetSkipCount(itup); + Datum datum; + float usize; + bool IsNull; + + if (count > maxoff - i) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index \"%s\" contains an invalid GiST skip tuple", + RelationGetRelationName(r)))); + for (OffsetNumber member = OffsetNumberNext(i); + member <= i + count; member = OffsetNumberNext(member)) + { + IndexTuple member_itup = (IndexTuple) PageGetItem(p, + PageGetItemId(p, member)); + + if (GistTupleIsSkip(member_itup)) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index \"%s\" contains an invalid GiST skip tuple", + RelationGetRelationName(r)))); + } + + datum = index_getattr(itup, 1, giststate->leafTupdesc, &IsNull); + gistdentryinit(giststate, 0, &entry, datum, r, p, i, + false, IsNull); + usize = gistpenalty(giststate, 0, &entry, IsNull, + &identry[0], isnull[0]); + if (best_penalty[0] >= 0 && usize > best_penalty[0]) + i += count; + continue; + } + zero_penalty = true; /* Loop over index attributes. */ diff --git a/src/backend/access/gist/gistvacuum.c b/src/backend/access/gist/gistvacuum.c index 686a0418054..0a7555e1539 100644 --- a/src/backend/access/gist/gistvacuum.c +++ b/src/backend/access/gist/gistvacuum.c @@ -51,6 +51,8 @@ static void gistvacuum_delete_empty_pages(IndexVacuumInfo *info, static bool gistdeletepage(IndexVacuumInfo *info, IndexBulkDeleteResult *stats, Buffer parentBuffer, OffsetNumber downlink, Buffer leafBuffer); +static void gistvacuumungroup(Relation rel, Buffer buffer, + BlockNumber childblkno); /* * VACUUM bulkdelete stage: remove index entries. @@ -555,6 +557,8 @@ gistvacuum_delete_empty_pages(IndexVacuumInfo *info, GistVacState *vstate) IndexTuple idxtuple = (IndexTuple) PageGetItem(page, iid); BlockNumber leafblk; + if (GistTupleIsSkip(idxtuple)) + continue; leafblk = ItemPointerGetBlockNumber(&(idxtuple->t_tid)); if (intset_is_member(vstate->empty_leaf_set, leafblk)) { @@ -670,8 +674,34 @@ gistdeletepage(IndexVacuumInfo *info, IndexBulkDeleteResult *stats, return false; } - if (PageGetMaxOffsetNumber(parentPage) < downlink - || PageGetMaxOffsetNumber(parentPage) <= FirstOffsetNumber) + /* + * Removing a group member would invalidate its skip count. Move this + * downlink out of its group in a separate WAL-logged update before + * deleting it. Keeping the downlink as an ordinary tuple makes a crash + * between the two records safe. + */ + gistvacuumungroup(info->index, parentBuffer, + BufferGetBlockNumber(leafBuffer)); + parentPage = BufferGetPage(parentBuffer); + + /* Re-find the downlink, because removing skip tuples changed offsets. */ + downlink = InvalidOffsetNumber; + for (OffsetNumber off = FirstOffsetNumber; + off <= PageGetMaxOffsetNumber(parentPage); + off = OffsetNumberNext(off)) + { + iid = PageGetItemId(parentPage, off); + idxtuple = (IndexTuple) PageGetItem(parentPage, iid); + if (BufferGetBlockNumber(leafBuffer) == + ItemPointerGetBlockNumber(&(idxtuple->t_tid))) + { + downlink = off; + break; + } + } + + if (!OffsetNumberIsValid(downlink) || + PageGetMaxOffsetNumber(parentPage) <= FirstOffsetNumber) return false; iid = PageGetItemId(parentPage, downlink); @@ -715,3 +745,110 @@ gistdeletepage(IndexVacuumInfo *info, IndexBulkDeleteResult *stats, return true; } + +/* Move one downlink out of its skip group, leaving other groups intact. */ +static void +gistvacuumungroup(Relation rel, Buffer buffer, BlockNumber childblkno) +{ + Page page = BufferGetPage(buffer); + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + OffsetNumber groupoff = InvalidOffsetNumber; + int groupcount = 0; + IndexTuple *toinsert; + OffsetNumber *todelete; + int ntoinsert = 0; + + Assert(!GistPageIsLeaf(page)); + + for (OffsetNumber off = FirstOffsetNumber; off <= maxoff; + off = OffsetNumberNext(off)) + { + IndexTuple marker = (IndexTuple) PageGetItem(page, + PageGetItemId(page, off)); + int count; + + if (!GistTupleIsSkip(marker)) + continue; + count = GistTupleGetSkipCount(marker); + if (count > maxoff - off) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("index \"%s\" contains an invalid GiST skip tuple", + RelationGetRelationName(rel)))); + + for (OffsetNumber member = OffsetNumberNext(off); + member <= off + count; member = OffsetNumberNext(member)) + { + IndexTuple member_itup = (IndexTuple) PageGetItem(page, + PageGetItemId(page, member)); + + if (ItemPointerGetBlockNumber(&member_itup->t_tid) == childblkno) + { + groupoff = off; + groupcount = count; + break; + } + } + if (OffsetNumberIsValid(groupoff)) + break; + off += count; + } + + if (!OffsetNumberIsValid(groupoff)) + return; + + todelete = palloc_array(OffsetNumber, groupcount + 1); + toinsert = palloc_array(IndexTuple, groupcount + 1); + + /* Keep the target as an ungrouped ordinary downlink. */ + for (OffsetNumber off = OffsetNumberNext(groupoff); + off <= groupoff + groupcount; off = OffsetNumberNext(off)) + { + IndexTuple member = (IndexTuple) PageGetItem(page, + PageGetItemId(page, off)); + + if (ItemPointerGetBlockNumber(&member->t_tid) == childblkno) + { + toinsert[ntoinsert++] = CopyIndexTuple(member); + break; + } + } + + /* Retain the old union key as a safe superset for the smaller group. */ + if (groupcount > 1) + { + IndexTuple marker = CopyIndexTuple((IndexTuple) PageGetItem(page, + PageGetItemId(page, groupoff))); + + GistTupleSetSkip(marker, groupcount - 1); + toinsert[ntoinsert++] = marker; + for (OffsetNumber off = OffsetNumberNext(groupoff); + off <= groupoff + groupcount; off = OffsetNumberNext(off)) + { + IndexTuple member = (IndexTuple) PageGetItem(page, + PageGetItemId(page, off)); + + if (ItemPointerGetBlockNumber(&member->t_tid) != childblkno) + toinsert[ntoinsert++] = CopyIndexTuple(member); + } + } + Assert(ntoinsert == (groupcount > 1 ? groupcount + 1 : 1)); + + for (int i = 0; i <= groupcount; i++) + todelete[i] = groupoff + i; + + START_CRIT_SECTION(); + MarkBufferDirty(buffer); + PageIndexMultiDelete(page, todelete, groupcount + 1); + gistfillbuffer(page, toinsert, ntoinsert, InvalidOffsetNumber); + if (RelationNeedsWAL(rel)) + { + XLogRecPtr recptr = gistXLogUpdate(buffer, todelete, groupcount + 1, + toinsert, ntoinsert, InvalidBuffer); + + PageSetLSN(page, recptr); + } + else + PageSetLSN(page, XLogGetFakeLSN(rel)); + END_CRIT_SECTION(); +} diff --git a/src/include/access/gist_private.h b/src/include/access/gist_private.h index 44514f1cb8d..413f5706315 100644 --- a/src/include/access/gist_private.h +++ b/src/include/access/gist_private.h @@ -288,6 +288,26 @@ typedef struct #define GistTupleIsInvalid(itup) ( ItemPointerGetOffsetNumber( &((itup)->t_tid) ) == TUPLE_IS_INVALID ) #define GistTupleSetValid(itup) ItemPointerSetOffsetNumber( &((itup)->t_tid), TUPLE_IS_VALID ) +/* + * A skip tuple summarizes a group of real tuples that immediately follows it + * on the same page. Real heap TIDs and downlinks always have a valid block + * number, so use InvalidBlockNumber as the marker and store the group size in + * ip_posid. This avoids consuming INDEX_AM_RESERVED_BIT, which is better + * left available for annotations of ordinary index tuples. + */ +#define GistTupleIsSkip(itup) \ + (ItemPointerGetBlockNumberNoCheck(&((itup)->t_tid)) == InvalidBlockNumber && \ + OffsetNumberIsValid(ItemPointerGetOffsetNumberNoCheck(&((itup)->t_tid)))) + +#define GistTupleSetSkip(itup, count) \ + do { \ + ItemPointerSetInvalid(&((itup)->t_tid)); \ + ItemPointerSetOffsetNumber(&((itup)->t_tid), (count)); \ + } while (0) + +#define GistTupleGetSkipCount(itup) \ + ItemPointerGetOffsetNumberNoCheck(&((itup)->t_tid)) + @@ -433,7 +453,16 @@ extern bool gistplacetopage(Relation rel, Size freespace, GISTSTATE *giststate, bool is_build); extern SplitPageLayout *gistSplit(Relation r, Page page, IndexTuple *itup, - int len, GISTSTATE *giststate); + int len, GISTSTATE *giststate, + int max_page_tuples); +extern bool gistFormSkipGroups(Relation rel, Page page, IndexTuple *itvec, + int len, GISTSTATE *giststate, + IndexTupleData **list, int *lenlist, + int *newlen); +extern SplitPageLayout *gistSplitPageWithSkipGroups(Relation rel, Page page, + IndexTuple *itvec, int len, + GISTSTATE *giststate, + bool force_split); /* gistxlog.c */ extern XLogRecPtr gistXLogPageDelete(Buffer buffer, @@ -477,6 +506,9 @@ extern void gistadjustmembers(Oid opfamilyoid, #define GIST_MIN_FILLFACTOR 10 #define GIST_DEFAULT_FILLFACTOR 90 +/* Maximum number of real tuples summarized by one skip tuple. */ +#define GIST_SKIP_GROUP_SIZE 16 + extern bytea *gistoptions(Datum reloptions, bool validate); extern bool gistproperty(Oid index_oid, int attno, IndexAMProperty prop, const char *propname, diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 098bb8142ae..37f2f568552 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -10,6 +10,7 @@ SUBDIRS = \ delay_execution \ dummy_index_am \ dummy_seclabel \ + gist \ index \ libpq_pipeline \ oauth_validator \ diff --git a/src/test/modules/gist/.gitignore b/src/test/modules/gist/.gitignore new file mode 100644 index 00000000000..716e17f5a2a --- /dev/null +++ b/src/test/modules/gist/.gitignore @@ -0,0 +1,2 @@ +# Generated subdirectories +/tmp_check/ diff --git a/src/test/modules/gist/Makefile b/src/test/modules/gist/Makefile new file mode 100644 index 00000000000..2b0103d53f7 --- /dev/null +++ b/src/test/modules/gist/Makefile @@ -0,0 +1,15 @@ +# src/test/modules/gist/Makefile + +EXTRA_INSTALL = contrib/pageinspect +TAP_TESTS = 1 + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/gist +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/gist/meson.build b/src/test/modules/gist/meson.build new file mode 100644 index 00000000000..9a47722fbaf --- /dev/null +++ b/src/test/modules/gist/meson.build @@ -0,0 +1,12 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +tests += { + 'name': 'gist', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_intrapage_index.pl', + ], + }, +} diff --git a/src/test/modules/gist/t/001_intrapage_index.pl b/src/test/modules/gist/t/001_intrapage_index.pl new file mode 100644 index 00000000000..490aefad0ca --- /dev/null +++ b/src/test/modules/gist/t/001_intrapage_index.pl @@ -0,0 +1,109 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub marker_count +{ + my ($node, $index) = @_; + + return $node->safe_psql( + 'postgres', + qq[ +SELECT count(*) +FROM generate_series(0, + (SELECT relpages - 1 FROM pg_class WHERE oid = '$index'::regclass)) AS b +CROSS JOIN LATERAL + gist_page_items_bytea(get_raw_page('$index', b)) AS i +WHERE i.ctid::text LIKE '(4294967295,%' +]); +} + +sub exact_result +{ + my ($node, $enable_seqscan) = @_; + + return $node->safe_psql( + 'postgres', + qq[ +SET enable_seqscan = $enable_seqscan; +SET enable_bitmapscan = off; +SELECT string_agg(id::text, ',' ORDER BY id) +FROM gist_intrapage +WHERE p <@ box(point(80, 80), point(20, 20)); +]); +} + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf('postgresql.conf', 'wal_consistency_checking = gist'); +$primary->start; +$primary->safe_psql('postgres', 'CREATE EXTENSION pageinspect'); + +my $backup_name = 'gist_intrapage_backup'; +$primary->backup($backup_name); +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, $backup_name, has_streaming => 1); +$standby->start; + +$primary->safe_psql( + 'postgres', q[ +CREATE TABLE gist_intrapage AS +SELECT i AS id, point(i % 200, i / 200) AS p +FROM generate_series(1, 40000) AS i; + +CREATE INDEX gist_intrapage_sorted ON gist_intrapage USING gist (p); +]); + +cmp_ok(marker_count($primary, 'gist_intrapage_sorted'), '>', 0, + 'sorted build creates skip tuples'); +is(exact_result($primary, 'off'), exact_result($primary, 'on'), + 'skip tuples preserve the exact scan result'); + +$primary->safe_psql( + 'postgres', q[ +CREATE INDEX gist_intrapage_buffered ON gist_intrapage USING gist (p) + WITH (buffering = on, fillfactor = 50); +]); +cmp_ok(marker_count($primary, 'gist_intrapage_buffered'), '>', 0, + 'buffered build creates skip tuples'); + +$primary->safe_psql( + 'postgres', q[ +INSERT INTO gist_intrapage +SELECT i AS id, point(i % 200, i / 200) AS p +FROM generate_series(40001, 50000) AS i; +]); +is(exact_result($primary, 'off'), exact_result($primary, 'on'), + 'inserting through skip groups preserves the exact scan result'); +cmp_ok(marker_count($primary, 'gist_intrapage_buffered'), '>', 0, + 'inserting through skip groups preserves skip tuples'); + +$primary->safe_psql( + 'postgres', q[ +DROP INDEX gist_intrapage_sorted; +DELETE FROM gist_intrapage WHERE id % 10 <> 0; +VACUUM gist_intrapage; +]); +is(exact_result($primary, 'off'), exact_result($primary, 'on'), + 'VACUUM and page deletion preserve the exact scan result'); +cmp_ok(marker_count($primary, 'gist_intrapage_buffered'), '>', 0, + 'VACUUM page deletion preserves unaffected skip tuples'); + +$primary->wait_for_replay_catchup($standby); +is(marker_count($standby, 'gist_intrapage_buffered'), + marker_count($primary, 'gist_intrapage_buffered'), + 'standby has the same skip tuples'); +is(exact_result($standby, 'off'), exact_result($primary, 'on'), + 'standby replay preserves the exact scan result'); + +$primary->stop('immediate'); +$primary->start; +is(exact_result($primary, 'off'), exact_result($primary, 'on'), + 'crash recovery preserves the exact scan result'); + +done_testing(); diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 4bca42bb370..67714e1beaf 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -6,6 +6,7 @@ subdir('delay_execution') subdir('dummy_index_am') subdir('dummy_seclabel') subdir('gin') +subdir('gist') subdir('index') subdir('injection_points') subdir('ldap_password_func') -- 2.50.1 (Apple Git-155)