From 65a29ccabd664246c9612c729aa83f3d636e0524 Mon Sep 17 00:00:00 2001 From: Peter Geoghegan Date: Mon, 20 Apr 2026 20:43:42 -0400 Subject: [PATCH 1/3] Add prefix compression to nbtree leaf pages. Teach nbtree to store the leading key attributes of a multi-column index only once per run of adjacent leaf tuples that share them. An index opts in at build time when it has at least two key columns and every leading key column (all but the last) uses an equalimage opclass, which is what makes byte-for-byte reuse of the leading attributes sound. The metapage records the opt-in in a new btm_prefix_compression field, and every leaf page that the index produces sets BTP_HAS_PREFIX_COMPRESSION. A prefix-compressed leaf page consists of "drifting" prefix groups. Each group starts with an anchor, a plain non-pivot tuple, followed by zero or more compressed followers. A follower omits the leading (nkeyatts - 1) key attributes, storing only its final key attribute plus any INCLUDE attributes; it inherits the omitted attributes from the nearest anchor to its left on the page. Followers are marked by INDEX_ALT_TID_MASK plus a new BT_IS_COMPRESSED status bit in t_tid's offset field, with the real heap tuple offset packed into the low 12 bits (MaxHeapTuplesPerPage always fits there). There is no per-follower metadata: a follower is smaller than its plain form by exactly the size of the omitted attributes. The first data item on a prefix-compressed page is always an anchor, and pivot tuples are never compressed. Prefix compression and posting list deduplication are mutually exclusive on any given index: btm_prefix_compression implies !btm_allequalimage, so a prefix-compressed page never contains a posting list tuple. xl_btree_metadata carries the new field so that the opt-in survives crash recovery and replication. Read paths materialize followers on demand. _bt_readpage, _bt_compare, the split point chooser, and _bt_set_startikey reconstruct a follower into a scratch buffer by merging the governing anchor's leading attributes with the follower's own; a per-page BTPrefixPageState caches the anchor, so consecutive followers from the same group are cheap. Code above nbtree (scan key evaluation, index-only scan tuple return, amcheck's heap verification) never sees a compressed tuple. Sites that consume a leaf tuple's t_tid directly (unique checking, _bt_killitems, simple and bottom-up deletion, and the VACUUM page callback) decode the follower's real heap TID first. Index-only scan tuple workspaces now grow on demand, since a page's worth of reconstructed tuples can exceed BLCKSZ. CREATE INDEX emits followers from _bt_buildadd whenever the next sorted tuple shares the current anchor's leading attributes. Retail inserts do the same on pages that already carry BTP_HAS_PREFIX_COMPRESSION, and the leaf root that _bt_getroot creates for an empty index inherits the flag from the metapage. A page split that lands inside a group promotes the right page's first item to plain form (and reconstructs lastleft) before suffix truncation, so both halves keep the first-item-is-an-anchor invariant. Both halves also keep BTP_HAS_PREFIX_COMPRESSION, which is why xl_btree_split now carries origpage's flags. VACUUM and simple deletion promote the first surviving follower of a deleted anchor to plain form; this always fits, because the deleted anchor was at least as large as the growth, and the affected page is logged as a full page image. Suffix truncation still works on prefix-compressed pages: the split point chooser reconstructs candidate lastleft/firstright tuples before calling _bt_keep_natts_fast, which now asserts that it is only ever passed plain tuples. Look-ahead during array key scans and bottom-up deletion are both disabled on prefix-compressed pages. The uniform tuple size gating condition in _bt_afternewitemoff is disabled for now, since prefix compression means that TPC-C style indexes no longer have uniform tuple sizes; the trade-offs there need to be revisited. Known limitation: nbtsplitloc.c does not account for the growth from promoting a follower at a mid-group split point, so the "promotion always fits" guarantee does not yet hold on the split path. The new README section describes the design and the space accounting argument in full. Add a regression test covering build, scans in both directions, inserts that extend and start groups, page splits, VACUUM promotion, ineligible index shapes, NULLs, and INCLUDE columns. The select_distinct expected output changes because distinct_tbl's index is no longer deduplicated, which makes it a few pages larger and flips the planned parallel worker count. --- src/include/access/nbtree.h | 171 ++++- src/include/access/nbtxlog.h | 18 +- src/backend/access/nbtree/Makefile | 1 + src/backend/access/nbtree/README | 150 +++++ src/backend/access/nbtree/meson.build | 1 + src/backend/access/nbtree/nbtdedup.c | 26 +- src/backend/access/nbtree/nbtinsert.c | 238 ++++++- src/backend/access/nbtree/nbtpage.c | 124 +++- src/backend/access/nbtree/nbtprefix.c | 605 ++++++++++++++++++ src/backend/access/nbtree/nbtreadpage.c | 114 +++- src/backend/access/nbtree/nbtree.c | 59 +- src/backend/access/nbtree/nbtsearch.c | 72 ++- src/backend/access/nbtree/nbtsort.c | 176 ++++- src/backend/access/nbtree/nbtsplitloc.c | 189 +++++- src/backend/access/nbtree/nbtutils.c | 61 +- src/backend/access/nbtree/nbtxlog.c | 41 +- contrib/amcheck/verify_nbtree.c | 5 +- .../expected/btree_prefix_compression.out | 250 ++++++++ src/test/regress/expected/select_distinct.out | 2 +- src/test/regress/parallel_schedule | 1 + .../regress/sql/btree_prefix_compression.sql | 128 ++++ 21 files changed, 2346 insertions(+), 86 deletions(-) create mode 100644 src/backend/access/nbtree/nbtprefix.c create mode 100644 src/test/regress/expected/btree_prefix_compression.out create mode 100644 src/test/regress/sql/btree_prefix_compression.sql diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 3097e9bb1..451b55cc5 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -83,6 +83,7 @@ typedef BTPageOpaqueData *BTPageOpaque; #define BTP_HAS_GARBAGE (1 << 6) /* page has LP_DEAD tuples (deprecated) */ #define BTP_INCOMPLETE_SPLIT (1 << 7) /* right sibling's downlink is missing */ #define BTP_HAS_FULLXID (1 << 8) /* contains BTDeletedPageData */ +#define BTP_HAS_PREFIX_COMPRESSION (1 << 9) /* leaf page uses prefix compression */ /* * The max allowed value of a cycle ID is a bit less than 64K. This is @@ -117,6 +118,7 @@ typedef struct BTMetaPageData float8 btm_last_cleanup_num_heap_tuples; bool btm_allequalimage; /* are all columns "equalimage"? */ + bool btm_prefix_compression; /* leaf pages use prefix compression? */ } BTMetaPageData; #define BTPageGetMeta(p) \ @@ -227,6 +229,8 @@ typedef struct BTMetaPageData #define P_HAS_GARBAGE(opaque) (((opaque)->btpo_flags & BTP_HAS_GARBAGE) != 0) #define P_INCOMPLETE_SPLIT(opaque) (((opaque)->btpo_flags & BTP_INCOMPLETE_SPLIT) != 0) #define P_HAS_FULLXID(opaque) (((opaque)->btpo_flags & BTP_HAS_FULLXID) != 0) +#define P_HAS_PREFIX_COMPRESSION(opaque) \ + (((opaque)->btpo_flags & BTP_HAS_PREFIX_COMPRESSION) != 0) /* * BTDeletedPageData is the page contents of a deleted page @@ -456,6 +460,28 @@ typedef struct BTVacState * number of columns stored is always implicitly the total number in the * index (in practice there can never be non-key columns stored, since * deduplication is not supported with INCLUDE indexes). + * + * A second alternative non-pivot tuple representation is used for prefix + * compression on multi-column leaf pages that set BTP_HAS_PREFIX_COMPRESSION + * in btpo_flags. On such pages: + * + * - "Anchor" tuples are byte-identical to plain non-pivot tuples: no + * INDEX_ALT_TID_MASK, t_tid stores the real heap TID, and the tuple body + * carries all key attributes plus any INCLUDE attributes. + * + * - "Compressed" followers omit the leading (nkeyatts - 1) key attributes, + * which are inherited from the most recent preceding anchor on the page. + * They set INDEX_ALT_TID_MASK in t_info and BT_IS_COMPRESSED in t_tid's + * offset status nibble. The real heap-tuple offset fits in the low 12 + * bits of t_tid's offset (BT_OFFSET_MASK), since MaxHeapTuplesPerPage is + * always well under 4096 for every supported BLCKSZ. t_tid's block + * number is the unchanged heap block. The tuple body carries only the + * trailing suffix key attribute plus any INCLUDE attributes -- no + * trailing ItemPointerData, zero bytes of per-follower metadata. + * + * Compressed followers require dedup to be disabled on the whole index + * (btm_prefix_compression implies !btm_allequalimage), so BT_IS_POSTING and + * BT_IS_COMPRESSED are mutually exclusive on any given leaf page. */ #define INDEX_ALT_TID_MASK INDEX_AM_RESERVED_BIT @@ -465,6 +491,7 @@ typedef struct BTVacState /* BT_STATUS_OFFSET_MASK status bits */ #define BT_PIVOT_HEAP_TID_ATTR 0x1000 #define BT_IS_POSTING 0x2000 +#define BT_IS_COMPRESSED 0x4000 /* * Mask allocated for number of keys in index tuple must be able to fit @@ -482,8 +509,9 @@ BTreeTupleIsPivot(IndexTuple itup) { if ((itup->t_info & INDEX_ALT_TID_MASK) == 0) return false; - /* absence of BT_IS_POSTING in offset number indicates pivot tuple */ - if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_POSTING) != 0) + /* absence of BT_IS_POSTING and BT_IS_COMPRESSED indicates pivot tuple */ + if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & + (BT_IS_POSTING | BT_IS_COMPRESSED)) != 0) return false; return true; @@ -501,6 +529,18 @@ BTreeTupleIsPosting(IndexTuple itup) return true; } +static inline bool +BTreeTupleIsCompressed(IndexTuple itup) +{ + if ((itup->t_info & INDEX_ALT_TID_MASK) == 0) + return false; + /* presence of BT_IS_COMPRESSED in offset indicates prefix-compressed follower */ + if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_COMPRESSED) == 0) + return false; + + return true; +} + static inline void BTreeTupleSetPosting(IndexTuple itup, uint16 nhtids, int postingoffset) { @@ -509,6 +549,7 @@ BTreeTupleSetPosting(IndexTuple itup, uint16 nhtids, int postingoffset) Assert((size_t) postingoffset == MAXALIGN(postingoffset)); Assert(postingoffset < INDEX_SIZE_MASK); Assert(!BTreeTupleIsPivot(itup)); + Assert(!BTreeTupleIsCompressed(itup)); itup->t_info |= INDEX_ALT_TID_MASK; ItemPointerSetOffsetNumber(&itup->t_tid, (nhtids | BT_IS_POSTING)); @@ -651,10 +692,56 @@ BTreeTupleGetHeapTID(IndexTuple itup) } else if (BTreeTupleIsPosting(itup)) return BTreeTupleGetPosting(itup); + else if (BTreeTupleIsCompressed(itup)) + { + /* + * Compressed followers store their real heap TID offset in the low + * 12 bits of t_tid's offset field (BT_OFFSET_MASK), with the + * BT_IS_COMPRESSED status bit occupying one of the upper 4 bits. + * Because the offset field isn't a clean ItemPointerData, callers + * that need a usable ItemPointer must use + * BTreeCompressedTupleGetHeapTID() with a caller-provided buffer. + */ + return NULL; + } return &itup->t_tid; } +/* + * Write the real heap TID of a prefix-compressed follower into the + * caller-provided output buffer. The follower's t_tid directly carries the + * heap block number, and the low 12 bits of its offset field carry the real + * heap-tuple offset; the upper nibble is masked off. + */ +static inline void +BTreeCompressedTupleGetHeapTID(IndexTuple itup, ItemPointer out) +{ + OffsetNumber raw; + + Assert(BTreeTupleIsCompressed(itup)); + + raw = ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_OFFSET_MASK; + out->ip_blkid = itup->t_tid.ip_blkid; + ItemPointerSetOffsetNumber(out, raw); +} + +/* + * Set t_tid on a to-be-written compressed follower from a real heap TID, + * packing the BT_IS_COMPRESSED status bit into the offset field. + */ +static inline void +BTreeCompressedTupleSetHeapTID(IndexTuple itup, ItemPointer heap_tid) +{ + OffsetNumber heap_off = ItemPointerGetOffsetNumberNoCheck(heap_tid); + + Assert((heap_off & ~BT_OFFSET_MASK) == 0); + itup->t_info |= INDEX_ALT_TID_MASK; + itup->t_tid.ip_blkid = heap_tid->ip_blkid; + ItemPointerSetOffsetNumber(&itup->t_tid, heap_off | BT_IS_COMPRESSED); + Assert(BTreeTupleIsCompressed(itup)); +} + /* * Get maximum heap TID attribute, which could be the only TID in the case of * a non-pivot tuple that does not have a posting list. @@ -796,6 +883,7 @@ typedef struct BTScanInsertData { bool heapkeyspace; bool allequalimage; + bool prefix_compression; /* index uses prefix-compressed leaves? */ bool anynullkeys; bool nextkey; bool backward; /* backward index scan? */ @@ -924,6 +1012,38 @@ typedef struct BTVacuumPostingData typedef BTVacuumPostingData *BTVacuumPosting; +/* + * BTPrefixPageState -- per-page working state for prefix-compressed leaf + * pages. Callers seed the state by passing a Page pointer to + * _bt_prefix_state_init(), then advance it across items on that page. The + * state caches the most recently seen anchor tuple (and its deformed + * Datums/isnulls) so that reconstructing consecutive compressed followers in + * the same group is cheap: each follower only needs its own suffix deformed + * and merged with the cached anchor values. + * + * The state is inexpensive to initialize (no allocations until first use) + * and must be passed to _bt_prefix_state_cleanup() before the owning frame + * disappears, so that any cached trailing TupleDesc is freed. + */ +typedef struct BTPrefixPageState +{ + Page page; /* the leaf page being walked */ + OffsetNumber anchor_off; /* offset of current anchor, or Invalid */ + IndexTuple anchor; /* pointer into the page for current anchor */ + int natts_anchor; /* number of leading key attrs anchor supplies */ + Datum anchor_values[INDEX_MAX_KEYS]; + bool anchor_isnull[INDEX_MAX_KEYS]; + bool anchor_deformed; /* are anchor_values/isnull filled in? */ + OffsetNumber next_anchor_off; /* offset of next anchor, or Invalid if unknown */ + + /* + * Trailing TupleDesc covering attrs (natts_anchor+1..natts) -- the on-disk + * layout of a compressed follower. Built lazily on first reconstruction + * and freed by _bt_prefix_state_cleanup(). + */ + struct TupleDescData *follower_tupdesc; +} BTPrefixPageState; + /* * BTScanOpaqueData is the btree-private state needed for an indexscan. * This consists of preprocessed scan keys (see _bt_preprocess_keys() for @@ -1074,11 +1194,15 @@ typedef struct BTScanOpaqueData /* * If we are doing an index-only scan, these are the tuple storage - * workspaces for the currPos and markPos respectively. Each is of size - * BLCKSZ, so it can hold as much as a full page's worth of tuples. + * workspaces for the currPos and markPos respectively. Sized to hold a + * full page's worth of tuples; on a prefix-compressed leaf the buffers + * may grow beyond the initial size to accommodate reconstructed tuples + * that are larger than their on-disk form. */ char *currTuples; /* tuple storage for currPos */ char *markTuples; /* tuple storage for markPos */ + Size currTuplesSize; /* bytes allocated at currTuples */ + Size markTuplesSize; /* bytes allocated at markTuples */ /* * If the marked position is on the same page as current position, we @@ -1192,6 +1316,41 @@ extern void _bt_parallel_done(IndexScanDesc scan); extern void _bt_parallel_primscan_schedule(IndexScanDesc scan, BlockNumber curr_page); +/* + * prototypes for functions in nbtprefix.c + */ +extern void _bt_prefix_state_init(BTPrefixPageState *st, Page page); +extern void _bt_prefix_state_cleanup(BTPrefixPageState *st); +extern void _bt_prefix_state_set_anchor(BTPrefixPageState *st, + OffsetNumber anchor_off); +extern OffsetNumber _bt_prefix_find_anchor(Page page, OffsetNumber offnum); +extern IndexTuple _bt_prefix_reconstruct(Relation rel, BTPrefixPageState *st, + OffsetNumber offnum, char *dstbuf); +extern IndexTuple _bt_prefix_form_tuple(Relation rel, BTPrefixPageState *st, + IndexTuple compressed, char *dstbuf); +extern bool _bt_prefix_leading_equalimage(Relation rel); +extern TupleDesc _bt_prefix_build_follower_tupdesc(Relation rel, + int natts_anchor); +extern IndexTuple _bt_prefix_compress_new_tuple(TupleDesc full_tupdesc, + TupleDesc follower_tupdesc, + int natts_anchor, + IndexTuple new_plain, + char *dstbuf); +typedef struct BTPrefixPromotion +{ + OffsetNumber off; + IndexTuple plain; /* palloc'd plain form */ + Size plainsz; /* MAXALIGNed */ +} BTPrefixPromotion; + +extern int _bt_prefix_vacuum_plan(Relation rel, Page page, + const OffsetNumber *deletable, + int ndeletable, + BTPrefixPromotion *promotions); +extern void _bt_prefix_vacuum_apply(Page page, + BTPrefixPromotion *promotions, + int nprom); + /* * prototypes for functions in nbtdedup.c */ @@ -1231,7 +1390,7 @@ extern OffsetNumber _bt_findsplitloc(Relation rel, Page origpage, * prototypes for functions in nbtpage.c */ extern void _bt_initmetapage(Page page, BlockNumber rootbknum, uint32 level, - bool allequalimage); + bool allequalimage, bool prefix_compression); extern bool _bt_vacuum_needs_cleanup(Relation rel); extern void _bt_set_cleanup_info(Relation rel, BlockNumber num_delpages); extern void _bt_upgrademetapage(Page page); @@ -1239,7 +1398,7 @@ extern Buffer _bt_getroot(Relation rel, Relation heaprel, int access); extern Buffer _bt_gettrueroot(Relation rel); extern int _bt_getrootheight(Relation rel); extern void _bt_metaversion(Relation rel, bool *heapkeyspace, - bool *allequalimage); + bool *allequalimage, bool *prefix_compression); extern void _bt_checkpage(Relation rel, Buffer buf); extern Buffer _bt_getbuf(Relation rel, BlockNumber blkno, int access); extern Buffer _bt_allocbuf(Relation rel, Relation heaprel); diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h index 3a78ec27f..15cefc8e2 100644 --- a/src/include/access/nbtxlog.h +++ b/src/include/access/nbtxlog.h @@ -55,6 +55,7 @@ typedef struct xl_btree_metadata uint32 fastlevel; uint32 last_cleanup_num_delpages; bool allequalimage; + bool prefix_compression; } xl_btree_metadata; /* @@ -156,9 +157,24 @@ typedef struct xl_btree_split OffsetNumber firstrightoff; /* first origpage item on rightpage */ OffsetNumber newitemoff; /* new item's offset */ uint16 postingoff; /* offset inside orig posting tuple */ + /* + * origpage's btpo_flags at split time. Replay uses this to carry + * forward BTP_HAS_PREFIX_COMPRESSION (and any future per-page flags) + * to the reinitialized right page. Filtered by + * BTP_SPLIT_PRESERVED_FLAGS in replay so that flags that only belong + * on origpage (BTP_INCOMPLETE_SPLIT, BTP_ROOT, BTP_HAS_GARBAGE, + * BTP_SPLIT_END) never cross over. + */ + uint16 origflags; } xl_btree_split; -#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16)) +#define SizeOfBtreeSplit (offsetof(xl_btree_split, origflags) + sizeof(uint16)) + +/* + * Flags that xl_btree_split replay preserves from origpage on the right + * page. Anything outside this mask is cleared. + */ +#define BTP_SPLIT_PRESERVED_FLAGS (BTP_LEAF | BTP_HAS_PREFIX_COMPRESSION) /* * When page is deduplicated, consecutive groups of tuples with equal keys are diff --git a/src/backend/access/nbtree/Makefile b/src/backend/access/nbtree/Makefile index 0daf640af..0818e2387 100644 --- a/src/backend/access/nbtree/Makefile +++ b/src/backend/access/nbtree/Makefile @@ -17,6 +17,7 @@ OBJS = \ nbtdedup.o \ nbtinsert.o \ nbtpage.o \ + nbtprefix.o \ nbtpreprocesskeys.o \ nbtreadpage.o \ nbtree.o \ diff --git a/src/backend/access/nbtree/README b/src/backend/access/nbtree/README index f38675476..0817a272e 100644 --- a/src/backend/access/nbtree/README +++ b/src/backend/access/nbtree/README @@ -1041,6 +1041,156 @@ that need a page split anyway. Besides, supporting variable "split points" while splitting posting lists won't actually improve overall space utilization. +Notes about prefix compression +------------------------------ + +Prefix compression is an orthogonal leaf-page encoding that targets +multi-column indexes whose leading key columns repeat across many adjacent +logical tuples -- for example, an index on (customer_id, order_date) with +a heavily skewed customer_id distribution. The encoding is modeled on +Bayer/Unterauer's "Prefix B-trees" as described by Graefe. It is opt-in +per index at build time, gated on the index having at least 2 key +attributes and the leading (nkeyatts - 1) columns being equalimage (which +makes byte-verbatim reuse of the anchor's leading attrs sound). + +An index opts in during bulk build by recording btm_prefix_compression on +the metapage; BTP_HAS_PREFIX_COMPRESSION is set on every leaf page the +index produces. Prefix compression and posting-list deduplication are +strictly mutually exclusive on any given index: btm_prefix_compression +implies !btm_allequalimage, and dedup only runs when allequalimage is set. +A prefix-compressed leaf page therefore never contains posting tuples, and +a dedup-eligible leaf page never contains compressed followers. + +On-disk structure. A prefix-compressed leaf page is organized as one or +more "drifting" prefix groups. Each group consists of: + + * An anchor tuple. Byte-identical to a plain non-pivot leaf tuple: + INDEX_ALT_TID_MASK is clear, t_tid carries the real heap TID, and the + body contains the full set of key + INCLUDE attrs. All regular leaf + tuple invariants (e.g. BTreeTupleGetHeapTID(itup) == &itup->t_tid) + hold unchanged. A page's first data item is always an anchor. + + * Zero or more compressed followers that physically follow the anchor in + offset order. A follower sets INDEX_ALT_TID_MASK in t_info and + encodes BT_IS_COMPRESSED (0x4000) in the upper nibble of t_tid's + offset field. The low 12 bits of the offset field (BT_OFFSET_MASK) + hold the real heap-tuple offset; the block number in t_tid.ip_blkid + is stored in the clear. Because MaxHeapTuplesPerPage fits in 12 bits + for every supported BLCKSZ, the encoding has zero per-follower + overhead: a follower is shorter than its plain form by exactly the + space of the omitted leading attrs. The body stores only the + trailing key attrs (natts_anchor+1 .. nkeyatts) plus INCLUDE attrs. + +A group ends at the next anchor (the next plain non-pivot tuple) or at +the end of the page's data area. A single page can host many groups, +each with a different prefix -- hence "drifting". Internal pages are +unaffected; only leaf pages carry BTP_HAS_PREFIX_COMPRESSION, and pivot +tuples (internal-page downlinks and leaf high keys) are always plain. + +Scan-time reconstruction. _bt_readpage, _bt_compare, _bt_killitems, and +the VACUUM page-walker materialize a follower into a plain IndexTuple on +demand: they combine the governing anchor's leading attrs (discovered via +a short leftward walk in _bt_prefix_find_anchor, cached in a +BTPrefixPageState across items of the same group) with the follower's +trailing attrs, and demask t_tid via BTreeCompressedTupleGetHeapTID. +Callers above nbtree (including _bt_checkkeys, amcheck, and index-only +scan tuple return) never see a compressed follower. + +Build path. _bt_buildadd emits the first tuple on each page as a plain +anchor and compares subsequent sorted input tuples to the current anchor +using _bt_keep_natts_fast. If the leading (nkeyatts - 1) attrs match, the +new tuple is emitted as a compressed follower via +_bt_prefix_compress_new_tuple. Otherwise the tuple is emitted plain and +becomes the anchor of a new group. Parallel build produces the same +plain sorted tuples as always; compression happens only inside +_bt_buildadd on the leader. + +Insert path. _bt_insertonpg compresses a newly inserted tuple when the +target leaf is BTP_HAS_PREFIX_COMPRESSION and the incoming tuple shares +the governing anchor's leading attrs (resolved via _bt_prefix_find_anchor +walking left from newitemoff). The incoming tuple's heap TID is passed +through as scantid for search, so the insert is placed at the correct +(key, TID) position before compression. A tuple that doesn't share the +anchor's leading attrs is inserted plain and starts a new group. + +Split path. Leaf page splits may land at a group boundary or inside a +group. If firstright (the first item of the right page) is a compressed +follower, _bt_split reconstructs it into plain form and uses that for +both the right page's leftmost item (satisfying "first data item is an +anchor") and for _bt_truncate's firstright input. If lastleft is a +compressed follower, _bt_split reconstructs it before passing to +_bt_truncate. The high key that gets produced is plain; internal pages +never carry compressed tuples or the BTP_HAS_PREFIX_COMPRESSION flag. + +Space management and the "splits never fail" invariant. Two places +perform a follower-to-plain-anchor promotion: (a) VACUUM's anchor- +promotion in _bt_delitems_vacuum/_bt_delitems_delete via +_bt_prefix_vacuum_plan/_apply, when an anchor is being deleted with at +least one surviving follower; and (b) _bt_split when firstright is a +compressed follower. Both rely on the following invariant: + + promoted_plain_size = header + prefix_keys + suffix_keys + include + follower_on_disk = header + suffix_keys + include + +so promotion costs exactly prefix_keys bytes of extra space per +promoted follower. For VACUUM we get those bytes for free because the +anchor (whose size is >= header + prefix_keys + suffix_keys + include, +i.e. >= prefix_keys by far) is being deleted in the same operation. +PageIndexMultiDelete runs first and frees the anchor's bytes; the +subsequent PageIndexTupleOverwrite for the promoted follower always fits +by construction, summed across all promotions on the same page. + +For _bt_split the story is worse: nbtsplitloc.c does not account for +promotion at all. The split chooser is unaware of prefix compression +entirely -- it scores candidate splits using on-disk sizes, ignoring +that a mid-group split forces _bt_split to reconstruct firstright into +a plain anchor that's prefix_keys bytes larger than the compressed +follower. There is no code in nbtsplitloc.c that reserves space for +this growth or preferentially picks group-boundary split points. The +"promotion always fits" guarantee does NOT hold on the split path. +Left as follow-up work. + +Mutual exclusion with posting-list dedup is the second leg of the "never +fail" argument. Dedup produces and rewrites posting tuples, and posting +splits expand an existing posting slot in place (same allocated size, TIDs +shifted). If dedup could run on a prefix-compressed page, its posting +tuples could turn into compressed followers (via a later bulk-style merge +in a future hybrid design), and the space accounting would get tangled +with promotion. The mutex keeps the two encodings strictly separated per +index: _bt_dedup_pass asserts !P_HAS_PREFIX_COMPRESSION(page) at entry, +and _bt_initmetapage refuses to set both flags. + +Orphan anchors. After VACUUM removes every follower of a group, the +anchor remains in place as a singleton group; no cleanup is necessary +because an anchor is byte-identical to a plain leaf tuple. The reverse +case (anchor deleted with followers surviving) is handled by the +promotion path above. + +On-the-fly scan-time interlocks. _bt_readpage's LP_DEAD-skip branch +still has to update the current prefix state when it skips a dead plain +tuple, because such a dead plain tuple is the governing anchor for any +compressed follower that physically follows it on the page. Silently +skipping would leave st->anchor_off pointing at an earlier anchor and +cause every subsequent follower to reconstruct under the wrong leading +attrs. + +WAL. Compressed-follower bytes travel verbatim through existing +xl_btree_insert and xl_btree_split records; replay uses the raw bytes +without reconstructing. The per-page BTP_HAS_PREFIX_COMPRESSION flag is +included in BTP_SPLIT_PRESERVED_FLAGS so split replay carries it forward +on both halves, and new-root replay (XLOG_BTREE_NEWROOT) sets the flag on +a fresh leaf root when btm_prefix_compression is set on the metapage. +xl_btree_metadata carries btm_prefix_compression alongside +btm_allequalimage so the mutex invariant survives recovery. + +Downgrade to a version that doesn't understand BT_IS_COMPRESSED is not +supported for indexes that opted in. An older binary reading a +compressed follower would misclassify it as a pivot (ALT-TID set, +BT_IS_POSTING clear), which is catastrophic on a leaf page. Affected +indexes need a REINDEX to downgrade. Anchors, being byte-identical to +plain leaf tuples, would read fine. This mirrors the upgrade story for +v4's introduction of posting lists. + Notes About Data Representation ------------------------------- diff --git a/src/backend/access/nbtree/meson.build b/src/backend/access/nbtree/meson.build index 812f067e7..dfdd4fde8 100644 --- a/src/backend/access/nbtree/meson.build +++ b/src/backend/access/nbtree/meson.build @@ -5,6 +5,7 @@ backend_sources += files( 'nbtdedup.c', 'nbtinsert.c', 'nbtpage.c', + 'nbtprefix.c', 'nbtpreprocesskeys.c', 'nbtreadpage.c', 'nbtree.c', diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c index af7affdf4..2b377e71f 100644 --- a/src/backend/access/nbtree/nbtdedup.c +++ b/src/backend/access/nbtree/nbtdedup.c @@ -71,6 +71,17 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel); XLogRecPtr recptr; + /* + * Dedup is mutually exclusive with prefix compression at the index level + * (btm_prefix_compression ⇒ !btm_allequalimage, enforced at index build). + * Callers only invoke dedup when itup_key->allequalimage is set, so a + * prefix-compressed leaf page must never reach here. Compressed + * followers carry BT_IS_COMPRESSED in their t_tid and would be + * misinterpreted by _bt_dedup_save_htid / _bt_form_posting as regular + * heap TIDs. + */ + Assert(!P_HAS_PREFIX_COMPRESSION(opaque)); + /* Passed-in newitemsz is MAXALIGNED but does not include line pointer */ newitemsz += sizeof(ItemIdData); @@ -665,7 +676,20 @@ _bt_bottomupdel_finish_pending(Page page, BTDedupState state, if (!BTreeTupleIsPosting(itup)) { /* Simple case: A plain non-pivot tuple */ - ideltid->tid = itup->t_tid; + if (BTreeTupleIsCompressed(itup)) + { + /* + * Compressed follower: t_tid packs BT_IS_COMPRESSED into its + * offset nibble. Feeding the raw bytes to the heap-side + * table_index_delete_tuples would point at a bogus heap + * offset (> MaxHeapTuplesPerPage) and trip an internal + * qsort-comparator assertion. Decode the real heap TID + * into the deltid slot instead. + */ + BTreeCompressedTupleGetHeapTID(itup, &ideltid->tid); + } + else + ideltid->tid = itup->t_tid; ideltid->id = delstate->ndeltids; istatus->idxoffnum = offnum; istatus->knowndeletable = false; /* for now */ diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index d5d964301..c7aa0037d 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -526,7 +526,18 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel, { /* ... htid is from simple non-pivot tuple */ Assert(!inposting); - htid = curitup->t_tid; + + /* + * A prefix-compressed follower packs BT_IS_COMPRESSED in + * t_tid's upper nibble; decode to a clean heap TID before + * any tableam fetch or TID comparison below. Plain + * (non-compressed) tuples, including anchors, fall through + * with their t_tid used verbatim. + */ + if (P_HAS_PREFIX_COMPRESSION(opaque) && BTreeTupleIsCompressed(curitup)) + BTreeCompressedTupleGetHeapTID(curitup, &htid); + else + htid = curitup->t_tid; } else if (!inposting) { @@ -910,7 +921,12 @@ _bt_findinsertloc(Relation rel, /* * If the target page cannot fit newitem, try to avoid splitting the - * page on insert by performing deletion or deduplication now + * page on insert by performing deletion or deduplication now. On + * prefix-compressed indexes deduplication is disabled index-wide + * (btm_allequalimage is false), but we still run the combined + * delete-or-dedup helper: the deletion pass (LP_DEAD and + * bottom-up) is still valuable, and the dedup pass is a no-op + * because allequalimage is false. */ if (PageGetFreeSpace(page) < insertstate->itemsz) _bt_delete_or_dedup_one_page(rel, heapRel, insertstate, false, @@ -1135,6 +1151,9 @@ _bt_insertonpg(Relation rel, IndexTuple oposting = NULL; IndexTuple origitup = NULL; IndexTuple nposting = NULL; + IndexTuple emit_itup; /* possibly-compressed form actually written */ + Size emit_itemsz; + char *emit_scratch = NULL; XLogRecPtr recptr; page = BufferGetPage(buf); @@ -1154,10 +1173,78 @@ _bt_insertonpg(Relation rel, BTreeTupleGetNAtts(itup, rel) <= IndexRelationGetNumberOfKeyAttributes(rel)); Assert(!BTreeTupleIsPosting(itup)); + Assert(!BTreeTupleIsCompressed(itup)); Assert(MAXALIGN(IndexTupleSize(itup)) == itemsz); /* Caller must always finish incomplete split for us */ Assert(!P_INCOMPLETE_SPLIT(opaque)); + /* + * Default: emit itup as-is with its plain size. When the target leaf page + * is prefix-compressed we may replace emit_itup with a compressed follower + * below. + */ + emit_itup = itup; + emit_itemsz = itemsz; + + /* + * Prefix compression: if this is a leaf page that already carries + * BTP_HAS_PREFIX_COMPRESSION and the incoming tuple shares the governing + * anchor's leading (nkeyatts-1) key attrs, emit the compressed form. + * + * The invariant that the leftmost data item on a prefix-compressed leaf + * page must be a plain anchor is preserved: we never compress when + * newitemoff == P_FIRSTDATAKEY (the to-be-leftmost position). Posting + * splits never happen on prefix-compressed pages because such indexes + * have btm_allequalimage=false, so dedup is disabled index-wide. + */ + if (isleaf && itup_key->prefix_compression && + P_HAS_PREFIX_COMPRESSION(opaque) && postingoff == 0 && + newitemoff > P_FIRSTDATAKEY(opaque)) + { + OffsetNumber aoff; + + aoff = _bt_prefix_find_anchor(page, OffsetNumberPrev(newitemoff)); + if (aoff == InvalidOffsetNumber) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg_internal("prefix-compressed leaf page has no anchor before offset %u in index \"%s\"", + newitemoff, + RelationGetRelationName(rel)))); + else + { + IndexTuple anchor = (IndexTuple) PageGetItem(page, + PageGetItemId(page, aoff)); + int natts_anchor = + IndexRelationGetNumberOfKeyAttributes(rel) - 1; + + if (_bt_keep_natts_fast(rel, anchor, itup) > natts_anchor) + { + TupleDesc follower_tupdesc; + + /* + * By btree sort-order invariant, any incoming tuple that lies + * inside the governing anchor's group (by offset and sort + * position) must share all natts_anchor leading attrs -- the + * check above turns that invariant into a precondition for + * compression, not a guarantee. A tuple landing between two + * groups, or at the start of a new group, will fall through + * and be emitted plain, becoming a new anchor. + */ + follower_tupdesc = + _bt_prefix_build_follower_tupdesc(rel, natts_anchor); + emit_scratch = palloc(BTMaxItemSize); + emit_itup = + _bt_prefix_compress_new_tuple(RelationGetDescr(rel), + follower_tupdesc, + natts_anchor, + itup, + emit_scratch); + emit_itemsz = MAXALIGN(IndexTupleSize(emit_itup)); + FreeTupleDesc(follower_tupdesc); + } + } + } + /* * Every internal page should have exactly one negative infinity item at * all times. Only _bt_split() and _bt_newlevel() should add items that @@ -1209,6 +1296,19 @@ _bt_insertonpg(Relation rel, nposting = _bt_swap_posting(itup, oposting, postingoff); /* itup now contains rightmost/max TID from oposting */ + /* + * The tuple physically inserted on the page after the posting split + * is 'itup' (with the TID swap applied by _bt_swap_posting), not + * 'emit_itup' (which still points at origitup). Master's code uses + * 'itup' directly. Our prefix-compression branch introduced + * emit_itup as the thing PageAddItem writes out, so we must update + * it to match 'itup' in the posting-split path or we'd write origitup + * (with its original unsw­apped TID) and corrupt the page's + * (key, TID) sort order. + */ + emit_itup = itup; + emit_itemsz = itemsz; + /* Alter offset so that newitem goes after posting list */ newitemoff = OffsetNumberNext(newitemoff); } @@ -1219,14 +1319,27 @@ _bt_insertonpg(Relation rel, * Note: PageGetFreeSpace() subtracts sizeof(ItemIdData) from its result, * so this comparison is correct even though we appear to be accounting * only for the item and not for its line pointer. + * + * Use emit_itemsz (the possibly-compressed size) here so that a tuple + * whose uncompressed size would not fit but whose compressed size does + * can avoid a page split. */ - if (PageGetFreeSpace(page) < itemsz) + if (PageGetFreeSpace(page) < emit_itemsz) { Buffer rbuf; Assert(!split_only_page); - /* split the buffer into left and right halves */ + /* + * _bt_split has not yet been taught about prefix compression; hand it + * the plain tuple and plain size. The split logic will produce pages + * that the build/insert paths can subsequently compress to. + */ + if (emit_scratch != NULL) + { + pfree(emit_scratch); + emit_scratch = NULL; + } rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz, itup, origitup, nposting, postingoff); PredicateLockPageSplit(rel, @@ -1297,7 +1410,7 @@ _bt_insertonpg(Relation rel, if (postingoff != 0) memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting))); - if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber) + if (PageAddItem(page, emit_itup, emit_itemsz, newitemoff, false, false) == InvalidOffsetNumber) elog(PANIC, "failed to add new item to block %u in index \"%s\"", BufferGetBlockNumber(buf), RelationGetRelationName(rel)); @@ -1373,6 +1486,7 @@ _bt_insertonpg(Relation rel, xlmeta.fastlevel = metad->btm_fastlevel; xlmeta.last_cleanup_num_delpages = metad->btm_last_cleanup_num_delpages; xlmeta.allequalimage = metad->btm_allequalimage; + xlmeta.prefix_compression = metad->btm_prefix_compression; XLogRegisterBuffer(2, metabuf, REGBUF_WILL_INIT | REGBUF_STANDARD); @@ -1384,8 +1498,11 @@ _bt_insertonpg(Relation rel, XLogRegisterBuffer(0, buf, REGBUF_STANDARD); if (postingoff == 0) { - /* Just log itup from caller */ - XLogRegisterBufData(0, itup, IndexTupleSize(itup)); + /* + * Log the bytes as they appear on the page -- compressed when + * prefix compression turned itup into a follower, else plain. + */ + XLogRegisterBufData(0, emit_itup, IndexTupleSize(emit_itup)); } else { @@ -1445,6 +1562,9 @@ _bt_insertonpg(Relation rel, if (BlockNumberIsValid(blockcache) && _bt_getrootheight(rel) >= BTREE_FASTPATH_MIN_LEVEL) RelationSetTargetBlock(rel, blockcache); + + if (emit_scratch != NULL) + pfree(emit_scratch); } /* be tidy */ @@ -1517,6 +1637,12 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, isleaf, isrightmost; XLogRecPtr recptr; + /* + * Scratch buffers used when prefix compression forces us to reconstruct + * firstright and/or lastleft to their plain form. + */ + char *firstright_scratch = NULL; + char *lastleft_scratch = NULL; /* * origpage is the original page to be split. leftpage is a temporary @@ -1638,6 +1764,19 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * lastleft. A tuple with all the keys from firstright but the max heap * TID from lastleft is used, to avoid introducing a special case.) */ + /* + * Prefix-compression bookkeeping: + * - firstright is the candidate first item on the new right page. On a + * BTP_HAS_PREFIX_COMPRESSION page, the leftmost data item on the right + * page must be a plain anchor (invariant #1). If the tuple at + * firstrightoff is a compressed follower (the split landed mid-group), + * we reconstruct its plain form and use that for both _bt_truncate's + * firstright input and the right page's P_FIRSTKEY. + * - lastleft must also be a plain tuple for _bt_truncate, so we + * reconstruct it on the fly if it was a compressed follower. + * Scratch buffers are declared above and freed near the end of the + * function. + */ if (!newitemonleft && newitemoff == firstrightoff) { /* incoming tuple becomes firstright */ @@ -1652,6 +1791,22 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, firstright = (IndexTuple) PageGetItem(origpage, itemid); if (firstrightoff == origpagepostingoff) firstright = nposting; + + /* Mid-group split: promote compressed follower to plain anchor */ + if (isleaf && itup_key->prefix_compression && + BTreeTupleIsCompressed(firstright)) + { + BTPrefixPageState rst; + + memset(&rst, 0, sizeof(rst)); + _bt_prefix_state_init(&rst, origpage); + firstright_scratch = palloc(BTMaxItemSize); + firstright = _bt_prefix_reconstruct(rel, &rst, + firstrightoff, + firstright_scratch); + _bt_prefix_state_cleanup(&rst); + itemsz = MAXALIGN(IndexTupleSize(firstright)); + } } if (isleaf) @@ -1675,6 +1830,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, lastleft = (IndexTuple) PageGetItem(origpage, itemid); if (lastleftoff == origpagepostingoff) lastleft = nposting; + + /* Reconstruct if lastleft is a compressed follower */ + if (itup_key->prefix_compression && + BTreeTupleIsCompressed(lastleft)) + { + BTPrefixPageState rst; + + memset(&rst, 0, sizeof(rst)); + _bt_prefix_state_init(&rst, origpage); + lastleft_scratch = palloc(BTMaxItemSize); + lastleft = _bt_prefix_reconstruct(rel, &rst, + lastleftoff, + lastleft_scratch); + _bt_prefix_state_cleanup(&rst); + } } lefthighkey = _bt_truncate(rel, lastleft, firstright, itup_key); @@ -1872,6 +2042,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, } else { + /* + * On a prefix-compressed page, if the split landed mid-group, we + * promote the first right-page data item to its plain form so + * the right page's leftmost data item is a plain anchor. + * Subsequent followers on the right page inherit from this new + * anchor (their leading attrs matched the original anchor, and + * the promotion preserves those attrs). + */ + if (i == firstrightoff && firstright_scratch != NULL) + { + dataitem = firstright; + itemsz = MAXALIGN(IndexTupleSize(dataitem)); + } + if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff, afterrightoff == minusinfoff)) { @@ -2004,6 +2188,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, xlrec.postingoff = 0; if (postingoff != 0 && origpagepostingoff < firstrightoff) xlrec.postingoff = postingoff; + /* Preserve origpage flags (BTP_HAS_PREFIX_COMPRESSION etc.) */ + xlrec.origflags = oopaque->btpo_flags; XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfBtreeSplit); @@ -2103,6 +2289,10 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, /* be tidy */ if (isleaf) pfree(lefthighkey); + if (firstright_scratch != NULL) + pfree(firstright_scratch); + if (lastleft_scratch != NULL) + pfree(lastleft_scratch); /* split's done */ return rbuf; @@ -2622,6 +2812,7 @@ _bt_newlevel(Relation rel, Relation heaprel, Buffer lbuf, Buffer rbuf) md.fastlevel = metad->btm_level; md.last_cleanup_num_delpages = metad->btm_last_cleanup_num_delpages; md.allequalimage = metad->btm_allequalimage; + md.prefix_compression = metad->btm_prefix_compression; XLogRegisterBufData(2, &md, sizeof(xl_btree_metadata)); @@ -2723,6 +2914,18 @@ _bt_pgaddtup(Page page, * all this because !heapkeyspace indexes must still do a "getting tired" * linear search, and so are likely to get some benefit from using it as a * gating condition.) + * + * XXX prefix compression interaction: bottom-up deletion and deduplication + * both ultimately call _bt_keep_natts_fast on pairs of on-page tuples, which + * cannot yet consume compressed followers directly (see the comment over + * _bt_keep_natts_fast itself). Dedup is already disabled at the index level + * (btm_prefix_compression ⇒ !btm_allequalimage); bottom-up delete is + * suppressed per-call below on prefix-compressed pages only. Simple + * deletion stays enabled for prefix-compressed pages because its per-tuple + * handling has been taught to decode compressed heap TIDs (see + * _bt_simpledel_pass), so there's no asymmetry of "delete nothing" -- + * simple deletion still runs, bottom-up does not. Remove that asymmetry + * once nbtsplitloc.c/dedup learn to reconstruct. */ static void _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel, @@ -2816,7 +3019,17 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel, * together index tuples, so the same correctness considerations do not * apply. We deliberately omit an index-is-allequalimage test here. */ + /* + * Bottom-up deletion compares on-page tuples via _bt_keep_natts_fast, + * which expects plain tuples with the full tupdesc. Compressed + * followers on a prefix-compressed leaf don't satisfy that contract, so + * skip the optimization on such pages -- this is purely advisory and + * simply gives up some free-space reclamation. The subsequent dedup + * pass is also guarded (and is always disabled for prefix-compressed + * indexes anyway), so insertion falls through to split as usual. + */ if ((indexUnchanged || uniquedup) && + !P_HAS_PREFIX_COMPRESSION(BTPageGetOpaque(BufferGetPage(buffer))) && _bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz)) return; @@ -2863,6 +3076,7 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel, int ndeadblocks; TM_IndexDeleteOp delstate; OffsetNumber offnum; + BTPageOpaque opaque = BTPageGetOpaque(page); /* Get array of table blocks pointed to by LP_DEAD-set tuples */ deadblocks = _bt_deadblocks(page, deletable, ndeletable, newitem, @@ -2902,9 +3116,15 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel, /* * TID's table block is among those pointed to by the TIDs from - * LP_DEAD-bit set tuples on page -- add TID to deltids + * LP_DEAD-bit set tuples on page -- add TID to deltids. For + * compressed followers we must demask the heap offset, because + * t_tid.ip_posid carries BT_IS_COMPRESSED in its upper nibble + * and the tableam below expects a clean ItemPointerData. */ - odeltid->tid = itup->t_tid; + if (P_HAS_PREFIX_COMPRESSION(opaque) && BTreeTupleIsCompressed(itup)) + BTreeCompressedTupleGetHeapTID(itup, &odeltid->tid); + else + odeltid->tid = itup->t_tid; odeltid->id = delstate.ndeltids; ostatus->idxoffnum = offnum; ostatus->knowndeletable = ItemIdIsDead(itemid); diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c index 83d6d367f..409a2613f 100644 --- a/src/backend/access/nbtree/nbtpage.c +++ b/src/backend/access/nbtree/nbtpage.c @@ -63,14 +63,20 @@ static void _bt_pendingfsm_add(BTVacState *vstate, BlockNumber target, /* * _bt_initmetapage() -- Fill a page buffer with a correct metapage image + * + * btm_allequalimage and btm_prefix_compression are mutually exclusive (the + * two on-disk encodings are incompatible). Callers must honor that; we + * assert it here. */ void _bt_initmetapage(Page page, BlockNumber rootbknum, uint32 level, - bool allequalimage) + bool allequalimage, bool prefix_compression) { BTMetaPageData *metad; BTPageOpaque metaopaque; + Assert(!(allequalimage && prefix_compression)); + _bt_pageinit(page, BLCKSZ); metad = BTPageGetMeta(page); @@ -83,6 +89,7 @@ _bt_initmetapage(Page page, BlockNumber rootbknum, uint32 level, metad->btm_last_cleanup_num_delpages = 0; metad->btm_last_cleanup_num_heap_tuples = -1.0; metad->btm_allequalimage = allequalimage; + metad->btm_prefix_compression = prefix_compression; metaopaque = BTPageGetOpaque(page); metaopaque->btpo_flags = BTP_META; @@ -122,9 +129,10 @@ _bt_upgrademetapage(Page page) metad->btm_version = BTREE_NOVAC_VERSION; metad->btm_last_cleanup_num_delpages = 0; metad->btm_last_cleanup_num_heap_tuples = -1.0; - /* Only a REINDEX can set this field */ + /* Only a REINDEX can set these fields */ Assert(!metad->btm_allequalimage); metad->btm_allequalimage = false; + metad->btm_prefix_compression = false; /* Adjust pd_lower (see _bt_initmetapage() for details) */ ((PageHeader) page)->pd_lower = @@ -299,6 +307,7 @@ _bt_set_cleanup_info(Relation rel, BlockNumber num_delpages) md.fastlevel = metad->btm_fastlevel; md.last_cleanup_num_delpages = num_delpages; md.allequalimage = metad->btm_allequalimage; + md.prefix_compression = metad->btm_prefix_compression; XLogRegisterBufData(0, &md, sizeof(xl_btree_metadata)); @@ -450,6 +459,16 @@ _bt_getroot(Relation rel, Relation heaprel, int access) rootopaque = BTPageGetOpaque(rootpage); rootopaque->btpo_prev = rootopaque->btpo_next = P_NONE; rootopaque->btpo_flags = (BTP_LEAF | BTP_ROOT); + /* + * The new leaf root inherits the index-wide prefix-compression + * opt-in. Without this, an initially-empty CREATE INDEX followed + * by retail INSERTs produces plain-only leaves forever (the insert + * path gates compression on BTP_HAS_PREFIX_COMPRESSION, and + * _bt_split propagates the flag from origpage to its new right + * sibling, so an unflagged first leaf poisons every descendant). + */ + if (metad->btm_prefix_compression) + rootopaque->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION; rootopaque->btpo_level = 0; rootopaque->btpo_cycleid = 0; /* Get raw page pointer for metapage */ @@ -490,6 +509,7 @@ _bt_getroot(Relation rel, Relation heaprel, int access) md.fastlevel = 0; md.last_cleanup_num_delpages = 0; md.allequalimage = metad->btm_allequalimage; + md.prefix_compression = metad->btm_prefix_compression; XLogRegisterBufData(2, &md, sizeof(xl_btree_metadata)); @@ -721,7 +741,8 @@ _bt_getrootheight(Relation rel) * pg_upgrade'd from Postgres 12. */ void -_bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage) +_bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage, + bool *prefix_compression) { BTMetaPageData *metad; @@ -741,6 +762,7 @@ _bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage) { *heapkeyspace = metad->btm_version > BTREE_NOVAC_VERSION; *allequalimage = metad->btm_allequalimage; + *prefix_compression = metad->btm_prefix_compression; _bt_relbuf(rel, metabuf); return; @@ -769,10 +791,15 @@ _bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage) Assert(metad->btm_version <= BTREE_VERSION); Assert(!metad->btm_allequalimage || metad->btm_version > BTREE_NOVAC_VERSION); + Assert(!metad->btm_prefix_compression || + metad->btm_version > BTREE_NOVAC_VERSION); + /* Mutually exclusive with deduplication */ + Assert(!(metad->btm_allequalimage && metad->btm_prefix_compression)); Assert(metad->btm_fastroot != P_NONE); *heapkeyspace = metad->btm_version > BTREE_NOVAC_VERSION; *allequalimage = metad->btm_allequalimage; + *prefix_compression = metad->btm_prefix_compression; } /* @@ -1170,6 +1197,8 @@ _bt_delitems_vacuum(Relation rel, Buffer buf, Size updatedbuflen = 0; OffsetNumber updatedoffsets[MaxIndexTuplesPerPage]; XLogRecPtr recptr; + BTPrefixPromotion promotions[MaxIndexTuplesPerPage]; + int nprom = 0; /* Shouldn't be called unless there's something to do */ Assert(ndeletable > 0 || nupdatable > 0); @@ -1180,6 +1209,28 @@ _bt_delitems_vacuum(Relation rel, Buffer buf, updatedoffsets, &updatedbuflen, needswal); + /* + * Plan survivor-follower promotions outside the critical section, so + * _bt_prefix_reconstruct's palloc()s are safe. The resulting + * 'promotions' array carries palloc'd plain tuples that we'll overwrite + * in place inside the critical section below. The plan records + * post-delete offsets: the apply step runs *after* PageIndexMultiDelete. + */ + if (ndeletable > 0) + nprom = _bt_prefix_vacuum_plan(rel, page, deletable, ndeletable, + promotions); + + /* + * Prefix compression and posting-list dedup are mutually exclusive at the + * index level (btm_prefix_compression ⇒ !btm_allequalimage), so a page + * that produced any promotions cannot also carry posting-tuple updates. + * That lets us safely reorder: promotions must run *after* + * PageIndexMultiDelete (so the deleted anchors' bytes are available to + * grow each survivor into its plain form), while posting updates keep + * their historical pre-delete slot for pages without promotions. + */ + Assert(nprom == 0 || nupdatable == 0); + /* No ereport(ERROR) until changes are logged */ START_CRIT_SECTION(); @@ -1212,6 +1263,15 @@ _bt_delitems_vacuum(Relation rel, Buffer buf, if (ndeletable > 0) PageIndexMultiDelete(page, deletable, ndeletable); + /* + * Promote survivor followers to plain anchors now that the original + * anchors' bytes have been freed by PageIndexMultiDelete. The plan's + * recorded offsets are already adjusted for the post-delete line pointer + * shift. This ordering is what makes the PageIndexTupleOverwrite + * always-fit guarantee hold (see comment in _bt_prefix_vacuum_apply). + */ + _bt_prefix_vacuum_apply(page, promotions, nprom); + /* * We can clear the vacuum cycle ID since this page has certainly been * processed by the current vacuum scan. @@ -1235,12 +1295,20 @@ _bt_delitems_vacuum(Relation rel, Buffer buf, if (needswal) { xl_btree_vacuum xlrec_vacuum; + uint8 xlflags = REGBUF_STANDARD; xlrec_vacuum.ndeleted = ndeletable; xlrec_vacuum.nupdated = nupdatable; XLogBeginInsert(); - XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + /* + * When anchor promotion happened, the page's new layout is not + * recoverable from the ndeletable/nupdatable deltas alone; force a + * full page image so replay sees the post-promotion bytes. + */ + if (nprom > 0) + xlflags |= REGBUF_FORCE_IMAGE; + XLogRegisterBuffer(0, buf, xlflags); XLogRegisterData(&xlrec_vacuum, SizeOfBtreeVacuum); if (ndeletable > 0) @@ -1269,6 +1337,9 @@ _bt_delitems_vacuum(Relation rel, Buffer buf, /* free tuples allocated within _bt_delitems_update() */ for (int i = 0; i < nupdatable; i++) pfree(updatable[i]->itup); + /* free promoted tuples allocated within _bt_prefix_vacuum_plan() */ + for (int i = 0; i < nprom; i++) + pfree(promotions[i].plain); } /* @@ -1302,6 +1373,8 @@ _bt_delitems_delete(Relation rel, Buffer buf, Size updatedbuflen = 0; OffsetNumber updatedoffsets[MaxIndexTuplesPerPage]; XLogRecPtr recptr; + BTPrefixPromotion promotions[MaxIndexTuplesPerPage]; + int nprom = 0; /* Shouldn't be called unless there's something to do */ Assert(ndeletable > 0 || nupdatable > 0); @@ -1312,6 +1385,18 @@ _bt_delitems_delete(Relation rel, Buffer buf, updatedoffsets, &updatedbuflen, needswal); + /* + * Plan survivor-follower promotions outside the critical section; the + * plan records post-delete offsets and the apply step runs *after* + * PageIndexMultiDelete. + */ + if (ndeletable > 0) + nprom = _bt_prefix_vacuum_plan(rel, page, deletable, ndeletable, + promotions); + + /* Mutually exclusive -- see comment in _bt_delitems_vacuum. */ + Assert(nprom == 0 || nupdatable == 0); + /* No ereport(ERROR) until changes are logged */ START_CRIT_SECTION(); @@ -1332,6 +1417,9 @@ _bt_delitems_delete(Relation rel, Buffer buf, if (ndeletable > 0) PageIndexMultiDelete(page, deletable, ndeletable); + /* Delete-first ordering frees anchor bytes; promotions always fit now. */ + _bt_prefix_vacuum_apply(page, promotions, nprom); + /* * Unlike _bt_delitems_vacuum, we *must not* clear the vacuum cycle ID at * this point. The VACUUM command alone controls vacuum cycle IDs. @@ -1353,6 +1441,7 @@ _bt_delitems_delete(Relation rel, Buffer buf, if (needswal) { xl_btree_delete xlrec_delete; + uint8 xlflags = REGBUF_STANDARD; xlrec_delete.snapshotConflictHorizon = snapshotConflictHorizon; xlrec_delete.ndeleted = ndeletable; @@ -1360,7 +1449,9 @@ _bt_delitems_delete(Relation rel, Buffer buf, xlrec_delete.isCatalogRel = isCatalogRel; XLogBeginInsert(); - XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + if (nprom > 0) + xlflags |= REGBUF_FORCE_IMAGE; + XLogRegisterBuffer(0, buf, xlflags); XLogRegisterData(&xlrec_delete, SizeOfBtreeDelete); if (ndeletable > 0) @@ -1389,6 +1480,9 @@ _bt_delitems_delete(Relation rel, Buffer buf, /* free tuples allocated within _bt_delitems_update() */ for (int i = 0; i < nupdatable; i++) pfree(updatable[i]->itup); + /* free promoted tuples allocated within _bt_prefix_vacuum_plan() */ + for (int i = 0; i < nprom; i++) + pfree(promotions[i].plain); } /* @@ -1588,8 +1682,23 @@ _bt_delitems_delete_check(Relation rel, Buffer buf, Relation heapRel, if (!BTreeTupleIsPosting(itup)) { - /* Plain non-pivot tuple */ - Assert(ItemPointerEquals(&itup->t_tid, &delstate->deltids[i].tid)); + /* + * Plain non-pivot tuple (or a prefix-compressed follower). Deltids + * entries were populated by the collector paths with decoded heap + * TIDs, so compare against the follower's decoded TID rather than + * the raw t_tid bytes (whose upper nibble carries BT_IS_COMPRESSED). + */ +#ifdef USE_ASSERT_CHECKING + { + ItemPointerData htid; + + if (BTreeTupleIsCompressed(itup)) + BTreeCompressedTupleGetHeapTID(itup, &htid); + else + htid = itup->t_tid; + Assert(ItemPointerEquals(&htid, &delstate->deltids[i].tid)); + } +#endif if (dstatus->knowndeletable) deletable[ndeletable++] = idxoffnum; continue; @@ -2732,6 +2841,7 @@ _bt_unlink_halfdead_page(Relation rel, Buffer leafbuf, BlockNumber scanblkno, xlmeta.fastlevel = metad->btm_fastlevel; xlmeta.last_cleanup_num_delpages = metad->btm_last_cleanup_num_delpages; xlmeta.allequalimage = metad->btm_allequalimage; + xlmeta.prefix_compression = metad->btm_prefix_compression; XLogRegisterBufData(4, &xlmeta, sizeof(xl_btree_metadata)); xlinfo = XLOG_BTREE_UNLINK_PAGE_META; diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c new file mode 100644 index 000000000..a480aa6cf --- /dev/null +++ b/src/backend/access/nbtree/nbtprefix.c @@ -0,0 +1,605 @@ +/*------------------------------------------------------------------------- + * + * nbtprefix.c + * Prefix-compressed leaf-page support for btrees. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/nbtree/nbtprefix.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/htup_details.h" +#include "access/nbtree.h" +#include "utils/lsyscache.h" +#include "utils/rel.h" + +/* + * MaxHeapTuplesPerPage must fit in BT_OFFSET_MASK so that a compressed + * follower's real heap-tuple offset can be packed into the low 12 bits of + * t_tid's offset field, leaving BT_IS_COMPRESSED in the upper nibble. + */ +StaticAssertDecl(MaxHeapTuplesPerPage <= BT_OFFSET_MASK, + "MaxHeapTuplesPerPage must fit in BT_OFFSET_MASK"); + +/* Internal helper also exposed via _bt_prefix_build_follower_tupdesc below */ +static TupleDesc build_follower_tupdesc(Relation rel, int natts_anchor); + + +/* + * _bt_prefix_state_init() -- begin walking a prefix-compressed leaf page. + * + * Must be paired with _bt_prefix_state_cleanup() once walking is finished + * (or before the frame owning 'st' is destroyed). It is legal to call + * _bt_prefix_state_init() again on the same state -- the previous state is + * cleaned up implicitly. + */ +void +_bt_prefix_state_init(BTPrefixPageState *st, Page page) +{ + _bt_prefix_state_cleanup(st); + st->page = page; + st->anchor_off = InvalidOffsetNumber; + st->anchor = NULL; + st->natts_anchor = 0; + st->anchor_deformed = false; + st->next_anchor_off = InvalidOffsetNumber; + st->follower_tupdesc = NULL; +} + +/* + * _bt_prefix_state_cleanup() -- release any per-page caches held by state. + */ +void +_bt_prefix_state_cleanup(BTPrefixPageState *st) +{ + if (st->follower_tupdesc != NULL) + { + FreeTupleDesc(st->follower_tupdesc); + st->follower_tupdesc = NULL; + } + st->page = NULL; + st->anchor_off = InvalidOffsetNumber; + st->anchor = NULL; + st->anchor_deformed = false; + st->next_anchor_off = InvalidOffsetNumber; +} + +/* + * _bt_prefix_state_set_anchor() -- mark anchor_off as the current anchor. + * + * The tuple at anchor_off must be a plain (non-compressed, non-pivot, + * non-posting) leaf tuple. The anchor's Datums/isnull arrays are NOT + * deformed here; deformation is deferred to first use in _bt_prefix_form_tuple + * so that anchors without any followers pay no reconstruction cost. + */ +void +_bt_prefix_state_set_anchor(BTPrefixPageState *st, OffsetNumber anchor_off) +{ + IndexTuple itup; + + Assert(st->page != NULL); + Assert(anchor_off >= FirstOffsetNumber); + Assert(anchor_off <= PageGetMaxOffsetNumber(st->page)); + + itup = (IndexTuple) PageGetItem(st->page, PageGetItemId(st->page, anchor_off)); + Assert(!BTreeTupleIsCompressed(itup)); + Assert(!BTreeTupleIsPivot(itup) || anchor_off == P_HIKEY); + Assert(!BTreeTupleIsPosting(itup)); + + st->anchor_off = anchor_off; + st->anchor = itup; + st->anchor_deformed = false; + st->next_anchor_off = InvalidOffsetNumber; +} + +/* + * _bt_prefix_find_anchor() -- walk left from offnum to find the governing + * anchor tuple on the same page. + * + * Returns the offset of the anchor, or InvalidOffsetNumber if no anchor is + * found before the start of the data area (which would indicate corruption). + * The item at offnum itself is examined first, so callers can use this to + * resolve any item to its anchor. + */ +OffsetNumber +_bt_prefix_find_anchor(Page page, OffsetNumber offnum) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber firstdata = P_FIRSTDATAKEY(opaque); + OffsetNumber off; + + Assert(P_ISLEAF(opaque)); + Assert(P_HAS_PREFIX_COMPRESSION(opaque)); + Assert(offnum >= firstdata); + + for (off = offnum; off >= firstdata; off--) + { + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + + if (!BTreeTupleIsCompressed(itup)) + return off; + } + + return InvalidOffsetNumber; +} + +/* + * build_follower_tupdesc() -- construct a trailing-attrs TupleDesc. + * + * The returned TupleDesc covers attrs (natts_anchor+1 .. natts) of the + * relation's tupdesc, renumbered to start at 1. This matches the on-disk + * layout of a compressed follower. Caller owns the returned TupleDesc and + * must free it with FreeTupleDesc(). + */ +static TupleDesc +build_follower_tupdesc(Relation rel, int natts_anchor) +{ + TupleDesc src = RelationGetDescr(rel); + int natts = src->natts; + int follower_natts = natts - natts_anchor; + TupleDesc desc; + + Assert(natts_anchor > 0); + Assert(follower_natts > 0); + + desc = CreateTemplateTupleDesc(follower_natts); + + memcpy(TupleDescAttr(desc, 0), + TupleDescAttr(src, natts_anchor), + follower_natts * sizeof(FormData_pg_attribute)); + + /* + * Renumber attnums so they run 1..follower_natts. Attribute metadata + * otherwise matches the source (including typlen, typalign, attoptions). + */ + for (int i = 0; i < follower_natts; i++) + { + Form_pg_attribute att = TupleDescAttr(desc, i); + + att->attnum = i + 1; + /* Constraint metadata is not copied and not needed for deform/form */ + att->attnotnull = false; + att->atthasdef = false; + att->atthasmissing = false; + att->attidentity = '\0'; + att->attgenerated = '\0'; + + populate_compact_attribute(desc, i); + } + + desc->tdtypeid = src->tdtypeid; + desc->tdtypmod = src->tdtypmod; + + TupleDescFinalize(desc); + + return desc; +} + +/* + * _bt_prefix_form_tuple() -- materialize a compressed follower. + * + * Combines the leading attrs from the state's current anchor with the + * trailing attrs from 'compressed' to produce a plain-looking, fully formed + * IndexTuple. The follower's real heap TID (extracted from the low 12 bits + * of its t_tid offset field) is written into the result's t_tid. + * + * If 'dstbuf' is non-NULL it is used as the output buffer (caller must + * ensure it is at least BTMaxItemSize bytes). Otherwise the returned tuple + * is palloc()'d in CurrentMemoryContext and caller is responsible for + * pfree()ing it. + * + * Requires that _bt_prefix_state_set_anchor() has been called for the + * governing anchor of 'compressed'. + */ +IndexTuple +_bt_prefix_form_tuple(Relation rel, BTPrefixPageState *st, + IndexTuple compressed, char *dstbuf) +{ + TupleDesc tupdesc = RelationGetDescr(rel); + int natts = tupdesc->natts; + int natts_anchor = st->natts_anchor; + int follower_natts = natts - natts_anchor; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + Datum trail_values[INDEX_MAX_KEYS]; + bool trail_isnull[INDEX_MAX_KEYS]; + IndexTuple result; + ItemPointerData heap_tid; + + Assert(BTreeTupleIsCompressed(compressed)); + Assert(st->anchor != NULL); + Assert(natts_anchor > 0 && natts_anchor < natts); + + /* Deform the anchor once per anchor-group */ + if (!st->anchor_deformed) + { + index_deform_tuple(st->anchor, tupdesc, + st->anchor_values, st->anchor_isnull); + st->anchor_deformed = true; + } + + /* + * Build (or reuse) the trailing-attrs TupleDesc; the on-disk layout of a + * follower matches this shape exactly. + */ + if (st->follower_tupdesc == NULL) + st->follower_tupdesc = build_follower_tupdesc(rel, natts_anchor); + + index_deform_tuple(compressed, st->follower_tupdesc, + trail_values, trail_isnull); + + /* Merge leading (anchor) + trailing (follower) into one Datum array */ + memcpy(values, st->anchor_values, natts_anchor * sizeof(Datum)); + memcpy(isnull, st->anchor_isnull, natts_anchor * sizeof(bool)); + memcpy(values + natts_anchor, trail_values, follower_natts * sizeof(Datum)); + memcpy(isnull + natts_anchor, trail_isnull, follower_natts * sizeof(bool)); + + result = index_form_tuple(tupdesc, values, isnull); + + /* Patch heap TID: compressed tuple's real heap TID lives in t_tid */ + BTreeCompressedTupleGetHeapTID(compressed, &heap_tid); + result->t_tid = heap_tid; + + if (dstbuf != NULL) + { + Size sz = IndexTupleSize(result); + + memcpy(dstbuf, result, sz); + pfree(result); + return (IndexTuple) dstbuf; + } + + return result; +} + +/* + * _bt_prefix_leading_equalimage() -- is the index eligible for prefix + * compression? + * + * True when the index has >= 2 key attrs and each of the leading + * (nkeyatts - 1) key columns is "equalimage" -- i.e. byte-equality of a + * stored attr is a sound proxy for opclass equality. That is what lets us + * inherit the anchor's leading attrs verbatim for every compressed follower + * in the group without collating or round-tripping the values. + * + * INCLUDE (non-key) attributes do not participate in the prefix and are + * never checked here. The trailing key attribute also isn't checked: it is + * always stored in full on every follower. + */ +bool +_bt_prefix_leading_equalimage(Relation rel) +{ + int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel); + + if (nkeyatts < 2) + return false; + + for (int i = 0; i < nkeyatts - 1; i++) + { + Oid opfamily = rel->rd_opfamily[i]; + Oid opcintype = rel->rd_opcintype[i]; + Oid collation = rel->rd_indcollation[i]; + Oid equalimageproc; + + equalimageproc = get_opfamily_proc(opfamily, opcintype, opcintype, + BTEQUALIMAGE_PROC); + if (!OidIsValid(equalimageproc) || + !DatumGetBool(OidFunctionCall1Coll(equalimageproc, collation, + ObjectIdGetDatum(opcintype)))) + return false; + } + + return true; +} + +/* + * _bt_prefix_build_follower_tupdesc() -- public wrapper for the internal + * follower-tupdesc builder. See build_follower_tupdesc() for details. + * + * Returned TupleDesc covers attrs (natts_anchor+1 .. natts), renumbered to + * start at 1. Caller owns the TupleDesc and must free it with + * FreeTupleDesc(). + */ +TupleDesc +_bt_prefix_build_follower_tupdesc(Relation rel, int natts_anchor) +{ + return build_follower_tupdesc(rel, natts_anchor); +} + +/* + * _bt_prefix_compress_new_tuple() -- build a compressed follower IndexTuple + * from a sorted/insert plain tuple that is known to share the anchor's + * leading natts_anchor key attrs. + * + * full_tupdesc is RelationGetDescr(rel), follower_tupdesc covers just the + * trailing attrs (built once by the caller via + * _bt_prefix_build_follower_tupdesc). The returned IndexTuple is palloc'd + * (or written to dstbuf when non-NULL). Its t_tid carries BT_IS_COMPRESSED + * in the upper nibble and the real heap-tuple offset in the low 12 bits. + */ +IndexTuple +_bt_prefix_compress_new_tuple(TupleDesc full_tupdesc, + TupleDesc follower_tupdesc, + int natts_anchor, + IndexTuple new_plain, + char *dstbuf) +{ + int natts = full_tupdesc->natts; + int follower_natts = natts - natts_anchor; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + Datum trail_values[INDEX_MAX_KEYS]; + bool trail_isnull[INDEX_MAX_KEYS]; + IndexTuple result; + + Assert(natts_anchor > 0 && natts_anchor < natts); + Assert(!BTreeTupleIsPivot(new_plain)); + Assert(!BTreeTupleIsPosting(new_plain)); + Assert(!BTreeTupleIsCompressed(new_plain)); + Assert(follower_tupdesc->natts == follower_natts); + + /* + * Deform new_plain once with the full tupdesc, then repack only the + * trailing attrs through the follower tupdesc. This is a tiny bit of + * extra work vs. walking just the trailing attrs, but it keeps us honest + * about NULL bitmap semantics and handles toasting/compression through + * index_form_tuple uniformly. + */ + index_deform_tuple(new_plain, full_tupdesc, values, isnull); + + memcpy(trail_values, values + natts_anchor, follower_natts * sizeof(Datum)); + memcpy(trail_isnull, isnull + natts_anchor, follower_natts * sizeof(bool)); + + result = index_form_tuple(follower_tupdesc, trail_values, trail_isnull); + + /* + * Patch t_tid to carry the real heap TID plus BT_IS_COMPRESSED. Caller + * passes new_plain's heap TID through new_plain->t_tid (non-ALT form), + * which we copy verbatim before setting the status bit. + */ + BTreeCompressedTupleSetHeapTID(result, &new_plain->t_tid); + + if (dstbuf != NULL) + { + Size sz = IndexTupleSize(result); + + memcpy(dstbuf, result, sz); + pfree(result); + return (IndexTuple) dstbuf; + } + + return result; +} + +/* + * _bt_prefix_vacuum_plan() -- plan survivor-follower promotions before VACUUM + * applies 'deletable' to a page. + * + * Walks deletable[] and, for each plain (anchor) tuple that still has at + * least one surviving compressed follower (one not also in deletable), + * reconstructs that follower's plain form into a freshly-palloc'd + * IndexTuple. The returned state describes which offsets must be + * PageIndexTupleOverwrite()-written before the main PageIndexMultiDelete + * runs. + * + * This runs OUTSIDE the vacuum path's critical section: the palloc()s inside + * _bt_prefix_reconstruct() are disallowed inside a critsec. The subsequent + * _bt_prefix_vacuum_apply() performs only MAXALIGN-safe memcpy-sized work + * and is safe to call inside the critsec. + * + * Caller-provided 'promotions' must have room for at least ndeletable + * entries. Returns the number of planned promotions. The palloc'd + * IndexTuple pointers inside 'promotions' must be pfreed by the caller after + * the critsec ends. + * + * 'deletable' must be sorted ascending (PostgreSQL's vacuum code already + * guarantees this). + */ +int +_bt_prefix_vacuum_plan(Relation rel, Page page, + const OffsetNumber *deletable, int ndeletable, + BTPrefixPromotion *promotions) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + int nprom = 0; + + if (!P_ISLEAF(opaque) || !P_HAS_PREFIX_COMPRESSION(opaque)) + return 0; + + for (int i = 0; i < ndeletable; i++) + { + OffsetNumber anchor_off = deletable[i]; + IndexTuple anchor; + OffsetNumber f; + OffsetNumber survivor = InvalidOffsetNumber; + BTPrefixPageState rst; + IndexTuple plain; + int precedingdeletes = 0; + + if (anchor_off < P_FIRSTDATAKEY(opaque) || anchor_off > maxoff) + continue; + + anchor = (IndexTuple) PageGetItem(page, PageGetItemId(page, anchor_off)); + /* + * Only plain (non-compressed, non-pivot, non-posting) tuples on a + * prefix-compressed leaf page can be anchors. + */ + if (BTreeTupleIsCompressed(anchor) || BTreeTupleIsPivot(anchor) || + BTreeTupleIsPosting(anchor)) + continue; + + /* + * Scan forward from the anchor looking for the first compressed + * follower that is NOT itself scheduled for deletion. Stop at the + * next plain tuple (group boundary). + */ + for (f = OffsetNumberNext(anchor_off); f <= maxoff; + f = OffsetNumberNext(f)) + { + IndexTuple item = (IndexTuple) PageGetItem(page, + PageGetItemId(page, f)); + bool in_deletable = false; + + if (!BTreeTupleIsCompressed(item)) + break; /* group boundary */ + + for (int j = i + 1; j < ndeletable && deletable[j] <= f; j++) + { + if (deletable[j] == f) + { + in_deletable = true; + break; + } + } + if (!in_deletable) + { + survivor = f; + break; + } + } + + if (survivor == InvalidOffsetNumber) + continue; + + /* Reconstruct into its own palloc'd IndexTuple */ + memset(&rst, 0, sizeof(rst)); + _bt_prefix_state_init(&rst, page); + plain = _bt_prefix_reconstruct(rel, &rst, survivor, NULL); + _bt_prefix_state_cleanup(&rst); + + /* + * Record how many deletable[] offsets are strictly less than the + * survivor's offset. After PageIndexMultiDelete() runs, the + * survivor's offset shifts down by exactly that count -- we need + * the post-delete offset to do the promotion PageIndexTupleOverwrite. + * + * Apply-time ordering (delete-first) is what makes promotion + * always fit on the page: deleting the anchor releases at least + * plain_sz - follower_sz bytes (the plain form is the full schema + * the anchor had; the follower only stored the trailing attr), so + * the subsequent overwrite can never run out of space. + */ + for (int j = 0; j < ndeletable; j++) + { + if (deletable[j] < survivor) + precedingdeletes++; + else + break; + } + + promotions[nprom].off = survivor - precedingdeletes; + promotions[nprom].plain = plain; + promotions[nprom].plainsz = MAXALIGN(IndexTupleSize(plain)); + nprom++; + } + + return nprom; +} + +/* + * _bt_prefix_vacuum_apply() -- apply the promotions planned by + * _bt_prefix_vacuum_plan(). + * + * Must be called *after* PageIndexMultiDelete() has applied the deletable[] + * list, so that the offsets recorded in promotions[] already reflect the + * post-delete line-pointer shift. Delete-first ordering is what makes this + * routine's space use always-fit by construction, as follows. + * + * Each promotion grows one follower from size F to plain size P, where + * P = header + prefix_keys + suffix_keys + follower_include, and + * F = header + suffix_keys + follower_include. + * So the growth is (P - F) = prefix_keys_size (within alignment slop). The + * anchor whose deletion motivated this promotion had size + * A = header + prefix_keys + suffix_keys + anchor_include >= prefix_keys, + * so PageIndexMultiDelete() has already freed at least (P - F) bytes per + * promotion. Across multiple promotions on the same page the accounting + * sums linearly (each promotion has its own deleted anchor contributing + * enough bytes), so PageIndexTupleOverwrite() cannot run out of space. + * + * Hence failure here would indicate either (a) an invariant break in the + * planner (e.g. a promotion scheduled without its anchor actually being + * deleted), or (b) a broken page. We PANIC because we are inside a critical + * section and past the point of no return. + * + * Safe to call inside a critical section: the palloc()s that build + * promotions[i].plain happen in the planner, outside the critsec. + */ +void +_bt_prefix_vacuum_apply(Page page, BTPrefixPromotion *promotions, int nprom) +{ + for (int i = 0; i < nprom; i++) + { + if (!PageIndexTupleOverwrite(page, promotions[i].off, + promotions[i].plain, + promotions[i].plainsz)) + elog(PANIC, + "failed to promote compressed follower to anchor at offset %u", + promotions[i].off); + } +} + +/* + * _bt_prefix_reconstruct() -- return a plain-looking IndexTuple for offnum. + * + * If the item at offnum is a plain (non-compressed) tuple, the raw pointer + * from PageGetItem() is returned unchanged. If it is a compressed follower, + * this function resolves its governing anchor (updating the state as needed) + * and materializes a full tuple via _bt_prefix_form_tuple(). + * + * 'dstbuf' behaves as described for _bt_prefix_form_tuple(). + */ +IndexTuple +_bt_prefix_reconstruct(Relation rel, BTPrefixPageState *st, + OffsetNumber offnum, char *dstbuf) +{ + IndexTuple raw; + + Assert(st->page != NULL); + Assert(offnum >= FirstOffsetNumber); + Assert(offnum <= PageGetMaxOffsetNumber(st->page)); + + raw = (IndexTuple) PageGetItem(st->page, PageGetItemId(st->page, offnum)); + + if (!BTreeTupleIsCompressed(raw)) + { + /* + * Plain anchor (or high key, or internal pivot if caller misused the + * helper). Opportunistically refresh the state so subsequent + * followers in the same group can skip the find-anchor walk. + */ + if (!BTreeTupleIsPivot(raw) && !BTreeTupleIsPosting(raw) && + offnum != P_HIKEY) + _bt_prefix_state_set_anchor(st, offnum); + return raw; + } + + /* Compressed follower: ensure we have the governing anchor loaded */ + if (st->anchor_off == InvalidOffsetNumber || + offnum < st->anchor_off || + (st->next_anchor_off != InvalidOffsetNumber && + offnum >= st->next_anchor_off)) + { + OffsetNumber aoff = _bt_prefix_find_anchor(st->page, offnum); + + if (aoff == InvalidOffsetNumber) + elog(ERROR, "no prefix anchor for compressed tuple at offset %u", + offnum); + _bt_prefix_state_set_anchor(st, aoff); + } + + /* + * Pick up natts_anchor from the relation's schema. For v1, the value is + * a per-index constant (= nkeyatts - 1) and the state's natts_anchor is + * set here; a future version that records natts_anchor per-anchor can + * read it from the anchor itself. + */ + st->natts_anchor = IndexRelationGetNumberOfKeyAttributes(rel) - 1; + + return _bt_prefix_form_tuple(rel, st, raw, dstbuf); +} diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c index 55722fd87..052572f05 100644 --- a/src/backend/access/nbtree/nbtreadpage.c +++ b/src/backend/access/nbtree/nbtreadpage.c @@ -145,6 +145,9 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, ignore_killed_tuples = scan->ignore_killed_tuples; int itemIndex, indnatts; + bool prefix_page; + BTPrefixPageState prefix_state; + char *rbuf = NULL; /* save the page/buffer block number, along with its sibling links */ page = BufferGetPage(so->currPos.buf); @@ -169,6 +172,20 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, minoff = P_FIRSTDATAKEY(opaque); maxoff = PageGetMaxOffsetNumber(page); + /* + * If the leaf uses prefix compression we must materialize compressed + * followers into full IndexTuples before passing them to _bt_checkkeys + * and _bt_saveitem. The state and scratch buffer live for the duration + * of this _bt_readpage call. + */ + prefix_page = P_HAS_PREFIX_COMPRESSION(opaque); + if (prefix_page) + { + memset(&prefix_state, 0, sizeof(prefix_state)); + _bt_prefix_state_init(&prefix_state, page); + rbuf = palloc(BTMaxItemSize); + } + /* initialize page-level state that we'll pass to _bt_checkkeys */ pstate.minoff = minoff; pstate.maxoff = maxoff; @@ -244,16 +261,36 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, /* * If the scan specifies not to return killed tuples, then we - * treat a killed tuple as not passing the qual + * treat a killed tuple as not passing the qual. + * + * On a prefix-compressed leaf we must still track prefix-group + * state across LP_DEAD plain tuples: a dead plain tuple is an + * anchor for the group that physically follows it on the page, + * so surviving compressed followers below must continue to + * reconstruct under *it*, not under some earlier anchor whose + * keys are different. Silently skipping the anchor would leave + * st->anchor_off pointing at a stale (earlier) anchor and give + * every subsequent follower the wrong leading attrs. */ if (ignore_killed_tuples && ItemIdIsDead(iid)) { + if (prefix_page) + { + IndexTuple deaditup = (IndexTuple) PageGetItem(page, iid); + + if (!BTreeTupleIsCompressed(deaditup) && + !BTreeTupleIsPivot(deaditup) && + !BTreeTupleIsPosting(deaditup)) + _bt_prefix_state_set_anchor(&prefix_state, offnum); + } offnum = OffsetNumberNext(offnum); continue; } itup = (IndexTuple) PageGetItem(page, iid); Assert(!BTreeTupleIsPivot(itup)); + if (prefix_page) + itup = _bt_prefix_reconstruct(rel, &prefix_state, offnum, rbuf); pstate.offnum = offnum; passes_quals = _bt_checkkeys(scan, &pstate, arrayKeys, @@ -417,6 +454,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, itup = (IndexTuple) PageGetItem(page, iid); Assert(!BTreeTupleIsPivot(itup)); + if (prefix_page) + itup = _bt_prefix_reconstruct(rel, &prefix_state, offnum, rbuf); pstate.offnum = offnum; if (arrayKeys && offnum == minoff && pstate.forcenonrequired) @@ -524,6 +563,12 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, */ Assert(!pstate.forcenonrequired); + if (prefix_page) + { + _bt_prefix_state_cleanup(&prefix_state); + pfree(rbuf); + } + return (so->currPos.firstItem <= so->currPos.lastItem); } @@ -623,8 +668,45 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate) iid = PageGetItemId(pstate->page, pstate->maxoff); lasttup = (IndexTuple) PageGetItem(pstate->page, iid); - /* Determine the first attribute whose values change on caller's page */ - firstchangingattnum = _bt_keep_natts_fast(rel, firsttup, lasttup); + /* + * On a prefix-compressed leaf page either tuple may be a compressed + * follower that _bt_keep_natts_fast cannot consume. Reconstruct into + * scratch buffers so the comparison sees plain IndexTuples. The first + * data item on a prefix-compressed page is always an anchor, so firsttup + * in practice only needs reconstruction for defensiveness. + */ + if (P_HAS_PREFIX_COMPRESSION(BTPageGetOpaque(pstate->page))) + { + BTPrefixPageState prefix_st; + char *fbuf = NULL; + char *lbuf = NULL; + + memset(&prefix_st, 0, sizeof(prefix_st)); + _bt_prefix_state_init(&prefix_st, pstate->page); + if (BTreeTupleIsCompressed(firsttup)) + { + fbuf = palloc(BTMaxItemSize); + firsttup = _bt_prefix_reconstruct(rel, &prefix_st, + pstate->minoff, fbuf); + } + if (BTreeTupleIsCompressed(lasttup)) + { + lbuf = palloc(BTMaxItemSize); + lasttup = _bt_prefix_reconstruct(rel, &prefix_st, + pstate->maxoff, lbuf); + } + firstchangingattnum = _bt_keep_natts_fast(rel, firsttup, lasttup); + _bt_prefix_state_cleanup(&prefix_st); + if (fbuf) + pfree(fbuf); + if (lbuf) + pfree(lbuf); + } + else + { + /* Determine the first attribute whose values change on caller's page */ + firstchangingattnum = _bt_keep_natts_fast(rel, firsttup, lasttup); + } for (; startikey < so->numberOfKeys; startikey++) { @@ -1042,6 +1124,20 @@ _bt_saveitem(BTScanOpaque so, int itemIndex, if (so->currTuples) { Size itupsz = IndexTupleSize(itup); + Size needed = so->currPos.nextTupleOffset + MAXALIGN(itupsz); + + if (needed > so->currTuplesSize) + { + /* + * Prefix-compressed pages can reconstruct to more bytes than the + * on-disk page holds, so the initial BLCKSZ-sized workspace can + * overflow. Grow geometrically to amortize future growth. + */ + Size newsize = Max(so->currTuplesSize * 2, needed); + + so->currTuples = (char *) repalloc(so->currTuples, newsize); + so->currTuplesSize = newsize; + } currItem->tupleOffset = so->currPos.nextTupleOffset; memcpy(so->currTuples + so->currPos.nextTupleOffset, itup, itupsz); @@ -2088,6 +2184,18 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate, aheadoffnum = Max((int) pstate->minoff, (int) pstate->offnum - pstate->targetdistance); + /* + * Disable the look-ahead optimization on prefix-compressed leaves. A + * follower at aheadoffnum has its leading-attr bytes on another item on + * the page (its governing anchor), and the array-key comparison below + * needs those leading attrs to decide correctness. Reconstructing the + * look-ahead tuple is possible but tricky to get right in all edge + * cases; simply skipping the optimization here is safe and only costs + * us the speculative skip opportunity. + */ + if (P_HAS_PREFIX_COMPRESSION(BTPageGetOpaque(pstate->page))) + return; + ahead = (IndexTuple) PageGetItem(pstate->page, PageGetItemId(pstate->page, aheadoffnum)); if (_bt_tuple_before_array_skeys(scan, dir, ahead, tupdesc, tupnatts, diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c index 0abdd7b49..7afe5f909 100644 --- a/src/backend/access/nbtree/nbtree.c +++ b/src/backend/access/nbtree/nbtree.c @@ -183,14 +183,21 @@ void btbuildempty(Relation index) { bool allequalimage = _bt_allequalimage(index, false); + bool prefix_compression = _bt_prefix_leading_equalimage(index); BulkWriteState *bulkstate; BulkWriteBuffer metabuf; bulkstate = smgr_bulk_start_rel(index, INIT_FORKNUM); - /* Construct metapage. */ + /* + * Construct metapage. allequalimage and prefix_compression are mutually + * exclusive; when prefix_compression is chosen, clear allequalimage + * (dedup is disabled index-wide for prefix-compressed indexes). + */ metabuf = smgr_bulk_get_buf(bulkstate); - _bt_initmetapage((Page) metabuf, P_NONE, 0, allequalimage); + _bt_initmetapage((Page) metabuf, P_NONE, 0, + allequalimage && !prefix_compression, + prefix_compression); smgr_bulk_write(bulkstate, BTREE_METAPAGE, metabuf, true); smgr_bulk_finish(bulkstate); @@ -373,6 +380,7 @@ btbeginscan(Relation rel, int nkeys, int norderbys) * scan->xs_itupdesc whether we'll need it or not, since that's so cheap. */ so->currTuples = so->markTuples = NULL; + so->currTuplesSize = so->markTuplesSize = 0; scan->xs_itupdesc = RelationGetDescr(rel); @@ -429,14 +437,17 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, /* * Allocate tuple workspace arrays, if needed for an index-only scan and - * not already done in a previous rescan call. To save on palloc - * overhead, both workspaces are allocated as one palloc block; only this - * function and btendscan know that. + * not already done in a previous rescan call. Each workspace must hold + * a full page's worth of tuples; BLCKSZ is the natural starting size, but + * prefix compression can produce reconstructed pages larger than BLCKSZ, + * so we allow _bt_ensure_tuple_workspace() to grow the buffers on demand. */ if (scan->xs_want_itup && so->currTuples == NULL) { - so->currTuples = (char *) palloc(BLCKSZ * 2); - so->markTuples = so->currTuples + BLCKSZ; + so->currTuples = (char *) palloc(BLCKSZ); + so->markTuples = (char *) palloc(BLCKSZ); + so->currTuplesSize = BLCKSZ; + so->markTuplesSize = BLCKSZ; } /* @@ -480,7 +491,8 @@ btendscan(IndexScanDesc scan) pfree(so->killedItems); if (so->currTuples != NULL) pfree(so->currTuples); - /* so->markTuples should not be pfree'd, see btrescan */ + if (so->markTuples != NULL) + pfree(so->markTuples); pfree(so); } @@ -554,8 +566,16 @@ btrestrpos(IndexScanDesc scan) offsetof(BTScanPosData, items[1]) + so->markPos.lastItem * sizeof(BTScanPosItem)); if (so->currTuples) + { + if (so->currTuplesSize < so->markPos.nextTupleOffset) + { + so->currTuples = (char *) repalloc(so->currTuples, + so->markPos.nextTupleOffset); + so->currTuplesSize = so->markPos.nextTupleOffset; + } memcpy(so->currTuples, so->markTuples, so->markPos.nextTupleOffset); + } /* Reset the scan's array keys (see _bt_steppage for why) */ if (so->numArrayKeys) { @@ -1575,8 +1595,27 @@ backtrack: Assert(!BTreeTupleIsPivot(itup)); if (!BTreeTupleIsPosting(itup)) { - /* Regular tuple, standard table TID representation */ - if (callback(&itup->t_tid, callback_state)) + ItemPointerData htidbuf; + ItemPointer htid; + + /* + * Regular tuple, standard table TID representation -- but + * if the tuple is a prefix-compressed follower, t_tid + * packs BT_IS_COMPRESSED in its upper nibble and the real + * heap offset in the low 12 bits. Feeding the raw bytes + * to the VACUUM callback would never match any dead heap + * TID and we'd silently leak the index entry; decode to + * a scratch ItemPointerData first. + */ + if (P_HAS_PREFIX_COMPRESSION(opaque) && BTreeTupleIsCompressed(itup)) + { + BTreeCompressedTupleGetHeapTID(itup, &htidbuf); + htid = &htidbuf; + } + else + htid = &itup->t_tid; + + if (callback(htid, callback_state)) { deletable[ndeletable++] = offnum; nhtidsdead++; diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index 61f63251c..306385a5e 100644 --- a/src/backend/access/nbtree/nbtsearch.c +++ b/src/backend/access/nbtree/nbtsearch.c @@ -701,6 +701,9 @@ _bt_compare(Relation rel, int ncmpkey; int ntupatts; int32 result; + BTPrefixPageState prefix_st; + bool prefix_st_init = false; + char *scratch = NULL; Assert(_bt_check_natts(rel, key->heapkeyspace, page, offnum)); Assert(key->keysz <= IndexRelationGetNumberOfKeyAttributes(rel)); @@ -714,6 +717,27 @@ _bt_compare(Relation rel, return 1; itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)); + + /* + * On a prefix-compressed leaf page a follower's leading attrs live on the + * governing anchor. Reconstruct the tuple into a scratch buffer so the + * rest of this function can treat it as an ordinary IndexTuple. For + * compressed tuples the caller-visible heap TID also requires decoding; + * we do that below via BTreeCompressedTupleGetHeapTID(). + */ + if (P_ISLEAF(opaque) && BTreeTupleIsCompressed(itup)) + { + memset(&prefix_st, 0, sizeof(prefix_st)); + _bt_prefix_state_init(&prefix_st, page); + prefix_st_init = true; + scratch = palloc(BTMaxItemSize); + /* + * _bt_prefix_reconstruct returns a plain-looking IndexTuple whose + * t_tid is already demasked into a real heap TID. + */ + itup = _bt_prefix_reconstruct(rel, &prefix_st, offnum, scratch); + } + ntupatts = BTreeTupleGetNAtts(itup, rel); /* @@ -776,7 +800,7 @@ _bt_compare(Relation rel, /* if the keys are unequal, return the difference */ if (result != 0) - return result; + goto done; scankey++; } @@ -791,7 +815,10 @@ _bt_compare(Relation rel, * necessary. */ if (key->keysz > ntupatts) - return 1; + { + result = 1; + goto done; + } /* * Use the heap TID attribute and scantid to try to break the tie. The @@ -826,10 +853,14 @@ _bt_compare(Relation rel, */ if (!key->backward && key->keysz == ntupatts && heapTid == NULL && key->heapkeyspace) - return 1; + { + result = 1; + goto done; + } /* All provided scankey arguments found to be equal */ - return 0; + result = 0; + goto done; } /* @@ -838,7 +869,10 @@ _bt_compare(Relation rel, */ Assert(key->keysz == IndexRelationGetNumberOfKeyAttributes(rel)); if (heapTid == NULL) - return 1; + { + result = 1; + goto done; + } /* * Scankey must be treated as equal to a posting list tuple if its scantid @@ -849,16 +883,27 @@ _bt_compare(Relation rel, Assert(ntupatts >= IndexRelationGetNumberOfKeyAttributes(rel)); result = ItemPointerCompare(key->scantid, heapTid); if (result <= 0 || !BTreeTupleIsPosting(itup)) - return result; + goto done; else { result = ItemPointerCompare(key->scantid, BTreeTupleGetMaxHeapTID(itup)); if (result > 0) - return 1; + { + result = 1; + goto done; + } } - return 0; + result = 0; + +done: + if (prefix_st_init) + { + _bt_prefix_state_cleanup(&prefix_st); + pfree(scratch); + } + return result; } /* @@ -1441,7 +1486,8 @@ _bt_first(IndexScanDesc scan, ScanDirection dir) * page _bt_search returns). *---------- */ - _bt_metaversion(rel, &inskey.heapkeyspace, &inskey.allequalimage); + _bt_metaversion(rel, &inskey.heapkeyspace, &inskey.allequalimage, + &inskey.prefix_compression); inskey.anynullkeys = false; /* unused */ inskey.scantid = NULL; inskey.keysz = keysz; @@ -1676,8 +1722,16 @@ _bt_steppage(IndexScanDesc scan, ScanDirection dir) offsetof(BTScanPosData, items[1]) + so->currPos.lastItem * sizeof(BTScanPosItem)); if (so->markTuples) + { + if (so->markTuplesSize < so->currPos.nextTupleOffset) + { + so->markTuples = (char *) repalloc(so->markTuples, + so->currPos.nextTupleOffset); + so->markTuplesSize = so->currPos.nextTupleOffset; + } memcpy(so->markTuples, so->currTuples, so->currPos.nextTupleOffset); + } so->markPos.itemIndex = so->markItemIndex; so->markItemIndex = -1; diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c index 756dfa3dc..8779bd5ad 100644 --- a/src/backend/access/nbtree/nbtsort.c +++ b/src/backend/access/nbtree/nbtsort.c @@ -242,6 +242,10 @@ typedef struct BTPageState uint32 btps_level; /* tree level (0 = leaf) */ Size btps_full; /* "full" if less than this much free space */ struct BTPageState *btps_next; /* link to parent level, if any */ + /* Prefix-compression bookkeeping (leaf level only) */ + bool btps_prefix_compression; /* produce compressed followers? */ + OffsetNumber btps_anchor_off; /* offset of current anchor on btps_buf */ + TupleDesc btps_follower_tupdesc; /* trailing-attrs desc, lazily built */ } BTPageState; /* @@ -254,6 +258,7 @@ typedef struct BTWriteState BulkWriteState *bulkstate; BTScanInsert inskey; /* generic insertion scankey */ BlockNumber btws_pages_alloced; /* # pages allocated */ + bool prefix_compression; /* emit prefix-compressed leaf pages? */ } BTWriteState; @@ -568,6 +573,16 @@ _bt_leafbuild(BTSpool *btspool, BTSpool *btspool2) /* _bt_mkscankey() won't set allequalimage without metapage */ wstate.inskey->allequalimage = _bt_allequalimage(wstate.index, true); + /* + * Decide whether this build produces prefix-compressed leaf pages. An + * index that qualifies is automatically opted in and opts out of posting + * list deduplication for the duration of its life (they are mutually + * exclusive on disk, so both metapage flags must never be true together). + */ + wstate.prefix_compression = _bt_prefix_leading_equalimage(wstate.index); + if (wstate.prefix_compression) + wstate.inskey->allequalimage = false; + /* reserve the metapage */ wstate.btws_pages_alloced = BTREE_METAPAGE + 1; @@ -673,6 +688,18 @@ _bt_pagestate(BTWriteState *wstate, uint32 level) /* no parent level, yet */ state->btps_next = NULL; + /* Prefix compression applies only at the leaf level */ + state->btps_prefix_compression = (level == 0 && wstate->prefix_compression); + state->btps_anchor_off = InvalidOffsetNumber; + state->btps_follower_tupdesc = NULL; + if (state->btps_prefix_compression) + { + /* Mark the initial leaf page */ + BTPageOpaque opq = BTPageGetOpaque((Page) state->btps_buf); + + opq->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION; + } + return state; } @@ -797,6 +824,9 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, Size pgspc; Size itupsz; bool isleaf; + IndexTuple emit_tup; /* tuple as it will be written (possibly compressed) */ + char *emit_scratch = NULL; /* scratch for compressed form */ + bool emit_is_compressed = false; /* * This is a handy place to check for cancel interrupts during the btree @@ -811,12 +841,49 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, last_truncextra = state->btps_lastextra; state->btps_lastextra = truncextra; - pgspc = PageGetFreeSpace(npage); - itupsz = IndexTupleSize(itup); - itupsz = MAXALIGN(itupsz); /* Leaf case has slightly different rules due to suffix truncation */ isleaf = (state->btps_level == 0); + /* + * If this leaf-level build is prefix-compressing and the incoming tuple + * shares the current anchor's leading (nkeyatts-1) key attrs, emit it as + * a compressed follower. Otherwise emit it plain (and record it as a + * new anchor after insertion). The first tuple on any page is always + * emitted plain, per invariant #1 (leftmost data item is always an + * anchor). + */ + emit_tup = itup; + if (isleaf && state->btps_prefix_compression && + last_off >= P_FIRSTKEY && + state->btps_anchor_off != InvalidOffsetNumber) + { + IndexTuple anchor; + int natts_anchor = + IndexRelationGetNumberOfKeyAttributes(wstate->index) - 1; + + anchor = (IndexTuple) PageGetItem(npage, + PageGetItemId(npage, + state->btps_anchor_off)); + if (_bt_keep_natts_fast(wstate->index, anchor, itup) > natts_anchor) + { + if (state->btps_follower_tupdesc == NULL) + state->btps_follower_tupdesc = + _bt_prefix_build_follower_tupdesc(wstate->index, + natts_anchor); + emit_scratch = palloc(BTMaxItemSize); + emit_tup = _bt_prefix_compress_new_tuple(RelationGetDescr(wstate->index), + state->btps_follower_tupdesc, + natts_anchor, + itup, + emit_scratch); + emit_is_compressed = true; + } + } + + pgspc = PageGetFreeSpace(npage); + itupsz = IndexTupleSize(emit_tup); + itupsz = MAXALIGN(itupsz); + /* * Check whether the new item can fit on a btree page on current level at * all. @@ -867,11 +934,21 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, ItemId ii; ItemId hii; IndexTuple oitup; + char *oitup_scratch = NULL; /* set when last item was compressed */ + Size oitup_sz; /* Create new page of same level */ nbuf = _bt_blnewpage(wstate, state->btps_level); npage = (Page) nbuf; + /* Carry BTP_HAS_PREFIX_COMPRESSION onto the new leaf page */ + if (isleaf && state->btps_prefix_compression) + { + BTPageOpaque nopq = BTPageGetOpaque(npage); + + nopq->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION; + } + /* and assign it a page position */ nblkno = wstate->btws_pages_alloced++; @@ -885,8 +962,31 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, Assert(last_off > P_FIRSTKEY); ii = PageGetItemId(opage, last_off); oitup = (IndexTuple) PageGetItem(opage, ii); - _bt_sortaddtup(npage, ItemIdGetLength(ii), oitup, P_FIRSTKEY, - !isleaf); + oitup_sz = ItemIdGetLength(ii); + + /* + * If the last-left tuple is a compressed follower, reconstruct it to + * its plain form for use both as npage's first data item (an anchor) + * and as _bt_truncate's firstright input. The opage high key below + * will be generated from the plain form too. + */ + if (isleaf && state->btps_prefix_compression && + BTreeTupleIsCompressed(oitup)) + { + BTPrefixPageState rst; + IndexTuple reconstructed; + + memset(&rst, 0, sizeof(rst)); + _bt_prefix_state_init(&rst, opage); + oitup_scratch = palloc(BTMaxItemSize); + reconstructed = _bt_prefix_reconstruct(wstate->index, &rst, + last_off, oitup_scratch); + _bt_prefix_state_cleanup(&rst); + oitup = reconstructed; + oitup_sz = IndexTupleSize(oitup); + } + + _bt_sortaddtup(npage, oitup_sz, oitup, P_FIRSTKEY, !isleaf); /* * Move 'last' into the high key position on opage. _bt_blnewpage() @@ -906,6 +1006,7 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, { IndexTuple lastleft; IndexTuple truncated; + char *lastleft_scratch = NULL; /* * Truncate away any unneeded attributes from high key on leaf @@ -933,18 +1034,44 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, ii = PageGetItemId(opage, OffsetNumberPrev(last_off)); lastleft = (IndexTuple) PageGetItem(opage, ii); + /* Reconstruct a compressed lastleft before handing it to _bt_truncate */ + if (state->btps_prefix_compression && + BTreeTupleIsCompressed(lastleft)) + { + BTPrefixPageState rst; + + memset(&rst, 0, sizeof(rst)); + _bt_prefix_state_init(&rst, opage); + lastleft_scratch = palloc(BTMaxItemSize); + lastleft = _bt_prefix_reconstruct(wstate->index, &rst, + OffsetNumberPrev(last_off), + lastleft_scratch); + _bt_prefix_state_cleanup(&rst); + } + Assert(IndexTupleSize(oitup) > last_truncextra); truncated = _bt_truncate(wstate->index, lastleft, oitup, wstate->inskey); if (!PageIndexTupleOverwrite(opage, P_HIKEY, truncated, IndexTupleSize(truncated))) elog(ERROR, "failed to add high key to the index page"); pfree(truncated); + if (lastleft_scratch != NULL) + pfree(lastleft_scratch); /* oitup should continue to point to the page's high key */ hii = PageGetItemId(opage, P_HIKEY); oitup = (IndexTuple) PageGetItem(opage, hii); } + /* + * oitup_scratch (when set) held the reconstructed plain form used as + * npage's new P_FIRSTKEY and as _bt_truncate's firstright. The + * bytes have been copied into their on-page homes; the scratch is no + * longer needed. + */ + if (oitup_scratch != NULL) + pfree(oitup_scratch); + /* * Link the old page into its parent, using its low key. If we don't * have a parent, we have to create one; this adds a new btree level. @@ -987,9 +1114,12 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, _bt_blwritepage(wstate, obuf, oblkno); /* - * Reset last_off to point to new page + * Reset last_off to point to new page. The item we just placed at + * P_FIRSTKEY is the new anchor (plain form) for npage. */ last_off = P_FIRSTKEY; + if (isleaf && state->btps_prefix_compression) + state->btps_anchor_off = P_FIRSTKEY; } /* @@ -1011,12 +1141,26 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, } /* - * Add the new item into the current page. + * Add the new item into the current page. emit_tup may be a compressed + * follower derived from itup (on leaf prefix-compressed builds); the + * plain path always has emit_tup == itup. */ last_off = OffsetNumberNext(last_off); - _bt_sortaddtup(npage, itupsz, itup, last_off, + _bt_sortaddtup(npage, itupsz, emit_tup, last_off, !isleaf && last_off == P_FIRSTKEY); + /* + * Anchor bookkeeping: the first tuple on a leaf page is always emitted + * plain and becomes that page's first anchor; any subsequent plain + * (non-compressed) tuple starts a new prefix group and becomes its + * anchor. Compressed followers leave btps_anchor_off unchanged. + */ + if (isleaf && state->btps_prefix_compression && !emit_is_compressed) + state->btps_anchor_off = last_off; + + if (emit_scratch != NULL) + pfree(emit_scratch); + state->btps_buf = nbuf; state->btps_blkno = nblkno; state->btps_lastoff = last_off; @@ -1119,6 +1263,18 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state) s->btps_buf = NULL; /* writepage took ownership of the buffer */ } + /* + * Free any cached prefix-compression state held on each page state. + */ + for (s = state; s != NULL; s = s->btps_next) + { + if (s->btps_follower_tupdesc != NULL) + { + FreeTupleDesc(s->btps_follower_tupdesc); + s->btps_follower_tupdesc = NULL; + } + } + /* * As the last step in the process, construct the metapage and make it * point to the new root (unless we had no data at all, in which case it's @@ -1127,7 +1283,9 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state) */ metabuf = smgr_bulk_get_buf(wstate->bulkstate); _bt_initmetapage((Page) metabuf, rootblkno, rootlevel, - wstate->inskey->allequalimage); + wstate->inskey->allequalimage && + !wstate->prefix_compression, + wstate->prefix_compression); _bt_blwritepage(wstate, metabuf, BTREE_METAPAGE); } diff --git a/src/backend/access/nbtree/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c index de9eca3c8..a9f7478ab 100644 --- a/src/backend/access/nbtree/nbtsplitloc.c +++ b/src/backend/access/nbtree/nbtsplitloc.c @@ -58,8 +58,95 @@ typedef struct int nsplits; /* current number of splits */ SplitPoint *splits; /* all candidate split points for page */ int interval; /* current range of acceptable split points */ + + /* + * Prefix-compression materialization state. Only initialized (and only + * non-NULL scratch buffers) when origpage is BTP_HAS_PREFIX_COMPRESSION. + * Lets _bt_split_keep_natts_fast() reconstruct compressed followers into + * plain tuples before handing them to _bt_keep_natts_fast(), which assumes + * full-tupdesc tuples. + */ + bool prefix_page; + BTPrefixPageState prefix_state; + char *lbuf; /* scratch for reconstructed lastleft */ + char *rbuf; /* scratch for reconstructed firstright */ } FindSplitData; +/* + * Materialize a tuple that may be a compressed follower into plain form for + * the benefit of _bt_keep_natts_fast(). Non-compressed tuples are returned + * as-is. The caller-provided scratch buffer is used so that we don't need to + * palloc per call. + * + * offnum must be the offset of 'tup' on state->origpage when tup came from + * the page, or InvalidOffsetNumber if tup is newitem (in which case tup is + * guaranteed not to be compressed). + */ +static inline IndexTuple +_bt_split_materialize(FindSplitData *state, IndexTuple tup, + OffsetNumber offnum, char *scratch) +{ + if (!state->prefix_page) + return tup; + if (!BTreeTupleIsCompressed(tup)) + return tup; + Assert(offnum >= FirstOffsetNumber); + return _bt_prefix_reconstruct(state->rel, &state->prefix_state, + offnum, scratch); +} + +/* + * Wrapper around _bt_keep_natts_fast() that accepts compressed followers + * from state->origpage. Either or both of lastleftoff/firstrightoff can be + * InvalidOffsetNumber when the corresponding tuple is newitem (plain). + */ +static inline int +_bt_split_keep_natts_fast(FindSplitData *state, + IndexTuple lastleft, OffsetNumber lastleftoff, + IndexTuple firstright, OffsetNumber firstrightoff) +{ + lastleft = _bt_split_materialize(state, lastleft, lastleftoff, state->lbuf); + firstright = _bt_split_materialize(state, firstright, firstrightoff, state->rbuf); + return _bt_keep_natts_fast(state->rel, lastleft, firstright); +} + +/* + * Like _bt_split_lastleft()/_bt_split_firstright() but also returns the + * on-origpage offset via *off (InvalidOffsetNumber for newitem). Used by + * _bt_split_keep_natts_fast() to reconstruct a compressed follower. + */ +static inline IndexTuple +_bt_split_lastleft_off(FindSplitData *state, SplitPoint *split, + OffsetNumber *off) +{ + ItemId itemid; + + if (split->newitemonleft && split->firstrightoff == state->newitemoff) + { + *off = InvalidOffsetNumber; + return state->newitem; + } + *off = OffsetNumberPrev(split->firstrightoff); + itemid = PageGetItemId(state->origpage, *off); + return (IndexTuple) PageGetItem(state->origpage, itemid); +} + +static inline IndexTuple +_bt_split_firstright_off(FindSplitData *state, SplitPoint *split, + OffsetNumber *off) +{ + ItemId itemid; + + if (!split->newitemonleft && split->firstrightoff == state->newitemoff) + { + *off = InvalidOffsetNumber; + return state->newitem; + } + *off = split->firstrightoff; + itemid = PageGetItemId(state->origpage, split->firstrightoff); + return (IndexTuple) PageGetItem(state->origpage, itemid); +} + static void _bt_recsplitloc(FindSplitData *state, OffsetNumber firstrightoff, bool newitemonleft, int olddataitemstoleft, @@ -200,6 +287,24 @@ _bt_findsplitloc(Relation rel, state.splits = palloc_array(SplitPoint, state.maxsplits); state.nsplits = 0; + /* + * Set up prefix-compression reconstruction state if the origpage is + * prefix-compressed. Without this, _bt_keep_natts_fast cannot evaluate + * same-group prefix matches between candidate lastleft and firstright + * tuples and suffix truncation would be degraded (the "no common prefix + * identified" fallback kicks in every time). + */ + state.prefix_page = state.is_leaf && P_HAS_PREFIX_COMPRESSION(opaque); + state.lbuf = NULL; + state.rbuf = NULL; + if (state.prefix_page) + { + memset(&state.prefix_state, 0, sizeof(state.prefix_state)); + _bt_prefix_state_init(&state.prefix_state, origpage); + state.lbuf = palloc(BTMaxItemSize); + state.rbuf = palloc(BTMaxItemSize); + } + /* * Scan through the data items and calculate space usage for a split at * each possible position @@ -314,6 +419,12 @@ _bt_findsplitloc(Relation rel, newitemoff == split->firstrightoff) { pfree(state.splits); + if (state.lbuf) + pfree(state.lbuf); + if (state.rbuf) + pfree(state.rbuf); + if (state.prefix_page) + _bt_prefix_state_cleanup(&state.prefix_state); *newitemonleft = true; return newitemoff; } @@ -424,6 +535,12 @@ _bt_findsplitloc(Relation rel, firstrightoff = _bt_bestsplitloc(&state, perfectpenalty, newitemonleft, strategy); pfree(state.splits); + if (state.lbuf) + pfree(state.lbuf); + if (state.rbuf) + pfree(state.rbuf); + if (state.prefix_page) + _bt_prefix_state_cleanup(&state.prefix_state); return firstrightoff; } @@ -659,11 +776,17 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff, * non-pivot tuples must be the same width as well. (Note that the * possibly-truncated existing high key isn't counted in * olddataitemstotal, and must be subtracted from maxoff.) + * + * XXX Disable this for the benefit of prefix compression, since we cannot + * expect TPC-C indexes to have uniform tuple size anymore. Revisit the + * trade-offs here. */ +#if 0 if (state->newitemsz != state->minfirstrightsz) return false; if (state->newitemsz * (maxoff - 1) != state->olddataitemstotal) return false; +#endif /* * Avoid applying optimization when tuples are wider than a tuple @@ -688,7 +811,9 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff, { itemid = PageGetItemId(state->origpage, maxoff); tup = (IndexTuple) PageGetItem(state->origpage, itemid); - keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem); + keepnatts = _bt_split_keep_natts_fast(state, tup, maxoff, + state->newitem, + InvalidOffsetNumber); if (keepnatts > 1 && keepnatts <= nkeyatts) { @@ -712,14 +837,28 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff, * optimization. Besides, all inappropriate cases triggered here will * still split in the middle of the page on average. */ - itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff)); - tup = (IndexTuple) PageGetItem(state->origpage, itemid); - /* Do cheaper test first */ - if (BTreeTupleIsPosting(tup) || - !_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid)) - return false; - /* Check same conditions as rightmost item case, too */ - keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem); + { + OffsetNumber tup_off = OffsetNumberPrev(state->newitemoff); + ItemPointerData left_htid; + + itemid = PageGetItemId(state->origpage, tup_off); + tup = (IndexTuple) PageGetItem(state->origpage, itemid); + /* Do cheaper test first */ + if (BTreeTupleIsPosting(tup)) + return false; + + if (BTreeTupleIsCompressed(tup)) + BTreeCompressedTupleGetHeapTID(tup, &left_htid); + else + left_htid = tup->t_tid; + if (!_bt_adjacenthtid(&left_htid, &state->newitem->t_tid)) + return false; + + /* Check same conditions as rightmost item case, too */ + keepnatts = _bt_split_keep_natts_fast(state, tup, tup_off, + state->newitem, + InvalidOffsetNumber); + } if (keepnatts > 1 && keepnatts <= nkeyatts) { @@ -937,6 +1076,8 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage, { IndexTuple leftmost, rightmost; + OffsetNumber leftmost_off, + rightmost_off; SplitPoint *leftinterval, *rightinterval; int perfectpenalty; @@ -960,15 +1101,17 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage, * current split interval */ _bt_interval_edges(state, &leftinterval, &rightinterval); - leftmost = _bt_split_lastleft(state, leftinterval); - rightmost = _bt_split_firstright(state, rightinterval); + leftmost = _bt_split_lastleft_off(state, leftinterval, &leftmost_off); + rightmost = _bt_split_firstright_off(state, rightinterval, &rightmost_off); /* * If initial split interval can produce a split point that will at least * avoid appending a heap TID in new high key, we're done. Finish split * with default strategy and initial split interval. */ - perfectpenalty = _bt_keep_natts_fast(state->rel, leftmost, rightmost); + perfectpenalty = _bt_split_keep_natts_fast(state, + leftmost, leftmost_off, + rightmost, rightmost_off); if (perfectpenalty <= indnkeyatts) return perfectpenalty; @@ -980,8 +1123,8 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage, * Use the leftmost split's lastleft tuple and the rightmost split's * firstright tuple to assess every possible split. */ - leftmost = _bt_split_lastleft(state, leftpage); - rightmost = _bt_split_firstright(state, rightpage); + leftmost = _bt_split_lastleft_off(state, leftpage, &leftmost_off); + rightmost = _bt_split_firstright_off(state, rightpage, &rightmost_off); /* * If page (including new item) has many duplicates but is not entirely @@ -989,7 +1132,9 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage, * If page is entirely full of duplicates, a single value strategy split * will be performed. */ - perfectpenalty = _bt_keep_natts_fast(state->rel, leftmost, rightmost); + perfectpenalty = _bt_split_keep_natts_fast(state, + leftmost, leftmost_off, + rightmost, rightmost_off); if (perfectpenalty <= indnkeyatts) { *strategy = SPLIT_MANY_DUPLICATES; @@ -1027,6 +1172,7 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage, itemid = PageGetItemId(state->origpage, P_HIKEY); hikey = (IndexTuple) PageGetItem(state->origpage, itemid); + /* hikey is a pivot, never compressed; newitem is plain */ perfectpenalty = _bt_keep_natts_fast(state->rel, hikey, state->newitem); if (perfectpenalty <= indnkeyatts) @@ -1147,10 +1293,17 @@ _bt_split_penalty(FindSplitData *state, SplitPoint *split) return MAXALIGN(ItemIdGetLength(itemid)) + sizeof(ItemIdData); } - lastleft = _bt_split_lastleft(state, split); - firstright = _bt_split_firstright(state, split); + { + OffsetNumber lastleft_off; + OffsetNumber firstright_off; - return _bt_keep_natts_fast(state->rel, lastleft, firstright); + lastleft = _bt_split_lastleft_off(state, split, &lastleft_off); + firstright = _bt_split_firstright_off(state, split, &firstright_off); + + return _bt_split_keep_natts_fast(state, + lastleft, lastleft_off, + firstright, firstright_off); + } } /* diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c index 85c1b6325..b74b53b3c 100644 --- a/src/backend/access/nbtree/nbtutils.c +++ b/src/backend/access/nbtree/nbtutils.c @@ -83,12 +83,14 @@ _bt_mkscankey(Relation rel, IndexTuple itup) key = palloc(offsetof(BTScanInsertData, scankeys) + sizeof(ScanKeyData) * indnkeyatts); if (itup) - _bt_metaversion(rel, &key->heapkeyspace, &key->allequalimage); + _bt_metaversion(rel, &key->heapkeyspace, &key->allequalimage, + &key->prefix_compression); else { /* Utility statement callers can set these fields themselves */ key->heapkeyspace = true; key->allequalimage = false; + key->prefix_compression = false; } key->anynullkeys = false; /* initial assumption */ key->nextkey = false; /* usual case, required by btinsert */ @@ -333,8 +335,23 @@ _bt_killitems(IndexScanDesc scan) if (j == nposting) killtuple = true; } - else if (ItemPointerEquals(&ituple->t_tid, &kitem->heapTid)) - killtuple = true; + else + { + /* + * Plain leaf tuple (or a prefix-compressed follower -- whose + * t_tid.ip_posid carries BT_IS_COMPRESSED in its upper nibble). + * Decode the follower's real heap TID before comparing; the + * scan recorded kitem->heapTid in its clean, demasked form. + */ + ItemPointerData htid; + + if (BTreeTupleIsCompressed(ituple)) + BTreeCompressedTupleGetHeapTID(ituple, &htid); + else + htid = ituple->t_tid; + if (ItemPointerEquals(&htid, &kitem->heapTid)) + killtuple = true; + } /* * Mark index item as dead, if it isn't already. Since this @@ -914,6 +931,17 @@ _bt_keep_natts_fast(Relation rel, IndexTuple lastleft, IndexTuple firstright) int keysz = IndexRelationGetNumberOfKeyAttributes(rel); int keepnatts; + /* + * index_getattr with the full tupdesc would walk off the end of a + * compressed follower's body and trip the indextuple.c fast-path + * assertion. Callers that work with on-page tuples must reconstruct + * compressed followers before calling (see _bt_split_keep_natts_fast() + * in nbtsplitloc.c for the split path, and the P_HAS_PREFIX_COMPRESSION + * guard in _bt_set_startikey()). + */ + Assert(!BTreeTupleIsCompressed(lastleft)); + Assert(!BTreeTupleIsCompressed(firstright)); + keepnatts = 1; for (int attnum = 1; attnum <= keysz; attnum++) { @@ -990,6 +1018,29 @@ _bt_check_natts(Relation rel, bool heapkeyspace, Page page, OffsetNumber offnum) if (natts != nkeyatts && BTreeTupleIsPosting(itup)) return false; + /* Compressed followers only appear on prefix-compressed leaf pages */ + if (BTreeTupleIsCompressed(itup)) + { + if (!P_ISLEAF(opaque)) + return false; + if (!P_HAS_PREFIX_COMPRESSION(opaque)) + return false; + if (offnum < P_FIRSTDATAKEY(opaque)) + return false; /* never a high key */ + /* Mutually exclusive with posting-list status */ + if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_POSTING) != 0) + return false; + /* BT_PIVOT_HEAP_TID_ATTR is a pivot-only bit */ + if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & + BT_PIVOT_HEAP_TID_ATTR) != 0) + return false; + } + + /* Posting tuples are forbidden on prefix-compressed leaf pages */ + if (BTreeTupleIsPosting(itup) && P_ISLEAF(opaque) && + P_HAS_PREFIX_COMPRESSION(opaque)) + return false; + if (P_ISLEAF(opaque)) { if (offnum >= P_FIRSTDATAKEY(opaque)) @@ -1004,8 +1055,8 @@ _bt_check_natts(Relation rel, bool heapkeyspace, Page page, OffsetNumber offnum) /* * Leaf tuples that are not the page high key (non-pivot tuples) * should never be truncated. (Note that tupnatts must have been - * inferred, even with a posting list tuple, because only pivot - * tuples store tupnatts directly.) + * inferred, even with a posting list tuple or a prefix-compressed + * follower, because only pivot tuples store tupnatts directly.) */ return tupnatts == natts; } diff --git a/src/backend/access/nbtree/nbtxlog.c b/src/backend/access/nbtree/nbtxlog.c index dff7d286f..74554c5f7 100644 --- a/src/backend/access/nbtree/nbtxlog.c +++ b/src/backend/access/nbtree/nbtxlog.c @@ -110,6 +110,8 @@ _bt_restore_meta(XLogReaderState *record, uint8 block_id) md->btm_last_cleanup_num_delpages = xlrec->last_cleanup_num_delpages; md->btm_last_cleanup_num_heap_tuples = -1.0; md->btm_allequalimage = xlrec->allequalimage; + md->btm_prefix_compression = xlrec->prefix_compression; + Assert(!(md->btm_allequalimage && md->btm_prefix_compression)); pageop = BTPageGetOpaque(metapg); pageop->btpo_flags = BTP_META; @@ -291,7 +293,12 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) ropaque->btpo_prev = origpagenumber; ropaque->btpo_next = spagenumber; ropaque->btpo_level = xlrec->level; - ropaque->btpo_flags = isleaf ? BTP_LEAF : 0; + /* + * Carry forward BTP_LEAF and BTP_HAS_PREFIX_COMPRESSION (plus any future + * flags added to BTP_SPLIT_PRESERVED_FLAGS) from origpage. Non-leaf + * origpages have no BTP_LEAF to carry, but the mask handles that too. + */ + ropaque->btpo_flags = xlrec->origflags & BTP_SPLIT_PRESERVED_FLAGS; ropaque->btpo_cycleid = 0; _bt_restore_page(rpage, datapos, datalen); @@ -411,10 +418,17 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) PageRestoreTempPage(leftpage, origpage); - /* Fix opaque fields */ - oopaque->btpo_flags = BTP_INCOMPLETE_SPLIT; - if (isleaf) - oopaque->btpo_flags |= BTP_LEAF; + /* + * Fix opaque fields. Preserve flags that survive a split on the + * primary (BTP_LEAF, BTP_HAS_PREFIX_COMPRESSION -- see + * BTP_SPLIT_PRESERVED_FLAGS) and then set BTP_INCOMPLETE_SPLIT on + * top. Without the mask, compressed followers on a prefix leaf + * would be left sitting on a page whose BTP_HAS_PREFIX_COMPRESSION + * flag got cleared by replay, breaking scans that gate + * reconstruction on the flag. + */ + oopaque->btpo_flags = (xlrec->origflags & BTP_SPLIT_PRESERVED_FLAGS) | + BTP_INCOMPLETE_SPLIT; oopaque->btpo_next = rightpagenumber; oopaque->btpo_cycleid = 0; @@ -944,7 +958,24 @@ btree_xlog_newroot(XLogReaderState *record) pageop->btpo_prev = pageop->btpo_next = P_NONE; pageop->btpo_level = xlrec->level; if (xlrec->level == 0) + { + xl_btree_metadata *md; + Size mdlen; + pageop->btpo_flags |= BTP_LEAF; + + /* + * On the primary _bt_getroot() marks the new leaf root with + * BTP_HAS_PREFIX_COMPRESSION when the index opts in. Replay must + * mirror that so standbys keep producing compressed pages. The + * relevant bit is on the metapage record regardless of level, so + * peek at it here before _bt_restore_meta runs below. + */ + md = (xl_btree_metadata *) XLogRecGetBlockData(record, 2, &mdlen); + Assert(mdlen == sizeof(xl_btree_metadata)); + if (md->prefix_compression) + pageop->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION; + } pageop->btpo_cycleid = 0; if (xlrec->level > 0) diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c index 97c519b47..96a4d1f02 100644 --- a/contrib/amcheck/verify_nbtree.c +++ b/contrib/amcheck/verify_nbtree.c @@ -312,7 +312,8 @@ bt_index_check_callback(Relation indrel, Relation heaprel, void *state, bool rea { BTCallbackState *args = (BTCallbackState *) state; bool heapkeyspace, - allequalimage; + allequalimage, + prefix_compression; if (!smgrexists(RelationGetSmgr(indrel), MAIN_FORKNUM)) ereport(ERROR, @@ -321,7 +322,7 @@ bt_index_check_callback(Relation indrel, Relation heaprel, void *state, bool rea RelationGetRelationName(indrel)))); /* Extract metadata from metapage, and sanitize it in passing */ - _bt_metaversion(indrel, &heapkeyspace, &allequalimage); + _bt_metaversion(indrel, &heapkeyspace, &allequalimage, &prefix_compression); if (allequalimage && !heapkeyspace) ereport(ERROR, (errcode(ERRCODE_INDEX_CORRUPTED), diff --git a/src/test/regress/expected/btree_prefix_compression.out b/src/test/regress/expected/btree_prefix_compression.out new file mode 100644 index 000000000..d18719a22 --- /dev/null +++ b/src/test/regress/expected/btree_prefix_compression.out @@ -0,0 +1,250 @@ +-- +-- BTREE_PREFIX_COMPRESSION +-- +-- Functional tests for leaf-level prefix compression: build, insert, split, +-- VACUUM's anchor-promotion path, and scans in both directions. These +-- tests operate at the SQL level -- for structural invariants covered by +-- amcheck and page-layout checks covered by pageinspect, see contrib/. +-- +-- Eligible: >= 2 key columns, all leading cols equalimage. We can't +-- easily see the metapage flag without pageinspect, so we exercise the +-- feature through its user-visible effects: correct query results plus +-- substantially smaller index size relative to a single-column variant +-- built on the same leading keys. +CREATE TABLE pc_t (a int, b int, c int, d text); +INSERT INTO pc_t + SELECT i / 1000, (i / 10) % 100, i, 'payload_' || (i % 7) + FROM generate_series(1, 50000) i; +-- Multi-column integer index: prefix compression applies. +CREATE INDEX pc_idx_abc ON pc_t (a, b, c); +-- Single-column index: ineligible for prefix compression. +CREATE INDEX pc_idx_a ON pc_t (a); +-- Forward scan correctness +SELECT count(*) FROM pc_t WHERE a = 7; + count +------- + 1000 +(1 row) + +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42; + count +------- + 10 +(1 row) + +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7420; + row +------------- + (7,42,7420) +(1 row) + +-- Range scan +SELECT count(*) FROM pc_t WHERE a BETWEEN 3 AND 5; + count +------- + 3000 +(1 row) + +SELECT count(*) FROM pc_t WHERE (a, b) BETWEEN (10, 50) AND (10, 55); + count +------- + 60 +(1 row) + +-- Backward scan (ORDER BY DESC) +SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3; + c +------ + 7429 + 7428 + 7427 +(3 rows) + +-- Forced index-only scan +SET enable_bitmapscan = off; +SET enable_seqscan = off; +EXPLAIN (COSTS off) SELECT c FROM pc_t WHERE a = 7 AND b = 42; + QUERY PLAN +------------------------------------------ + Index Only Scan using pc_idx_abc on pc_t + Index Cond: ((a = 7) AND (b = 42)) +(2 rows) + +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42; + count +------- + 10 +(1 row) + +RESET enable_bitmapscan; +RESET enable_seqscan; +-- Insert new rows that share an existing anchor's leading attrs (in-group +-- follower path), rows starting new groups (new-anchor path), and rows +-- landing at the leftmost position (forced plain anchor). +INSERT INTO pc_t VALUES + (7, 42, 7420001, 'insert_in_group'), + (7, 42, 7420002, 'insert_in_group'), + (7, 999, 7999999, 'insert_new_group'), -- new (a,b) combo in existing a group + (99, 0, -1, 'insert_new_a'), -- first row of new a group + (-1, -1, -1, 'insert_leftmost'); -- leftmost data item on its page +-- Verify inserted rows are scannable +SELECT count(*) FROM pc_t WHERE d LIKE 'insert_%'; + count +------- + 5 +(1 row) + +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c BETWEEN 7420001 AND 7420002 + ORDER BY c; + row +---------------- + (7,42,7420001) + (7,42,7420002) +(2 rows) + +SELECT (a,b,c) FROM pc_t WHERE a = -1; + row +------------ + (-1,-1,-1) +(1 row) + +SELECT (a,b,c) FROM pc_t WHERE a = 99; + row +----------- + (99,0,-1) +(1 row) + +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 999; + row +----------------- + (7,999,7999999) +(1 row) + +-- Exercise the page-split path: enough inserts to force leaf splits on a +-- fully-loaded group. Verify no rows are lost. +BEGIN; +INSERT INTO pc_t + SELECT 7, 42, 7420000 + 1000 + i, 'split_forcer' + FROM generate_series(1, 5000) i; +SELECT count(*) FROM pc_t WHERE d = 'split_forcer'; + count +------- + 5000 +(1 row) + +ROLLBACK; +-- VACUUM's anchor-promotion path: delete exactly the rows that serve as +-- anchors in their respective prefix groups, so VACUUM must promote the +-- first surviving follower in each group to a plain form. +-- For the (a, b, c) index, the anchor of each (a, b) group is the smallest +-- c -- i.e. i where (i/10) % 100 * 10 == (i % 1000). Simpler rule: the +-- smallest i within each range of 10 is the anchor-producing row, so +-- deleting rows where c % 10 = 0 targets those anchors. +DELETE FROM pc_t WHERE c % 10 = 0 AND d LIKE 'payload_%'; +VACUUM (INDEX_CLEANUP on) pc_t; +-- Scans must continue to return correct results after promotion +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42; + count +------- + 11 +(1 row) + +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7421; + row +------------- + (7,42,7421) +(1 row) + +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42 AND c % 10 = 0; + count +------- + 0 +(1 row) + +SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3; + c +--------- + 7420002 + 7420001 + 7429 +(3 rows) + +-- Ineligible index shapes: non-equalimage leading col, single key col, +-- index with only NULLs in leading col. All three should still work +-- correctly; they just do not enable prefix compression. +CREATE TABLE pc_t2 (a text COLLATE "C", b int, c int); +INSERT INTO pc_t2 + SELECT (i / 100)::text, (i / 10) % 10, i + FROM generate_series(1, 5000) i; +CREATE INDEX pc_idx2 ON pc_t2 (a, b, c); +SELECT count(*) FROM pc_t2 WHERE a = '42'; + count +------- + 100 +(1 row) + +SELECT (a,b,c) FROM pc_t2 WHERE a = '42' AND b = 5 AND c = 4255; + row +------------- + (42,5,4255) +(1 row) + +-- NULL handling in compressed followers: a row with NULL leading attr +-- and the same NULL-prefix neighbours should still be in a coherent +-- prefix group. +CREATE TABLE pc_t3 (a int, b int, c int); +INSERT INTO pc_t3 + SELECT NULL, (i / 10) % 10, i FROM generate_series(1, 1000) i; +INSERT INTO pc_t3 + SELECT 1, (i / 10) % 10, i FROM generate_series(1, 1000) i; +CREATE INDEX pc_idx3 ON pc_t3 (a, b, c); +SELECT count(*) FROM pc_t3 WHERE a IS NULL; + count +------- + 1000 +(1 row) + +SELECT count(*) FROM pc_t3 WHERE a = 1; + count +------- + 1000 +(1 row) + +SELECT count(*) FROM pc_t3 WHERE a IS NULL AND b = 5; + count +------- + 100 +(1 row) + +-- INCLUDE columns: non-key attrs are stored per-tuple (never part of the +-- prefix). Verify correctness end-to-end. +CREATE TABLE pc_t4 (a int, b int, c int, payload text); +INSERT INTO pc_t4 + SELECT i / 100, (i / 10) % 10, i, 'row_' || i + FROM generate_series(1, 10000) i; +CREATE INDEX pc_idx4 ON pc_t4 (a, b, c) INCLUDE (payload); +SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255; + payload +---------- + row_4255 +(1 row) + +-- Index-only scan using INCLUDE column +SET enable_bitmapscan = off; +SET enable_seqscan = off; +EXPLAIN (COSTS off) SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255; + QUERY PLAN +----------------------------------------------------- + Index Only Scan using pc_idx4 on pc_t4 + Index Cond: ((a = 42) AND (b = 5) AND (c = 4255)) +(2 rows) + +SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255; + payload +---------- + row_4255 +(1 row) + +RESET enable_bitmapscan; +RESET enable_seqscan; +DROP TABLE pc_t, pc_t2, pc_t3, pc_t4; diff --git a/src/test/regress/expected/select_distinct.out b/src/test/regress/expected/select_distinct.out index 741f72387..fbdefd033 100644 --- a/src/test/regress/expected/select_distinct.out +++ b/src/test/regress/expected/select_distinct.out @@ -542,7 +542,7 @@ SELECT DISTINCT y, x FROM distinct_tbl limit 10; Limit -> Unique -> Gather Merge - Workers Planned: 1 + Workers Planned: 2 -> Unique -> Parallel Index Only Scan using distinct_tbl_x_y_idx on distinct_tbl (6 rows) diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 75063f87a..9b5d04c35 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -62,6 +62,7 @@ test: sanity_check # join depends on create_misc # ---------- test: select_into select_distinct select_distinct_on select_implicit select_having subselect union case join aggregates transactions random portals arrays btree_index hash_index update delete namespace prepared_xacts +test: btree_prefix_compression # ---------- # Another group of parallel tests diff --git a/src/test/regress/sql/btree_prefix_compression.sql b/src/test/regress/sql/btree_prefix_compression.sql new file mode 100644 index 000000000..e270d9367 --- /dev/null +++ b/src/test/regress/sql/btree_prefix_compression.sql @@ -0,0 +1,128 @@ +-- +-- BTREE_PREFIX_COMPRESSION +-- +-- Functional tests for leaf-level prefix compression: build, insert, split, +-- VACUUM's anchor-promotion path, and scans in both directions. These +-- tests operate at the SQL level -- for structural invariants covered by +-- amcheck and page-layout checks covered by pageinspect, see contrib/. +-- + +-- Eligible: >= 2 key columns, all leading cols equalimage. We can't +-- easily see the metapage flag without pageinspect, so we exercise the +-- feature through its user-visible effects: correct query results plus +-- substantially smaller index size relative to a single-column variant +-- built on the same leading keys. +CREATE TABLE pc_t (a int, b int, c int, d text); +INSERT INTO pc_t + SELECT i / 1000, (i / 10) % 100, i, 'payload_' || (i % 7) + FROM generate_series(1, 50000) i; + +-- Multi-column integer index: prefix compression applies. +CREATE INDEX pc_idx_abc ON pc_t (a, b, c); +-- Single-column index: ineligible for prefix compression. +CREATE INDEX pc_idx_a ON pc_t (a); + +-- Forward scan correctness +SELECT count(*) FROM pc_t WHERE a = 7; +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42; +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7420; + +-- Range scan +SELECT count(*) FROM pc_t WHERE a BETWEEN 3 AND 5; +SELECT count(*) FROM pc_t WHERE (a, b) BETWEEN (10, 50) AND (10, 55); + +-- Backward scan (ORDER BY DESC) +SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3; + +-- Forced index-only scan +SET enable_bitmapscan = off; +SET enable_seqscan = off; +EXPLAIN (COSTS off) SELECT c FROM pc_t WHERE a = 7 AND b = 42; +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42; +RESET enable_bitmapscan; +RESET enable_seqscan; + +-- Insert new rows that share an existing anchor's leading attrs (in-group +-- follower path), rows starting new groups (new-anchor path), and rows +-- landing at the leftmost position (forced plain anchor). +INSERT INTO pc_t VALUES + (7, 42, 7420001, 'insert_in_group'), + (7, 42, 7420002, 'insert_in_group'), + (7, 999, 7999999, 'insert_new_group'), -- new (a,b) combo in existing a group + (99, 0, -1, 'insert_new_a'), -- first row of new a group + (-1, -1, -1, 'insert_leftmost'); -- leftmost data item on its page + +-- Verify inserted rows are scannable +SELECT count(*) FROM pc_t WHERE d LIKE 'insert_%'; +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c BETWEEN 7420001 AND 7420002 + ORDER BY c; +SELECT (a,b,c) FROM pc_t WHERE a = -1; +SELECT (a,b,c) FROM pc_t WHERE a = 99; +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 999; + +-- Exercise the page-split path: enough inserts to force leaf splits on a +-- fully-loaded group. Verify no rows are lost. +BEGIN; +INSERT INTO pc_t + SELECT 7, 42, 7420000 + 1000 + i, 'split_forcer' + FROM generate_series(1, 5000) i; +SELECT count(*) FROM pc_t WHERE d = 'split_forcer'; +ROLLBACK; + +-- VACUUM's anchor-promotion path: delete exactly the rows that serve as +-- anchors in their respective prefix groups, so VACUUM must promote the +-- first surviving follower in each group to a plain form. +-- For the (a, b, c) index, the anchor of each (a, b) group is the smallest +-- c -- i.e. i where (i/10) % 100 * 10 == (i % 1000). Simpler rule: the +-- smallest i within each range of 10 is the anchor-producing row, so +-- deleting rows where c % 10 = 0 targets those anchors. +DELETE FROM pc_t WHERE c % 10 = 0 AND d LIKE 'payload_%'; +VACUUM (INDEX_CLEANUP on) pc_t; + +-- Scans must continue to return correct results after promotion +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42; +SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7421; +SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42 AND c % 10 = 0; +SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3; + +-- Ineligible index shapes: non-equalimage leading col, single key col, +-- index with only NULLs in leading col. All three should still work +-- correctly; they just do not enable prefix compression. +CREATE TABLE pc_t2 (a text COLLATE "C", b int, c int); +INSERT INTO pc_t2 + SELECT (i / 100)::text, (i / 10) % 10, i + FROM generate_series(1, 5000) i; +CREATE INDEX pc_idx2 ON pc_t2 (a, b, c); +SELECT count(*) FROM pc_t2 WHERE a = '42'; +SELECT (a,b,c) FROM pc_t2 WHERE a = '42' AND b = 5 AND c = 4255; + +-- NULL handling in compressed followers: a row with NULL leading attr +-- and the same NULL-prefix neighbours should still be in a coherent +-- prefix group. +CREATE TABLE pc_t3 (a int, b int, c int); +INSERT INTO pc_t3 + SELECT NULL, (i / 10) % 10, i FROM generate_series(1, 1000) i; +INSERT INTO pc_t3 + SELECT 1, (i / 10) % 10, i FROM generate_series(1, 1000) i; +CREATE INDEX pc_idx3 ON pc_t3 (a, b, c); +SELECT count(*) FROM pc_t3 WHERE a IS NULL; +SELECT count(*) FROM pc_t3 WHERE a = 1; +SELECT count(*) FROM pc_t3 WHERE a IS NULL AND b = 5; + +-- INCLUDE columns: non-key attrs are stored per-tuple (never part of the +-- prefix). Verify correctness end-to-end. +CREATE TABLE pc_t4 (a int, b int, c int, payload text); +INSERT INTO pc_t4 + SELECT i / 100, (i / 10) % 10, i, 'row_' || i + FROM generate_series(1, 10000) i; +CREATE INDEX pc_idx4 ON pc_t4 (a, b, c) INCLUDE (payload); +SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255; +-- Index-only scan using INCLUDE column +SET enable_bitmapscan = off; +SET enable_seqscan = off; +EXPLAIN (COSTS off) SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255; +SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255; +RESET enable_bitmapscan; +RESET enable_seqscan; + +DROP TABLE pc_t, pc_t2, pc_t3, pc_t4; -- 2.55.0