From 357c80b1fffe49a88360bd5af8a9be5132b98292 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sat, 7 Nov 2026 17:34:37 -0500 Subject: [PATCH v9] nbtree: opt-in leaf key prefix compression Add a prefix_compression storage parameter for btree indexes (default off). With it on, a leaf page may store the leading bytes that a run of its tuples share just once, in a per-page directory, and keep only each tuple's remainder. Indexes on keys with long shared prefixes (URLs, jsonb paths, zero-padded codes, multicolumn keys with low-cardinality leading columns) get much smaller. Sharing is decided by equality of stored bytes, never by opclass equality. That keeps it sound for every type, including types whose equal values can differ in representation (numeric 1.0 and 1.00): only bytes that are already identical are shared, so an index-only scan gets back exactly what was stored, and no equalimage support is required. A tuple shares the longest common prefix of its data area with its group's anchor, so whole leading columns and leading bytes of the first differing column are both covered. A compressed leaf carries the new BTP_PREFIX flag and one directory item at the offset where the first data item would otherwise be. Its data items follow in key order, so P_FIRSTDATAKEY still depends only on the page flags, binary search is unchanged, and neither BTPageOpaqueData nor BTREE_VERSION changes. The directory holds one or more groups of consecutive items, each with an anchor (a copy of the key part of its first member). A coded item keeps its real heap TID in t_tid and any posting list in the standard layout, so code that reads only TIDs (VACUUM, LP_DEAD hinting, deletion, posting list splits) works on coded items unchanged. Pivot tuples are never coded. Readers get a decoded tuple through _bt_getitem(); _bt_readpage() skips decoding when a scan needs only heap TIDs and every scan key is already satisfied for the page. Leaves are compressed when they are written whole. CREATE INDEX fills each leaf according to a running estimate of its coded size, honoring fillfactor in coded bytes; a page split writes both halves plain and codes each. An insert codes the new tuple against its neighbor's group. Deduplication and bottom-up deletion work on decoded copies of a coded page's items, and deduplication re-codes the result against the page's existing directory, so prefix compression and deduplication compose on one page. An emptied coded leaf drops its directory and becomes a plain page again, as page deletion expects. A coded page can hold more than one page of decoded data but must still split into two plain pages. The directory caches the decoded size of the page's items; inserts split before that size would exceed what a balanced split can always place on two pages. For the rare case where a large incoming tuple leaves no legal split point, the existing items are split alone and the insert is retried. Coding and decoding use only the page and the tuple, never the catalog, so WAL replay rebuilds coded pages without full-page images. An insert logs the coded tuple. xl_btree_split gains a flags field saying whether the original page and each half were coded; redo decodes the original page, collects the left half's items as the primary did and lays them out with the same routine, while the right half arrives as its coded items. Deduplication and posting list splits on coded pages are replayed by repeating the primary's steps. The xl_btree_split change needs an XLOG_PAGE_MAGIC bump. Indexes without the parameter, and pages without the flag, are read exactly as before, so pg_upgrade and existing indexes need nothing. Turning the parameter on or off with ALTER INDEX rewrites nothing: each leaf converts the next time it is written whole, and coded leaves stay readable with the parameter off. amcheck decodes coded items and checks the directory's cached sizes. pageinspect's bt_page_items shows coded items decoded, with itemlen giving the stored size. A new recovery test replays every coded-page WAL record on a standby with wal_consistency_checking enabled. On 2 million URL-like keys (one shared 37-byte prefix plus a random number), compared with the same index without the parameter: the index is 37% smaller when grown by inserts and 35% smaller when built (52% for zero-padded sequential keys); inserts take about 10% more time and write 17% less WAL; CREATE INDEX takes about 10% longer; warm reads are unchanged and cold-cache index scans are 25-35% faster. Discussion: https://postgr.es/m/at-01F1wsXLRH0qo@floki --- contrib/amcheck/expected/check_btree.out | 27 + contrib/amcheck/sql/check_btree.sql | 18 + contrib/amcheck/verify_nbtree.c | 76 +- contrib/pageinspect/btreefuncs.c | 15 +- contrib/pageinspect/expected/btree.out | 35 + contrib/pageinspect/sql/btree.sql | 24 + doc/src/sgml/pageinspect.sgml | 12 + doc/src/sgml/ref/create_index.sgml | 37 + src/backend/access/common/reloptions.c | 10 + src/backend/access/nbtree/Makefile | 1 + src/backend/access/nbtree/README | 116 +++ src/backend/access/nbtree/meson.build | 1 + src/backend/access/nbtree/nbtdedup.c | 138 ++- src/backend/access/nbtree/nbtinsert.c | 353 ++++++- src/backend/access/nbtree/nbtpage.c | 2 + src/backend/access/nbtree/nbtprefix.c | 1039 +++++++++++++++++++++ src/backend/access/nbtree/nbtreadpage.c | 117 ++- src/backend/access/nbtree/nbtree.c | 1 + src/backend/access/nbtree/nbtsearch.c | 11 +- src/backend/access/nbtree/nbtsort.c | 271 +++++- src/backend/access/nbtree/nbtutils.c | 4 +- src/backend/access/nbtree/nbtxlog.c | 209 ++++- src/backend/access/rmgrdesc/nbtdesc.c | 5 + src/include/access/nbtree.h | 137 ++- src/include/access/nbtxlog.h | 17 +- src/test/recovery/Makefile | 4 +- src/test/recovery/meson.build | 1 + src/test/recovery/t/061_nbtree_prefix.pl | 116 +++ src/test/regress/expected/btree_index.out | 285 ++++++ src/test/regress/sql/btree_index.sql | 187 ++++ src/tools/pgindent/typedefs.list | 7 + 31 files changed, 3131 insertions(+), 145 deletions(-) create mode 100644 src/backend/access/nbtree/nbtprefix.c create mode 100644 src/test/recovery/t/061_nbtree_prefix.pl diff --git a/contrib/amcheck/expected/check_btree.out b/contrib/amcheck/expected/check_btree.out index 4fcee5da463..896d8bfbb0e 100644 --- a/contrib/amcheck/expected/check_btree.out +++ b/contrib/amcheck/expected/check_btree.out @@ -283,6 +283,32 @@ SELECT bt_index_check('varlena_bug_idx', true); (1 row) +-- Check a prefix-compressed (prefix_compression=on) index: the data items are +-- stored coded against a per-page PREFIX item, so amcheck must decode them and +-- validate the BTP_PREFIX structural invariants. Use keys with a long shared +-- leading prefix (so pages actually compress) plus some that share nothing and +-- a NULL (stored uncoded), and enough rows to force splits and deduplication. +CREATE TABLE prefix_bug (v text COLLATE "C"); +INSERT INTO prefix_bug + SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') + FROM generate_series(1, 20000) g; +INSERT INTO prefix_bug + SELECT 'https://example.com/path/to/resource/' || lpad((g % 100)::text, 8, '0') + FROM generate_series(1, 20000) g; +INSERT INTO prefix_bug VALUES ('zzz-no-shared-prefix'), (NULL); +CREATE INDEX prefix_bug_idx ON prefix_bug (v) WITH (prefix_compression = on); +SELECT bt_index_check('prefix_bug_idx', heapallindexed => true); + bt_index_check +---------------- + +(1 row) + +SELECT bt_index_parent_check('prefix_bug_idx', heapallindexed => true, rootdescend => true); + bt_index_parent_check +----------------------- + +(1 row) + -- cleanup DROP TABLE bttest_a; DROP TABLE bttest_b; @@ -294,3 +320,4 @@ DROP TABLE bttest_unique_nulls; DROP OWNED BY regress_bttest_role; -- permissions DROP ROLE regress_bttest_role; DROP TABLE varlena_bug; +DROP TABLE prefix_bug; diff --git a/contrib/amcheck/sql/check_btree.sql b/contrib/amcheck/sql/check_btree.sql index a5585085fad..7d2c6a8e8ff 100644 --- a/contrib/amcheck/sql/check_btree.sql +++ b/contrib/amcheck/sql/check_btree.sql @@ -183,6 +183,23 @@ INSERT INTO varlena_bug VALUES (repeat('Test', 250)); ALTER TABLE varlena_bug ALTER COLUMN v SET STORAGE extended; SELECT bt_index_check('varlena_bug_idx', true); +-- Check a prefix-compressed (prefix_compression=on) index: the data items are +-- stored coded against a per-page PREFIX item, so amcheck must decode them and +-- validate the BTP_PREFIX structural invariants. Use keys with a long shared +-- leading prefix (so pages actually compress) plus some that share nothing and +-- a NULL (stored uncoded), and enough rows to force splits and deduplication. +CREATE TABLE prefix_bug (v text COLLATE "C"); +INSERT INTO prefix_bug + SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') + FROM generate_series(1, 20000) g; +INSERT INTO prefix_bug + SELECT 'https://example.com/path/to/resource/' || lpad((g % 100)::text, 8, '0') + FROM generate_series(1, 20000) g; +INSERT INTO prefix_bug VALUES ('zzz-no-shared-prefix'), (NULL); +CREATE INDEX prefix_bug_idx ON prefix_bug (v) WITH (prefix_compression = on); +SELECT bt_index_check('prefix_bug_idx', heapallindexed => true); +SELECT bt_index_parent_check('prefix_bug_idx', heapallindexed => true, rootdescend => true); + -- cleanup DROP TABLE bttest_a; DROP TABLE bttest_b; @@ -194,3 +211,4 @@ DROP TABLE bttest_unique_nulls; DROP OWNED BY regress_bttest_role; -- permissions DROP ROLE regress_bttest_role; DROP TABLE varlena_bug; +DROP TABLE prefix_bug; diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c index 97c519b47f5..42af2a1c8e9 100644 --- a/contrib/amcheck/verify_nbtree.c +++ b/contrib/amcheck/verify_nbtree.c @@ -131,6 +131,10 @@ typedef struct BtreeCheckState bloom_filter *filter; /* Debug counter */ int64 heaptuplespresent; + + /* Decode workspaces for prefix-compressed leaf items */ + BTItemBuf itembuf; /* current target item */ + BTItemBuf nextbuf; /* any other item looked at meanwhile */ } BtreeCheckState; /* @@ -1266,6 +1270,30 @@ bt_target_page_check(BtreeCheckState *state) } } + /* + * A prefix-compressed leaf's directory caches the decoded size of its + * data items; it must match a recount. + */ + if (P_HASPREFIX(topaque)) + { + PGAlignedBlock copy; + uint32 cached, + actual; + + memcpy(copy.data, state->target, BLCKSZ); + cached = _bt_prefix_cached_size((Page) copy.data); + _bt_prefix_recount((Page) copy.data); + actual = _bt_prefix_cached_size((Page) copy.data); + if (cached != actual) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("prefix directory decoded size is wrong in index \"%s\"", + RelationGetRelationName(state->rel)), + errdetail_internal("Block=%u cached=%u actual=%u page lsn=%X/%08X.", + state->targetblock, cached, actual, + LSN_FORMAT_ARGS(state->targetlsn)))); + } + /* * Loop over page items, starting from first non-highkey item, not high * key (if any). Most tests are not performed for the "negative infinity" @@ -1313,6 +1341,16 @@ bt_target_page_check(BtreeCheckState *state) LSN_FORMAT_ARGS(state->targetlsn)), errhint("This could be a torn page problem."))); + /* + * On a prefix-compressed leaf, check the decoded data item (the coded + * form's size was just checked against lp_len). + */ + if (P_HASPREFIX(topaque)) + { + itup = _bt_getitem(state->target, offset, &state->itembuf); + tupsize = IndexTupleSize(itup); + } + /* Check the number of index tuple attributes */ if (!_bt_check_natts(state->rel, state->heapkeyspace, state->target, offset)) @@ -1606,7 +1644,8 @@ bt_target_page_check(BtreeCheckState *state) itemid = PageGetItemIdCareful(state, state->targetblock, state->target, OffsetNumberNext(offset)); - itup = (IndexTuple) PageGetItem(state->target, itemid); + itup = _bt_getitem(state->target, OffsetNumberNext(offset), + &state->nextbuf); tid = BTreeTupleGetPointsToTID(itup); nhtid = psprintf("(%u,%u)", ItemPointerGetBlockNumberNoCheck(tid), @@ -1793,7 +1832,8 @@ bt_target_page_check(BtreeCheckState *state) itemid = PageGetItemIdCareful(state, rightblock_number, rightpage, rightfirstoffset); - itup = (IndexTuple) PageGetItem(rightpage, itemid); + itup = _bt_getitem(rightpage, rightfirstoffset, + &state->nextbuf); bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis); @@ -2047,7 +2087,11 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs * Return first real item scankey. Note that this relies on right page * memory remaining allocated. */ - firstitup = (IndexTuple) PageGetItem(rightpage, rightitem); + if (P_ISLEAF(opaque)) + firstitup = CopyIndexTuple(_bt_getitem(rightpage, *rightfirstoffset, + &state->nextbuf)); + else + firstitup = (IndexTuple) PageGetItem(rightpage, rightitem); return bt_mkscankey_pivotsearch(state->rel, firstitup); } @@ -3095,14 +3139,13 @@ static inline bool invariant_l_offset(BtreeCheckState *state, BTScanInsert key, OffsetNumber upperbound) { - ItemId itemid; int32 cmp; Assert(!key->nextkey && key->backward); /* Verify line pointer before checking tuple */ - itemid = PageGetItemIdCareful(state, state->targetblock, state->target, - upperbound); + (void) PageGetItemIdCareful(state, state->targetblock, state->target, + upperbound); /* pg_upgrade'd indexes may legally have equal sibling tuples */ if (!key->heapkeyspace) return invariant_leq_offset(state, key, upperbound); @@ -3126,8 +3169,8 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key, ItemPointer rheaptid; bool nonpivot; - ritup = (IndexTuple) PageGetItem(state->target, itemid); topaque = BTPageGetOpaque(state->target); + ritup = _bt_getitem(state->target, upperbound, &state->nextbuf); nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque); /* Get number of keys + heap TID for item to the right */ @@ -3218,14 +3261,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key, BlockNumber nontargetblock, Page nontarget, OffsetNumber upperbound) { - ItemId itemid; int32 cmp; Assert(!key->nextkey && key->backward); /* Verify line pointer before checking tuple */ - itemid = PageGetItemIdCareful(state, nontargetblock, nontarget, - upperbound); + (void) PageGetItemIdCareful(state, nontargetblock, nontarget, + upperbound); cmp = _bt_compare(state->rel, key, nontarget, upperbound); /* pg_upgrade'd indexes may legally have equal sibling tuples */ @@ -3241,8 +3283,8 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key, BTPageOpaque copaque; bool nonpivot; - child = (IndexTuple) PageGetItem(nontarget, itemid); copaque = BTPageGetOpaque(nontarget); + child = _bt_getitem(nontarget, upperbound, &state->nextbuf); nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque); /* Get number of keys + heap TID for child/non-target item */ @@ -3398,6 +3440,18 @@ palloc_btree_page(BtreeCheckState *state, BlockNumber blocknum) errmsg("non-rightmost leaf block %u in index \"%s\" lacks high key item", blocknum, RelationGetRelationName(state->rel)))); + /* + * A prefix-compressed page is a live leaf holding its directory item and + * at least one coded data item (an emptied page drops the directory). + */ + if (P_HASPREFIX(opaque) && + (!P_ISLEAF(opaque) || P_ISDELETED(opaque) || P_ISHALFDEAD(opaque) || + maxoffset < P_FIRSTDATAKEY(opaque))) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("block %u in index \"%s\" has an invalid prefix-compressed layout", + blocknum, RelationGetRelationName(state->rel)))); + /* * In general, internal pages are never marked half-dead, except on * versions of Postgres prior to 9.4, where it can be valid transient diff --git a/contrib/pageinspect/btreefuncs.c b/contrib/pageinspect/btreefuncs.c index 9917663593b..5f0b71740fc 100644 --- a/contrib/pageinspect/btreefuncs.c +++ b/contrib/pageinspect/btreefuncs.c @@ -96,6 +96,7 @@ typedef struct ua_page_items bool leafpage; bool rightmost; TupleDesc tupd; + BTItemBuf decodebuf; /* decoded data item of a BTP_PREFIX leaf */ } ua_page_items; @@ -505,11 +506,23 @@ bt_page_print_tuples(ua_page_items *uargs) itup = (IndexTuple) PageGetItem(page, id); + /* + * On a prefix-compressed leaf (BTP_PREFIX), show each data item decoded, + * so its data and heap TIDs read as on a plain page. itemlen still shows + * the stored (coded) size. The prefix directory item before the data + * items is shown raw. Decoding needs only the page, so this also works + * on a page image passed as bytea. + */ + if (leafpage && + P_HASPREFIX(BTPageGetOpaque(page)) && + offset >= P_FIRSTDATAKEY(BTPageGetOpaque(page))) + itup = _bt_getitem(page, offset, &uargs->decodebuf); + j = 0; memset(nulls, 0, sizeof(nulls)); values[j++] = Int16GetDatum(offset); values[j++] = ItemPointerGetDatum(&itup->t_tid); - values[j++] = Int16GetDatum(IndexTupleSize(itup)); + values[j++] = Int16GetDatum(ItemIdGetLength(id)); values[j++] = BoolGetDatum(IndexTupleHasNulls(itup)); values[j++] = BoolGetDatum(IndexTupleHasVarwidths(itup)); diff --git a/contrib/pageinspect/expected/btree.out b/contrib/pageinspect/expected/btree.out index 0aa5d73322f..db8abcef80d 100644 --- a/contrib/pageinspect/expected/btree.out +++ b/contrib/pageinspect/expected/btree.out @@ -219,3 +219,38 @@ SELECT bt_page_items(decode(repeat('00', :block_size), 'hex')); bt_page_items | DROP TABLE test1; +-- A prefix-compressed leaf: data items are shown decoded, so data and htid +-- read as on a plain page, while itemlen shows the stored (coded) size. +CREATE TABLE test_prefix (u text COLLATE "C"); +INSERT INTO test_prefix + SELECT 'https://example.com/path/' || lpad(g::text, 4, '0') + FROM generate_series(1, 20) g; +CREATE INDEX test_prefix_idx ON test_prefix (u) WITH (prefix_compression = on); +CREATE INDEX test_plain_idx ON test_prefix (u); +SELECT btpo_flags & 512 <> 0 AS coded FROM bt_page_stats('test_prefix_idx', 1); +-[ RECORD 1 ] +coded | t + +-- same decoded items as the plain index (offset 1 is the prefix directory) +SELECT count(*) AS mismatches + FROM bt_page_items('test_prefix_idx', 1) c + FULL JOIN bt_page_items('test_plain_idx', 1) p ON p.itemoffset = c.itemoffset - 1 + WHERE c.itemoffset > 1 AND (c.data IS DISTINCT FROM p.data OR c.htid IS DISTINCT FROM p.htid); +-[ RECORD 1 ]- +mismatches | 0 + +-- and the same from a raw page image +SELECT count(*) AS mismatches + FROM bt_page_items(get_raw_page('test_prefix_idx', 1)) c + JOIN bt_page_items('test_prefix_idx', 1) r USING (itemoffset) + WHERE c.data IS DISTINCT FROM r.data; +-[ RECORD 1 ]- +mismatches | 0 + +-- coded items are smaller than the plain ones +SELECT (SELECT sum(itemlen) FROM bt_page_items('test_prefix_idx', 1) WHERE itemoffset > 1) < + (SELECT sum(itemlen) FROM bt_page_items('test_plain_idx', 1)) AS smaller; +-[ RECORD 1 ] +smaller | t + +DROP TABLE test_prefix; diff --git a/contrib/pageinspect/sql/btree.sql b/contrib/pageinspect/sql/btree.sql index 102ebdefe3c..17c015928ef 100644 --- a/contrib/pageinspect/sql/btree.sql +++ b/contrib/pageinspect/sql/btree.sql @@ -60,3 +60,27 @@ SHOW block_size \gset SELECT bt_page_items(decode(repeat('00', :block_size), 'hex')); DROP TABLE test1; + +-- A prefix-compressed leaf: data items are shown decoded, so data and htid +-- read as on a plain page, while itemlen shows the stored (coded) size. +CREATE TABLE test_prefix (u text COLLATE "C"); +INSERT INTO test_prefix + SELECT 'https://example.com/path/' || lpad(g::text, 4, '0') + FROM generate_series(1, 20) g; +CREATE INDEX test_prefix_idx ON test_prefix (u) WITH (prefix_compression = on); +CREATE INDEX test_plain_idx ON test_prefix (u); +SELECT btpo_flags & 512 <> 0 AS coded FROM bt_page_stats('test_prefix_idx', 1); +-- same decoded items as the plain index (offset 1 is the prefix directory) +SELECT count(*) AS mismatches + FROM bt_page_items('test_prefix_idx', 1) c + FULL JOIN bt_page_items('test_plain_idx', 1) p ON p.itemoffset = c.itemoffset - 1 + WHERE c.itemoffset > 1 AND (c.data IS DISTINCT FROM p.data OR c.htid IS DISTINCT FROM p.htid); +-- and the same from a raw page image +SELECT count(*) AS mismatches + FROM bt_page_items(get_raw_page('test_prefix_idx', 1)) c + JOIN bt_page_items('test_prefix_idx', 1) r USING (itemoffset) + WHERE c.data IS DISTINCT FROM r.data; +-- coded items are smaller than the plain ones +SELECT (SELECT sum(itemlen) FROM bt_page_items('test_prefix_idx', 1) WHERE itemoffset > 1) < + (SELECT sum(itemlen) FROM bt_page_items('test_plain_idx', 1)) AS smaller; +DROP TABLE test_prefix; diff --git a/doc/src/sgml/pageinspect.sgml b/doc/src/sgml/pageinspect.sgml index 3a113439e1d..3d4345f099e 100644 --- a/doc/src/sgml/pageinspect.sgml +++ b/doc/src/sgml/pageinspect.sgml @@ -471,6 +471,18 @@ test=# SELECT itemoffset, ctid, itemlen, nulls, vars, data, dead, htid, tids[0:2 valid downlink in its ctid field, however. + + On a leaf page of an index with + prefix_compression + enabled, the item after the high key (or the first item, on a rightmost + page) may be the page's prefix directory, which holds the leading bytes + that the page's data items share. Such a page has bit 512 + (BTP_PREFIX) set in btpo_flags. + Its data items are shown decoded, so data + and htid read as they would on an + uncompressed page, while itemlen shows the + item's stored, compressed size. + For more details about the structure of B-tree indexes, see . For more details about diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml index bb7505d171b..3591a83c450 100644 --- a/doc/src/sgml/ref/create_index.sgml +++ b/doc/src/sgml/ref/create_index.sgml @@ -477,6 +477,43 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] + + + prefix_compression (boolean) + + prefix_compression storage parameter + + + + + Controls leaf key prefix compression. When set to + ON, a leaf page may store the leading bytes that a + run of its tuples share just once and record only each tuple's + unshared remainder, which can substantially shrink indexes whose keys + share long leading bytes (for example URLs, jsonb paths, + zero-padded codes, or low-cardinality leading columns of a multicolumn + index). It applies to key columns of any data type. Compression saves + space and I/O at some cost in CPU time for inserts. Set to + ON or OFF to enable or disable the + optimization. The default is OFF. + + + + + Prefix compression only changes how keys are stored; comparisons always + see the original key values, so every collation and operator class + behaves identically with it on or off. A leaf is compressed only when + it is written as a whole (during an index build, or either half of a + page split), so turning prefix_compression on or off + via ALTER INDEX does not rewrite the index: existing + pages are left as they are and are converted the next time they are + written whole (REINDEX converts them all at once). No REINDEX is required, and indexes + upgraded with pg_upgrade keep working + unchanged. + + + + diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index ea9a0417909..be1fcd9a15b 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -153,6 +153,16 @@ static relopt_bool boolRelOpts[] = }, true }, + { + { + "prefix_compression", + "Enables leaf key prefix compression for this btree index", + RELOPT_KIND_BTREE, + ShareUpdateExclusiveLock /* since it applies only to later + * whole-page writes */ + }, + false + }, /* list terminator */ {{NULL}} }; diff --git a/src/backend/access/nbtree/Makefile b/src/backend/access/nbtree/Makefile index 0daf640af96..0044d77f548 100644 --- a/src/backend/access/nbtree/Makefile +++ b/src/backend/access/nbtree/Makefile @@ -18,6 +18,7 @@ OBJS = \ nbtinsert.o \ nbtpage.o \ nbtpreprocesskeys.o \ + nbtprefix.o \ nbtreadpage.o \ nbtree.o \ nbtsearch.o \ diff --git a/src/backend/access/nbtree/README b/src/backend/access/nbtree/README index f3867547691..103d5007e7d 100644 --- a/src/backend/access/nbtree/README +++ b/src/backend/access/nbtree/README @@ -1081,3 +1081,119 @@ item is irrelevant, and need not be stored at all. This arrangement corresponds to the fact that an L&Y non-leaf page has one more pointer than key. Suffix truncation's negative infinity attributes behave in the same way. + +Notes about leaf key prefix compression +---------------------------------------- + +With the prefix_compression storage parameter (default off) a leaf page may +store the leading bytes that a run of its data tuples share just once, in a +per-page directory, and record only each tuple's unshared remainder. This +shrinks indexes whose keys share long leading bytes: URLs, jsonb paths, +zero-padded codes, multi-column keys with low-cardinality leading columns. +Like deduplication, it alters the physical representation of tuples without +changing the logical contents of the index, and comparisons always operate on +the reconstructed original values, so every collation and operator class is +unaffected. + +Sharing is decided by equality of stored bytes, never by the opclass notion +of equality. That is what makes it sound for every column type, including +types whose equal values can have different representations (numeric 1.0 and +1.00): only bytes that are already identical are shared, so an index-only +scan gets back exactly the bytes that were stored, and no equalimage support +function is needed. Deduplication needs equalimage because it merges tuples +the comparator calls equal and keeps one copy; prefix compression never +merges anything. Sharing is not tied to column boundaries: a member shares +the longest common prefix of its data area with its group's anchor, which +covers whole leading columns plus any leading bytes of the first column that +differs. + +A leaf that uses the technique carries the BTP_PREFIX page flag and one extra +item, the PREFIX DIRECTORY, at the offset where its first data item would +otherwise be (P_HIKEY on a rightmost page, P_FIRSTKEY otherwise); data items +begin one offset later, which P_FIRSTDATAKEY() accounts for. Keeping the +whole directory in a single item preserves the "exactly one item before the +data" property, so P_FIRSTDATAKEY depends only on the page flag, neither +BTPageOpaqueData nor the on-disk page header changes, and binary search over +the data items is unaffected. The directory holds one or more GROUPS of +consecutive data items. A group's ANCHOR is a copy of the key part of its +first member; a new group starts when an item shares no byte with the current +anchor. Each data item is stored "coded": an IndexTupleData header whose +t_tid is the item's own heap TID (no t_tid bit is stolen), a small code header +(group, shared byte count, the original's t_info), the original's null bitmap, +the residual -- the original's data-area bytes after the shared ones -- and, +for a posting list tuple, its posting list in the standard layout. Code that +reads only heap TIDs (VACUUM, LP_DEAD hinting, index deletion, posting list +splits) therefore works on coded items as is. Pivots (high keys and +downlinks) are never coded. Decoding splices the anchor's shared bytes back +in front of the residual and reproduces the original tuple byte for byte. + +Coding and decoding depend only on the page and the tuple, never on the +catalog. _bt_getitem() is the one accessor through which readers obtain a +decoded leaf data item; _bt_compare() decodes the item it compares against, +and _bt_readpage() skips decoding altogether when a scan needs only heap TIDs +and no key comparison remains to be made on the page (not an index-only scan, +every scan key already satisfied for the whole page, no array keys). + +A page gets a directory only when it is written as a whole: a leaf during an +index build, or either half of a leaf page split. An index build collects a +leaf's tuples and fills it according to a running estimate of the coded size, +honoring fillfactor in coded bytes, so a compressible index is built dense +rather than coded after the fact. An insert codes the new tuple against the +group of its neighbor, sharing however much it happens to share (possibly +nothing); no insert, VACUUM or deduplication ever rewrites a page to keep its +directory "good". Deduplication and bottom-up deletion both work on decoded +copies of a coded page's items; deduplication re-codes each resulting tuple +against the page's existing directory, so the page can only shrink. When +VACUUM or page deletion empties a coded leaf, the directory is removed and the +flag cleared, so an emptied leaf holds only its high key, as page deletion +expects. + +A coded page can hold more data than fits on a plain page, and it must still +be possible to split it into two plain pages. The directory caches the +decoded size of the page's items (their sum and the largest), maintained +exactly by every change and repeated by redo, and an insert splits the page +before its decoded content would exceed what a size-balanced split can always +place on two pages, counting the left half's new high key +(_bt_prefix_split_needed has the argument). A split decodes the page, +chooses a split point by plain sizes (upstream _bt_findsplitloc when the +decoded items fit one page, otherwise a size-checked balanced point), writes +both halves plain, and codes each half as a whole page. For the rare case +where a large incoming tuple leaves no legal split point once it is added, the +existing items are split alone and the insert is retried from the top. + +Because the format is a per-page opt-in flag with no btree version change, +pages without the flag are read exactly as before, so pg_upgrade and +replication keep working and existing indexes continue to work with no +REINDEX. Turning the parameter on with ALTER INDEX rewrites nothing; each +leaf is converted the next time it is written whole, and compressed and +uncompressed leaves coexist in the same index indefinitely. Turning it off +likewise rewrites nothing: existing BTP_PREFIX leaves keep their coded form +and are still read correctly, since every reader decodes based on the page +flag, not on the current setting. Index-only scans keep their per-page tuple +workspace sized for a plain page and grow it the first time they read a +BTP_PREFIX page, since a decoded page can exceed one block. + +WAL replay never needs a full-page image for a coded page. An in-place +insert logs the coded tuple, which redo adds verbatim before updating the +directory's cached size. A split record says, in its flags, whether the +original page and each half were coded; for a coded original page its +offsets refer to the decoded item sequence. Redo decodes the original page's +items, collects the left half's items exactly as the primary did, and lays +the left half out with the same routine the primary used +(_bt_prefix_form_page); the right half arrives as its coded items, directory +included, which _bt_restore_page lays out exactly as the primary did. +Deduplication and posting list splits on a coded page are replayed by +repeating the primary's decode, merge and re-code steps. The coded records +are smaller than the plain ones, since every item and the right half travel +in coded form. + +Prefix compression is a separate mechanism from deduplication rather than an +extension of it because the two attack different redundancy. Deduplication +collapses tuples whose whole key is equal into one posting list tuple; it does +nothing for keys that are all distinct. Prefix compression factors out a +leading run shared by distinct keys and leaves their differing suffixes alone; +it does nothing for exact duplicates that a posting list already collapses. +The common case this targets -- a URL or jsonb-path column whose values are +distinct but share a long prefix -- is exactly where deduplication does not +help. The two compose on one page: a posting list tuple is coded like any +other, carrying its posting list after the residual. diff --git a/src/backend/access/nbtree/meson.build b/src/backend/access/nbtree/meson.build index 812f067e710..cbd34201328 100644 --- a/src/backend/access/nbtree/meson.build +++ b/src/backend/access/nbtree/meson.build @@ -6,6 +6,7 @@ backend_sources += files( 'nbtinsert.c', 'nbtpage.c', 'nbtpreprocesskeys.c', + 'nbtprefix.c', 'nbtreadpage.c', 'nbtree.c', 'nbtsearch.c', diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c index af7affdf409..ac436687d97 100644 --- a/src/backend/access/nbtree/nbtdedup.c +++ b/src/backend/access/nbtree/nbtdedup.c @@ -23,6 +23,7 @@ static void _bt_bottomupdel_finish_pending(Page page, BTDedupState state, TM_IndexDeleteOp *delstate); + static bool _bt_do_singleval(Relation rel, Page page, BTDedupState state, OffsetNumber minoff, IndexTuple newitem); static void _bt_singleval_fillfactor(Page page, BTDedupState state, @@ -70,6 +71,8 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, bool singlevalstrat = false; int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel); XLogRecPtr recptr; + bool coded = P_HASPREFIX(opaque); + IndexTuple *ditems = NULL; /* Passed-in newitemsz is MAXALIGNED but does not include line pointer */ newitemsz += sizeof(ItemIdData); @@ -131,12 +134,36 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, elog(ERROR, "deduplication failed to add highkey"); } + /* + * On a prefix-compressed page, merge decoded copies of the items, and + * keep the page's directory: each output tuple is coded against the group + * of its interval's base item (_bt_dedup_finish_pending_coded). Merging + * never enlarges a coded page, so the result always fits. + */ + if (coded) + { + OffsetNumber prefixoff = BTPageGetPrefixOff(opaque); + ItemId diritemid = PageGetItemId(page, prefixoff); + IndexTuple dummyhikey; + Size dummyhikeysz; + + if (PageAddItem(newpage, PageGetItem(page, diritemid), + ItemIdGetLength(diritemid), prefixoff, + false, false) == InvalidOffsetNumber) + elog(ERROR, "deduplication failed to add prefix directory"); + (void) _bt_prefix_decode_items(page, &ditems, &dummyhikey, + &dummyhikeysz); + if (dummyhikey) + pfree(dummyhikey); + } + for (offnum = minoff; offnum <= maxoff; offnum = OffsetNumberNext(offnum)) { ItemId itemid = PageGetItemId(page, offnum); - IndexTuple itup = (IndexTuple) PageGetItem(page, itemid); + IndexTuple itup = coded ? ditems[offnum - minoff] : + (IndexTuple) PageGetItem(page, itemid); Assert(!ItemIdIsDead(itemid)); @@ -170,7 +197,10 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, * form new posting tuple and add it to our temp page (newpage). * Else add pending interval's base tuple to the temp page as-is. */ - pagesaving += _bt_dedup_finish_pending(newpage, state); + if (coded) + _bt_dedup_finish_pending_coded(page, newpage, state); + else + pagesaving += _bt_dedup_finish_pending(newpage, state); if (singlevalstrat) { @@ -203,7 +233,17 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, } /* Handle the last item */ - pagesaving += _bt_dedup_finish_pending(newpage, state); + if (coded) + _bt_dedup_finish_pending_coded(page, newpage, state); + else + pagesaving += _bt_dedup_finish_pending(newpage, state); + + if (ditems) + { + for (int i = 0; i <= maxoff - minoff; i++) + pfree(ditems[i]); + pfree(ditems); + } /* * If no items suitable for deduplication were found, newpage must be @@ -240,6 +280,9 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, START_CRIT_SECTION(); + /* the copied directory's cached sizes describe the old items */ + if (coded) + _bt_prefix_recount(newpage); PageRestoreTempPage(newpage, page); MarkBufferDirty(buf); @@ -251,6 +294,7 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, xlrec_dedup.nintervals = state->nintervals; XLogBeginInsert(); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup); @@ -272,7 +316,8 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, END_CRIT_SECTION(); /* Local space accounting should agree with page accounting */ - Assert(pagesaving < newitemsz || PageGetExactFreeSpace(page) >= newitemsz); + Assert(coded || pagesaving < newitemsz || + PageGetExactFreeSpace(page) >= newitemsz); /* cannot leak memory here */ pfree(state->htids); @@ -317,6 +362,7 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, BTDedupState state; TM_IndexDeleteOp delstate; bool neverdedup; + IndexTuple *ditems = NULL; int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel); /* Passed-in newitemsz is MAXALIGNED but does not include line pointer */ @@ -362,12 +408,30 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, minoff = P_FIRSTDATAKEY(opaque); maxoff = PageGetMaxOffsetNumber(page); + + /* + * On a prefix-compressed page compare decoded copies of the items. The + * coded tuples keep their heap TIDs (and posting lists) in the standard + * layout, so everything below that reads only TIDs works on them as is. + */ + if (P_HASPREFIX(opaque)) + { + IndexTuple dummyhikey; + Size dummyhikeysz; + + (void) _bt_prefix_decode_items(page, &ditems, &dummyhikey, + &dummyhikeysz); + if (dummyhikey) + pfree(dummyhikey); + } + for (offnum = minoff; offnum <= maxoff; offnum = OffsetNumberNext(offnum)) { ItemId itemid = PageGetItemId(page, offnum); - IndexTuple itup = (IndexTuple) PageGetItem(page, itemid); + IndexTuple itup = ditems ? ditems[offnum - minoff] : + (IndexTuple) PageGetItem(page, itemid); Assert(!ItemIdIsDead(itemid)); @@ -393,6 +457,13 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel, /* Finalize final interval -- move its TIDs to delete state */ _bt_bottomupdel_finish_pending(page, state, &delstate); + if (ditems) + { + for (int i = 0; i <= maxoff - minoff; i++) + pfree(ditems[i]); + pfree(ditems); + } + /* * We don't give up now in the event of having few (or even zero) * promising tuples for the tableam because it's not up to us as the index @@ -608,6 +679,55 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state) return spacesaving; } +/* + * _bt_dedup_finish_pending for a prefix-compressed page: like the plain + * version, but the tuple added to newpage is coded against the group of the + * interval's base item, using the source page's directory (which newpage + * carries unchanged). A lone base item is copied in its existing coded form. + * Page-only, so WAL redo replays a deduplication of a coded page with it. + */ +void +_bt_dedup_finish_pending_coded(Page page, Page newpage, BTDedupState state) +{ + OffsetNumber tupoff = OffsetNumberNext(PageGetMaxOffsetNumber(newpage)); + IndexTuple out; + Size outsz; + + Assert(state->nitems > 0); + Assert(state->nitems <= state->nhtids); + Assert(state->intervals[state->nintervals].baseoff == state->baseoff); + + if (state->nitems == 1) + { + ItemId itemid = PageGetItemId(page, state->baseoff); + + out = (IndexTuple) PageGetItem(page, itemid); + outsz = ItemIdGetLength(itemid); + if (PageAddItem(newpage, out, outsz, tupoff, false, false) == InvalidOffsetNumber) + elog(ERROR, "deduplication failed to add tuple to page"); + } + else + { + IndexTuple final; + + final = _bt_form_posting(state->base, state->htids, state->nhtids); + Assert(IndexTupleSize(final) <= state->maxpostingsize); + state->intervals[state->nintervals].nitems = state->nitems; + + out = _bt_prefix_recode(page, state->baseoff, final); + outsz = IndexTupleSize(out); + if (PageAddItem(newpage, out, outsz, tupoff, false, false) == InvalidOffsetNumber) + elog(ERROR, "deduplication failed to add tuple to page"); + pfree(out); + pfree(final); + state->nintervals++; + } + + state->nhtids = 0; + state->nitems = 0; + state->phystupsize = 0; +} + /* * Finalize interval during bottom-up index deletion. * @@ -783,16 +903,14 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state, OffsetNumber minoff, IndexTuple newitem) { int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel); - ItemId itemid; IndexTuple itup; + BTItemBuf buf; - itemid = PageGetItemId(page, minoff); - itup = (IndexTuple) PageGetItem(page, itemid); + itup = _bt_getitem(page, minoff, &buf); if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts) { - itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page)); - itup = (IndexTuple) PageGetItem(page, itemid); + itup = _bt_getitem(page, PageGetMaxOffsetNumber(page), &buf); if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts) return true; diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index d5d964301c1..4cb4d345da3 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -46,7 +46,7 @@ static OffsetNumber _bt_findinsertloc(Relation rel, Relation heapRel); static void _bt_stepright(Relation rel, Relation heaprel, BTInsertState insertstate, BTStack stack); -static void _bt_insertonpg(Relation rel, Relation heaprel, BTScanInsert itup_key, +static bool _bt_insertonpg(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, Buffer cbuf, BTStack stack, @@ -58,7 +58,8 @@ static void _bt_insertonpg(Relation rel, Relation heaprel, BTScanInsert itup_key static Buffer _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, Buffer cbuf, OffsetNumber newitemoff, Size newitemsz, IndexTuple newitem, IndexTuple orignewitem, - IndexTuple nposting, uint16 postingoff); + IndexTuple nposting, uint16 postingoff, + bool *newitemplaced); static void _bt_insert_parent(Relation rel, Relation heaprel, Buffer buf, Buffer rbuf, BTStack stack, bool isroot, bool isonly); static void _bt_freestack(BTStack stack); @@ -260,9 +261,28 @@ search: */ newitemoff = _bt_findinsertloc(rel, &insertstate, checkingunique, indexUnchanged, stack, heapRel); - _bt_insertonpg(rel, heapRel, itup_key, insertstate.buf, InvalidBuffer, - stack, itup, insertstate.itemsz, newitemoff, - insertstate.postingoff, false); + if (!_bt_insertonpg(rel, heapRel, itup_key, insertstate.buf, + InvalidBuffer, stack, itup, insertstate.itemsz, + newitemoff, insertstate.postingoff, false)) + { + /* + * A prefix-compressed leaf whose decoded items exceeded one page + * was split without placing itup (no legal split point kept both + * halves within a page once itup was added). itup now fits a + * plain-sized half; retry the insertion from the top. (Redo the + * uniqueness check too: we released the page lock, so another + * backend may have inserted a conflicting duplicate meanwhile.) + */ + if (stack) + _bt_freestack(stack); + if (checkingunique && itup_key->heapkeyspace && + !itup_key->anynullkeys) + itup_key->scantid = NULL; + insertstate.buf = InvalidBuffer; + insertstate.bounds_valid = false; + insertstate.postingoff = 0; + goto search; + } } else { @@ -347,9 +367,11 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate) if (P_RIGHTMOST(opaque) && P_ISLEAF(opaque) && !P_IGNORE(opaque) && + !P_HASPREFIX(opaque) && PageGetFreeSpace(page) > insertstate->itemsz && - PageGetMaxOffsetNumber(page) >= P_HIKEY && - _bt_compare(rel, insertstate->itup_key, page, P_HIKEY) > 0) + PageGetMaxOffsetNumber(page) >= P_FIRSTDATAKEY(opaque) && + _bt_compare(rel, insertstate->itup_key, page, + P_FIRSTDATAKEY(opaque)) > 0) { /* * Caller can use the fastpath optimization because cached @@ -910,12 +932,30 @@ _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 a + * prefix page the item is stored coded, which can be up to the coded + * header larger than the plain tuple when it shares little, so + * reserve that slack here to avoid a coded PageAddItem failure after + * the fit check passed. */ - if (PageGetFreeSpace(page) < insertstate->itemsz) - _bt_delete_or_dedup_one_page(rel, heapRel, insertstate, false, - checkingunique, uniquedup, - indexUnchanged); + { + Size need = insertstate->itemsz; + + if (P_HASPREFIX(opaque)) + need += BTREE_PREFIX_CODE_OVERHEAD; + + /* + * A prefix page also splits once its decoded content nears the + * split-fit limit, often before it is physically full; try + * deletion/deduplication then too. + */ + if (PageGetFreeSpace(page) < need || + (P_HASPREFIX(opaque) && + _bt_prefix_split_needed(page, insertstate->itemsz))) + _bt_delete_or_dedup_one_page(rel, heapRel, insertstate, false, + checkingunique, uniquedup, + indexUnchanged); + } } else { @@ -1113,7 +1153,7 @@ _bt_stepright(Relation rel, Relation heaprel, BTInsertState insertstate, * INCOMPLETE_SPLIT flag on it, and release the buffer. *---------- */ -static void +static bool _bt_insertonpg(Relation rel, Relation heaprel, BTScanInsert itup_key, @@ -1135,6 +1175,12 @@ _bt_insertonpg(Relation rel, IndexTuple oposting = NULL; IndexTuple origitup = NULL; IndexTuple nposting = NULL; + IndexTuple codedposting = NULL; /* nposting coded (prefix leaf) */ + BTItemBuf postbuf; + IndexTuple additup; /* tuple actually added (coded or plain) */ + Size additemsz; + bool needsplit; + bool newitemplaced = true; XLogRecPtr recptr; page = BufferGetPage(buf); @@ -1182,6 +1228,11 @@ _bt_insertonpg(Relation rel, */ Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage); oposting = (IndexTuple) PageGetItem(page, itemid); + if (P_HASPREFIX(opaque)) + { + /* swap on the decoded tuple; coded below, before the split check */ + oposting = CopyIndexTuple(_bt_getitem(page, newitemoff, &postbuf)); + } /* * postingoff value comes from earlier call to _bt_binsrch_posting(). @@ -1209,18 +1260,56 @@ _bt_insertonpg(Relation rel, nposting = _bt_swap_posting(itup, oposting, postingoff); /* itup now contains rightmost/max TID from oposting */ + if (P_HASPREFIX(opaque)) + { + /* + * The page keeps the coded posting tuple; overwrite it with the + * coded post-split version (same key, same size: the posting list + * just trades one TID). _bt_split decodes from the page, so give + * it the plain nposting it expects. + */ + codedposting = _bt_prefix_recode(page, newitemoff, nposting); + Assert(IndexTupleSize(codedposting) == ItemIdGetLength(itemid)); + pfree(oposting); + oposting = (IndexTuple) PageGetItem(page, itemid); + } + /* Alter offset so that newitem goes after posting list */ newitemoff = OffsetNumberNext(newitemoff); } /* - * Do we need to split the page to fit the item on it? + * On a prefix leaf the new item is stored coded. Compute its coded form + * now (before any critical section; coding pallocs) so both the split + * decision and the in-place insert below use it. Plain tuples elsewhere. + */ + if (isleaf && P_HASPREFIX(opaque)) + { + additup = _bt_prefix_encode_insert(page, newitemoff, itup); + additemsz = MAXALIGN(IndexTupleSize(additup)); + } + else + { + additup = itup; + additemsz = itemsz; + } + + /* + * Do we need to split the page to fit the item? For a prefix leaf we + * split when the coded item would not fit the coded page, or when adding + * its plain form would push the page's decoded content past one page (so + * the split's decode-to-plain path still fits a page). * - * 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. + * Note: PageGetFreeSpace() subtracts sizeof(ItemIdData) from its result. */ - if (PageGetFreeSpace(page) < itemsz) + if (isleaf && P_HASPREFIX(opaque)) + needsplit = + PageGetFreeSpace(page) < additemsz || + _bt_prefix_split_needed(page, itemsz); + else + needsplit = PageGetFreeSpace(page) < itemsz; + + if (needsplit) { Buffer rbuf; @@ -1228,7 +1317,7 @@ _bt_insertonpg(Relation rel, /* split the buffer into left and right halves */ rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz, - itup, origitup, nposting, postingoff); + itup, origitup, nposting, postingoff, &newitemplaced); PredicateLockPageSplit(rel, BufferGetBlockNumber(buf), BufferGetBlockNumber(rbuf)); @@ -1259,6 +1348,19 @@ _bt_insertonpg(Relation rel, #endif _bt_insert_parent(rel, heaprel, buf, rbuf, stack, isroot, isonly); + + if (additup != itup) + pfree(additup); + + /* be tidy (posting split path) */ + if (postingoff != 0) + { + pfree(nposting); + pfree(itup); + if (codedposting) + pfree(codedposting); + } + return newitemplaced; } else { @@ -1295,11 +1397,17 @@ _bt_insertonpg(Relation rel, START_CRIT_SECTION(); if (postingoff != 0) - memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting))); + { + IndexTuple src = codedposting ? codedposting : nposting; - if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber) + memcpy(oposting, src, MAXALIGN(IndexTupleSize(src))); + } + + if (PageAddItem(page, additup, additemsz, newitemoff, false, false) == InvalidOffsetNumber) elog(PANIC, "failed to add new item to block %u in index \"%s\"", BufferGetBlockNumber(buf), RelationGetRelationName(rel)); + if (isleaf && P_HASPREFIX(opaque)) + _bt_prefix_note_insert(page, additup); MarkBufferDirty(buf); @@ -1384,8 +1492,8 @@ _bt_insertonpg(Relation rel, XLogRegisterBuffer(0, buf, REGBUF_STANDARD); if (postingoff == 0) { - /* Just log itup from caller */ - XLogRegisterBufData(0, itup, IndexTupleSize(itup)); + /* Log the coded (or plain) tuple actually added to the page */ + XLogRegisterBufData(0, additup, IndexTupleSize(additup)); } else { @@ -1418,6 +1526,9 @@ _bt_insertonpg(Relation rel, END_CRIT_SECTION(); + if (additup != itup) + pfree(additup); + /* Release subsidiary buffers */ if (BufferIsValid(metabuf)) _bt_relbuf(rel, metabuf); @@ -1453,7 +1564,11 @@ _bt_insertonpg(Relation rel, /* itup is actually a modified copy of caller's original */ pfree(nposting); pfree(itup); + if (codedposting) + pfree(codedposting); } + + return true; } /* @@ -1486,8 +1601,11 @@ _bt_insertonpg(Relation rel, static Buffer _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, Buffer cbuf, OffsetNumber newitemoff, Size newitemsz, IndexTuple newitem, - IndexTuple orignewitem, IndexTuple nposting, uint16 postingoff) + IndexTuple orignewitem, IndexTuple nposting, uint16 postingoff, + bool *newitemplaced) { + bool placenewitem = true; /* false: split existing items only */ + bool leftcoded; /* left half written in BTP_PREFIX form? */ Buffer rbuf; Page origpage; Page leftpage, @@ -1502,6 +1620,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, Buffer sbuf = InvalidBuffer; Page spage = NULL; BTPageOpaque sopaque = NULL; + bool origprefix = false; /* was origpage a coded prefix page? */ + bool origprefix_array = false; /* using decoded-item array path? */ + Page origbufpage; /* the real buffer page (origpage may be + * scratch) */ + IndexTuple *ditems = NULL; /* decoded data items (prefix split) */ Size itemsz; ItemId itemid; IndexTuple firstright, @@ -1532,12 +1655,63 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * only workspace. */ origpage = BufferGetPage(buf); + origbufpage = origpage; oopaque = BTPageGetOpaque(origpage); isleaf = P_ISLEAF(oopaque); isrightmost = P_RIGHTMOST(oopaque); maxoff = PageGetMaxOffsetNumber(origpage); origpagenumber = BufferGetBlockNumber(buf); + /* + * A coded (BTP_PREFIX) leaf is split by reasoning about its decoded plain + * items, since both halves are first written plain: either from a plain + * scratch copy of the page (when the decoded items fit one page), or from + * an array of the decoded items, in which case the split point is chosen + * by _bt_prefix_findsplitloc from their plain sizes. The two halves are + * coded as whole pages at the end. + */ + if (P_HASPREFIX(oopaque)) + { + Page plain; + + origprefix = true; + + /* + * Decode to plain. If the decoded page fits BLCKSZ (the common + * case), use it directly so upstream _bt_findsplitloc and the plain + * reads -- including their heap-TID tiebreak at duplicate boundaries + * -- apply unchanged. If it exceeds a page (a heavily compressed + * page), fall back to the decoded-item array and a size-balanced + * split point. + */ + plain = _bt_prefix_try_plain_page(rel, origpage); + if (plain != NULL) + { + OffsetNumber codedfirst = P_FIRSTDATAKEY(oopaque); + + origpage = plain; + oopaque = BTPageGetOpaque(origpage); + maxoff = PageGetMaxOffsetNumber(origpage); + origprefix_array = false; + + /* + * newitemoff was computed against the coded page, whose data + * items start one offset later (after the directory item). The + * plain scratch page has no directory, so shift newitemoff into + * its offset space. + */ + newitemoff -= (codedfirst - P_FIRSTDATAKEY(oopaque)); + } + else + { + IndexTuple dhikey; + Size dhikeysz; + + (void) _bt_prefix_decode_items(origpage, &ditems, &dhikey, &dhikeysz); + origprefix_array = true; + } + } + /* * Choose a point to split origpage at. * @@ -1562,8 +1736,34 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * newitem the firstright tuple, though, so this case isn't a special * case. */ - firstrightoff = _bt_findsplitloc(rel, origpage, newitemoff, newitemsz, - newitem, &newitemonleft); + if (origprefix_array) + { + firstrightoff = _bt_prefix_findsplitloc(rel, origpage, ditems, + newitemoff, newitemsz, + &newitemonleft); + if (!OffsetNumberIsValid(firstrightoff)) + { + /* + * No split of these decoded items plus newitem keeps both halves + * within a plain page (newitem is large and the page already + * dense). Split the existing items alone; caller re-inserts + * newitem into the appropriate half, which then fits (or splits + * again by the ordinary rule). Make newitemoff unreachable so + * the transfer loop below never places newitem. + */ + placenewitem = false; + firstrightoff = _bt_prefix_findsplitloc(rel, origpage, ditems, + InvalidOffsetNumber, 0, + &newitemonleft); + newitemonleft = false; + newitemoff = maxoff + 2; /* past the "goes at end" case too */ + postingoff = 0; /* posting split is part of the deferred + * insert */ + } + } + else + firstrightoff = _bt_findsplitloc(rel, origpage, newitemoff, newitemsz, + newitem, &newitemonleft); /* Use temporary buffer for leftpage */ leftpage = leftpage_buf.data; @@ -1588,7 +1788,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * updated version of the page. We need this because XLogInsert will * examine the LSN and possibly dump it in a page image. */ - PageSetLSN(leftpage, PageGetLSN(origpage)); + PageSetLSN(leftpage, PageGetLSN(origbufpage)); /* * Determine page offset number of existing overlapped-with-orignewitem @@ -1616,6 +1816,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, &newitem->t_tid) < 0); Assert(BTreeTupleIsPosting(nposting)); origpagepostingoff = OffsetNumberPrev(newitemoff); + if (origprefix_array) + ditems[origpagepostingoff - P_FIRSTDATAKEY(oopaque)] = nposting; } /* @@ -1647,11 +1849,19 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, else { /* existing item at firstrightoff becomes firstright */ - itemid = PageGetItemId(origpage, firstrightoff); - itemsz = ItemIdGetLength(itemid); - firstright = (IndexTuple) PageGetItem(origpage, itemid); - if (firstrightoff == origpagepostingoff) - firstright = nposting; + if (origprefix_array) + { + firstright = ditems[firstrightoff - P_FIRSTDATAKEY(oopaque)]; + itemsz = IndexTupleSize(firstright); + } + else + { + itemid = PageGetItemId(origpage, firstrightoff); + itemsz = ItemIdGetLength(itemid); + firstright = (IndexTuple) PageGetItem(origpage, itemid); + if (firstrightoff == origpagepostingoff) + firstright = nposting; + } } if (isleaf) @@ -1671,10 +1881,15 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, /* existing item before firstrightoff becomes lastleft */ lastleftoff = OffsetNumberPrev(firstrightoff); Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque)); - itemid = PageGetItemId(origpage, lastleftoff); - lastleft = (IndexTuple) PageGetItem(origpage, itemid); - if (lastleftoff == origpagepostingoff) - lastleft = nposting; + if (origprefix_array) + lastleft = ditems[lastleftoff - P_FIRSTDATAKEY(oopaque)]; + else + { + itemid = PageGetItemId(origpage, lastleftoff); + lastleft = (IndexTuple) PageGetItem(origpage, itemid); + if (lastleftoff == origpagepostingoff) + lastleft = nposting; + } } lefthighkey = _bt_truncate(rel, lastleft, firstright, itup_key); @@ -1818,12 +2033,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, { IndexTuple dataitem; - itemid = PageGetItemId(origpage, i); - itemsz = ItemIdGetLength(itemid); - dataitem = (IndexTuple) PageGetItem(origpage, itemid); + if (origprefix_array) + { + dataitem = ditems[i - P_FIRSTDATAKEY(oopaque)]; + itemsz = IndexTupleSize(dataitem); + } + else + { + itemid = PageGetItemId(origpage, i); + itemsz = ItemIdGetLength(itemid); + dataitem = (IndexTuple) PageGetItem(origpage, itemid); + } /* replace original item with nposting due to posting split? */ - if (i == origpagepostingoff) + if (!origprefix_array && i == origpagepostingoff) { Assert(BTreeTupleIsPosting(dataitem)); Assert(itemsz == MAXALIGN(IndexTupleSize(nposting))); @@ -1884,7 +2107,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, } /* Handle case where newitem goes at the end of rightpage */ - if (i <= newitemoff) + if (placenewitem && i <= newitemoff) { /* * Can't have newitemonleft here; that would imply we were told to put @@ -1947,6 +2170,28 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * not starting the critical section till here because we haven't been * scribbling on the original page yet; see comments above. */ + + /* + * Both halves were built as plain pages (the split-fit guarantee makes + * the decoded items fit), but may have inherited BTP_PREFIX from a coded + * original page; clear it. Then, if prefix compression is enabled, code + * each half as a whole page. That must happen before the critical + * section, since coding pallocs. + */ + if (isleaf) + { + lopaque->btpo_flags &= ~BTP_PREFIX; + ropaque->btpo_flags &= ~BTP_PREFIX; + if (_bt_prefix_enabled(rel)) + { + _bt_prefix_compress_page(rel, leftpage); + _bt_prefix_compress_page(rel, rightpage); + lopaque = BTPageGetOpaque(leftpage); + ropaque = BTPageGetOpaque(rightpage); + } + } + leftcoded = P_HASPREFIX(lopaque); + START_CRIT_SECTION(); /* @@ -1956,7 +2201,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, * original. We need to do this before writing the WAL record, so that * XLogInsert can WAL log an image of the page if necessary. */ - memcpy(origpage, leftpage, BLCKSZ); + memcpy(origbufpage, leftpage, BLCKSZ); /* leftpage, lopaque must not be used below here */ /* @@ -2005,6 +2250,27 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, if (postingoff != 0 && origpagepostingoff < firstrightoff) xlrec.postingoff = postingoff; + /* + * A coded original page is described to redo in its decoded offset + * space (see XLH_SPLIT_ORIG_CODED). The plain-scratch path already + * works there; the decoded-array path works in the coded page's + * offsets, one past the directory item. + */ + xlrec.flags = 0; + if (origprefix) + { + xlrec.flags |= XLH_SPLIT_ORIG_CODED; + if (origprefix_array) + { + xlrec.firstrightoff--; + xlrec.newitemoff--; + } + } + if (leftcoded) + xlrec.flags |= XLH_SPLIT_LEFT_CODED; + if (P_HASPREFIX(ropaque)) + xlrec.flags |= XLH_SPLIT_RIGHT_CODED; + XLogBeginInsert(); XLogRegisterData(&xlrec, SizeOfBtreeSplit); @@ -2083,7 +2349,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, else recptr = XLogGetFakeLSN(rel); - PageSetLSN(origpage, recptr); + PageSetLSN(origbufpage, recptr); PageSetLSN(rightpage, recptr); if (!isrightmost) PageSetLSN(spage, recptr); @@ -2105,6 +2371,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf, pfree(lefthighkey); /* split's done */ + *newitemplaced = placenewitem; return rbuf; } diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c index 83d6d367f81..9fc8e9f1fea 100644 --- a/src/backend/access/nbtree/nbtpage.c +++ b/src/backend/access/nbtree/nbtpage.c @@ -1211,6 +1211,7 @@ _bt_delitems_vacuum(Relation rel, Buffer buf, /* Now handle simple deletes of entire tuples */ if (ndeletable > 0) PageIndexMultiDelete(page, deletable, ndeletable); + _bt_prefix_recount(page); /* * We can clear the vacuum cycle ID since this page has certainly been @@ -1331,6 +1332,7 @@ _bt_delitems_delete(Relation rel, Buffer buf, if (ndeletable > 0) PageIndexMultiDelete(page, deletable, ndeletable); + _bt_prefix_recount(page); /* * Unlike _bt_delitems_vacuum, we *must not* clear the vacuum cycle ID at diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c new file mode 100644 index 00000000000..d399b76237c --- /dev/null +++ b/src/backend/access/nbtree/nbtprefix.c @@ -0,0 +1,1039 @@ +/*------------------------------------------------------------------------- + * + * nbtprefix.c + * Leaf key prefix compression for Postgres btrees. + * + * A leaf of an index whose prefix_compression reloption is on may be flagged + * BTP_PREFIX: its data items are stored "coded" against per-page group + * anchors, dropping the leading bytes each shares with its anchor. The + * on-disk format is described at BTP_PREFIX in nbtree.h; the README covers how + * the rest of nbtree uses it. + * + * Everything here works on pages and tuples alone, never the catalog, so WAL + * redo can lay out a coded page exactly as the primary did. A page gets its + * directory when it is written as a whole (CREATE INDEX, either half of a + * split); an insert codes the new tuple against an existing group, sharing + * however much it happens to share, and deduplication re-codes against the + * page's existing directory. + * + * 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/itup.h" +#include "access/nbtree.h" +#include "access/tupmacs.h" +#include "storage/bufpage.h" +#include "utils/rel.h" + +/* + * In-memory description of one group's anchor: a representative plain tuple + * (the group's first member) whose leading data-area bytes the members share. + */ +typedef struct BTPrefixAnchor +{ + IndexTuple rep; /* representative tuple owning the shared + * bytes */ +} BTPrefixAnchor; + +/* + * In-memory prefix directory for a page under construction (build, split, + * dedup). Written to the on-disk directory item by _bt_prefix_form_directory. + */ +typedef struct BTPrefixDir +{ + int ngroups; + BTPrefixAnchor anchors[FLEXIBLE_ARRAY_MEMBER]; +} BTPrefixDir; + +/* + * On-disk directory item header. decodedsz and maxdecoded cache the decoded + * (plain) size of the page's data items, each counted with its line pointer: + * their sum and the largest. They make _bt_prefix_split_needed O(1). Every + * change to the data items keeps them exact, through page-only routines that + * WAL redo repeats (_bt_prefix_note_insert, _bt_prefix_recount). + */ +typedef struct BTPrefixDirHdr +{ + IndexTupleData header; /* t_tid invalid; t_info = whole item size */ + uint16 ngroups; + uint16 decodedsz; /* sum of decoded item sizes + line pointers */ + uint16 maxdecoded; /* largest of those */ + uint16 anchoroff[FLEXIBLE_ARRAY_MEMBER]; /* from item start */ + /* then the packed anchors */ +} BTPrefixDirHdr; + +/* One packed anchor in the directory item. */ +typedef struct BTPrefixAnchorDisk +{ + uint16 repsz; /* size of the representative tuple blob */ + /* IndexTupleData rep[repsz] follows, MAXALIGN'd */ +} BTPrefixAnchorDisk; + +/* + * A coded member's on-disk body (follows the IndexTupleData header, whose + * t_tid is the member's own heap TID, or posting list metadata): + * + * BTPrefixCode { group, shared_len, orig_info } + * null bitmap of the original tuple (present iff orig_info says so) + * residual: the original key part's data-area bytes from shared_len on + * posting list, if any, in the standard posting tuple layout + * + * Decode is pure byte-splicing and needs no catalog (so WAL redo can do it): + * + * plain = [IndexTupleData with orig_info][null bitmap] + * [rep data bytes 0 .. shared_len)][residual][posting list] + * + * shared_len is the length of the common prefix of the two data areas, so the + * bytes taken from rep are bitwise-identical to the original's, and decode + * reproduces the original tuple byte for byte. + */ +typedef struct BTPrefixCode +{ + uint16 group; /* anchor index in the directory */ + uint16 shared_len; /* leading data-area bytes taken from rep */ + uint16 orig_info; /* original tuple's t_info (size, flags) */ +} BTPrefixCode; + + +/* + * _bt_prefix_enabled + * Is prefix compression turned on for this index? + */ +bool +_bt_prefix_enabled(Relation index) +{ + return index->rd_options && + ((BTOptions *) index->rd_options)->prefix_compression; +} + +/* + * Size of a tuple's key part: everything but a posting list. Index tuples are + * MAXALIGN'd, and so is a posting list's offset, so this is MAXALIGN'd too. + */ +static inline Size +_bt_prefix_keysz(IndexTuple itup) +{ + return BTreeTupleIsPosting(itup) ? BTreeTupleGetPostingOffset(itup) : + IndexTupleSize(itup); +} + +/* + * Size of a directory item for `ngroups' groups whose MAXALIGN'd anchor blobs + * total `anchorsz' bytes. + */ +static inline Size +_bt_prefix_diritem_size(int ngroups, Size anchorsz) +{ + return MAXALIGN(offsetof(BTPrefixDirHdr, anchoroff) + + sizeof(uint16) * ngroups) + anchorsz; +} + +/* + * _bt_prefix_shared_len + * How many leading data-area bytes plain tuple `itup' can take from group + * representative `rep': the length of the common prefix of their key + * parts' data areas. Sharing is by stored bytes, never by the opclass + * notion of equality, so it is sound for any type (an index-only scan + * gets back exactly the bytes that were stored) and needs no catalog. + */ +static Size +_bt_prefix_shared_len(IndexTuple rep, IndexTuple itup) +{ + Size rephoff = IndexInfoFindDataOffset(rep->t_info); + Size hoff = IndexInfoFindDataOffset(itup->t_info); + const char *rp = (const char *) rep + rephoff; + const char *mp = (const char *) itup + hoff; + Size limit = Min(_bt_prefix_keysz(rep) - rephoff, + _bt_prefix_keysz(itup) - hoff); + Size n = 0; + + while (n < limit && rp[n] == mp[n]) + n++; + return n; +} + +/* + * Size of `itup' coded against representative `rep' (what + * _bt_prefix_encode_against would produce), without forming it. + */ +static Size +_bt_prefix_coded_size(IndexTuple rep, IndexTuple itup, Size shared_len) +{ + Size hoff = IndexInfoFindDataOffset(itup->t_info); + Size sz; + + sz = MAXALIGN(sizeof(IndexTupleData) + sizeof(BTPrefixCode) + + (hoff - sizeof(IndexTupleData)) + + (_bt_prefix_keysz(itup) - hoff - shared_len)); + if (BTreeTupleIsPosting(itup)) + sz = MAXALIGN(sz + BTreeTupleGetNPosting(itup) * sizeof(ItemPointerData)); + return sz; +} + +/* + * Running size estimate of a BTP_PREFIX page laid out from a sequence of plain + * tuples in key order, as _bt_prefix_form_page would lay it out (same + * grouping, same coded sizes). Used by CREATE INDEX to fill coded pages. + * The tuples passed to _bt_prefix_est_add must stay valid while the estimate + * is in use. + */ +void +_bt_prefix_est_add(BTPrefixPageEst *est, IndexTuple itup) +{ + Size plain = MAXALIGN(IndexTupleSize(itup)) + sizeof(ItemIdData); + Size shared = 0; + + if (est->ngroups > 0) + shared = _bt_prefix_shared_len(est->grouprep, itup); + if (est->ngroups == 0 || shared == 0) + { + /* starts a new group, the way _bt_prefix_choose_groups does */ + est->grouprep = itup; + est->ngroups++; + est->anchorsz += MAXALIGN(MAXALIGN(sizeof(BTPrefixAnchorDisk)) + + _bt_prefix_keysz(itup)); + shared = _bt_prefix_shared_len(itup, itup); + } + est->lastcoded = _bt_prefix_coded_size(est->grouprep, itup, shared) + + sizeof(ItemIdData); + est->codedsz += est->lastcoded; + est->plainsz += plain; + est->maxplain = Max(est->maxplain, plain); + est->nitems++; +} + +/* Bytes (items plus line pointers) the coded page would use */ +Size +_bt_prefix_est_coded(const BTPrefixPageEst *est) +{ + return _bt_prefix_diritem_size(est->ngroups, est->anchorsz) + + sizeof(ItemIdData) + est->codedsz; +} + +/* + * Is a coded page worth having, given its coded and plain sizes? Demand a + * real saving: decoding costs CPU on every access, so a page that would save + * only a few bytes stays plain. + */ +bool +_bt_prefix_worthwhile(Size codedsz, Size plainsz) +{ + return codedsz * 16 <= plainsz * 15; +} + +/* + * _bt_prefix_choose_groups + * Given `n' plain data tuples in key order (items[0..n-1]), decide the + * prefix groups: a group's representative is its first tuple, and a new + * group starts whenever a tuple shares no leading data-area byte with the + * current representative. Returns a palloc'd BTPrefixDir (caller frees), + * or NULL when nothing at all is shared. Uses only the tuples' bytes, so + * it is deterministic and catalog-free. + */ +static BTPrefixDir * +_bt_prefix_choose_groups(IndexTuple *items, int n) +{ + BTPrefixDir *dir; + int ngroups = 0; + + StaticAssertStmt(MaxIndexTuplesPerPage <= BTREE_PREFIX_MAXGROUPS, + "group number must fit in uint16"); + + if (n <= 1) + return NULL; + + dir = palloc(offsetof(BTPrefixDir, anchors) + sizeof(BTPrefixAnchor) * n); + for (int i = 0; i < n; i++) + { + if (ngroups == 0 || + _bt_prefix_shared_len(dir->anchors[ngroups - 1].rep, items[i]) == 0) + dir->anchors[ngroups++].rep = items[i]; + } + + if (ngroups >= n) + { + pfree(dir); + return NULL; /* every tuple its own group: nothing shared */ + } + + dir->ngroups = ngroups; + return dir; +} + +/* + * _bt_prefix_form_diritem + * Serialize an in-memory BTPrefixDir into the on-disk directory item: + * IndexTupleData header (t_tid zero, t_info = total size) + * uint16 ngroups + * uint16 anchoroff[ngroups] (byte offsets from item start) + * anchor blobs, each MAXALIGN'd: + * BTPrefixAnchorDisk { repsz } + * (padded to MAXALIGN so rep is aligned) + * IndexTuple rep (repsz bytes) + * + * Returns a palloc'd IndexTuple (the directory item). *itemsz is its size. + */ +static IndexTuple +_bt_prefix_form_diritem(BTPrefixDir *dir, Size *itemsz) +{ + int ng = dir->ngroups; + Size total = _bt_prefix_diritem_size(ng, 0); + Size *anchoroff = palloc(sizeof(Size) * ng); + char *buf; + IndexTuple item; + BTPrefixDirHdr *hdr; + + for (int g = 0; g < ng; g++) + { + Size repsz = _bt_prefix_keysz(dir->anchors[g].rep); + + anchoroff[g] = total; + total += MAXALIGN(MAXALIGN(sizeof(BTPrefixAnchorDisk)) + repsz); + } + + if (total > INDEX_SIZE_MASK) + elog(ERROR, "nbtree prefix directory too large (%zu bytes)", total); + + buf = palloc0(total); + item = (IndexTuple) buf; + ItemPointerSetInvalid(&item->t_tid); + item->t_info = (unsigned short) total; /* size only; no null/varwidth */ + + hdr = (BTPrefixDirHdr *) buf; + hdr->ngroups = (uint16) ng; + hdr->decodedsz = 0; /* set by the caller once items are placed */ + hdr->maxdecoded = 0; + for (int g = 0; g < ng; g++) + hdr->anchoroff[g] = (uint16) anchoroff[g]; + + for (int g = 0; g < ng; g++) + { + BTPrefixAnchorDisk *ad = (BTPrefixAnchorDisk *) (buf + anchoroff[g]); + Size repsz = _bt_prefix_keysz(dir->anchors[g].rep); + IndexTuple rep; + + ad->repsz = (uint16) repsz; + /* store the key part only, as a plain non-posting tuple */ + rep = (IndexTuple) ((char *) ad + MAXALIGN(sizeof(BTPrefixAnchorDisk))); + memcpy(rep, dir->anchors[g].rep, repsz); + rep->t_info = (rep->t_info & ~(INDEX_SIZE_MASK | INDEX_ALT_TID_MASK)) | + repsz; + } + + pfree(anchoroff); + *itemsz = total; + return item; +} + +/* + * Read the directory item off a BTP_PREFIX page. + */ +static inline char * +_bt_prefix_diritem(Page page) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber off = BTPageGetPrefixOff(opaque); + + Assert(P_HASPREFIX(opaque)); + return (char *) PageGetItem(page, PageGetItemId(page, off)); +} + +static inline int +_bt_prefix_ngroups(char *diritem) +{ + return ((BTPrefixDirHdr *) diritem)->ngroups; +} + +/* Pointer to anchor g's on-disk blob within the directory item. */ +static inline BTPrefixAnchorDisk * +_bt_prefix_anchor(char *diritem, int g) +{ + return (BTPrefixAnchorDisk *) (diritem + + ((BTPrefixDirHdr *) diritem)->anchoroff[g]); +} + +/* The representative IndexTuple inside an on-disk anchor blob. */ +static inline IndexTuple +_bt_prefix_anchor_rep(BTPrefixAnchorDisk *ad) +{ + return (IndexTuple) ((char *) ad + MAXALIGN(sizeof(BTPrefixAnchorDisk))); +} + + +/* + * _bt_prefix_encode_against + * Produce the coded form of plain tuple `itup' against group `group', + * whose representative is `rep'. Returns a palloc'd coded IndexTuple; + * t_tid (or a posting list) is carried over unchanged. + */ +static IndexTuple +_bt_prefix_encode_against(IndexTuple rep, IndexTuple itup, int group) +{ + Size hoff = IndexInfoFindDataOffset(itup->t_info); + Size keysz = _bt_prefix_keysz(itup); + bool posting = BTreeTupleIsPosting(itup); + Size shared_len; /* bytes of the data area taken from rep */ + Size residual; + Size bodysz; + Size codesz; + char *buf; + IndexTuple coded; + BTPrefixCode *code; + + shared_len = _bt_prefix_shared_len(rep, itup); + + residual = keysz - hoff - shared_len; + bodysz = MAXALIGN(sizeof(IndexTupleData) + sizeof(BTPrefixCode) + + (hoff - sizeof(IndexTupleData)) + residual); + codesz = bodysz; + if (posting) + codesz = MAXALIGN(bodysz + BTreeTupleGetNPosting(itup) * + sizeof(ItemPointerData)); + if (codesz > INDEX_SIZE_MASK) + elog(ERROR, "coded nbtree tuple too large"); + + buf = palloc0(codesz); + coded = (IndexTuple) buf; + coded->t_tid = itup->t_tid; + coded->t_info = (unsigned short) codesz; + + code = (BTPrefixCode *) (buf + sizeof(IndexTupleData)); + code->group = (uint16) group; + code->shared_len = (uint16) shared_len; + /* flags and key-part size of the original; posting-ness is the coded's */ + code->orig_info = (itup->t_info & ~(INDEX_SIZE_MASK | INDEX_ALT_TID_MASK)) | + keysz; + + /* copy the original's null bitmap (bytes between header and data area) */ + memcpy(buf + sizeof(IndexTupleData) + sizeof(BTPrefixCode), + (char *) itup + sizeof(IndexTupleData), + hoff - sizeof(IndexTupleData)); + + /* copy the residual: the original's data-area bytes from shared_len on */ + memcpy(buf + sizeof(IndexTupleData) + sizeof(BTPrefixCode) + + (hoff - sizeof(IndexTupleData)), + (char *) itup + hoff + shared_len, residual); + + /* + * A posting list stays uncoded, in the standard posting tuple layout, so + * code that only reads heap TIDs (VACUUM, LP_DEAD hinting, deletion, + * posting list splits) works on the coded tuple as is. + */ + if (posting) + { + int nhtids = BTreeTupleGetNPosting(itup); + + BTreeTupleSetPosting(coded, nhtids, bodysz); + memcpy(BTreeTupleGetPosting(coded), BTreeTupleGetPosting(itup), + nhtids * sizeof(ItemPointerData)); + } + + return coded; +} + +/* + * _bt_prefix_decode + * Decode coded member `coded' on `page' into caller workspace `buf', + * returning the plain IndexTuple. Pure byte-splicing; no catalog, so WAL + * redo can call it. + */ +IndexTuple +_bt_prefix_decode(Page page, IndexTuple coded, BTItemBuf *buf) +{ + char *diritem = _bt_prefix_diritem(page); + BTPrefixCode *code = (BTPrefixCode *) ((char *) coded + + sizeof(IndexTupleData)); + BTPrefixAnchorDisk *ad = _bt_prefix_anchor(diritem, code->group); + IndexTuple rep = _bt_prefix_anchor_rep(ad); + Size rephoff = IndexInfoFindDataOffset(rep->t_info); + Size hoff = IndexInfoFindDataOffset(code->orig_info); + Size bitmaplen = hoff - sizeof(IndexTupleData); + char *codebody = (char *) code + sizeof(BTPrefixCode); + Size residual; + char *out = buf->data; + IndexTuple plain = (IndexTuple) out; + Size keysz; + + /* + * The original's key-part size is in orig_info; derive the residual + * length from it, not from the coded tuple's size (which has its own + * padding and any posting list). + */ + keysz = (code->orig_info & INDEX_SIZE_MASK); + residual = keysz - hoff - code->shared_len; + + /* header + null bitmap from the original */ + plain->t_tid = coded->t_tid; + plain->t_info = code->orig_info; + memcpy(out + sizeof(IndexTupleData), codebody, bitmaplen); + /* shared leading data bytes from the representative */ + memcpy(out + hoff, (char *) rep + rephoff, code->shared_len); + /* the member's own residual */ + memcpy(out + hoff + code->shared_len, codebody + bitmaplen, residual); + + if (BTreeTupleIsPosting(coded)) + { + int nhtids = BTreeTupleGetNPosting(coded); + Size listsz = nhtids * sizeof(ItemPointerData); + Size plainsz = MAXALIGN(keysz + listsz); + + Assert(plainsz <= BLCKSZ); + plain->t_info = (code->orig_info & ~INDEX_SIZE_MASK) | plainsz; + BTreeTupleSetPosting(plain, nhtids, keysz); + memcpy(out + keysz, BTreeTupleGetPosting(coded), listsz); + memset(out + keysz + listsz, 0, plainsz - keysz - listsz); + } + + return plain; +} + + +/* + * _bt_prefix_form_page + * Lay out a BTP_PREFIX leaf in `newpage' (already _bt_pageinit'd, with + * its opaque area set) from high key `hikey' (NULL on a rightmost page) + * and plain data tuples items[0..n-1] in key order. Returns false, + * leaving `newpage' unspecified, if prefix coding does not pay off (unless + * `force') or the coded items do not fit one page. The page's LSN and + * prune XID are the caller's business. + */ +bool +_bt_prefix_form_page(Page newpage, IndexTuple hikey, IndexTuple *items, int n, + bool force) +{ + BTPrefixPageEst est; + BTPrefixDir *dir; + IndexTuple diritem; + Size diritemsz; + OffsetNumber newoff; + bool ok = true; + + if (n <= 0) + return false; + + /* Is it worth it? The estimate is exact: same grouping, same sizes. */ + memset(&est, 0, sizeof(est)); + for (int i = 0; i < n; i++) + _bt_prefix_est_add(&est, items[i]); + if (!force && !_bt_prefix_worthwhile(_bt_prefix_est_coded(&est), + est.plainsz)) + return false; + + dir = _bt_prefix_choose_groups(items, n); + if (dir == NULL) + { + Assert(!force || n == 1); + return false; /* nothing shared */ + } + + if (hikey && + PageAddItem(newpage, hikey, MAXALIGN(IndexTupleSize(hikey)), P_HIKEY, + false, false) == InvalidOffsetNumber) + elog(ERROR, "could not add high key during prefix compression"); + + diritem = _bt_prefix_form_diritem(dir, &diritemsz); + if (PageAddItem(newpage, diritem, diritemsz, + BTPageGetPrefixOff(BTPageGetOpaque(newpage)), + false, false) == InvalidOffsetNumber) + ok = false; + newoff = OffsetNumberNext(BTPageGetPrefixOff(BTPageGetOpaque(newpage))); + + /* code each data item against its group */ + for (int i = 0, g = 0; ok && i < n; i++) + { + IndexTuple coded; + + /* advance g to the group this item belongs to */ + while (g + 1 < dir->ngroups && items[i] == dir->anchors[g + 1].rep) + g++; + + coded = _bt_prefix_encode_against(dir->anchors[g].rep, items[i], g); + if (PageAddItem(newpage, coded, IndexTupleSize(coded), newoff, + false, false) == InvalidOffsetNumber) + ok = false; /* the coded items do not fit one page */ + newoff = OffsetNumberNext(newoff); + pfree(coded); + } + + pfree(diritem); + pfree(dir); + if (!ok) + return false; + + BTPageGetOpaque(newpage)->btpo_flags |= BTP_PREFIX; + _bt_prefix_recount(newpage); + +#ifdef USE_ASSERT_CHECKING + /* decode must reproduce every plain tuple byte for byte */ + for (int i = 0; i < n; i++) + { + BTItemBuf chk; + IndexTuple d = _bt_getitem(newpage, + P_FIRSTDATAKEY(BTPageGetOpaque(newpage)) + i, + &chk); + + Assert(IndexTupleSize(d) == IndexTupleSize(items[i]) && + memcmp(d, items[i], IndexTupleSize(d)) == 0); + } +#endif + + return true; +} + +/* + * _bt_prefix_compress_page + * Rewrite a finished plain leaf `page' into BTP_PREFIX form, in place, + * if a prefix directory pays off. Called for each half of a split. + * Leaves the page unchanged (and not BTP_PREFIX) when prefix compression + * would not help. + */ +void +_bt_prefix_compress_page(Relation index, Page page) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber minoff, + maxoff; + int n; + IndexTuple *items; + IndexTuple hikey = NULL; + PGAlignedBlock newpagebuf; + Page newpage = (Page) newpagebuf.data; + + if (!_bt_prefix_enabled(index) || !P_ISLEAF(opaque) || P_HASPREFIX(opaque)) + return; + + minoff = P_FIRSTDATAKEY(opaque); + maxoff = PageGetMaxOffsetNumber(page); + if (maxoff < minoff) + return; /* empty page */ + n = maxoff - minoff + 1; + + if (!P_RIGHTMOST(opaque)) + hikey = (IndexTuple) PageGetItem(page, PageGetItemId(page, P_HIKEY)); + + items = palloc(sizeof(IndexTuple) * n); + for (int i = 0; i < n; i++) + items[i] = (IndexTuple) PageGetItem(page, + PageGetItemId(page, minoff + i)); + + _bt_pageinit(newpage, BLCKSZ); + memcpy(BTPageGetOpaque(newpage), opaque, sizeof(BTPageOpaqueData)); + if (_bt_prefix_form_page(newpage, hikey, items, n, false)) + { + PageSetLSN(newpage, PageGetLSN(page)); + ((PageHeader) newpage)->pd_prune_xid = ((PageHeader) page)->pd_prune_xid; + memcpy(page, newpage, BLCKSZ); + } + pfree(items); +} + +/* + * _bt_prefix_encode_insert + * Code plain tuple `itup' for insertion at `newitemoff' on BTP_PREFIX + * page `page'. The new tuple joins the group of its neighbors: the + * governing group is read from the coded item currently at newitemoff + * (the item the insert shifts right) or, at the end of the data area, + * from the item just before. `itup' is coded against that group's + * anchor, sharing however many leading bytes it happens to share + * (possibly none); it is never larger than the plain tuple plus the small + * code header. Returns a palloc'd coded + * IndexTuple (caller frees). Must run BEFORE the WAL critical section. + */ +IndexTuple +_bt_prefix_encode_insert(Page page, OffsetNumber newitemoff, IndexTuple itup) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + char *diritem = _bt_prefix_diritem(page); + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + OffsetNumber neighbor; + IndexTuple ncoded; + BTPrefixCode *ncode; + BTPrefixAnchorDisk *ad; + IndexTuple rep; + int group; + + Assert(P_HASPREFIX(opaque)); + + if (newitemoff <= maxoff && newitemoff >= P_FIRSTDATAKEY(opaque)) + neighbor = newitemoff; + else if (maxoff >= P_FIRSTDATAKEY(opaque)) + neighbor = maxoff; + else + { + /* no data items yet: code uncoded against group 0 */ + ad = _bt_prefix_anchor(diritem, 0); + return _bt_prefix_encode_against(_bt_prefix_anchor_rep(ad), itup, 0); + } + + ncoded = (IndexTuple) PageGetItem(page, PageGetItemId(page, neighbor)); + ncode = (BTPrefixCode *) ((char *) ncoded + sizeof(IndexTupleData)); + group = ncode->group; + ad = _bt_prefix_anchor(diritem, group); + rep = _bt_prefix_anchor_rep(ad); + + return _bt_prefix_encode_against(rep, itup, group); +} + + + +/* + * _bt_prefix_recode + * Code plain tuple `itup' against the group of the coded item at `offnum' + * on BTP_PREFIX page `page'. Used to replace a coded posting list tuple + * with its post-split version (_bt_swap_posting), which keeps the same + * key and therefore codes identically. palloc'd result. + */ +IndexTuple +_bt_prefix_recode(Page page, OffsetNumber offnum, IndexTuple itup) +{ + IndexTuple ncoded = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)); + BTPrefixCode *ncode = (BTPrefixCode *) ((char *) ncoded + + sizeof(IndexTupleData)); + BTPrefixAnchorDisk *ad = _bt_prefix_anchor(_bt_prefix_diritem(page), + ncode->group); + IndexTuple rep = _bt_prefix_anchor_rep(ad); + + return _bt_prefix_encode_against(rep, itup, ncode->group); +} + +/* + * _bt_prefix_decode_items + * Decode every data item of BTP_PREFIX page `page' into a palloc'd array + * of plain IndexTuples (each palloc'd via CopyIndexTuple). *hikey gets a + * palloc'd copy of the high key (NULL if rightmost). Returns the item + * count. Used by _bt_split to split a coded page whose decoded content + * may exceed one page, by reasoning about the plain tuples directly. + */ +int +_bt_prefix_decode_items(Page page, IndexTuple **items_out, + IndexTuple *hikey, Size *hikeysz) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber minoff = P_FIRSTDATAKEY(opaque); + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + int n = (maxoff >= minoff) ? (maxoff - minoff + 1) : 0; + IndexTuple *items = n ? palloc(sizeof(IndexTuple) * n) : NULL; + BTItemBuf decbuf; + int k = 0; + + if (!P_RIGHTMOST(opaque)) + { + ItemId hii = PageGetItemId(page, P_HIKEY); + + *hikey = CopyIndexTuple((IndexTuple) PageGetItem(page, hii)); + *hikeysz = ItemIdGetLength(hii); + } + else + { + *hikey = NULL; + *hikeysz = 0; + } + + for (OffsetNumber off = minoff; off <= maxoff; off = OffsetNumberNext(off)) + { + IndexTuple coded = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + + items[k++] = CopyIndexTuple(_bt_prefix_decode(page, coded, &decbuf)); + } + *items_out = items; + return n; +} + +/* + * _bt_prefix_try_plain_page + * If BTP_PREFIX page `page' decodes to content that fits one BLCKSZ page, + * return a static scratch plain copy of it (high key + decoded items in + * key order). If it would overflow a page, return NULL (caller uses the + * decoded-item array path for the split instead). Valid until next call. + */ +Page +_bt_prefix_try_plain_page(Relation rel, Page page) +{ + static PGAlignedBlock scratch; + Page out = (Page) scratch.data; + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber minoff = P_FIRSTDATAKEY(opaque); + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + OffsetNumber newoff; + BTItemBuf decbuf; + + _bt_pageinit(out, BLCKSZ); + memcpy(BTPageGetOpaque(out), opaque, sizeof(BTPageOpaqueData)); + BTPageGetOpaque(out)->btpo_flags &= ~BTP_PREFIX; + + if (!P_RIGHTMOST(opaque)) + { + ItemId hii = PageGetItemId(page, P_HIKEY); + + if (PageAddItem(out, PageGetItem(page, hii), ItemIdGetLength(hii), + P_HIKEY, false, false) == InvalidOffsetNumber) + return NULL; + } + + newoff = P_FIRSTDATAKEY(BTPageGetOpaque(out)); + for (OffsetNumber off = minoff; off <= maxoff; off = OffsetNumberNext(off)) + { + IndexTuple coded = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + IndexTuple pt = _bt_prefix_decode(page, coded, &decbuf); + + if (PageAddItem(out, pt, IndexTupleSize(pt), newoff, false, false) == + InvalidOffsetNumber) + return NULL; /* decoded content exceeds a page */ + newoff = OffsetNumberNext(newoff); + } + + /* + * _bt_findsplitloc models the split as if the new item were already on + * the page, and sizes its candidate array by the current item count. If + * the decoded page is so full that there's no room to imagine the new + * item, use the decoded-item array split path instead (signalled by + * returning NULL). + */ + if (PageGetFreeSpace(out) < (Size) BTMaxItemSize) + return NULL; + return out; +} + +/* + * Plain (decoded) size of coded member `coded', from its header alone. + */ +static inline Size +_bt_prefix_plain_size(IndexTuple coded) +{ + BTPrefixCode *code = (BTPrefixCode *) ((char *) coded + + sizeof(IndexTupleData)); + Size keysz = code->orig_info & INDEX_SIZE_MASK; + + if (BTreeTupleIsPosting(coded)) + return MAXALIGN(keysz + BTreeTupleGetNPosting(coded) * + sizeof(ItemPointerData)); + return keysz; +} + +/* + * _bt_prefix_split_needed + * Must inserting a plain tuple of (MAXALIGN'd) size `newitemsz' into + * BTP_PREFIX page `page' split it first? + * + * The split-fit guarantee. A coded page may hold more decoded content than a + * plain page, but its decoded items must always split into two plain pages. + * Each coded page keeps + * + * T + hikey + 2 * m + heap TID <= 2 * usable (*) + * + * where T is the decoded size of its data items (including line pointers) and + * m the largest. Under (*) a split of the page plus a new item x always has a + * legal point when x was admitted by this test, because admitting x requires + * (*) to hold with x counted in T and m. Proof: take the leftmost point whose + * right half (items plus the old high key) fits. The point before it did + * not, so the left items before it total less than T - (usable - hikey); + * adding one more item and the new high key (a truncated copy of firstright + * plus a heap TID) gives a left half under T - usable + hikey + 2m + heap TID, + * at most usable by (*). + * + * So insert splits a page before it would break (*). _bt_split's split then + * places the new item too: (*) with the new item counted is exactly what + * failed, but the page without it satisfied (*), and a page that a plain + * split would handle (decoded content at most one page) always has a legal + * point by the usual one-third-of-a-page item size rule; the remaining case + * (content between one and two pages, new item large) is handled by + * _bt_prefix_findsplitloc trying the split without the new item first. + */ +bool +_bt_prefix_split_needed(Page page, Size newitemsz) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + BTPrefixDirHdr *hdr = (BTPrefixDirHdr *) _bt_prefix_diritem(page); + Size usable = BLCKSZ - SizeOfPageHeaderData - + MAXALIGN(sizeof(BTPageOpaqueData)); + Size newsz = MAXALIGN(newitemsz) + sizeof(ItemIdData); + Size total = hdr->decodedsz + newsz; + Size maxitem = Max((Size) hdr->maxdecoded, newsz); + Size hikeysz = 0; + + if (!P_RIGHTMOST(opaque)) + hikeysz = MAXALIGN(ItemIdGetLength(PageGetItemId(page, P_HIKEY))) + + sizeof(ItemIdData); + + return total + hikeysz + 2 * maxitem + MAXALIGN(sizeof(ItemPointerData)) > + 2 * usable; +} + +/* + * _bt_prefix_recount + * Recompute the directory's cached decoded sizes after a change to the + * page's data items (deletion, posting list update, a rebuilt page), or + * drop the directory if no data items remain. Page-only, so WAL redo + * calls it after replaying the same change and reproduces the page. + */ +void +_bt_prefix_recount(Page page) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber minoff, + maxoff; + BTPrefixDirHdr *hdr; + Size total = 0, + maxitem = 0; + + if (!P_HASPREFIX(opaque)) + return; + + minoff = P_FIRSTDATAKEY(opaque); + maxoff = PageGetMaxOffsetNumber(page); + if (maxoff < minoff) + { + /* + * An emptied leaf holds only its high key (page deletion relies on + * that): drop the directory and become a plain page. + */ + PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque)); + opaque->btpo_flags &= ~BTP_PREFIX; + return; + } + + for (OffsetNumber off = minoff; off <= maxoff; off = OffsetNumberNext(off)) + { + IndexTuple coded = (IndexTuple) PageGetItem(page, PageGetItemId(page, off)); + Size sz = _bt_prefix_plain_size(coded) + sizeof(ItemIdData); + + total += sz; + maxitem = Max(maxitem, sz); + } + Assert(total <= PG_UINT16_MAX); + hdr = (BTPrefixDirHdr *) _bt_prefix_diritem(page); + hdr->decodedsz = (uint16) total; + hdr->maxdecoded = (uint16) maxitem; +} + +/* + * The directory's cached decoded sizes packed into one value, for amcheck. + */ +uint32 +_bt_prefix_cached_size(Page page) +{ + BTPrefixDirHdr *hdr = (BTPrefixDirHdr *) _bt_prefix_diritem(page); + + return ((uint32) hdr->decodedsz << 16) | hdr->maxdecoded; +} + +/* + * _bt_prefix_note_insert + * Account for coded item `coded' just added to BTP_PREFIX page `page'. + * Page-only; insert redo calls it too. + */ +void +_bt_prefix_note_insert(Page page, IndexTuple coded) +{ + BTPrefixDirHdr *hdr = (BTPrefixDirHdr *) _bt_prefix_diritem(page); + Size sz = _bt_prefix_plain_size(coded) + sizeof(ItemIdData); + + Assert(hdr->decodedsz + sz <= PG_UINT16_MAX); + hdr->decodedsz += (uint16) sz; + hdr->maxdecoded = (uint16) Max((Size) hdr->maxdecoded, sz); +} + +/* + * _bt_prefix_findsplitloc + * Choose a split point for coded leaf `page', reasoning about its decoded + * items (ditems[], one per data item) as the two plain halves they will + * be written as. With newitemoff valid, the new item (plain size + * newitemsz) is imagined at newitemoff and the result may be + * InvalidOffsetNumber if no point keeps both halves within a page; with + * InvalidOffsetNumber the existing items alone are split and a legal + * point always exists. Returns firstrightoff and sets *newitemonleft, + * with the same meaning as _bt_findsplitloc. + * + * Every candidate is checked against the actual sizes of both halves: the + * left half's new high key is charged as firstright plus a heap TID (as + * _bt_findsplitloc does), the right half keeps the old high key. Among legal + * points the most even one wins. _bt_prefix_split_needed guarantees a legal + * point exists. + */ +OffsetNumber +_bt_prefix_findsplitloc(Relation rel, Page page, IndexTuple *ditems, + OffsetNumber newitemoff, Size newitemsz, + bool *newitemonleft) +{ + BTPageOpaque opaque = BTPageGetOpaque(page); + OffsetNumber mn = P_FIRSTDATAKEY(opaque); + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + bool hasnew = OffsetNumberIsValid(newitemoff); + int n = maxoff - mn + 1; /* existing items */ + int nall = n + (hasnew ? 1 : 0); + int newpos = hasnew ? newitemoff - mn : -1; + Size usable = BLCKSZ - SizeOfPageHeaderData - + MAXALIGN(sizeof(BTPageOpaqueData)); + Size hikeysz = 0; + Size *sz = palloc(sizeof(Size) * nall); + Size total = 0, + acc = 0; + int best = -1; + Size bestdelta = SIZE_MAX; + + if (!P_RIGHTMOST(opaque)) + hikeysz = MAXALIGN(ItemIdGetLength(PageGetItemId(page, P_HIKEY))) + + sizeof(ItemIdData); + + /* sizes of the combined sequence, new item spliced in at newpos */ + for (int k = 0, i = 0; k < nall; k++) + { + if (k == newpos) + sz[k] = MAXALIGN(newitemsz) + sizeof(ItemIdData); + else + sz[k] = MAXALIGN(IndexTupleSize(ditems[i++])) + sizeof(ItemIdData); + total += sz[k]; + } + + /* split before position k: left = [0,k), right = [k,nall) */ + for (int k = 1; k < nall; k++) + { + Size left, + right; + + acc += sz[k - 1]; + left = acc + sz[k] + MAXALIGN(sizeof(ItemPointerData)); + right = (total - acc) + hikeysz; + if (left <= usable && right <= usable) + { + Size delta = left > right ? left - right : right - left; + + if (delta < bestdelta) + { + bestdelta = delta; + best = k; + } + } + } + pfree(sz); + + /* + * With a new item, a missing legal point is possible (the item is large + * and the page dense): return InvalidOffsetNumber so the caller splits + * without it. Without a new item, the split-fit invariant guarantees a + * point (see _bt_prefix_split_needed). + */ + if (best < 0) + { + if (hasnew) + return InvalidOffsetNumber; + elog(ERROR, "could not find a legal split point for prefix-compressed page in index \"%s\"", + RelationGetRelationName(rel)); + } + + /* map combined position back to (firstrightoff, newitemonleft) */ + if (!hasnew || best <= newpos) + { + *newitemonleft = false; + return mn + best; + } + *newitemonleft = true; + return mn + best - 1; +} diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c index 55722fd8791..0ad8564a624 100644 --- a/src/backend/access/nbtree/nbtreadpage.c +++ b/src/backend/access/nbtree/nbtreadpage.c @@ -22,6 +22,7 @@ #include "access/relscan.h" #include "storage/predicate.h" #include "utils/datum.h" +#include "utils/memutils.h" #include "utils/rel.h" @@ -130,6 +131,30 @@ static bool _bt_verify_keys_with_arraykeys(IndexScanDesc scan); * * Returns true if any matching items found on the page, false if none. */ +/* + * _bt_scan_skip_key -- can _bt_readpage skip decoding the current item? + * + * On a prefix-compressed leaf, decoding an item costs a copy. It is wasted + * when (a) this is not an index-only scan (so->currTuples == NULL), so only + * the heap TID or posting list is needed, both of which coding leaves in + * place in the on-page tuple; and (b) _bt_checkkeys would read no key + * attribute: every scan key is already proven satisfied for the whole page + * (startikey reached the end of the scan keys) and there are no array keys + * (array-key advancement reads the key regardless of startikey). Then + * _bt_check_compare would compare nothing and report "passes, continue", so + * the caller treats the item as a match and reads it raw. + * + * The _bt_checkkeys call must be skipped, not just the decode: assert-enabled + * builds re-run _bt_check_compare from ikey 0 there to verify startikey, which + * would read the still-coded key. + */ +static inline bool +_bt_scan_skip_key(BTScanOpaque so, BTReadPageState *pstate, bool arrayKeys) +{ + return so->currTuples == NULL && !arrayKeys && + pstate->startikey >= so->numberOfKeys; +} + bool _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, bool firstpage) @@ -141,6 +166,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, OffsetNumber minoff; OffsetNumber maxoff; BTReadPageState pstate; + BTItemBuf itupbuf; /* decode workspace for a loop's current item */ + BTItemBuf finalbuf; /* decode workspace for finaltup */ bool arrayKeys, ignore_killed_tuples = scan->ignore_killed_tuples; int itemIndex, @@ -156,6 +183,26 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, pstate.dir = so->currPos.dir = dir; so->currPos.nextTupleOffset = 0; + /* + * A prefix-compressed leaf can decode to up to two pages of tuples (see + * _bt_prefix_split_needed), more than the BLCKSZ index-only scan + * workspaces hold. Grow them the first time such a page is read. + */ + if (unlikely(P_HASPREFIX(opaque)) && so->currTuples && !so->bigTuples) + { + char *big; + + big = MemoryContextAlloc(GetMemoryChunkContext(so->currTuples), + 4 * BLCKSZ); + if (BTScanPosIsValid(so->markPos)) + memcpy(big + 2 * BLCKSZ, so->markTuples, + so->markPos.nextTupleOffset); + pfree(so->currTuples); + so->currTuples = big; + so->markTuples = big + 2 * BLCKSZ; + so->bigTuples = true; + } + /* either moreRight or moreLeft should be set now (may be unset later) */ Assert(ScanDirectionIsForward(dir) ? so->currPos.moreRight : so->currPos.moreLeft); @@ -252,12 +299,23 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, continue; } - itup = (IndexTuple) PageGetItem(page, iid); - Assert(!BTreeTupleIsPivot(itup)); + if (_bt_scan_skip_key(so, &pstate, arrayKeys)) + { + /* only the heap TID(s) are needed: read the item raw */ + itup = (IndexTuple) PageGetItem(page, iid); + Assert(!BTreeTupleIsPivot(itup)); + pstate.offnum = offnum; + passes_quals = true; + } + else + { + itup = _bt_getitem(page, offnum, &itupbuf); + Assert(!BTreeTupleIsPivot(itup)); - pstate.offnum = offnum; - passes_quals = _bt_checkkeys(scan, &pstate, arrayKeys, - itup, indnatts); + pstate.offnum = offnum; + passes_quals = _bt_checkkeys(scan, &pstate, arrayKeys, + itup, indnatts); + } /* * Check if we need to skip ahead to a later tuple (only possible @@ -353,9 +411,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, { if (minoff <= maxoff && !P_LEFTMOST(opaque)) { - ItemId iid = PageGetItemId(page, minoff); - - pstate.finaltup = (IndexTuple) PageGetItem(page, iid); + pstate.finaltup = _bt_getitem(page, minoff, &finalbuf); if (unlikely(so->scanBehind) && !_bt_scanbehind_checkkeys(scan, dir, pstate.finaltup)) @@ -415,19 +471,29 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum, else tuple_alive = true; - itup = (IndexTuple) PageGetItem(page, iid); - Assert(!BTreeTupleIsPivot(itup)); - pstate.offnum = offnum; - if (arrayKeys && offnum == minoff && pstate.forcenonrequired) + if (_bt_scan_skip_key(so, &pstate, arrayKeys)) { - /* Reset arrays, per _bt_set_startikey contract */ - pstate.forcenonrequired = false; - pstate.startikey = 0; - _bt_start_array_keys(scan, dir); + /* only the heap TID(s) are needed: read the item raw */ + itup = (IndexTuple) PageGetItem(page, iid); + Assert(!BTreeTupleIsPivot(itup)); + passes_quals = true; + } + else + { + itup = _bt_getitem(page, offnum, &itupbuf); + Assert(!BTreeTupleIsPivot(itup)); + + if (arrayKeys && offnum == minoff && pstate.forcenonrequired) + { + /* Reset arrays, per _bt_set_startikey contract */ + pstate.forcenonrequired = false; + pstate.startikey = 0; + _bt_start_array_keys(scan, dir); + } + passes_quals = _bt_checkkeys(scan, &pstate, arrayKeys, + itup, indnatts); } - passes_quals = _bt_checkkeys(scan, &pstate, arrayKeys, - itup, indnatts); if (arrayKeys && so->scanBehind) { @@ -596,7 +662,9 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate) BTScanOpaque so = (BTScanOpaque) scan->opaque; Relation rel = scan->indexRelation; TupleDesc tupdesc = RelationGetDescr(rel); - ItemId iid; + ItemId iid = NULL; + BTItemBuf firstbuf, + lastbuf; IndexTuple firsttup, lasttup; int startikey = 0, @@ -616,12 +684,11 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate) return; /* minoff is an offset to the lowest non-pivot tuple on the page */ - iid = PageGetItemId(pstate->page, pstate->minoff); - firsttup = (IndexTuple) PageGetItem(pstate->page, iid); + (void) iid; + firsttup = _bt_getitem(pstate->page, pstate->minoff, &firstbuf); /* maxoff is an offset to the highest non-pivot tuple on the page */ - iid = PageGetItemId(pstate->page, pstate->maxoff); - lasttup = (IndexTuple) PageGetItem(pstate->page, iid); + lasttup = _bt_getitem(pstate->page, pstate->maxoff, &lastbuf); /* Determine the first attribute whose values change on caller's page */ firstchangingattnum = _bt_keep_natts_fast(rel, firsttup, lasttup); @@ -2053,6 +2120,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate, ScanDirection dir = pstate->dir; OffsetNumber aheadoffnum; IndexTuple ahead; + BTItemBuf aheadbuf; Assert(!pstate->forcenonrequired); @@ -2088,8 +2156,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate, aheadoffnum = Max((int) pstate->minoff, (int) pstate->offnum - pstate->targetdistance); - ahead = (IndexTuple) PageGetItem(pstate->page, - PageGetItemId(pstate->page, aheadoffnum)); + ahead = _bt_getitem(pstate->page, aheadoffnum, &aheadbuf); if (_bt_tuple_before_array_skeys(scan, dir, ahead, tupdesc, tupnatts, false, 0, NULL)) { diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c index 0abdd7b49f5..cadcbb82565 100644 --- a/src/backend/access/nbtree/nbtree.c +++ b/src/backend/access/nbtree/nbtree.c @@ -373,6 +373,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->bigTuples = false; scan->xs_itupdesc = RelationGetDescr(rel); diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c index 61f63251c6f..8cf4441df69 100644 --- a/src/backend/access/nbtree/nbtsearch.c +++ b/src/backend/access/nbtree/nbtsearch.c @@ -713,7 +713,16 @@ _bt_compare(Relation rel, if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque)) return 1; - itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)); + /* + * On a prefix-compressed leaf a data item is stored coded; compare + * against its decoded form so the heap TID tiebreak below applies + * unchanged. + */ + { + static BTItemBuf prefixbuf; + + itup = _bt_getitem(page, offnum, &prefixbuf); + } ntupatts = BTreeTupleGetNAtts(itup, rel); /* diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c index 756dfa3dcf4..0d165ed4963 100644 --- a/src/backend/access/nbtree/nbtsort.c +++ b/src/backend/access/nbtree/nbtsort.c @@ -242,6 +242,18 @@ 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 */ + + /* + * Leaf level of a prefix_compression index: tuples are collected here + * (copied) instead of on btps_buf, and a leaf is laid out -- coded or + * plain -- when the next tuple would overfill it. See + * _bt_buildadd_coded. + */ + IndexTuple *btps_items; /* pending leaf tuples, palloc'd copies */ + int btps_nitems; + int btps_maxitems; + BTPrefixPageEst btps_est; /* coded estimate of btps_items */ + Size btps_lastkey; /* worst-case high key from the last item */ } BTPageState; /* @@ -268,6 +280,10 @@ static void _bt_build_callback(Relation index, ItemPointer tid, Datum *values, static BulkWriteBuffer _bt_blnewpage(BTWriteState *wstate, uint32 level); static BTPageState *_bt_pagestate(BTWriteState *wstate, uint32 level); static void _bt_slideleft(Page rightmostpage); +static void _bt_buildadd_coded(BTWriteState *wstate, BTPageState *state, + IndexTuple itup, Size truncextra); +static void _bt_flush_coded(BTWriteState *wstate, BTPageState *state, + bool last); static void _bt_sortaddtup(Page page, Size itemsize, const IndexTupleData *itup, OffsetNumber itup_off, bool newfirstdataitem); @@ -804,6 +820,12 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, */ CHECK_FOR_INTERRUPTS(); + if (state->btps_level == 0 && _bt_prefix_enabled(wstate->index)) + { + _bt_buildadd_coded(wstate, state, itup, truncextra); + return; + } + nbuf = state->btps_buf; npage = (Page) nbuf; nblkno = state->btps_blkno; @@ -1022,6 +1044,248 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, state->btps_lastoff = last_off; } +/* + * _bt_buildadd_coded -- add a leaf tuple to a prefix_compression index build. + * + * Leaf tuples are collected in state->btps_items with a running estimate of + * the page they would make when coded (BTPrefixPageEst). Before adding itup, + * the pending leaf is laid out and written if itup would push it past either + * limit: + * + * - the leaf fillfactor, applied to the coded (on-disk) size, so a coded + * leaf leaves the same insert headroom that a plain one does; and + * - the split-fit budget (see _bt_prefix_split_needed): the decoded items of + * a coded page must still split into two plain pages. + * + * A pending leaf that would not compress worthwhile is laid out plain, which + * caps it at what a plain page holds (the coded limits above are then + * irrelevant), exactly as the non-prefix build does. + */ +static void +_bt_buildadd_coded(BTWriteState *wstate, BTPageState *state, IndexTuple itup, + Size truncextra) +{ + Size usable = BLCKSZ - SizeOfPageHeaderData - + MAXALIGN(sizeof(BTPageOpaqueData)); + Size fillsz = usable - state->btps_full; + Size itupsz = MAXALIGN(IndexTupleSize(itup)); + BTPrefixPageEst est; + + CHECK_FOR_INTERRUPTS(); + + if (unlikely(itupsz > BTMaxItemSize)) + _bt_check_third_page(wstate->index, wstate->heap, true, + (Page) state->btps_buf, itup); + + if (state->btps_items == NULL) + { + state->btps_maxitems = 2 * MaxIndexTuplesPerPage; + state->btps_items = palloc_array(IndexTuple, state->btps_maxitems); + } + + /* + * Mirror _bt_buildadd's two tests, in terms of the leaf the pending + * tuples would make. A finished leaf keeps all pending tuples but the + * last, which moves to the next leaf and supplies the new high key (at + * most its key part plus a heap TID, stored plain): + * + * - soft: finish the leaf once it is fillfactor-full as it stands; - + * hard: finish it if admitting itup could leave it unable to hold its + * tuples and high key, or (coded) beyond the split-fit budget (see + * _bt_prefix_split_needed), or out of line pointers. + * + * Sizes are coded when coding is worthwhile and plain otherwise, so a + * build that does not compress fills leaves exactly as a plain one. + */ + /* + * Copy itup first: the running estimate keeps a pointer to its group's + * anchor tuple, which may be this one, so it must see the copy that is + * kept. Then estimate the leaf with itup admitted, once. + */ + itup = CopyIndexTuple(itup); + est = state->btps_est; + _bt_prefix_est_add(&est, itup); + + if (state->btps_nitems >= 2) + { + BTPrefixPageEst *pend = &state->btps_est; + Size lastplain = MAXALIGN(IndexTupleSize(state->btps_items[state->btps_nitems - 1])) + + sizeof(ItemIdData); + Size itupplain = itupsz + sizeof(ItemIdData); + Size itupkey = itupsz - truncextra + + MAXALIGN(sizeof(ItemPointerData)) + sizeof(ItemIdData); + Size leafnow, + leafwith; + bool full; + + if (_bt_prefix_worthwhile(_bt_prefix_est_coded(pend), pend->plainsz)) + leafnow = _bt_prefix_est_coded(pend) - pend->lastcoded + + state->btps_lastkey; + else + leafnow = pend->plainsz - lastplain + state->btps_lastkey; + full = leafnow > fillsz; + + if (!full) + { + if (_bt_prefix_worthwhile(_bt_prefix_est_coded(&est), est.plainsz)) + { + leafwith = _bt_prefix_est_coded(&est) - est.lastcoded + itupkey; + full = est.plainsz - itupplain + itupkey + 2 * est.maxplain + + MAXALIGN(sizeof(ItemPointerData)) > 2 * usable; + } + else + leafwith = est.plainsz - itupplain + itupkey; + full = full || leafwith > usable || + state->btps_nitems >= MaxIndexTuplesPerPage - 2; + } + + if (full) + { + /* itup starts the next leaf: its estimate starts over */ + _bt_flush_coded(wstate, state, false); + est = state->btps_est; + _bt_prefix_est_add(&est, itup); + } + } + + if (state->btps_nitems >= state->btps_maxitems) + { + state->btps_maxitems *= 2; + state->btps_items = repalloc_array(state->btps_items, IndexTuple, + state->btps_maxitems); + } + state->btps_items[state->btps_nitems++] = itup; + state->btps_est = est; + state->btps_lastkey = itupsz - truncextra + + MAXALIGN(sizeof(ItemPointerData)) + sizeof(ItemIdData); +} + +/* + * _bt_flush_coded -- lay out and write the pending leaf of a coded build. + * + * All pending tuples but the last go on the leaf (the last one starts the next + * leaf and supplies the new high key, as in _bt_buildadd). When `last' the + * whole pending set makes the final, rightmost leaf, which is left in + * state->btps_buf for _bt_uppershutdown to write. + */ +static void +_bt_flush_coded(BTWriteState *wstate, BTPageState *state, bool last) +{ + Page page = (Page) state->btps_buf; + BTPageOpaque opaque = BTPageGetOpaque(page); + int nleaf = last ? state->btps_nitems : state->btps_nitems - 1; + IndexTuple firstright = last ? NULL : state->btps_items[nleaf]; + IndexTuple hikey = NULL; + BTPageOpaqueData saveopaque; + Size usable = BLCKSZ - SizeOfPageHeaderData - + MAXALIGN(sizeof(BTPageOpaqueData)); + Size plainsz; + bool trycode; + BulkWriteBuffer nbuf = NULL; + BlockNumber nblkno = InvalidBlockNumber; + + Assert(nleaf >= 1); + + /* + * A finished leaf is not rightmost: link it to the next leaf before + * laying it out, since the layout (high key, directory offset) depends on + * that. + */ + if (!last) + { + nbuf = _bt_blnewpage(wstate, 0); + nblkno = wstate->btws_pages_alloced++; + opaque->btpo_next = nblkno; + BTPageGetOpaque((Page) nbuf)->btpo_prev = state->btps_blkno; + } + saveopaque = *opaque; + + if (!last) + hikey = _bt_truncate(wstate->index, state->btps_items[nleaf - 1], + firstright, wstate->inskey); + + /* + * Lay out the leaf: coded if worthwhile, else plain. Leaving out the + * last tuple can tip the worthwhile test; if the plain layout would then + * overflow a page, code it anyway (the admission test in + * _bt_buildadd_coded guaranteed the coded layout fits). + * + * The running estimate covers all pending tuples; without the last one + * (when it moves on) the leaf is coded unless its plain size, which is + * exact, says otherwise. Skip the coding attempt outright when the + * estimate says it does not pay and plain fits: that is the common case + * for an incompressible index, and it saves a second pass over the + * tuples. + */ + plainsz = hikey ? MAXALIGN(IndexTupleSize(hikey)) + sizeof(ItemIdData) : 0; + for (int i = 0; i < nleaf; i++) + plainsz += MAXALIGN(IndexTupleSize(state->btps_items[i])) + + sizeof(ItemIdData); + trycode = plainsz > usable || + _bt_prefix_worthwhile(_bt_prefix_est_coded(&state->btps_est), + state->btps_est.plainsz); + _bt_pageinit(page, BLCKSZ); + *BTPageGetOpaque(page) = saveopaque; + opaque = BTPageGetOpaque(page); + if (!trycode || + !_bt_prefix_form_page(page, hikey, state->btps_items, + nleaf, plainsz > usable)) + { + OffsetNumber off = P_FIRSTKEY; + + if (plainsz > usable) + elog(ERROR, "could not lay out prefix-compressed leaf during build of index \"%s\"", + RelationGetRelationName(wstate->index)); + + _bt_pageinit(page, BLCKSZ); + *BTPageGetOpaque(page) = saveopaque; + opaque = BTPageGetOpaque(page); + if (hikey) + _bt_sortaddtup(page, IndexTupleSize(hikey), hikey, P_HIKEY, false); + else + off = P_HIKEY; /* rightmost: no high key */ + for (int i = 0; i < nleaf; i++, off = OffsetNumberNext(off)) + _bt_sortaddtup(page, MAXALIGN(IndexTupleSize(state->btps_items[i])), + state->btps_items[i], off, false); + } + + /* minus infinity low key for the leftmost leaf */ + if (state->btps_lowkey == NULL) + { + state->btps_lowkey = palloc0_object(IndexTupleData); + state->btps_lowkey->t_info = sizeof(IndexTupleData); + BTreeTupleSetNAtts(state->btps_lowkey, 0, false); + } + + if (!last) + { + /* link the finished leaf into its parent, using its low key */ + if (state->btps_next == NULL) + state->btps_next = _bt_pagestate(wstate, 1); + BTreeTupleSetDownLink(state->btps_lowkey, state->btps_blkno); + _bt_buildadd(wstate, state->btps_next, state->btps_lowkey, 0); + pfree(state->btps_lowkey); + state->btps_lowkey = hikey; /* the next leaf's low key */ + + _bt_blwritepage(wstate, state->btps_buf, state->btps_blkno); + state->btps_buf = nbuf; + state->btps_blkno = nblkno; + } + + /* keep only the carried-over first tuple of the next leaf */ + for (int i = 0; i < nleaf; i++) + pfree(state->btps_items[i]); + state->btps_nitems = 0; + memset(&state->btps_est, 0, sizeof(BTPrefixPageEst)); + if (!last) + { + state->btps_items[0] = firstright; + state->btps_nitems = 1; + _bt_prefix_est_add(&state->btps_est, firstright); + /* btps_lastkey still describes firstright */ + } +} + /* * Finalize pending posting list tuple, and add it to the index. Final tuple * is based on saved base tuple, and saved list of heap TIDs. @@ -1074,6 +1338,10 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state) /* * Each iteration of this loop completes one more level of the tree. */ + /* lay out the final leaf of a prefix_compression index */ + if (state != NULL && state->btps_items != NULL) + _bt_flush_coded(wstate, state, true); + for (s = state; s != NULL; s = s->btps_next) { BlockNumber blkno; @@ -1114,7 +1382,8 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state) * This is the rightmost page, so the ItemId array needs to be slid * back one slot. Then we can dump out the page. */ - _bt_slideleft((Page) s->btps_buf); + if (s->btps_items == NULL) /* coded-build leaves are laid out exactly */ + _bt_slideleft((Page) s->btps_buf); _bt_blwritepage(wstate, s->btps_buf, s->btps_blkno); s->btps_buf = NULL; /* writepage took ownership of the buffer */ } diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c index 85c1b632522..5dc2b2f3050 100644 --- a/src/backend/access/nbtree/nbtutils.c +++ b/src/backend/access/nbtree/nbtutils.c @@ -602,7 +602,9 @@ btoptions(Datum reloptions, bool validate) {"vacuum_cleanup_index_scale_factor", RELOPT_TYPE_REAL, offsetof(BTOptions, vacuum_cleanup_index_scale_factor)}, {"deduplicate_items", RELOPT_TYPE_BOOL, - offsetof(BTOptions, deduplicate_items)} + offsetof(BTOptions, deduplicate_items)}, + {"prefix_compression", RELOPT_TYPE_BOOL, + offsetof(BTOptions, prefix_compression)} }; return (bytea *) build_reloptions(reloptions, validate, diff --git a/src/backend/access/nbtree/nbtxlog.c b/src/backend/access/nbtree/nbtxlog.c index dff7d286fc8..cb8846b59da 100644 --- a/src/backend/access/nbtree/nbtxlog.c +++ b/src/backend/access/nbtree/nbtxlog.c @@ -186,6 +186,11 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting, /* Simple retail insertion */ if (PageAddItem(page, datapos, datalen, xlrec->offnum, false, false) == InvalidOffsetNumber) elog(PANIC, "failed to add new item"); + /* the logged item is the coded one on a prefix leaf */ + if (isleaf && P_HASPREFIX(BTPageGetOpaque(page))) + _bt_prefix_note_insert(page, + (IndexTuple) PageGetItem(page, + PageGetItemId(page, xlrec->offnum))); } else { @@ -215,15 +220,45 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting, /* Use mutable, aligned newitem copy in _bt_swap_posting() */ Assert(isleaf && postingoff > 0); newitem = CopyIndexTuple((IndexTuple) datapos); - nposting = _bt_swap_posting(newitem, oposting, postingoff); - /* Replace existing posting list with post-split version */ - memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting))); + if (P_HASPREFIX(BTPageGetOpaque(page))) + { + /* + * Coded leaf: repeat _bt_insertonpg's steps -- swap on the + * decoded posting list, then code both results against their + * neighbors' groups (catalog-free, so redo can). + */ + BTItemBuf pbuf; + IndexTuple plainpost = CopyIndexTuple(_bt_getitem(page, + OffsetNumberPrev(xlrec->offnum), + &pbuf)); + IndexTuple codedpost; + IndexTuple codednew; + + nposting = _bt_swap_posting(newitem, plainpost, postingoff); + codedpost = _bt_prefix_recode(page, + OffsetNumberPrev(xlrec->offnum), + nposting); + Assert(IndexTupleSize(codedpost) == ItemIdGetLength(itemid)); + memcpy(oposting, codedpost, MAXALIGN(IndexTupleSize(codedpost))); + codednew = _bt_prefix_encode_insert(page, xlrec->offnum, newitem); + if (PageAddItem(page, codednew, MAXALIGN(IndexTupleSize(codednew)), + xlrec->offnum, false, false) == InvalidOffsetNumber) + elog(PANIC, "failed to add posting split new item"); + _bt_prefix_note_insert(page, codednew); + } + else + { + nposting = _bt_swap_posting(newitem, oposting, postingoff); - /* Insert "final" new item (not orignewitem from WAL stream) */ - Assert(IndexTupleSize(newitem) == datalen); - if (PageAddItem(page, newitem, datalen, xlrec->offnum, false, false) == InvalidOffsetNumber) - elog(PANIC, "failed to add posting split new item"); + /* Replace existing posting list with post-split version */ + memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting))); + + /* Insert "final" new item (not orignewitem from WAL stream) */ + Assert(IndexTupleSize(newitem) == datalen); + if (PageAddItem(page, newitem, datalen, xlrec->offnum, false, false) == InvalidOffsetNumber) + elog(PANIC, "failed to add posting split new item"); + } } PageSetLSN(page, lsn); @@ -280,7 +315,11 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) if (!isleaf) _bt_clear_incomplete_split(record, 3); - /* Reconstruct right (new) sibling page from scratch */ + /* + * Reconstruct right (new) sibling page from scratch. A coded right half + * was logged as its coded items, directory included, which + * _bt_restore_page lays out exactly as the primary did. + */ rbuf = XLogInitBufferForRedo(record, 1); datapos = XLogRecGetBlockData(record, 1, &datalen); rpage = BufferGetPage(rbuf); @@ -292,6 +331,8 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) ropaque->btpo_next = spagenumber; ropaque->btpo_level = xlrec->level; ropaque->btpo_flags = isleaf ? BTP_LEAF : 0; + if (xlrec->flags & XLH_SPLIT_RIGHT_CODED) + ropaque->btpo_flags |= BTP_PREFIX; ropaque->btpo_cycleid = 0; _bt_restore_page(rpage, datapos, datalen); @@ -320,7 +361,35 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) left_hikeysz = 0; Page leftpage; OffsetNumber leftoff, + firstdata, replacepostingoff = InvalidOffsetNumber; + IndexTuple *ditems = NULL; /* decoded items of a coded origpage */ + IndexTuple *litems; /* the left half's items, in order */ + int nlitems = 0; + + /* + * The original page's data items, as plain tuples. For a coded page + * (XLH_SPLIT_ORIG_CODED) the record's offsets are into the decoded + * sequence, which starts at the prefix directory's offset. + */ + if (xlrec->flags & XLH_SPLIT_ORIG_CODED) + { + IndexTuple dhikey; + Size dhikeysz; + + Assert(P_HASPREFIX(oopaque)); + (void) _bt_prefix_decode_items(origpage, &ditems, &dhikey, + &dhikeysz); + if (dhikey) + pfree(dhikey); + firstdata = BTPageGetPrefixOff(oopaque); + } + else + firstdata = P_FIRSTDATAKEY(oopaque); + +#define ORIG_ITEM(o) \ + (ditems ? ditems[(o) - firstdata] : \ + (IndexTuple) PageGetItem(origpage, PageGetItemId(origpage, (o)))) datapos = XLogRecGetBlockData(record, 0, &datalen); @@ -333,7 +402,6 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) if (xlrec->postingoff != 0) { - ItemId itemid; IndexTuple oposting; /* Posting list must be at offset number before new item's */ @@ -341,8 +409,7 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) /* Use mutable, aligned newitem copy in _bt_swap_posting() */ newitem = CopyIndexTuple(newitem); - itemid = PageGetItemId(origpage, replacepostingoff); - oposting = (IndexTuple) PageGetItem(origpage, itemid); + oposting = ORIG_ITEM(replacepostingoff); nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff); } @@ -360,64 +427,77 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record) Assert(datalen == 0); - leftpage = PageGetTempPageCopySpecial(origpage); - - /* Add high key tuple from WAL record to temp page */ - leftoff = P_HIKEY; - if (PageAddItem(leftpage, left_hikey, left_hikeysz, P_HIKEY, false, false) == InvalidOffsetNumber) - elog(ERROR, "failed to add high key to left page after split"); - leftoff = OffsetNumberNext(leftoff); - - for (off = P_FIRSTDATAKEY(oopaque); off < xlrec->firstrightoff; off++) + /* Collect the left half's items, in item number order */ + litems = palloc(sizeof(IndexTuple) * (MaxIndexTuplesPerPage * 2 + 1)); + for (off = firstdata; off < xlrec->firstrightoff; off++) { - ItemId itemid; - Size itemsz; - IndexTuple item; - /* Add replacement posting list when required */ if (off == replacepostingoff) { Assert(newitemonleft || xlrec->firstrightoff == xlrec->newitemoff); - if (PageAddItem(leftpage, nposting, MAXALIGN(IndexTupleSize(nposting)), leftoff, false, false) == InvalidOffsetNumber) - elog(ERROR, "failed to add new posting list item to left page after split"); - leftoff = OffsetNumberNext(leftoff); + litems[nlitems++] = nposting; continue; /* don't insert oposting */ } /* add the new item if it was inserted on left page */ else if (newitemonleft && off == xlrec->newitemoff) - { - if (PageAddItem(leftpage, newitem, newitemsz, leftoff, false, false) == InvalidOffsetNumber) - elog(ERROR, "failed to add new item to left page after split"); - leftoff = OffsetNumberNext(leftoff); - } + litems[nlitems++] = newitem; - itemid = PageGetItemId(origpage, off); - itemsz = ItemIdGetLength(itemid); - item = (IndexTuple) PageGetItem(origpage, itemid); - if (PageAddItem(leftpage, item, itemsz, leftoff, false, false) == InvalidOffsetNumber) - elog(ERROR, "failed to add old item to left page after split"); - leftoff = OffsetNumberNext(leftoff); + litems[nlitems++] = ORIG_ITEM(off); } /* cope with possibility that newitem goes at the end */ if (newitemonleft && off == xlrec->newitemoff) - { - if (PageAddItem(leftpage, newitem, newitemsz, leftoff, false, false) == InvalidOffsetNumber) - elog(ERROR, "failed to add new item to left page after split"); - leftoff = OffsetNumberNext(leftoff); - } + litems[nlitems++] = newitem; +#undef ORIG_ITEM - PageRestoreTempPage(leftpage, origpage); + /* + * To retain the same physical order of the tuples that they had, we + * initialize a temporary empty page for the left page and add all the + * items to that in item number order. This mirrors how _bt_split() + * works. Retaining the same physical order makes WAL consistency + * checking possible. See also _bt_restore_page(), which does the + * same for the right page. + */ + leftpage = PageGetTempPageCopySpecial(origpage); - /* Fix opaque fields */ + /* Fix opaque fields (the layout of a coded page depends on them) */ + oopaque = BTPageGetOpaque(leftpage); oopaque->btpo_flags = BTP_INCOMPLETE_SPLIT; if (isleaf) oopaque->btpo_flags |= BTP_LEAF; oopaque->btpo_next = rightpagenumber; oopaque->btpo_cycleid = 0; + if (xlrec->flags & XLH_SPLIT_LEFT_CODED) + { + /* lay out the coded left half exactly as _bt_split did */ + if (!_bt_prefix_form_page(leftpage, left_hikey, litems, nlitems, + true)) + elog(PANIC, "failed to lay out prefix-compressed left page after split"); + } + else + { + /* Add high key tuple from WAL record to temp page */ + if (PageAddItem(leftpage, left_hikey, left_hikeysz, P_HIKEY, false, false) == InvalidOffsetNumber) + elog(ERROR, "failed to add high key to left page after split"); + leftoff = OffsetNumberNext(P_HIKEY); + + for (int i = 0; i < nlitems; i++) + { + if (PageAddItem(leftpage, litems[i], + MAXALIGN(IndexTupleSize(litems[i])), leftoff, + false, false) == InvalidOffsetNumber) + elog(ERROR, "failed to add item to left page after split"); + leftoff = OffsetNumberNext(leftoff); + } + } + + /* decoded items, nposting and newitem go with the redo memory context */ + PageRestoreTempPage(leftpage, origpage); + pfree(litems); + PageSetLSN(origpage, lsn); MarkBufferDirty(buf); } @@ -468,6 +548,8 @@ btree_xlog_dedup(XLogReaderState *record) BTDedupState state; BTDedupInterval *intervals; Page newpage; + bool coded = P_HASPREFIX(opaque); + IndexTuple *ditems = NULL; state = palloc_object(BTDedupStateData); state->deduplicate = true; /* unused */ @@ -497,13 +579,33 @@ btree_xlog_dedup(XLogReaderState *record) elog(ERROR, "deduplication failed to add highkey"); } + /* + * A prefix-compressed page is deduplicated on decoded copies of its + * items and keeps its directory, as in _bt_dedup_pass. + */ + if (coded) + { + OffsetNumber prefixoff = BTPageGetPrefixOff(opaque); + ItemId diritemid = PageGetItemId(page, prefixoff); + IndexTuple dummyhikey; + Size dummyhikeysz; + + if (PageAddItem(newpage, PageGetItem(page, diritemid), + ItemIdGetLength(diritemid), prefixoff, + false, false) == InvalidOffsetNumber) + elog(ERROR, "deduplication failed to add prefix directory"); + (void) _bt_prefix_decode_items(page, &ditems, &dummyhikey, + &dummyhikeysz); + } + intervals = (BTDedupInterval *) ptr; for (offnum = minoff; offnum <= maxoff; offnum = OffsetNumberNext(offnum)) { ItemId itemid = PageGetItemId(page, offnum); - IndexTuple itup = (IndexTuple) PageGetItem(page, itemid); + IndexTuple itup = coded ? ditems[offnum - minoff] : + (IndexTuple) PageGetItem(page, itemid); if (offnum == minoff) _bt_dedup_start_pending(state, itup, offnum); @@ -516,12 +618,21 @@ btree_xlog_dedup(XLogReaderState *record) } else { - _bt_dedup_finish_pending(newpage, state); + if (coded) + _bt_dedup_finish_pending_coded(page, newpage, state); + else + _bt_dedup_finish_pending(newpage, state); _bt_dedup_start_pending(state, itup, offnum); } } - _bt_dedup_finish_pending(newpage, state); + if (coded) + { + _bt_dedup_finish_pending_coded(page, newpage, state); + _bt_prefix_recount(newpage); + } + else + _bt_dedup_finish_pending(newpage, state); Assert(state->nintervals == xlrec->nintervals); Assert(memcmp(state->intervals, intervals, state->nintervals * sizeof(BTDedupInterval)) == 0); @@ -620,6 +731,7 @@ btree_xlog_vacuum(XLogReaderState *record) if (xlrec->ndeleted > 0) PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted); + _bt_prefix_recount(page); /* * Clear the vacuum cycle ID, and mark the page as not containing any @@ -686,6 +798,7 @@ btree_xlog_delete(XLogReaderState *record) if (xlrec->ndeleted > 0) PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted); + _bt_prefix_recount(page); /* * Do *not* clear the vacuum cycle ID, but do mark the page as not diff --git a/src/backend/access/rmgrdesc/nbtdesc.c b/src/backend/access/rmgrdesc/nbtdesc.c index 1d08f9957bd..cc5edbc1dbc 100644 --- a/src/backend/access/rmgrdesc/nbtdesc.c +++ b/src/backend/access/rmgrdesc/nbtdesc.c @@ -46,6 +46,11 @@ btree_desc(StringInfo buf, XLogReaderState *record) appendStringInfo(buf, "level: %u, firstrightoff: %d, newitemoff: %d, postingoff: %d", xlrec->level, xlrec->firstrightoff, xlrec->newitemoff, xlrec->postingoff); + if (xlrec->flags != 0) + appendStringInfo(buf, ", prefix:%s%s%s", + (xlrec->flags & XLH_SPLIT_ORIG_CODED) ? " orig" : "", + (xlrec->flags & XLH_SPLIT_LEFT_CODED) ? " left" : "", + (xlrec->flags & XLH_SPLIT_RIGHT_CODED) ? " right" : ""); break; } case XLOG_BTREE_DEDUP: diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 3097e9bb1af..903acc6fbf9 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -83,6 +83,8 @@ 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_PREFIX (1 << 9) /* leaf keys coded against a page prefix + * (see nbtprefix.c) */ /* * The max allowed value of a cycle ID is a bit less than 64K. This is @@ -367,7 +369,55 @@ typedef struct BTVacState #define P_HIKEY ((OffsetNumber) 1) #define P_FIRSTKEY ((OffsetNumber) 2) -#define P_FIRSTDATAKEY(opaque) (P_RIGHTMOST(opaque) ? P_HIKEY : P_FIRSTKEY) + +/* + * Leaf key prefix compression (see nbtprefix.c for the code). + * + * A leaf of an index whose prefix_compression reloption is on may be flagged + * BTP_PREFIX. Its data items are organized in GROUPS of consecutive items; + * each group has an ANCHOR, a copy of the key part of the group's first item, + * and each member is stored "coded": without the leading data-area bytes it + * shares with the anchor. Sharing is by stored bytes, never by the opclass + * notion of equality, so it is sound for any column type (an index-only scan + * gets back exactly the bytes that were stored) and needs no equalimage + * support; it covers whole leading columns and any leading bytes of the + * first column that differs. + * + * Page layout. At BTPageGetPrefixOff(opaque) (right after the high key) is a + * single PREFIX DIRECTORY item that holds every group's anchor; the data items + * follow it, in key order, starting at P_FIRSTDATAKEY. One item before the + * data keeps P_FIRSTDATAKEY a function of the page flag alone, so neither + * BTPageOpaqueData nor the on-disk page header changes, and binary search over + * the data items is unaffected. The directory item is an IndexTupleData header + * (invalid t_tid; t_info = its size) followed by the group count, the cached + * decoded size of the page's data items (see _bt_prefix_split_needed), the + * anchor offsets, and the anchors (BTPrefixDirHdr, BTPrefixAnchorDisk). + * + * Coded member. An IndexTupleData header whose t_tid is the member's own heap + * TID (or posting list metadata), a small code header (group, shared byte + * count, the original's t_info), the original's null bitmap, the residual (the + * original's data-area bytes after the shared ones) and, for a posting list + * tuple, its posting list in the standard layout. Code that reads only heap + * TIDs therefore works on coded items as is. Decoding splices the anchor's + * shared bytes back in front of the residual and reproduces the original tuple + * byte for byte. Coding and decoding use only the page and the tuple, never + * the catalog, so WAL redo repeats them. Pivots (high keys, downlinks) are + * never coded. + */ +#define BTREE_PREFIX_MAXGROUPS 0xFFFE /* group-id ceiling (uint16) */ + +/* + * Worst-case extra bytes a coded member can cost over the plain tuple (the + * code header, MAXALIGN'd). Insert reserves this on a prefix page so a coded + * PageAddItem never fails after the fit check. + */ +#define BTREE_PREFIX_CODE_OVERHEAD MAXALIGN(16) + +#define P_HASPREFIX(opaque) (((opaque)->btpo_flags & BTP_PREFIX) != 0) +#define BTPageGetPrefixOff(opaque) \ + (P_RIGHTMOST(opaque) ? P_HIKEY : P_FIRSTKEY) +#define P_FIRSTDATAKEY(opaque) \ + (BTPageGetPrefixOff(opaque) + (P_HASPREFIX(opaque) ? 1 : 0)) /* * Notes on B-Tree tuple format, and key and non-key attributes: @@ -676,6 +726,36 @@ BTreeTupleGetMaxHeapTID(IndexTuple itup) return &itup->t_tid; } +/* + * Leaf key prefix compression (see nbtprefix.c for the format). + * + * Workspace for _bt_getitem. Every read of a leaf DATA item (any item but + * the high key or a pivot) that could be on a BTP_PREFIX page goes through + * that function rather than bare PageGetItem, so decoding has one home. The + * workspace holds a decoded copy; the returned tuple is valid until the + * workspace is reused or the page is unlocked. High keys and pivots, and + * items a caller changes in place, are read with PageGetItem. + */ +typedef PGAlignedBlock BTItemBuf; + +extern IndexTuple _bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf); + +/* + * Return the data item at `offnum' on `page' as a plain IndexTuple: on a + * BTP_PREFIX page, the tuple decoded into `buf'; otherwise the on-page tuple, + * and `buf' is not used. Callers must not write through the result. + */ +static inline IndexTuple +_bt_getitem(Page page, OffsetNumber offnum, BTItemBuf *buf) +{ + IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)); + BTPageOpaque opaque = BTPageGetOpaque(page); + + if (unlikely(P_HASPREFIX(opaque)) && offnum > BTPageGetPrefixOff(opaque)) + return _bt_prefix_decode(page, itup, buf); + return itup; +} + /* * Operator strategy numbers for B-tree have been moved to access/stratnum.h, * because many places need to use them in ScanKeyInit() calls. @@ -1075,10 +1155,13 @@ 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. + * BLCKSZ, so it can hold as much as a full page's worth of tuples, or 2 * + * BLCKSZ once bigTuples is set (a prefix-compressed leaf can decode to + * more than one page; see _bt_prefix_split_needed). */ char *currTuples; /* tuple storage for currPos */ char *markTuples; /* tuple storage for markPos */ + bool bigTuples; /* workspaces grown for prefix pages? */ /* * If the marked position is on the same page as current position, we @@ -1122,6 +1205,7 @@ typedef struct BTOptions int fillfactor; /* page fill factor in percent (0..100) */ float8 vacuum_cleanup_index_scale_factor; /* deprecated */ bool deduplicate_items; /* Try to deduplicate items? */ + bool prefix_compression; /* Code leaf keys against a page prefix? */ } BTOptions; #define BTGetFillFactor(relation) \ @@ -1137,6 +1221,11 @@ typedef struct BTOptions relation->rd_rel->relam == BTREE_AM_OID), \ ((relation)->rd_options ? \ ((BTOptions *) (relation)->rd_options)->deduplicate_items : true)) +#define BTGetPrefixCompression(relation) \ + (AssertMacro(relation->rd_rel->relkind == RELKIND_INDEX && \ + relation->rd_rel->relam == BTREE_AM_OID), \ + ((relation)->rd_options ? \ + ((BTOptions *) (relation)->rd_options)->prefix_compression : false)) /* * Constant definition for progress reporting. Phase numbers must match @@ -1203,6 +1292,8 @@ extern void _bt_dedup_start_pending(BTDedupState state, IndexTuple base, OffsetNumber baseoff); extern bool _bt_dedup_save_htid(BTDedupState state, IndexTuple itup); extern Size _bt_dedup_finish_pending(Page newpage, BTDedupState state); +extern void _bt_dedup_finish_pending_coded(Page page, Page newpage, + BTDedupState state); extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids, int nhtids); extern void _bt_update_posting(BTVacuumPosting vacposting); @@ -1315,6 +1406,48 @@ extern void _bt_check_third_page(Relation rel, Relation heap, bool needheaptidspace, Page page, IndexTuple newtup); extern bool _bt_allequalimage(Relation rel, bool debugmessage); +/* + * prototypes for functions in nbtprefix.c + */ +extern const char *_bt_page_get_prefix(Page page, Size *len); +extern bool _bt_prefix_enabled(Relation index); +extern IndexTuple _bt_prefix_encode_insert(Page page, + OffsetNumber newitemoff, IndexTuple itup); +extern void _bt_prefix_compress_page(Relation index, Page page); + +/* Running estimate of a coded leaf, for filling pages during CREATE INDEX */ +typedef struct BTPrefixPageEst +{ + int nitems; + int ngroups; + IndexTuple grouprep; /* current group's representative */ + Size anchorsz; /* directory anchor blobs so far */ + Size codedsz; /* coded items, plus line pointers */ + Size lastcoded; /* most recent item's share of codedsz */ + Size plainsz; /* plain items, plus line pointers */ + Size maxplain; /* largest plain item, plus line pointer */ +} BTPrefixPageEst; + +extern void _bt_prefix_est_add(BTPrefixPageEst *est, IndexTuple itup); +extern Size _bt_prefix_est_coded(const BTPrefixPageEst *est); +extern bool _bt_prefix_worthwhile(Size codedsz, Size plainsz); +extern bool _bt_prefix_form_page(Page newpage, IndexTuple hikey, + IndexTuple *items, int n, bool force); +extern IndexTuple _bt_prefix_recode(Page page, OffsetNumber offnum, + IndexTuple itup); +extern int _bt_prefix_decode_items(Page page, IndexTuple **items_out, + IndexTuple *hikey, Size *hikeysz); +extern Page _bt_prefix_try_plain_page(Relation rel, Page page); +extern bool _bt_prefix_split_needed(Page page, Size newitemsz); +extern OffsetNumber _bt_prefix_findsplitloc(Relation rel, Page page, + IndexTuple *ditems, + OffsetNumber newitemoff, + Size newitemsz, + bool *newitemonleft); +extern void _bt_prefix_recount(Page page); +extern uint32 _bt_prefix_cached_size(Page page); +extern void _bt_prefix_note_insert(Page page, IndexTuple coded); + /* * prototypes for functions in nbtvalidate.c */ diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h index 3a78ec27fe8..1a4ec469612 100644 --- a/src/include/access/nbtxlog.h +++ b/src/include/access/nbtxlog.h @@ -156,9 +156,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 */ + uint8 flags; /* XLH_SPLIT_* prefix compression flags */ } xl_btree_split; -#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16)) +#define SizeOfBtreeSplit (offsetof(xl_btree_split, flags) + sizeof(uint8)) + +/* + * xl_btree_split flags for prefix-compressed (BTP_PREFIX) leaves. When the + * original page was coded, firstrightoff/newitemoff are offsets into its + * DECODED item sequence (the page as it would be without the prefix + * directory, so data items start at BTPageGetPrefixOff); redo decodes the + * original page's items to rebuild the left half. A coded left half is laid + * out by redo with _bt_prefix_form_page from those items, exactly as the + * primary did; a coded right half arrives as its coded items (including the + * directory) and only needs the page flag set. + */ +#define XLH_SPLIT_ORIG_CODED 0x01 +#define XLH_SPLIT_LEFT_CODED 0x02 +#define XLH_SPLIT_RIGHT_CODED 0x04 /* * When page is deduplicated, consecutive groups of tuples with equal keys are diff --git a/src/test/recovery/Makefile b/src/test/recovery/Makefile index 9c4102b6b2c..aba5bfc0a3d 100644 --- a/src/test/recovery/Makefile +++ b/src/test/recovery/Makefile @@ -9,7 +9,9 @@ # #------------------------------------------------------------------------- -EXTRA_INSTALL=contrib/pg_prewarm \ +EXTRA_INSTALL=contrib/amcheck \ + contrib/pageinspect \ + contrib/pg_prewarm \ contrib/pg_stat_statements \ contrib/test_decoding \ src/test/modules/injection_points \ diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index b6ac9dd07a7..293a06c7298 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -69,6 +69,7 @@ tests += { 't/058_shutdown_crash_restart.pl', 't/059_remote_apply_status_interval.pl', 't/060_multixact_crashed_updater.pl', + 't/061_nbtree_prefix.pl', ], }, } diff --git a/src/test/recovery/t/061_nbtree_prefix.pl b/src/test/recovery/t/061_nbtree_prefix.pl new file mode 100644 index 00000000000..6837b2cae10 --- /dev/null +++ b/src/test/recovery/t/061_nbtree_prefix.pl @@ -0,0 +1,116 @@ + +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Replay of prefix-compressed btree leaves (the prefix_compression index +# storage parameter) on a streaming standby. +# +# Every page change on a BTP_PREFIX leaf -- in-place inserts of coded tuples, +# leaf splits whose halves redo lays out in coded form, deduplication, +# posting list splits, VACUUM, simple and bottom-up deletion, page deletion -- +# is replayed by redo routines that rebuild the coded page from the WAL record +# without a full-page image. wal_consistency_checking compares each replayed +# page with the primary's, so any divergence fails replay on the standby. +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf( + 'postgresql.conf', q{ +wal_consistency_checking = 'btree' +autovacuum = off +}); +$primary->start; +$primary->backup('backup'); + +my $standby = PostgreSQL::Test::Cluster->new('standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +$primary->safe_psql( + 'postgres', q{ +CREATE EXTENSION amcheck; + +-- Distinct keys sharing a long prefix: coded inserts and splits, including +-- splits of pages whose decoded content exceeds one page. +CREATE TABLE pfx_url (u text COLLATE "C", id int); +CREATE INDEX pfx_url_u ON pfx_url (u) WITH (prefix_compression = on); +INSERT INTO pfx_url + SELECT 'https://example.com/path/to/resource/' || (hashint4(g) & x'7fffffff'::int), g + FROM generate_series(1, 30000) g; + +-- Bulk-built coded leaves, then inserts into them. +CREATE INDEX pfx_url_u_id ON pfx_url (u, id) WITH (prefix_compression = on); +INSERT INTO pfx_url + SELECT 'https://example.com/path/to/resource/' || g, -g + FROM generate_series(1, 10000) g; + +-- Duplicates in random order: deduplication of coded pages, then posting +-- list splits on them. +CREATE TABLE pfx_dup (u text COLLATE "C", n int); +CREATE INDEX pfx_dup_u ON pfx_dup (u) WITH (prefix_compression = on); +INSERT INTO pfx_dup + SELECT 'https://example.com/path/to/resource/' || lpad((g % 300)::text, 8, '0'), g + FROM generate_series(1, 30000) g ORDER BY random(); +INSERT INTO pfx_dup + SELECT 'https://example.com/path/to/resource/' || lpad((g % 300)::text, 8, '0'), g + FROM generate_series(30001, 60000) g ORDER BY random(); + +-- VACUUM of coded pages (posting list updates and deletions), then page +-- deletion of emptied coded leaves. +DELETE FROM pfx_dup WHERE n % 3 = 0; +VACUUM pfx_dup; +DELETE FROM pfx_dup WHERE u < 'https://example.com/path/to/resource/00000150'; +VACUUM pfx_dup; + +-- Non-key updates: bottom-up deletion on coded pages of a unique index. +CREATE TABLE pfx_upd (id int PRIMARY KEY, u text COLLATE "C", f int); +CREATE INDEX pfx_upd_u ON pfx_upd (u) WITH (prefix_compression = on); +INSERT INTO pfx_upd + SELECT g, 'https://example.com/x/' || lpad((g % 500)::text, 8, '0'), 0 + FROM generate_series(1, 20000) g; +UPDATE pfx_upd SET f = f + 1; +UPDATE pfx_upd SET f = f + 1; +}); + +$primary->wait_for_replay_catchup($standby); + +# The standby replayed everything (a consistency failure would have stopped +# it) and is still serving queries. +is($standby->safe_psql('postgres', 'SELECT 1'), '1', 'standby is alive'); + +my $query = q{ +SET enable_seqscan = off; +SET enable_bitmapscan = off; +SELECT count(*), md5(string_agg(u, '|' ORDER BY u)) FROM pfx_url; +SELECT count(*), md5(string_agg(u || n, '|' ORDER BY u, n)) FROM pfx_dup; +SELECT count(*) FROM pfx_upd WHERE u = 'https://example.com/x/00000007'; +}; +is( $standby->safe_psql('postgres', $query), + $primary->safe_psql('postgres', $query), + 'standby index scans match primary'); + +# The replayed indexes are structurally valid. +foreach my $idx (qw(pfx_url_u pfx_url_u_id pfx_dup_u pfx_upd_u)) +{ + is( $standby->safe_psql( + 'postgres', "SELECT bt_index_check('$idx', true)"), + '', + "amcheck on standby: $idx"); +} + +# The workload did use prefix-compressed leaves. +ok( $primary->safe_psql( + 'postgres', q{ +CREATE EXTENSION pageinspect; +SELECT count(*) > 0 + FROM generate_series(1, (pg_relation_size('pfx_url_u') / 8192)::int - 1) b, + LATERAL bt_page_stats('pfx_url_u', b) s + WHERE s.type = 'l' AND (s.btpo_flags & 512) <> 0; +}) eq 't', + 'index has prefix-compressed leaves'); + +done_testing(); diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out index 7c53d598649..f5fe863ef73 100644 --- a/src/test/regress/expected/btree_index.out +++ b/src/test/regress/expected/btree_index.out @@ -891,3 +891,288 @@ ALTER INDEX btree_part_idx ALTER COLUMN id SET (n_distinct=100); ERROR: ALTER action ALTER COLUMN ... SET cannot be performed on relation "btree_part_idx" DETAIL: This operation is not supported for partitioned indexes. DROP TABLE btree_part; +-- +-- Leaf key prefix compression (prefix_compression reloption). +-- +-- Correctness is checked against an independent oracle: a forced index scan +-- over a prefix-compressed index must return exactly the rows a sequential +-- scan of the same data returns, across build, retail insert/split, delete +-- and VACUUM, for equality, range, backward, index-only and NULL queries. +-- The key column uses the C collation so the recorded output is deterministic +-- across platforms. The page format is exercised by keys that share a long +-- prefix (URL-like), by keys around 127 bytes (where a varlena changes from a +-- 1-byte to a 4-byte header, so neighbors share a header-length prefix only), +-- by keys longer than a group's anchor, by keys that share nothing, and by a +-- NULL key. +-- +CREATE TABLE btree_prefix (u text COLLATE "C"); +-- Build-path pages (CREATE INDEX writes whole leaves). A shared 40-byte +-- prefix, plus explicit rows around the 1-byte/4-byte varlena header boundary. +INSERT INTO btree_prefix + SELECT 'https://example.com/path/to/resource/' || lpad((g % 20000)::text, 8, '0') + FROM generate_series(1, 20000) g; +INSERT INTO btree_prefix + SELECT repeat('a', 40) || repeat('b', n) FROM generate_series(85, 90) n; +INSERT INTO btree_prefix VALUES + (repeat('a', 125)), (repeat('a', 126)), (repeat('a', 127)), (repeat('a', 300)), + ('zzz-no-shared-prefix'), (NULL); +CREATE INDEX btree_prefix_on ON btree_prefix (u) WITH (prefix_compression = on); +-- Confirm the index really is used (and is prefix-compressed), so the queries +-- below exercise the coded page format rather than silently falling back. +SET enable_seqscan = off; +SET enable_bitmapscan = off; +EXPLAIN (costs off) + SELECT u FROM btree_prefix WHERE u = 'https://example.com/path/to/resource/00012345'; + QUERY PLAN +--------------------------------------------------------------------------- + Index Only Scan using btree_prefix_on on btree_prefix + Index Cond: (u = 'https://example.com/path/to/resource/00012345'::text) +(2 rows) + +-- Equality, range and backward scans forced through the prefix index. Their +-- results are recorded in the expected output, which is itself the oracle: a +-- decode regression changes these rows. (The index-only digest below adds an +-- oracle that does not read the index at all.) +SELECT count(*) FROM btree_prefix + WHERE u = 'https://example.com/path/to/resource/00012345'; + count +------- + 1 +(1 row) + +SELECT count(*) FROM btree_prefix + WHERE u BETWEEN 'https://example.com/path/to/resource/00000100' + AND 'https://example.com/path/to/resource/00000900'; + count +------- + 801 +(1 row) + +SELECT u FROM btree_prefix + WHERE u > 'https://example.com/path/to/resource/00019996' ORDER BY u DESC; + u +----------------------------------------------- + zzz-no-shared-prefix + https://example.com/path/to/resource/00019999 + https://example.com/path/to/resource/00019998 + https://example.com/path/to/resource/00019997 +(4 rows) + +-- Boundary and oversized keys round-trip to their exact stored values. +SELECT u FROM btree_prefix WHERE u LIKE 'aaaa%' ORDER BY u; + u +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +(10 rows) + +SELECT count(*) FROM btree_prefix WHERE u = repeat('a', 300); + count +------- + 1 +(1 row) + +SELECT count(*) FROM btree_prefix WHERE u IS NULL; + count +------- + 1 +(1 row) + +-- Index-only scan over the prefix index, compared to the literal values that +-- were inserted (an oracle that does not itself read the index). +SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) + FROM btree_prefix WHERE u IS NOT NULL) = + md5(string_agg(x, '|' ORDER BY x)) AS ios_ok + FROM ( + SELECT 'https://example.com/path/to/resource/' || lpad((g % 20000)::text, 8, '0') AS x + FROM generate_series(1, 20000) g + UNION ALL SELECT repeat('a', 40) || repeat('b', n) FROM generate_series(85, 90) n + UNION ALL SELECT v FROM (VALUES (repeat('a',125)),(repeat('a',126)), + (repeat('a',127)),(repeat('a',300)), + ('zzz-no-shared-prefix')) t(v) + ) oracle; + ios_ok +-------- + t +(1 row) + +-- Retail inserts cause splits that fault pages forward into the coded format; +-- an index-only scan must still agree with the independent oracle afterwards. +INSERT INTO btree_prefix + SELECT 'https://example.com/other/' || lpad(g::text, 8, '0') + FROM generate_series(1, 10000) g; +-- Delete then VACUUM, then re-insert into freed space. The delete targets +-- only part of the URL namespace so the boundary keys (aaaa.../zzz...) survive +-- for the checks below. +DELETE FROM btree_prefix + WHERE u >= 'https://example.com/path/to/resource/00000000' + AND u < 'https://example.com/path/to/resource/00005000'; +VACUUM btree_prefix; +INSERT INTO btree_prefix + SELECT 'https://example.com/path/to/resource/' || lpad((g % 5000)::text, 8, '0') + FROM generate_series(1, 5000) g; +-- After the churn, an index-only scan of the live keys must equal the same +-- keys fetched from the heap by a sequential scan (an independent path): if +-- decoding the coded leaves were wrong, the two digests would differ. +SET enable_indexonlyscan = on; +SELECT md5(string_agg(u, '|' ORDER BY u)) AS ios_digest + FROM btree_prefix WHERE u IS NOT NULL \gset +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +SET enable_seqscan = on; +SELECT :'ios_digest' = + (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM btree_prefix WHERE u IS NOT NULL) + AS post_churn_ok; + post_churn_ok +--------------- + t +(1 row) + +SET enable_indexscan = on; +RESET enable_seqscan; +RESET enable_bitmapscan; +-- The reloption round-trips and can be toggled without a rewrite. +SELECT reloptions FROM pg_class WHERE relname = 'btree_prefix_on'; + reloptions +------------------------- + {prefix_compression=on} +(1 row) + +ALTER INDEX btree_prefix_on SET (prefix_compression = off); +SELECT reloptions FROM pg_class WHERE relname = 'btree_prefix_on'; + reloptions +-------------------------- + {prefix_compression=off} +(1 row) + +-- Reading back with the option off still returns correct rows: existing +-- BTP_PREFIX leaves keep their coded form and decode on the page flag. +SET enable_seqscan = off; +SELECT count(*) FROM btree_prefix WHERE u = repeat('a', 126); + count +------- + 1 +(1 row) + +RESET enable_seqscan; +-- A non-varlena key column is compressed too (shared bytes need not be text); +-- verify a scan over such an index returns correct rows. +CREATE INDEX btree_prefix_int ON btree_prefix ((length(u))) WITH (prefix_compression = on); +SET enable_seqscan = off; +SELECT length(u) FROM btree_prefix WHERE length(u) = 300; + length +-------- + 300 +(1 row) + +RESET enable_seqscan; +DROP TABLE btree_prefix; +-- +-- Bottom-up index deletion on prefix-compressed pages. +-- +-- An UPDATE that does not change the indexed column marks the resulting index +-- insert "logically unchanged", which lets nbtree run a bottom-up deletion +-- pass before splitting a leaf. That pass reads and (only) removes items; it +-- must work on coded pages too. Here we exercise the path and verify the +-- index stays correct; committed update batches between VACUUMs leave +-- removable duplicates for the pass to find. (Bloat is quantified in the +-- performance benchmark, not here, since removability is timing-dependent.) +-- +CREATE TABLE btree_prefix_bu (id int PRIMARY KEY, u text COLLATE "C", filler text); +INSERT INTO btree_prefix_bu + SELECT g, 'https://example.com/path/' || lpad((g % 500)::text, 8, '0'), repeat('x', 40) + FROM generate_series(1, 4000) g; +CREATE INDEX btree_prefix_bu_u ON btree_prefix_bu (u) WITH (prefix_compression = on); +-- Several committed non-key UPDATE batches (version churn on the prefix index). +UPDATE btree_prefix_bu SET filler = repeat('y', 40); +UPDATE btree_prefix_bu SET filler = repeat('z', 40); +UPDATE btree_prefix_bu SET filler = repeat('w', 40); +-- Correctness after the churn: an index-only scan of the key column must equal +-- the independent oracle (the literal keys), proving no decode/deletion damage. +SET enable_seqscan = off; +SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM btree_prefix_bu) = + md5(string_agg(x, '|' ORDER BY x)) AS bottomup_ok + FROM (SELECT 'https://example.com/path/' || lpad((g % 500)::text, 8, '0') AS x + FROM generate_series(1, 4000) g) oracle; + bottomup_ok +------------- + t +(1 row) + +SELECT count(*) FROM btree_prefix_bu + WHERE u = 'https://example.com/path/' || lpad('7', 8, '0'); + count +------- + 8 +(1 row) + +RESET enable_seqscan; +DROP TABLE btree_prefix_bu; +-- +-- Runtime deduplication on a prefix-compressed page. Duplicate keys inserted +-- one at a time into an existing prefix index fill a leaf; before it splits, +-- the deduplication pass merges decoded copies of the items into posting +-- lists and re-codes them against the page's directory. +-- +CREATE TABLE btree_prefix_dd (u text COLLATE "C"); +CREATE INDEX btree_prefix_dd_u ON btree_prefix_dd (u) WITH (prefix_compression = on); +INSERT INTO btree_prefix_dd + SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0') + FROM generate_series(1, 40000) g; +SET enable_seqscan = off; +SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM btree_prefix_dd) = + md5(string_agg(x, '|' ORDER BY x)) AS runtime_dedup_ok + FROM (SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0') AS x + FROM generate_series(1, 40000) g) oracle; + runtime_dedup_ok +------------------ + t +(1 row) + +SELECT count(*) FROM btree_prefix_dd + WHERE u = 'https://example.com/path/to/resource/' || lpad('42', 8, '0'); + count +------- + 200 +(1 row) + +RESET enable_seqscan; +DROP TABLE btree_prefix_dd; +-- +-- Compression actually happens, both when an index is built and when it grows +-- by insertion: a prefix-compressed index on keys with a long shared prefix +-- is much smaller than a plain one. +-- +CREATE TABLE btree_prefix_sz (u text COLLATE "C"); +INSERT INTO btree_prefix_sz + SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') + FROM generate_series(1, 20000) g; +CREATE INDEX btree_prefix_sz_on ON btree_prefix_sz (u) WITH (prefix_compression = on); +CREATE INDEX btree_prefix_sz_off ON btree_prefix_sz (u); +SELECT pg_relation_size('btree_prefix_sz_on') * 3 < + pg_relation_size('btree_prefix_sz_off') * 2 AS build_compresses; + build_compresses +------------------ + t +(1 row) + +TRUNCATE btree_prefix_sz; +INSERT INTO btree_prefix_sz + SELECT 'https://example.com/path/to/resource/' || lpad(((g * 7919) % 20000)::text, 8, '0') + FROM generate_series(1, 20000) g; +SELECT pg_relation_size('btree_prefix_sz_on') * 3 < + pg_relation_size('btree_prefix_sz_off') * 2 AS insert_compresses; + insert_compresses +------------------- + t +(1 row) + +DROP TABLE btree_prefix_sz; diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql index 6990d7818da..413c14c2274 100644 --- a/src/test/regress/sql/btree_index.sql +++ b/src/test/regress/sql/btree_index.sql @@ -520,3 +520,190 @@ CREATE TABLE btree_part (id int4) PARTITION BY RANGE (id); CREATE INDEX btree_part_idx ON btree_part(id); ALTER INDEX btree_part_idx ALTER COLUMN id SET (n_distinct=100); DROP TABLE btree_part; + +-- +-- Leaf key prefix compression (prefix_compression reloption). +-- +-- Correctness is checked against an independent oracle: a forced index scan +-- over a prefix-compressed index must return exactly the rows a sequential +-- scan of the same data returns, across build, retail insert/split, delete +-- and VACUUM, for equality, range, backward, index-only and NULL queries. +-- The key column uses the C collation so the recorded output is deterministic +-- across platforms. The page format is exercised by keys that share a long +-- prefix (URL-like), by keys around 127 bytes (where a varlena changes from a +-- 1-byte to a 4-byte header, so neighbors share a header-length prefix only), +-- by keys longer than a group's anchor, by keys that share nothing, and by a +-- NULL key. +-- +CREATE TABLE btree_prefix (u text COLLATE "C"); +-- Build-path pages (CREATE INDEX writes whole leaves). A shared 40-byte +-- prefix, plus explicit rows around the 1-byte/4-byte varlena header boundary. +INSERT INTO btree_prefix + SELECT 'https://example.com/path/to/resource/' || lpad((g % 20000)::text, 8, '0') + FROM generate_series(1, 20000) g; +INSERT INTO btree_prefix + SELECT repeat('a', 40) || repeat('b', n) FROM generate_series(85, 90) n; +INSERT INTO btree_prefix VALUES + (repeat('a', 125)), (repeat('a', 126)), (repeat('a', 127)), (repeat('a', 300)), + ('zzz-no-shared-prefix'), (NULL); +CREATE INDEX btree_prefix_on ON btree_prefix (u) WITH (prefix_compression = on); + +-- Confirm the index really is used (and is prefix-compressed), so the queries +-- below exercise the coded page format rather than silently falling back. +SET enable_seqscan = off; +SET enable_bitmapscan = off; +EXPLAIN (costs off) + SELECT u FROM btree_prefix WHERE u = 'https://example.com/path/to/resource/00012345'; + +-- Equality, range and backward scans forced through the prefix index. Their +-- results are recorded in the expected output, which is itself the oracle: a +-- decode regression changes these rows. (The index-only digest below adds an +-- oracle that does not read the index at all.) +SELECT count(*) FROM btree_prefix + WHERE u = 'https://example.com/path/to/resource/00012345'; +SELECT count(*) FROM btree_prefix + WHERE u BETWEEN 'https://example.com/path/to/resource/00000100' + AND 'https://example.com/path/to/resource/00000900'; +SELECT u FROM btree_prefix + WHERE u > 'https://example.com/path/to/resource/00019996' ORDER BY u DESC; +-- Boundary and oversized keys round-trip to their exact stored values. +SELECT u FROM btree_prefix WHERE u LIKE 'aaaa%' ORDER BY u; +SELECT count(*) FROM btree_prefix WHERE u = repeat('a', 300); +SELECT count(*) FROM btree_prefix WHERE u IS NULL; + +-- Index-only scan over the prefix index, compared to the literal values that +-- were inserted (an oracle that does not itself read the index). +SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) + FROM btree_prefix WHERE u IS NOT NULL) = + md5(string_agg(x, '|' ORDER BY x)) AS ios_ok + FROM ( + SELECT 'https://example.com/path/to/resource/' || lpad((g % 20000)::text, 8, '0') AS x + FROM generate_series(1, 20000) g + UNION ALL SELECT repeat('a', 40) || repeat('b', n) FROM generate_series(85, 90) n + UNION ALL SELECT v FROM (VALUES (repeat('a',125)),(repeat('a',126)), + (repeat('a',127)),(repeat('a',300)), + ('zzz-no-shared-prefix')) t(v) + ) oracle; + +-- Retail inserts cause splits that fault pages forward into the coded format; +-- an index-only scan must still agree with the independent oracle afterwards. +INSERT INTO btree_prefix + SELECT 'https://example.com/other/' || lpad(g::text, 8, '0') + FROM generate_series(1, 10000) g; +-- Delete then VACUUM, then re-insert into freed space. The delete targets +-- only part of the URL namespace so the boundary keys (aaaa.../zzz...) survive +-- for the checks below. +DELETE FROM btree_prefix + WHERE u >= 'https://example.com/path/to/resource/00000000' + AND u < 'https://example.com/path/to/resource/00005000'; +VACUUM btree_prefix; +INSERT INTO btree_prefix + SELECT 'https://example.com/path/to/resource/' || lpad((g % 5000)::text, 8, '0') + FROM generate_series(1, 5000) g; +-- After the churn, an index-only scan of the live keys must equal the same +-- keys fetched from the heap by a sequential scan (an independent path): if +-- decoding the coded leaves were wrong, the two digests would differ. +SET enable_indexonlyscan = on; +SELECT md5(string_agg(u, '|' ORDER BY u)) AS ios_digest + FROM btree_prefix WHERE u IS NOT NULL \gset +SET enable_indexscan = off; +SET enable_indexonlyscan = off; +SET enable_seqscan = on; +SELECT :'ios_digest' = + (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM btree_prefix WHERE u IS NOT NULL) + AS post_churn_ok; +SET enable_indexscan = on; +RESET enable_seqscan; +RESET enable_bitmapscan; + +-- The reloption round-trips and can be toggled without a rewrite. +SELECT reloptions FROM pg_class WHERE relname = 'btree_prefix_on'; +ALTER INDEX btree_prefix_on SET (prefix_compression = off); +SELECT reloptions FROM pg_class WHERE relname = 'btree_prefix_on'; +-- Reading back with the option off still returns correct rows: existing +-- BTP_PREFIX leaves keep their coded form and decode on the page flag. +SET enable_seqscan = off; +SELECT count(*) FROM btree_prefix WHERE u = repeat('a', 126); +RESET enable_seqscan; + +-- A non-varlena key column is compressed too (shared bytes need not be text); +-- verify a scan over such an index returns correct rows. +CREATE INDEX btree_prefix_int ON btree_prefix ((length(u))) WITH (prefix_compression = on); +SET enable_seqscan = off; +SELECT length(u) FROM btree_prefix WHERE length(u) = 300; +RESET enable_seqscan; +DROP TABLE btree_prefix; + +-- +-- Bottom-up index deletion on prefix-compressed pages. +-- +-- An UPDATE that does not change the indexed column marks the resulting index +-- insert "logically unchanged", which lets nbtree run a bottom-up deletion +-- pass before splitting a leaf. That pass reads and (only) removes items; it +-- must work on coded pages too. Here we exercise the path and verify the +-- index stays correct; committed update batches between VACUUMs leave +-- removable duplicates for the pass to find. (Bloat is quantified in the +-- performance benchmark, not here, since removability is timing-dependent.) +-- +CREATE TABLE btree_prefix_bu (id int PRIMARY KEY, u text COLLATE "C", filler text); +INSERT INTO btree_prefix_bu + SELECT g, 'https://example.com/path/' || lpad((g % 500)::text, 8, '0'), repeat('x', 40) + FROM generate_series(1, 4000) g; +CREATE INDEX btree_prefix_bu_u ON btree_prefix_bu (u) WITH (prefix_compression = on); +-- Several committed non-key UPDATE batches (version churn on the prefix index). +UPDATE btree_prefix_bu SET filler = repeat('y', 40); +UPDATE btree_prefix_bu SET filler = repeat('z', 40); +UPDATE btree_prefix_bu SET filler = repeat('w', 40); +-- Correctness after the churn: an index-only scan of the key column must equal +-- the independent oracle (the literal keys), proving no decode/deletion damage. +SET enable_seqscan = off; +SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM btree_prefix_bu) = + md5(string_agg(x, '|' ORDER BY x)) AS bottomup_ok + FROM (SELECT 'https://example.com/path/' || lpad((g % 500)::text, 8, '0') AS x + FROM generate_series(1, 4000) g) oracle; +SELECT count(*) FROM btree_prefix_bu + WHERE u = 'https://example.com/path/' || lpad('7', 8, '0'); +RESET enable_seqscan; +DROP TABLE btree_prefix_bu; + +-- +-- Runtime deduplication on a prefix-compressed page. Duplicate keys inserted +-- one at a time into an existing prefix index fill a leaf; before it splits, +-- the deduplication pass merges decoded copies of the items into posting +-- lists and re-codes them against the page's directory. +-- +CREATE TABLE btree_prefix_dd (u text COLLATE "C"); +CREATE INDEX btree_prefix_dd_u ON btree_prefix_dd (u) WITH (prefix_compression = on); +INSERT INTO btree_prefix_dd + SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0') + FROM generate_series(1, 40000) g; +SET enable_seqscan = off; +SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM btree_prefix_dd) = + md5(string_agg(x, '|' ORDER BY x)) AS runtime_dedup_ok + FROM (SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0') AS x + FROM generate_series(1, 40000) g) oracle; +SELECT count(*) FROM btree_prefix_dd + WHERE u = 'https://example.com/path/to/resource/' || lpad('42', 8, '0'); +RESET enable_seqscan; +DROP TABLE btree_prefix_dd; + +-- +-- Compression actually happens, both when an index is built and when it grows +-- by insertion: a prefix-compressed index on keys with a long shared prefix +-- is much smaller than a plain one. +-- +CREATE TABLE btree_prefix_sz (u text COLLATE "C"); +INSERT INTO btree_prefix_sz + SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') + FROM generate_series(1, 20000) g; +CREATE INDEX btree_prefix_sz_on ON btree_prefix_sz (u) WITH (prefix_compression = on); +CREATE INDEX btree_prefix_sz_off ON btree_prefix_sz (u); +SELECT pg_relation_size('btree_prefix_sz_on') * 3 < + pg_relation_size('btree_prefix_sz_off') * 2 AS build_compresses; +TRUNCATE btree_prefix_sz; +INSERT INTO btree_prefix_sz + SELECT 'https://example.com/path/to/resource/' || lpad(((g * 7919) % 20000)::text, 8, '0') + FROM generate_series(1, 20000) g; +SELECT pg_relation_size('btree_prefix_sz_on') * 3 < + pg_relation_size('btree_prefix_sz_off') * 2 AS insert_compresses; +DROP TABLE btree_prefix_sz; diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 553162c0810..3c67950620f 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -215,6 +215,7 @@ BTDeletedPageData BTIndexStat BTInsertState BTInsertStateData +BTItemBuf BTLeader BTMetaPageData BTOneVacInfo @@ -226,6 +227,12 @@ BTPageStat BTPageState BTParallelScanDesc BTPendingFSM +BTPrefixAnchor +BTPrefixAnchorDisk +BTPrefixCode +BTPrefixDir +BTPrefixDirHdr +BTPrefixPageEst BTReadPageState BTScanInsert BTScanInsertData -- 2.54.0